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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@

New features, integrations, and notable improvements to Open-Inspect — newest first.

## September 2, 2026

**Opening a session marks it read.** A session's latest reply is read as soon as its page is
visible, without scrolling to the end of the timeline. The sidebar reflects the change on every row
at once, including sessions loaded through "Load more", and Needs attention lists only sessions with
an unread reply.

## September 1, 2026

**Workspace audit log.** Owners, Administrators, and authorized custom roles can review paginated
Expand Down
14 changes: 13 additions & 1 deletion packages/control-plane/src/session/alarm/scheduler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,19 @@ export function createEarliestAlarmScheduler(
};
}

/** Track delivery separately so retries cannot acknowledge a replacement deadline. */
/**
* Runs one alarm delivery against the runtime's single alarm slot.
*
* `beginDelivery` moves the pending deadline into flight and clears it, so
* while `handle` runs the store holds no future deadline. (Only a retry of
* a delivery already in flight keeps its replacement pending deadline.)
* Every step of the handler that still needs a wake-up must schedule it
* again from its own persisted state (a deferred projection, a
* stop-confirmation deadline, an execution timeout); otherwise `rearm` finds
* nothing and that deadline is lost until the next rehydration. Delivery is
* tracked separately from the pending deadline so a retry cannot acknowledge
* a replacement deadline.
*/
export async function handleAlarmDelivery(
deadlines: AlarmDeadlineStore,
handle: () => Promise<void>,
Expand Down
9 changes: 1 addition & 8 deletions packages/control-plane/src/session/message-queue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -649,14 +649,7 @@ export class SessionMessageQueue {
completion.messageId,
completion.messageCreatedAt,
completion.completedAt
)
.catch((projectionError) => {
this.log.error("terminal_message.projection_failed", {
message_id: message.id,
error: projectionError,
});
})
.then(() => this.messenger.broadcast({ type: "sandbox_event", event })),
).then(() => this.messenger.broadcast({ type: "sandbox_event", event })),
{
name: "terminal_message.project",
context: { message_id: message.id },
Expand Down
18 changes: 17 additions & 1 deletion packages/web/src/components/session-list-item.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,18 @@ expect.extend(matchers);

const mocks = vi.hoisted(() => ({
allowedPermissions: new Set<string>(),
renameOptions: undefined as Record<string, unknown> | undefined,
}));

vi.mock("next/link", () => ({
default: ({ children, ...props }: React.ComponentProps<"a">) => <a {...props}>{children}</a>,
}));

vi.mock("@/hooks/use-session-rename", () => ({
useSessionRename: () => ({ optimisticTitle: null, renameSession: vi.fn() }),
useSessionRename: (options: Record<string, unknown>) => {
mocks.renameOptions = options;
return { optimisticTitle: null, renameSession: vi.fn() };
},
}));

vi.mock("@/hooks/use-current-user-authorization", () => ({
Expand Down Expand Up @@ -62,6 +66,18 @@ function renderItem(unread = false) {
);
}

it("keeps a confirmed rename until the row's fetched title catches up", () => {
renderItem();

// Rows from loaded pages never refetch, so the rename overlay must wait for
// this row's own title rather than clear on request success.
expect(mocks.renameOptions).toMatchObject({
sessionId: "session-1",
authoritativeTitle: "Session one",
awaitAuthoritativeTitle: true,
});
});

it("fails closed when sessions.lifecycle is denied", () => {
renderItem();

Expand Down
4 changes: 4 additions & 0 deletions packages/web/src/components/session-list-item.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,13 @@ export function SessionListItem({
session.repositories
);
const prDisplay = pullRequestSummaryDisplay(session.pullRequestSummary);
// A row from a loaded page never refetches, so a confirmed rename stays
// on screen until the fetched title catches up.
const { optimisticTitle, renameSession } = useSessionRename({
sessionId: session.id,
currentTitle: session.title,
authoritativeTitle: session.title,
awaitAuthoritativeTitle: true,
});
const displayTitle = optimisticTitle ?? session.title ?? repoInfo;
// Orphan child (parent filtered out) — show a subtle badge
Expand Down
95 changes: 90 additions & 5 deletions packages/web/src/hooks/use-mark-session-read.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,28 @@ import { act, cleanup, renderHook } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { SessionReadResult } from "@open-inspect/shared/types/sessions";
import { useMarkSessionRead } from "./use-mark-session-read";
import { SessionReadRequestError } from "@/lib/session-read-state";
import {
getSessionReadOverlay,
resetSessionReadOverlay,
SessionReadRequestError,
} from "@/lib/session-read-state";

const markMessageRead =
vi.fn<(sessionId: string, messageId: string) => Promise<SessionReadResult>>();
const reconcileSessionReadState = vi.fn(async (_result: SessionReadResult) => {});

const mutate = vi.fn(async (_key: unknown) => []);
let viewerId: string | null = "viewer-a";

vi.mock("@/lib/session-read-state", async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
markMessageRead: (sessionId: string, messageId: string) => markMessageRead(sessionId, messageId),
reconcileSessionReadState: (result: SessionReadResult) => reconcileSessionReadState(result),
}));
vi.mock("swr", async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useSWRConfig: () => ({ mutate }),
}));
vi.mock("@/lib/auth-session", () => ({
useAuthSession: () => ({ data: viewerId ? { user: { id: viewerId } } : undefined }),
}));

function result(
Expand All @@ -36,8 +48,11 @@ function setVisibility(value: "visible" | "hidden") {

beforeEach(() => {
setVisibility("visible");
resetSessionReadOverlay();
viewerId = "viewer-a";
markMessageRead.mockReset();
reconcileSessionReadState.mockClear();
mutate.mockReset();
mutate.mockImplementation(async () => []);
vi.spyOn(console, "error").mockImplementation(() => {});
});
afterEach(() => {
Expand All @@ -56,7 +71,29 @@ describe("useMarkSessionRead", () => {
});

expect(markMessageRead).toHaveBeenCalledExactlyOnceWith("session-1", "message-1");
expect(reconcileSessionReadState).toHaveBeenCalledExactlyOnceWith(result("marked_read"));
expect(getSessionReadOverlay("viewer-a").get("session-1")).toEqual({
latestMessageId: "message-1",
unread: false,
version: 1,
});
});

it("does not ask again about a message this page already read", async () => {
markMessageRead.mockResolvedValue(result("marked_read"));
const first = await act(async () =>
renderHook(() => useMarkSessionRead("session-1", "message-1"))
);
first.unmount();

await act(async () => {
renderHook(() => useMarkSessionRead("session-1", "message-1"));
});
expect(markMessageRead).toHaveBeenCalledTimes(1);

await act(async () => {
renderHook(() => useMarkSessionRead("session-1", "message-2"));
});
expect(markMessageRead).toHaveBeenCalledTimes(2);
});

it("acknowledges each message once and follows the message ID", async () => {
Expand All @@ -74,6 +111,54 @@ describe("useMarkSessionRead", () => {
expect(markMessageRead).toHaveBeenLastCalledWith("session-1", "message-2");
});

it("refetches the inbox once after a new read and does not resend the read if that fails", async () => {
vi.useFakeTimers();
mutate.mockRejectedValueOnce(new Error("offline"));
markMessageRead.mockResolvedValue(result("marked_read"));

await act(async () => {
renderHook(() => useMarkSessionRead("session-1", "message-1"));
});
await act(async () => vi.advanceTimersByTimeAsync(60_000));

expect(markMessageRead).toHaveBeenCalledTimes(1);
expect(mutate).toHaveBeenCalledTimes(1);
expect(console.error).toHaveBeenCalled();
});

it("records a late response under the viewer who sent it", async () => {
let resolveFirst!: (value: SessionReadResult) => void;
markMessageRead
.mockImplementationOnce(
() =>
new Promise<SessionReadResult>((resolve) => {
resolveFirst = resolve;
})
)
.mockResolvedValue(result("marked_read"));
const { rerender } = await act(async () =>
renderHook(() => useMarkSessionRead("session-1", "message-1"))
);
expect(markMessageRead).toHaveBeenCalledTimes(1);

viewerId = "viewer-b";
await act(async () => rerender());
expect(markMessageRead).toHaveBeenCalledTimes(2);

await act(async () => resolveFirst(result("not_latest", true)));

expect(getSessionReadOverlay("viewer-a").get("session-1")).toEqual({
latestMessageId: "message-1",
unread: true,
version: 1,
});
expect(getSessionReadOverlay("viewer-b").get("session-1")).toEqual({
latestMessageId: "message-1",
unread: false,
version: 1,
});
});

it("does nothing until the session has a terminal message", async () => {
await act(async () => {
renderHook(() => useMarkSessionRead("session-1", null));
Expand Down
61 changes: 36 additions & 25 deletions packages/web/src/hooks/use-mark-session-read.ts
Original file line number Diff line number Diff line change
@@ -1,50 +1,61 @@
"use client";

import { useEffect } from "react";
import { useSWRConfig } from "swr";
import { useAuthSession } from "@/lib/auth-session";
import {
classifySessionReadAttempt,
applySessionReadResult,
isSessionMessageRead,
markMessageRead,
reconcileSessionReadState,
SessionReadRequestError,
type SessionReadAttemptDisposition,
} from "@/lib/session-read-state";

const SESSION_READ_RETRY_MS = 2_000;
const SESSION_READ_MAX_ATTEMPTS = 4;
const PERMANENT_FAILURE_STATUSES = new Set([400, 401, 403, 404, 405]);

async function attemptMarkMessageRead(
sessionId: string,
messageId: string
): Promise<SessionReadAttemptDisposition> {
try {
const result = await markMessageRead(sessionId, messageId);
await reconcileSessionReadState(result);
return classifySessionReadAttempt(result);
} catch (error) {
if (error instanceof SessionReadRequestError && PERMANENT_FAILURE_STATUSES.has(error.status)) {
return "permanent_failure";
}
console.error("Failed to mark session message read", error);
return "retry";
}
}

/**
* Opening a session reads its latest terminal message. Each message ID is
* acknowledged once while the document is visible; a hidden tab waits for
* visibility. Focus is not required, since the terminal pane holds it for
* much of a working session.
* much of a working session. A message this viewer already read in this
* page is not asked about again.
*
* Only a missing projection is retried: the message exists on the client,
* so the server row will catch up. A `not_latest` result means a newer
* message is on its way to the client, which acknowledges that one instead.
*/
export function useMarkSessionRead(sessionId: string, messageId: string | null): void {
const { mutate } = useSWRConfig();
const { data: authSession } = useAuthSession();
const viewerId = authSession?.user.id ?? null;

useEffect(() => {
if (!messageId) return;
if (!messageId || !viewerId) return;
if (isSessionMessageRead(viewerId, sessionId, messageId)) return;
let cancelled = false;
let settled = false;
let inFlight = false;
let attempts = 0;
let retryTimer: ReturnType<typeof setTimeout> | null = null;

const attemptOnce = async (): Promise<"settled" | "retry"> => {
try {
const result = await markMessageRead(sessionId, messageId);
applySessionReadResult(result, mutate, viewerId);
return result.outcome === "no_terminal_message" ? "retry" : "settled";
} catch (error) {
if (
error instanceof SessionReadRequestError &&
PERMANENT_FAILURE_STATUSES.has(error.status)
) {
return "settled";
}
console.error("Failed to mark session message read", error);
return "retry";
}
};

const attempt = async () => {
if (
cancelled ||
Expand All @@ -57,10 +68,10 @@ export function useMarkSessionRead(sessionId: string, messageId: string | null):
}
inFlight = true;
attempts += 1;
const disposition = await attemptMarkMessageRead(sessionId, messageId);
const disposition = await attemptOnce();
inFlight = false;
if (cancelled) return;
if (disposition !== "retry") {
if (disposition === "settled") {
settled = true;
return;
}
Expand Down Expand Up @@ -88,5 +99,5 @@ export function useMarkSessionRead(sessionId: string, messageId: string | null):
document.removeEventListener("visibilitychange", onVisibilityChange);
if (retryTimer) clearTimeout(retryTimer);
};
}, [messageId, sessionId]);
}, [messageId, mutate, sessionId, viewerId]);
}
5 changes: 2 additions & 3 deletions packages/web/src/hooks/use-session-rename.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,14 @@ import { applyTitleUpdate, isSessionListKey, type SessionListResponse } from "@/
import {
applySessionInboxTitleUpdate,
isSessionInboxKey,
type SessionInboxPage,
type SessionInboxSnapshot,
} from "@/lib/session-inbox-api";

type SessionCacheMutator = ReturnType<typeof useSWRConfig>["mutate"];

/**
* A session's title is cached in two payload families: session-list responses
* and inbox snapshots/pages. Every optimistic update, settlement, and rollback
* and inbox snapshots. Every optimistic update, settlement, and rollback
* must touch both, or the sidebar inbox briefly reverts to the stale title
* once the optimistic overlay clears.
*/
Expand All @@ -30,7 +29,7 @@ function applyTitleToSessionCaches(
(current) => applyTitleUpdate(current, sessionId, title),
{ populateCache: true, revalidate: false }
),
mutate<SessionInboxSnapshot | SessionInboxPage>(
mutate<SessionInboxSnapshot>(
Comment thread
ColeMurray marked this conversation as resolved.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[deep review] This breaks rename settlement for any session that exists only in a loaded tail page. Those pages now live exclusively in useCategoryPagination state, so this mutation cannot update them. The row shows optimisticTitle during the request, but the success path clears that overlay when there is no authoritative subscriber; rendering then falls back to the unchanged tail row, and revalidating the head cannot repair a row outside the first page. This is the consequence of giving pages one storage owner without giving that owner a canonical mutation boundary. Please either route typed session updates through the pagination owner or keep a render-time title projection until fetched data catches up; snapshot-only mutation is not sufficient.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 6dfd1b6 via the rename hook's existing awaitAuthoritativeTitle path: the row keeps the confirmed title until its own fetched title catches up, which is the render-time projection you describe. See the sibling thread for why this predates the PR.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and scoped in #1722. Passing authoritativeTitle from the row (6dfd1b6) covers the case where the session page is not open. When it is, the page header's own awaitAuthoritativeTitle subscriber clears the shared optimistic title once its detail title catches up, and a row on a loaded page falls back to its stale fetched title. Same sequence on main (tuple-key pages never matched isSessionInboxKey). Fixing it needs a per-subscriber clear or a title overlay for loaded pages; tracked separately rather than folded into this PR.

isSessionInboxKey,
(current) => applySessionInboxTitleUpdate(current, sessionId, title),
{ populateCache: true, revalidate: false }
Expand Down
Loading
Loading