Compare commits

...

4 Commits

Author SHA1 Message Date
Daniel Lok 401f91c14e test(e2e): guard pinned-session delete clears the Pinned section
Adds a browser e2e that pins a session (while sitting on `/`, so it isn't
the active chat) and deletes it, asserting the "Pinned" section unmounts
in place — no reload.

Two harness details are load-bearing, and getting them wrong yields a
test that passes even against the buggy build:

- Delete a NON-active pinned session (page on `/`). Deleting the open
  session navigates away and refetches; an active session also gets a
  WS `removed`-frame reconcile. Either clears the row regardless of the
  cache bug.
- Assert the "Pinned" SECTION disappears, not the row's href. While the
  delete is in flight the row swaps to a hrefless "Deleting…" status row,
  so an href-count assertion flickers to 0 during that transient and
  passes spuriously; the section stays mounted until the pinned cache is
  actually empty.

Verified it fails (~3s) against a build with the pinned-cache delete
patch removed, and passes with it.

Co-authored-by: Isaac
2026-07-29 17:17:16 +08:00
Daniel Lok 21eebbef49 fix(web): keep the sidebar row size stable when editing the title
The inline rename row rendered a `text-sm` (14px) input inside a wrapper
whose `py-1` + `size-7` buttons summed to ~36px, while the interactive
row is `h-7` (28px) with the 13px `sidebar-compact-text` font. So
double-clicking to rename made the row grow taller and bump the font
size, an input visibly larger than the row it replaced.

Match the edit row's box metrics to the interactive row (h-7,
sidebar-compact-text, otto-sm radius) and drop the buttons to icon-xs
(24px) so they sit inside the 28px row, leaving only the muted edit
background to signal the mode.

Co-authored-by: Isaac
2026-07-29 16:42:38 +08:00
Daniel Lok f464e47492 fix(web): keep the sidebar row height stable during delete
The in-flight "Deleting…" status row that replaces an interactive
conversation row used `text-sm py-2` with no height constraint, while
the interactive row uses `sidebar-compact-text h-7 py-0.5`. So starting
a delete didn't just recolor the row — it grew taller and changed font
size, shifting the surrounding list.

Match the deleting row's box metrics to the interactive row (h-7,
sidebar-compact-text font size, otto-sm radius) so the swap only changes
color/opacity.

