Compare commits

...

3 Commits

Author SHA1 Message Date
Corey Zumar 8daa1be14f Merge branch 'main' into fix/approva-button
E2E UI Tests / gate (push) Failing after 0s
E2E UI Tests / setup (push) Has been skipped
E2E UI Tests / E2E UI Tests (shard ${{ matrix.shard_id }}/${{ matrix.num_shards }}) (push) Has been skipped
E2E UI Tests / Merge Ready rerun (push) Has been cancelled
2026-06-22 11:33:28 -07:00
Yuan Tang 75439b9784 style: fix prettier formatting for query key array 2026-06-21 23:32:20 -04:00
Yuan Tang b13121a145 fix(inbox): clear stale approval verdict when elicitation is re-parked
When a hook retry re-parks the same elicitation id after the user
approved the previous attempt, the inbox's local optimistic verdict
kept the card stuck on "Approved" with no way to act on the new prompt.

Two fixes:

1. Include `row.updated_at` in the snapshot query key so the snapshot
   refetches when the session changes, even if pending_elicitations_count
   settles back to the same value within one WS tick.

2. Add a useEffect that watches snapshot query freshness
   (dataUpdatedAt). When any snapshot delivers new data, sweep verdicts
   whose elicitation id is still pending on the server — those approvals
   were consumed and the prompt was re-parked.
2026-06-21 23:17:43 -04:00
2 changed files with 80 additions and 2 deletions
+50
View File
@@ -256,6 +256,56 @@ describe("InboxPage approval items", () => {
);
});
it("clears a stale verdict when a snapshot refresh still shows the elicitation as pending", async () => {
// WHY: when a hook retry re-parks the same elicitation id, the local
// `responded` entry from the first approval keeps the card stuck on
// "Approved". After the snapshot query delivers fresh data that still
// lists the id as pending, the stale verdict must be cleared so the
// approve button reappears.
const row = conversation({ id: "sess_1" });
vi.mocked(conversationsHook.useConversations).mockReturnValue(conversationsStub([row]));
vi.mocked(sessionsApi.getSession).mockResolvedValue({
pendingElicitations: [rawElicitation("eli_1", "Approve this?")],
} as unknown as Awaited<ReturnType<typeof sessionsApi.getSession>>);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
const { rerender } = render(
<QueryClientProvider client={queryClient}>
<MemoryRouter>
<InboxPage />
</MemoryRouter>
</QueryClientProvider>,
);
// Approve the card — it flips to responded.
fireEvent.click(await screen.findByRole("button", { name: "Stub Accept" }));
await waitFor(() =>
expect(screen.getByTestId("approval-card")).toHaveAttribute("data-status", "responded"),
);
// Simulate a snapshot refresh that still lists the same elicitation
// (hook retry re-parked it). Update the row's updated_at so the
// query key changes, which triggers a refetch with fresh data.
vi.mocked(sessionsApi.getSession).mockResolvedValue({
pendingElicitations: [rawElicitation("eli_1", "Approve this?")],
} as unknown as Awaited<ReturnType<typeof sessionsApi.getSession>>);
const updatedRow = conversation({ id: "sess_1", updated_at: 1_700_000_001 });
vi.mocked(conversationsHook.useConversations).mockReturnValue(conversationsStub([updatedRow]));
rerender(
<QueryClientProvider client={queryClient}>
<MemoryRouter>
<InboxPage />
</MemoryRouter>
</QueryClientProvider>,
);
// The stale verdict should be cleared — card reverts to pending.
await waitFor(() =>
expect(screen.getByTestId("approval-card")).toHaveAttribute("data-status", "pending"),
);
});
it("routes the verdict to the child session when the prompt is mirrored", async () => {
// WHY: a mirrored child prompt carries target_session_id; the resolve POST
// must target that session, not the row it surfaced under.
+30 -2
View File
@@ -35,7 +35,7 @@
* the prompt timing out) is what clears an approval.
*/
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { useQueries, useQueryClient } from "@tanstack/react-query";
import {
AlertTriangleIcon,
@@ -98,7 +98,7 @@ export function InboxPage() {
// server; persistent failures surface in the error banner below.
const snapshotQueries = useQueries({
queries: rows.map((row) => ({
queryKey: ["inbox-elicitations", row.id, row.pending_elicitations_count],
queryKey: ["inbox-elicitations", row.id, row.pending_elicitations_count, row.updated_at],
queryFn: () => getSession(row.id),
retry: 1,
})),
@@ -111,6 +111,34 @@ export function InboxPage() {
});
const items = collectInboxItems(sources);
// Clear stale optimistic verdicts when snapshot data refreshes.
// If a hook retry re-parks the same elicitation id after the user
// approved the previous attempt, the local `responded` entry would
// otherwise keep the card stuck on "Approved" indefinitely. When
// any snapshot query delivers fresh data (dataUpdatedAt advances),
// sweep verdicts whose id is still pending on the server — those
// approvals were consumed and the server re-parked the prompt.
// eslint-disable-next-line react-hooks/exhaustive-deps
const snapshotVersionKey = snapshotQueries.map((q) => q.dataUpdatedAt ?? 0).join(",");
const isFirstRender = useRef(true);
useEffect(() => {
// Skip the first render — there are no stale verdicts yet.
if (isFirstRender.current) {
isFirstRender.current = false;
return;
}
setResponded((prev) => {
if (Object.keys(prev).length === 0) return prev;
const pendingIds = new Set(items.map((i) => i.elicitation.elicitationId));
const stale = Object.keys(prev).filter((id) => pendingIds.has(id));
if (stale.length === 0) return prev;
const next = { ...prev };
for (const id of stale) delete next[id];
return next;
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [snapshotVersionKey]);
// "Settled" gating for the empty state: while the session list is
// still paging or ANY snapshot is in flight, an empty `items` only
// means "not assembled yet" — showing "No approvals waiting" then