Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
12 changes: 11 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,17 @@ 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. Every step of the
* handler that still needs a wake-up must schedule it again from its own
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
* 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
34 changes: 29 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,18 @@ 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) => {});

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),
}));

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

beforeEach(() => {
setVisibility("visible");
resetSessionReadOverlay();
markMessageRead.mockReset();
reconcileSessionReadState.mockClear();
vi.spyOn(console, "error").mockImplementation(() => {});
});
afterEach(() => {
Expand All @@ -56,7 +58,29 @@ describe("useMarkSessionRead", () => {
});

expect(markMessageRead).toHaveBeenCalledExactlyOnceWith("session-1", "message-1");
expect(reconcileSessionReadState).toHaveBeenCalledExactlyOnceWith(result("marked_read"));
expect(getSessionReadOverlay().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 Down
57 changes: 32 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,57 @@
"use client";

import { useEffect } from "react";
import { useSWRConfig } from "swr";
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 page already read 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();

useEffect(() => {
if (!messageId) return;
if (!messageId || isSessionMessageRead(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);
await applySessionReadResult(result, mutate);
Comment thread
ColeMurray marked this conversation as resolved.
Outdated
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 +64,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 +95,5 @@ export function useMarkSessionRead(sessionId: string, messageId: string | null):
document.removeEventListener("visibilitychange", onVisibilityChange);
if (retryTimer) clearTimeout(retryTimer);
};
}, [messageId, sessionId]);
}, [messageId, mutate, sessionId]);
}
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