Co-authored-by: Isaac
2026-07-29 16:18:06 +08:00
Daniel Lok 2202ed81d5 fix(web): clear deleted pinned sessions from the sidebar's Pinned section
The Pinned section reads a sibling ["pinned-conversations"] cache that the
delete mutations' prefix-matched ["conversations"] sweep deliberately skips
(nesting it under that prefix breaks the pin-toggle's cache patch). That
isolation is by design, but it means the delete handlers must drop the row
from the pinned cache explicitly — which they didn't. So deleting a pinned
session removed it from the flat list but left it lingering in the Pinned
section until a full reload.

Mirror the unpin removal in all three delete paths (single-delete onSuccess,
bulk-delete onSuccess, and bulk-delete onError's partial-success branch),
patching the pinned cache in place rather than invalidating for the same
search-reindex-lag reason the list is patched in place.

Co-authored-by: Isaac
2026-07-29 15:41:02 +08:00
4 changed files with 172 additions and 7 deletions
@@ -0,0 +1,124 @@
"""Browser e2e for deleting a *pinned* session from the sidebar.
The Pinned section renders from a query cache (``["pinned-conversations"]``)
that is a deliberate sibling of the paginated ``["conversations"]`` list, so
the delete mutation's prefix-matched sweep over ``["conversations"]`` does not
touch it (see ``useConversations.ts`` — nesting the pinned key under that
prefix would break the pin-toggle's own cache patch). The delete handlers must
therefore drop the row from the pinned cache *explicitly*.
The regression this guards: they didn't, so deleting a pinned session removed
it from the flat list but left its row stranded in the "Pinned" section until a
full reload.
Two harness details are load-bearing here — without them a green test would
prove nothing:
- The page sits on ``/`` (no active chat). Deleting the *open* session
bounces the SPA to ``/`` and refetches, and a live ``WS /v1/sessions/updates``
``removed`` frame reconciles an active session's list too — either path
clears the Pinned row regardless of the cache bug.
- No ``page.reload()``. A reload refetches ``?pinned=true`` and converges the
section on its own. Only an in-place assertion isolates the delete handler's
own pinned-cache patch — the code under test.
"""
from __future__ import annotations
import time
import uuid
import httpx
from playwright.sync_api import Locator, Page, expect
def _section(page: Page, title: str) -> Locator:
"""Locate the sidebar ``<section>`` whose header reads *title*."""
return page.locator("section").filter(has=page.get_by_role("button", name=title, exact=True))
def _row(page: Page, session_id: str) -> Locator:
"""Locate the sidebar row (``<li>``) for *session_id* by its href."""
return page.locator("li").filter(has=page.locator(f'a[href="/c/{session_id}"]'))
def test_delete_pinned_session_clears_it_from_pinned_section(
page: Page,
seeded_session: tuple[str, str],
) -> None:
"""Deleting a pinned session clears its row from the Pinned section.
Failure mode this catches that the flat-list delete test can't: the
delete splices the row out of ``["conversations"]`` but leaves it in the
sibling ``["pinned-conversations"]`` cache, so the Pinned section keeps
rendering the deleted row until a reload.
:param page: Playwright page fixture (fresh context per test).
:param seeded_session: ``(base_url, session_id)`` for a pre-created
runner-bound session.
"""
base_url, session_id = seeded_session
title = f"e2e-delete-pinned-{uuid.uuid4().hex[:8]}"
httpx.patch(
f"{base_url}/v1/sessions/{session_id}",
json={"title": title},
timeout=10.0,
).raise_for_status()
# Sit on home so the session is NOT the active chat — deleting the open
# session would navigate/refetch and mask the pinned-cache bug.
page.goto(f"{base_url}/")
row = _row(page, session_id)
expect(row).to_be_visible()
# Pin via the row's quick action, then confirm it landed under "Pinned".
row.hover()
row.get_by_test_id("quick-pin-conversation").click()
pinned_row = (
_section(page, "Pinned")
.locator("li")
.filter(has=page.locator(f'a[href="/c/{session_id}"]'))
)
expect(pinned_row.locator(f'a[href="/c/{session_id}"]')).to_be_visible()
# Delete from within the Pinned section: kebab → Delete → confirm.
pinned_row.hover()
pinned_row.get_by_test_id("conversation-actions").click()
page.get_by_test_id("delete-conversation").click()
dialog = page.get_by_role("dialog")
expect(dialog).to_be_visible()
dialog.get_by_role("button", name="Delete", exact=True).click()
# This was the only pinned session, so once the delete patches the pinned
# cache the whole "Pinned" section unmounts. Assert on the SECTION, not the
# row's href: while the delete is in flight the row swaps to a "Deleting…"
# status row that has no href, so an href-count assertion flickers to 0
# during that transient and would pass even against the buggy build (the
# href reappears a beat later when the stale pinned cache re-renders the
# row). The section only disappears when the pinned cache is truly empty —
# the DeletingRow keeps it mounted — so it can't be fooled by the transient.
#
# In place, no reload: a reload refetches ?pinned=true and converges the
# section on its own, hiding the bug. A tight 3s timeout (vs the suite's
# 15s streaming default) keeps a regression failing fast; the delete's
# stop→DELETE round-trip resolves well inside it.
fast = 3_000
expect(_section(page, "Pinned")).to_have_count(0, timeout=fast)
# And it's gone from the sidebar entirely.
expect(page.locator(f'a[href="/c/{session_id}"]')).to_have_count(0, timeout=fast)
# The deletion is durable: gone from the store, not just a cache splice a
# refetch could resurrect. The DELETE trails a best-effort stop, so poll
# until ``GET /v1/sessions/{id}`` reports 404 (already landed by the time
# the UI assertions above pass, so this resolves on the first iteration).
deadline = time.monotonic() + 5.0
last_status = None
while time.monotonic() < deadline:
last_status = httpx.get(f"{base_url}/v1/sessions/{session_id}", timeout=10.0).status_code
if last_status == 404:
break
time.sleep(0.25)
assert last_status == 404, (
f"deleted session should be gone from the store (404), got {last_status}"
)
+21
View File
@@ -445,6 +445,12 @@ describe("useStopAndDeleteConversation cache eviction", () => {
parentSessionId: null,
subAgentName: null,
} satisfies Session);
// The deleted session is also pinned. The Pinned section reads a sibling
// cache the ["conversations"] sweep skips, so delete must drop it here too.
queryClient.setQueryData<PinnedConversationsResult>(PINNED_CONVERSATIONS_KEY, {
conversations: [conversation({ id: "conv_x" }), conversation({ id: "conv_pinned_other" })],
filterHonored: true,
});
const wrapper = ({ children }: { children: ReactNode }) =>
createElement(QueryClientProvider, { client: queryClient }, children);
const rendered = renderHook(() => useStopAndDeleteConversation(), { wrapper });
@@ -497,6 +503,21 @@ describe("useStopAndDeleteConversation cache eviction", () => {
expect(queryClient.getQueryData(["session", "conv_x"])).toBeUndefined();
});
it("drops a deleted pinned session from the sibling pinned cache", async () => {
const { queryClient, rendered } = seedAndDelete();
rendered.result.current.mutate({ id: "conv_x" });
await waitFor(() => expect(rendered.result.current.isSuccess).toBe(true));
// The Pinned section renders from PINNED_CONVERSATIONS_KEY, a sibling of
// ["conversations"] that the delete sweep deliberately skips — so without
// an explicit patch the deleted row lingers in Pinned until a reload.
const pinned = queryClient.getQueryData<PinnedConversationsResult>(PINNED_CONVERSATIONS_KEY);
expect(pinned!.conversations.map((c) => c.id)).toEqual(["conv_pinned_other"]);
// The patch preserves the query's filterHonored flag.
expect(pinned!.filterHonored).toBe(true);
});
it("does not refetch the conversations list, but does refresh the project list", async () => {
const { queryClient, rendered } = seedAndDelete();
const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries");
+15
View File
@@ -584,6 +584,13 @@ export function useStopAndDeleteConversation() {
}
queryClient.removeQueries({ queryKey: ["conversation-backfill", id] });
queryClient.removeQueries({ queryKey: ["session", id] });
// The Pinned section reads a separate, sibling cache that the
// ["conversations"] sweep above deliberately skips, so a deleted pinned
// row lingers there until a reload unless we drop it explicitly. Patched
// (not invalidated) for the same reindex-lag reason as the list.
queryClient.setQueryData<PinnedConversationsResult>(PINNED_CONVERSATIONS_KEY, (old) =>
old ? { ...old, conversations: old.conversations.filter((c) => !ids.has(c.id)) } : old,
);
// Deleting the last member of a project empties it, so refresh the
// project list to drop the now-empty folder. Unlike the conversations
// list, /v1/sessions/projects reads the DB directly (no search-index
@@ -703,6 +710,10 @@ export function useBulkDeleteConversations() {
queryClient.removeQueries({ queryKey: ["conversation-backfill", id] });
queryClient.removeQueries({ queryKey: ["session", id] });
}
// Drop deleted rows from the sibling Pinned cache the sweep above skips.
queryClient.setQueryData<PinnedConversationsResult>(PINNED_CONVERSATIONS_KEY, (old) =>
old ? { ...old, conversations: old.conversations.filter((c) => !idSet.has(c.id)) } : old,
);
// Refresh the project list so a project emptied by these deletes drops
// its now-empty folder (DB-direct read, no search-index lag).
void queryClient.invalidateQueries({ queryKey: ["projects"] });
@@ -723,6 +734,10 @@ export function useBulkDeleteConversations() {
queryClient.removeQueries({ queryKey: ["conversation-backfill", id] });
queryClient.removeQueries({ queryKey: ["session", id] });
}
// Drop the successfully-deleted rows from the sibling Pinned cache too.
queryClient.setQueryData<PinnedConversationsResult>(PINNED_CONVERSATIONS_KEY, (old) =>
old ? { ...old, conversations: old.conversations.filter((c) => !idSet.has(c.id)) } : old,
);
void queryClient.invalidateQueries({ queryKey: ["projects"] });
void queryClient.invalidateQueries({ queryKey: ARCHIVED_PROJECT_NAMES_KEY });
}
+12 -7
View File
@@ -3374,8 +3374,11 @@ function DeletingRow({
);
}
return (
// Match the interactive row's box metrics (h-7, font-size, radius) so the
// swap only changes color/opacity — otherwise the row visibly grows and
// shifts the list while a delete is in flight.
<div
className="flex w-full items-center gap-1.5 rounded-md px-2 py-2 text-sm text-muted-foreground opacity-70"
className="sidebar-compact-text flex h-7 w-full items-center gap-1.5 rounded-[var(--radius-otto-sm)] px-2 text-muted-foreground opacity-70"
data-testid="conversation-deleting"
aria-live="polite"
>
@@ -3795,9 +3798,11 @@ function ConversationEditRow({ initialTitle, onCommit, onCancel }: ConversationE
}
return (
// pl-1 + the input's px-1 line the text up with the row's px-2 title;
// py-1 around the size-7 buttons matches the 36px single-line row height.
<div className="flex items-center gap-1 rounded-md bg-muted py-1 pr-1 pl-1">
// Match the interactive row's box metrics (h-7, sidebar-compact-text) so
// entering edit mode doesn't grow the row or bump the font size. pl-1 + the
// input's px-1 line the text up with the row's px-2 title; the size-6
// buttons sit inside the 28px row height.
<div className="sidebar-compact-text flex h-7 items-center gap-1 rounded-[var(--radius-otto-sm)] bg-muted pr-1 pl-1">
<input
ref={inputRef}
type="text"
@@ -3812,12 +3817,12 @@ function ConversationEditRow({ initialTitle, onCommit, onCancel }: ConversationE
onKeyDown={handleKeyDown}
onBlur={handleBlur}
data-testid="rename-conversation-input"
className="min-w-0 flex-1 truncate rounded bg-transparent px-1 py-1 text-sm outline-none md:select-text"
className="min-w-0 flex-1 truncate rounded bg-transparent px-1 py-0.5 outline-none md:select-text"
/>
<Button
type="button"
variant="ghost"
size="icon-sm"
size="icon-xs"
aria-label="Save rename"
onMouseDown={(e) => {
// Prevent the input's blur from firing before the commit.
@@ -3830,7 +3835,7 @@ function ConversationEditRow({ initialTitle, onCommit, onCancel }: ConversationE
<Button
type="button"
variant="ghost"
size="icon-sm"
size="icon-xs"
aria-label="Cancel rename"
onMouseDown={(e) => e.preventDefault()}
onClick={() => {