Skip to content
Closed
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
56 changes: 56 additions & 0 deletions apps/web/src/components/Sidebar.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
pinOrderKeyBetween,
resolveProjectStatusIndicator,
resolveSidebarSweepKeys,
resolveSidebarRowStatus,
resolveSidebarThreadStatus,
resolveThreadStatusPill,
resolveWorkingStartedAt,
Expand Down Expand Up @@ -339,6 +340,55 @@ describe("resolveSidebarThreadStatus", () => {
});
});

describe("resolveSidebarRowStatus", () => {
const failedRuntime = {
status: "failed" as const,
activeRunId: null,
providerInstanceId: ProviderInstanceId.make("codex"),
providerName: "Codex",
lastError: "Provider turn failed.",
updatedAt: "2026-03-09T10:05:00.000Z",
};
const failed = {
hasActionableProposedPlan: false,
hasPendingApprovals: false,
hasPendingUserInput: false,
interactionMode: "default" as const,
runtime: failedRuntime,
latestRun: { ...makeLatestRun(), status: "failed" as const },
};

it("clears a failure once opened, without showing Done, and shows the next failure", () => {
expect(resolveSidebarRowStatus({ ...failed, lastVisitedAt: undefined })).toBe("failed");

const opened = { ...failed, lastVisitedAt: "2026-03-09T10:06:00.000Z" };
expect(resolveSidebarRowStatus(opened)).toBe("ready");
expect(hasUnseenCompletion(opened)).toBe(false);

const failedAgain = {
...opened,
latestRun: { ...opened.latestRun, completedAt: "2026-03-09T10:10:00.000Z" },
};
expect(resolveSidebarRowStatus(failedAgain)).toBe("failed");
});

it("dates a failure without a completed run by the runtime update", () => {
const visitedAfter = "2026-03-09T10:06:00.000Z";
const visitedBefore = "2026-03-09T10:00:00.000Z";
const noCompletedAt = {
...failed,
latestRun: { ...failed.latestRun, completedAt: null },
};
expect(resolveSidebarRowStatus({ ...noCompletedAt, lastVisitedAt: visitedAfter })).toBe(
"ready",
);

const noRun = { ...failed, latestRun: null };
expect(resolveSidebarRowStatus({ ...noRun, lastVisitedAt: visitedBefore })).toBe("failed");
expect(resolveSidebarRowStatus({ ...noRun, lastVisitedAt: visitedAfter })).toBe("ready");
});
});

describe("searchSidebarThreads", () => {
const searchThread = (id: string, title: string, latestUserMessageAt: string) => ({
environmentId: localEnvironmentId,
Expand Down Expand Up @@ -1445,5 +1495,11 @@ describe("resolveSidebarFolderDot", () => {
expect(resolveSidebarFolderDot([idle, working])).toBe("working");
expect(resolveSidebarFolderDot([working, unread])).toBe("unread");
expect(resolveSidebarFolderDot([unread, limited])).toBe("attention");
// A failure the user already opened clears, like its row.
const failed = { ...idle, runtime: { ...runtime, status: "failed" as const } };
expect(resolveSidebarFolderDot([failed])).toBeNull();
expect(
resolveSidebarFolderDot([{ ...failed, lastVisitedAt: "2026-03-09T09:00:00.000Z" }]),
).toBe("failed");
});
});
33 changes: 25 additions & 8 deletions apps/web/src/components/Sidebar.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -697,13 +697,22 @@ export function hasUnseenCompletion(thread: ThreadStatusInput): boolean {
}

/** Unopened threads' failures still count as unseen, unlike completions. */
function hasUnseenFailure(thread: ThreadStatusInput): boolean {
if (thread.latestRun?.status !== "failed") return false;
function hasUnseenFailure(
thread: Pick<ThreadStatusInput, "latestRun" | "runtime" | "lastVisitedAt">,
): boolean {
// Failed runs can lack completedAt, and a session can fail before any run.
const failedAt =
thread.latestRun?.status === "failed"
? (thread.latestRun.completedAt ?? thread.runtime?.updatedAt)
: thread.runtime?.status === "failed"
? thread.runtime.updatedAt
: undefined;
if (failedAt === undefined) return false;
if (!thread.lastVisitedAt) return true;
const completedAt = Date.parse(thread.latestRun.completedAt ?? "");
const lastVisitedAt = Date.parse(thread.lastVisitedAt);
if (Number.isNaN(completedAt) || Number.isNaN(lastVisitedAt)) return true;
return completedAt > lastVisitedAt;
const failedAtMs = Date.parse(failedAt ?? "");
const lastVisitedAtMs = Date.parse(thread.lastVisitedAt);
if (Number.isNaN(failedAtMs) || Number.isNaN(lastVisitedAtMs)) return true;
return failedAtMs > lastVisitedAtMs;
}

// Suppress trailing double-click navigation; keyboard and synthetic clicks use detail 0.
Expand Down Expand Up @@ -872,14 +881,22 @@ export function resolveSidebarThreadStatus(thread: SidebarThreadStatusInput): Si
return "ready";
}

/** Sidebar row status: a failure the user has already opened settles to ready, like Done. */
export function resolveSidebarRowStatus(
thread: SidebarThreadStatusInput & Pick<ThreadStatusInput, "latestRun" | "lastVisitedAt">,
): SidebarThreadStatus {
const status = resolveSidebarThreadStatus(thread);
return status === "failed" && !hasUnseenFailure(thread) ? "ready" : status;
}

/**
* The dot on a closed boring-sidebar folder: the most urgent state among its
* hidden threads. Waiting threads stay quiet, like their rows.
*/
export function resolveSidebarFolderDot(
threads: ReadonlyArray<ThreadStatusInput & SidebarThreadStatusInput>,
): "attention" | "failed" | "unread" | "working" | null {
const statuses = new Set(threads.map(resolveSidebarThreadStatus));
const statuses = new Set(threads.map(resolveSidebarRowStatus));
if (
statuses.has("input") ||
statuses.has("approval") ||
Expand All @@ -891,7 +908,7 @@ export function resolveSidebarFolderDot(
if (statuses.has("failed")) return "failed";
// Rows show Done only for ready threads, so the dot follows the same rule.
const hasUnread = threads.some(
(thread) => resolveSidebarThreadStatus(thread) === "ready" && hasUnseenCompletion(thread),
(thread) => resolveSidebarRowStatus(thread) === "ready" && hasUnseenCompletion(thread),
);
if (hasUnread) return "unread";
if (statuses.has("working")) return "working";
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/components/sidebar/SidebarThreadRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ import {
hasUnseenCompletion,
resolveSidebarRowAccessibility,
type SidebarDropVerb,
resolveSidebarThreadStatus,
resolveSidebarRowStatus,
resolveThreadLastVisitedAt,
shouldRecedeSidebarThread,
resolveWorkingStartedAt,
Expand Down Expand Up @@ -705,7 +705,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP
: null;

const isUnread = hasUnseenCompletion({ ...thread, lastVisitedAt });
const status = resolveSidebarThreadStatus(thread);
const status = resolveSidebarRowStatus({ ...thread, lastVisitedAt });
const lastVisitedDate = lastVisitedAt === undefined ? null : parseTimestampDate(lastVisitedAt);
const wokeAtDate = props.wokeAt === null ? null : parseTimestampDate(props.wokeAt);
const isWoke =
Expand Down
Loading