,
+ title: string,
+ fallback: string,
+ tone: PRDisplayTone,
+): PRAttentionItem {
+ const fileLinks = (pr.mergeability.conflictFiles ?? []).slice(0, 3).map((file) => ({
+ label: file.path,
+ href: file.url || pr.mergeability.prUrl || undefined,
+ }));
+ const reasonLinks =
+ fileLinks.length > 0
+ ? []
+ : pr.mergeability.reasons.slice(0, 3).map((reason) => ({
+ label: mergeReasonLabel(reason),
+ href: pr.mergeability.prUrl || undefined,
+ }));
+ const links = fileLinks.length > 0 ? fileLinks : reasonLinks;
+ return {
+ kind,
+ title,
+ summary: links.length === 0 ? fallback : undefined,
+ links,
+ overflowLabel:
+ fileLinks.length > 0
+ ? overflowLabel(pr.mergeability.conflictFiles?.length ?? 0, 3, "file")
+ : overflowLabel(pr.mergeability.reasons.length, 3, "reason"),
+ tone,
+ };
+}
+
+function reviewerLabel(reviewer: SessionPRSummary["review"]["unresolvedBy"][number]): string {
+ if (reviewer.count <= 1) {
+ return reviewer.reviewerId;
+ }
+ return `${reviewer.reviewerId} +${reviewer.count - 1}`;
+}
+
+function mergeReasonLabel(reason: string): string {
+ switch (reason) {
+ case "behind_base":
+ return "branch behind base";
+ case "ci_failing":
+ return "CI failing";
+ case "changes_requested":
+ return "changes requested";
+ case "review_required":
+ return "review required";
+ case "blocked_by_provider":
+ return "provider blocked";
+ default:
+ return reason.replaceAll("_", " ");
+ }
+}
+
+function overflowLabel(total: number, shown: number, noun: string): string | undefined {
+ const extra = total - shown;
+ if (extra <= 0) {
+ return undefined;
+ }
+ return `+${extra} ${pluralize(noun, extra)}`;
+}
+
+function pluralize(noun: string, count: number): string {
+ return count === 1 ? noun : `${noun}s`;
+}
diff --git a/frontend/src/renderer/routes/_shell.tsx b/frontend/src/renderer/routes/_shell.tsx
index 7c26c176..87615604 100644
--- a/frontend/src/renderer/routes/_shell.tsx
+++ b/frontend/src/renderer/routes/_shell.tsx
@@ -1,4 +1,4 @@
-import { createFileRoute, Outlet, useNavigate, useRouterState } from "@tanstack/react-router";
+import { createFileRoute, Outlet, useNavigate } from "@tanstack/react-router";
import { useQueryClient } from "@tanstack/react-query";
import { type CSSProperties, useCallback, useEffect } from "react";
import { ShellTopbar } from "../components/ShellTopbar";
@@ -40,13 +40,11 @@ function errorMessage(error: unknown) {
// instead of Zustand. The daemon-status effect runs here exactly once.
function ShellLayout() {
const navigate = useNavigate();
- const pathname = useRouterState({ select: (state) => state.location.pathname });
const queryClient = useQueryClient();
const workspaceQuery = useWorkspaceQuery();
const workspaces = workspaceQuery.data ?? [];
const daemonStatus = useDaemonStatus(queryClient);
const { theme, setTheme, isSidebarOpen, toggleSidebar } = useUiStore();
- const isBoardRoute = pathname === "/" || /^\/projects\/[^/]+$/.test(pathname);
const updateWorkspaces = useCallback(
(updater: (workspaces: WorkspaceSummary[]) => WorkspaceSummary[]) => {
@@ -155,7 +153,7 @@ function ShellLayout() {
in the layout, not the screens, so the crumb and actions never shift
when the outlet content swaps. */}
- {!isBoardRoute && }
+
{/* Controlled by the ui-store so TitlebarNav / Topbar toggles (which
call the store directly) stay in sync. --sidebar-width chains to
the drag-resizable --ao-sidebar-w set on :root by useResizable. */}
@@ -167,7 +165,7 @@ function ShellLayout() {
>
{
expect(toSessionStatus("no_signal")).toBe("no_signal");
});
- it("overrides to terminated when the session is terminated", () => {
- expect(toSessionStatus("working", true)).toBe("terminated");
+ it("keeps a backend merged status even when the session is terminated", () => {
+ expect(toSessionStatus("merged", true)).toBe("merged");
});
- it("falls back to working for an unknown status", () => {
- expect(toSessionStatus("bogus")).toBe("working");
+ it("uses terminated only as a fallback when a terminated session has no known status", () => {
+ expect(toSessionStatus(undefined, true)).toBe("terminated");
});
- it("falls back to working when status is undefined", () => {
- expect(toSessionStatus(undefined)).toBe("working");
+ it("falls back to unknown for an unknown live status", () => {
+ expect(toSessionStatus("bogus")).toBe("unknown");
+ expect(toSessionStatus(undefined)).toBe("unknown");
});
});
@@ -79,6 +80,7 @@ describe("workerDisplayStatus", () => {
["mergeable", "mergeable"],
["merged", "done"],
["terminated", "done"],
+ ["unknown", "unknown"],
["working", "working"],
["idle", "working"],
] as const)("maps %s to %s", (status, expected) => {
@@ -130,9 +132,12 @@ describe("findProjectOrchestrator", () => {
});
describe("sessionNeedsAttention", () => {
- it.each(["needs_input", "changes_requested", "review_pending", "ci_failed"] as const)("is true for %s", (status) => {
- expect(sessionNeedsAttention(sessionWith({ status }))).toBe(true);
- });
+ it.each(["needs_input", "no_signal", "changes_requested", "review_pending", "ci_failed"] as const)(
+ "is true for %s",
+ (status) => {
+ expect(sessionNeedsAttention(sessionWith({ status }))).toBe(true);
+ },
+ );
it("treats no_signal as needing attention", () => {
expect(sessionNeedsAttention(sessionWith({ status: "no_signal" }))).toBe(true);
@@ -151,6 +156,7 @@ describe("workerStatusPulses", () => {
expect(workerStatusPulses("mergeable")).toBe(false);
expect(workerStatusPulses("no_signal")).toBe(false);
expect(workerStatusPulses("done")).toBe(false);
+ expect(workerStatusPulses("unknown")).toBe(false);
});
});
@@ -205,12 +211,13 @@ describe("attentionZone", () => {
["mergeable", "merge"],
["approved", "merge"],
["needs_input", "action"],
- ["ci_failed", "action"],
["no_signal", "action"],
+ ["ci_failed", "action"],
["changes_requested", "action"],
["review_pending", "pending"],
["pr_open", "pending"],
["draft", "pending"],
+ ["unknown", "pending"],
["working", "working"],
["idle", "working"],
["merged", "done"],
diff --git a/frontend/src/renderer/types/workspace.ts b/frontend/src/renderer/types/workspace.ts
index 47918246..f9203864 100644
--- a/frontend/src/renderer/types/workspace.ts
+++ b/frontend/src/renderer/types/workspace.ts
@@ -11,7 +11,8 @@ export type SessionStatus =
| "needs_input"
| "no_signal"
| "idle"
- | "terminated";
+ | "terminated"
+ | "unknown";
const sessionStatuses = new Set([
"working",
@@ -30,8 +31,8 @@ const sessionStatuses = new Set([
]);
export function toSessionStatus(status?: string, isTerminated = false): SessionStatus {
- if (isTerminated) return "terminated";
- return status && sessionStatuses.has(status as SessionStatus) ? (status as SessionStatus) : "working";
+ if (status && sessionStatuses.has(status as SessionStatus)) return status as SessionStatus;
+ return isTerminated ? "terminated" : "unknown";
}
export type AgentProvider =
@@ -121,7 +122,14 @@ export type WorkspaceSession = {
};
/** Glanceable worker status. Maps 1:1 to the accent colors in DESIGN.md. */
-export type WorkerDisplayStatus = "working" | "needs_you" | "mergeable" | "ci_failed" | "no_signal" | "done";
+export type WorkerDisplayStatus =
+ | "working"
+ | "needs_you"
+ | "mergeable"
+ | "ci_failed"
+ | "no_signal"
+ | "done"
+ | "unknown";
export function workerDisplayStatus(session: WorkspaceSession): WorkerDisplayStatus {
if (session.displayStatus) return session.displayStatus;
@@ -140,6 +148,8 @@ export function workerDisplayStatus(session: WorkspaceSession): WorkerDisplaySta
case "merged":
case "terminated":
return "done";
+ case "unknown":
+ return "unknown";
default:
return "working";
}
@@ -212,6 +222,7 @@ export const workerStatusLabel: Record = {
ci_failed: "ci failed",
no_signal: "no signal",
done: "done",
+ unknown: "unknown",
};
/** Whether a status should breathe (alive/working). */
@@ -260,6 +271,7 @@ export function attentionZone(session: WorkspaceSession): AttentionZone {
case "review_pending":
case "pr_open":
case "draft":
+ case "unknown":
return "pending";
// Agents doing their thing — don't interrupt.
case "working":