Skip to content
Merged
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
71 changes: 71 additions & 0 deletions apps/web/src/components/Sidebar.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1367,9 +1367,80 @@ describe("resolveThreadStatusPill", () => {
}),
).toMatchObject({ label: "Completed", pulse: false });
});

it("shows failed, not completed, when the unseen run failed", () => {
expect(
resolveThreadStatusPill({
thread: {
...baseThread,
interactionMode: "default",
latestRun: { ...makeLatestRun(), status: "failed" },
lastVisitedAt: "2026-03-09T10:04:00.000Z",
runtime: {
...baseThread.runtime,
status: "failed",
activeRunId: null,
lastError: "Provider turn failed.",
},
},
}),
).toMatchObject({ label: "Failed", pulse: false });
});

it("shows failed for a thread nobody has opened yet", () => {
expect(
resolveThreadStatusPill({
thread: {
...baseThread,
interactionMode: "default",
latestRun: { ...makeLatestRun(), status: "failed" },
lastVisitedAt: null,
runtime: { ...baseThread.runtime, status: "failed", activeRunId: null },
},
}),
).toMatchObject({ label: "Failed" });
});

it("shows failed, not plan ready, when a plan run proposed a plan and then failed", () => {
expect(
resolveThreadStatusPill({
thread: {
...baseThread,
hasActionableProposedPlan: true,
latestRun: { ...makeLatestRun(), status: "failed" },
lastVisitedAt: "2026-03-09T10:04:00.000Z",
runtime: { ...baseThread.runtime, status: "failed", activeRunId: null },
},
}),
).toMatchObject({ label: "Failed" });
});

it("drops the failed pill once the failure has been seen", () => {
expect(
resolveThreadStatusPill({
thread: {
...baseThread,
interactionMode: "default",
latestRun: { ...makeLatestRun(), status: "failed" },
lastVisitedAt: "2026-03-09T10:06:00.000Z",
runtime: { ...baseThread.runtime, status: "failed", activeRunId: null },
},
}),
).toBeNull();
});
});

describe("resolveProjectStatusIndicator", () => {
it("ranks a failure above work in progress and completions", () => {
expect(
resolveProjectStatusIndicator([
{ label: "Completed", colorClass: "", dotClass: "", pulse: false },
{ label: "Working", colorClass: "", dotClass: "", pulse: true },
{ label: "Failed", colorClass: "", dotClass: "", pulse: false },
]),
).toMatchObject({ label: "Failed" });
});

it("returns null when no threads have a notable status", () => {
expect(resolveProjectStatusIndicator([null, null])).toBeNull();
});
Expand Down
25 changes: 25 additions & 0 deletions apps/web/src/components/Sidebar.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -571,6 +571,7 @@ export interface ThreadStatusPill {
| "Working"
| "Connecting"
| "Completed"
| "Failed"
| "Pending Approval"
| "Awaiting Input"
| "Waiting"
Expand All @@ -583,6 +584,7 @@ export interface ThreadStatusPill {
const THREAD_STATUS_PRIORITY: Record<ThreadStatusPill["label"], number> = {
"Pending Approval": 5,
"Awaiting Input": 4,
Failed: 3.5,
Working: 3,
Connecting: 3,
Waiting: 2.5,
Expand Down Expand Up @@ -718,6 +720,20 @@ export function hasUnseenCompletion(thread: ThreadStatusInput): boolean {
return completedAt > lastVisitedAt;
}

/**
* A failed latest run the person hasn't looked at since it ended. Unlike a
* completion, a thread nobody has opened yet (an automation's, say) counts:
* its failure still needs someone.
*/
function hasUnseenFailure(thread: ThreadStatusInput): boolean {
if (thread.latestRun?.status !== "failed") 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;
}

export function shouldClearThreadSelectionOnMouseDown(target: HTMLElement | null): boolean {
if (target === null) return true;
return !target.closest(THREAD_SELECTION_SAFE_SELECTOR);
Expand Down Expand Up @@ -1131,6 +1147,15 @@ export function resolveThreadStatusPill(input: {
};
}

if (hasUnseenFailure(thread)) {
return {
label: "Failed",
colorClass: "text-red-600 dark:text-red-300/90",
dotClass: "bg-red-500 dark:bg-red-300/90",
pulse: false,
};
}

const hasPlanReadyPrompt =
!thread.hasPendingUserInput &&
thread.interactionMode === "plan" &&
Expand Down
Loading