From b2259aa7b0d719774c13395cfd814c2f70bcd742 Mon Sep 17 00:00:00 2001 From: Cameron Molen Date: Mon, 9 Mar 2026 07:55:56 -0600 Subject: [PATCH] Extract sidebar status pill logic and handle pending user input - move thread status pill derivation into `Sidebar.logic.ts` - add unit tests for status priority and unseen completion behavior - show `Pending Approval` when either approvals or user input are pending --- apps/web/src/components/Sidebar.logic.test.ts | 142 ++++++++++++++++++ apps/web/src/components/Sidebar.logic.ts | 67 +++++++++ apps/web/src/components/Sidebar.tsx | 77 ++-------- 3 files changed, 222 insertions(+), 64 deletions(-) create mode 100644 apps/web/src/components/Sidebar.logic.test.ts create mode 100644 apps/web/src/components/Sidebar.logic.ts diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts new file mode 100644 index 0000000000..d716006369 --- /dev/null +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -0,0 +1,142 @@ +import { ProjectId, ThreadId, TurnId } from "@t3tools/contracts"; +import { describe, expect, it } from "vitest"; +import type { Thread } from "../types"; +import { resolveThreadStatusPill } from "./Sidebar.logic"; + +function makeThread(overrides: Partial = {}): Thread { + return { + id: ThreadId.makeUnsafe("thread-1"), + codexThreadId: null, + projectId: ProjectId.makeUnsafe("project-1"), + title: "Thread", + model: "gpt-5-codex", + runtimeMode: "full-access", + interactionMode: "default", + session: null, + messages: [], + proposedPlans: [], + error: null, + createdAt: "2026-03-09T00:00:00.000Z", + latestTurn: null, + branch: null, + worktreePath: null, + turnDiffSummaries: [], + activities: [], + ...overrides, + }; +} + +describe("resolveThreadStatusPill", () => { + it("returns Pending Approval when an approval request is open", () => { + expect( + resolveThreadStatusPill( + makeThread({ + session: { + provider: "codex", + status: "running", + orchestrationStatus: "running", + createdAt: "2026-03-09T00:00:00.000Z", + updatedAt: "2026-03-09T00:00:00.000Z", + }, + }), + { hasPendingApproval: true, hasPendingUserInput: false }, + ), + ).toMatchObject({ label: "Pending Approval", pulse: false }); + }); + + it("returns Pending Approval when a user-input request is open", () => { + expect( + resolveThreadStatusPill( + makeThread({ + session: { + provider: "codex", + status: "running", + orchestrationStatus: "running", + createdAt: "2026-03-09T00:00:00.000Z", + updatedAt: "2026-03-09T00:00:00.000Z", + }, + }), + { hasPendingApproval: false, hasPendingUserInput: true }, + ), + ).toMatchObject({ label: "Pending Approval", pulse: false }); + }); + + it("returns Pending Approval when both request types are open", () => { + expect( + resolveThreadStatusPill( + makeThread({ + session: { + provider: "codex", + status: "running", + orchestrationStatus: "running", + createdAt: "2026-03-09T00:00:00.000Z", + updatedAt: "2026-03-09T00:00:00.000Z", + }, + }), + { hasPendingApproval: true, hasPendingUserInput: true }, + ), + ).toMatchObject({ label: "Pending Approval", pulse: false }); + }); + + it("returns Working when the session is running without blocking requests", () => { + expect( + resolveThreadStatusPill( + makeThread({ + session: { + provider: "codex", + status: "running", + orchestrationStatus: "running", + createdAt: "2026-03-09T00:00:00.000Z", + updatedAt: "2026-03-09T00:00:00.000Z", + }, + }), + { hasPendingApproval: false, hasPendingUserInput: false }, + ), + ).toMatchObject({ label: "Working", pulse: true }); + }); + + it("returns Connecting when the session is connecting without blocking requests", () => { + expect( + resolveThreadStatusPill( + makeThread({ + session: { + provider: "codex", + status: "connecting", + orchestrationStatus: "starting", + createdAt: "2026-03-09T00:00:00.000Z", + updatedAt: "2026-03-09T00:00:00.000Z", + }, + }), + { hasPendingApproval: false, hasPendingUserInput: false }, + ), + ).toMatchObject({ label: "Connecting", pulse: true }); + }); + + it("returns Completed when there is unseen completion and no stronger status", () => { + expect( + resolveThreadStatusPill( + makeThread({ + latestTurn: { + turnId: TurnId.makeUnsafe("turn-1"), + state: "completed", + requestedAt: "2026-03-09T00:00:00.000Z", + startedAt: "2026-03-09T00:00:01.000Z", + completedAt: "2026-03-09T00:00:02.000Z", + assistantMessageId: null, + }, + lastVisitedAt: "2026-03-09T00:00:01.500Z", + }), + { hasPendingApproval: false, hasPendingUserInput: false }, + ), + ).toMatchObject({ label: "Completed", pulse: false }); + }); + + it("returns null when no status applies", () => { + expect( + resolveThreadStatusPill(makeThread(), { + hasPendingApproval: false, + hasPendingUserInput: false, + }), + ).toBeNull(); + }); +}); diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts new file mode 100644 index 0000000000..9df929e379 --- /dev/null +++ b/apps/web/src/components/Sidebar.logic.ts @@ -0,0 +1,67 @@ +import type { Thread } from "../types"; + +export interface ThreadStatusPill { + label: "Working" | "Connecting" | "Completed" | "Pending Approval"; + colorClass: string; + dotClass: string; + pulse: boolean; +} + +interface ResolveThreadStatusPillOptions { + hasPendingApproval: boolean; + hasPendingUserInput: boolean; +} + +export function hasUnseenCompletion(thread: Thread): boolean { + if (!thread.latestTurn?.completedAt) return false; + const completedAt = Date.parse(thread.latestTurn.completedAt); + if (Number.isNaN(completedAt)) return false; + if (!thread.lastVisitedAt) return true; + + const lastVisitedAt = Date.parse(thread.lastVisitedAt); + if (Number.isNaN(lastVisitedAt)) return true; + return completedAt > lastVisitedAt; +} + +export function resolveThreadStatusPill( + thread: Thread, + options: ResolveThreadStatusPillOptions, +): ThreadStatusPill | null { + if (options.hasPendingApproval || options.hasPendingUserInput) { + return { + label: "Pending Approval", + colorClass: "text-amber-600 dark:text-amber-300/90", + dotClass: "bg-amber-500 dark:bg-amber-300/90", + pulse: false, + }; + } + + if (thread.session?.status === "running") { + return { + label: "Working", + colorClass: "text-sky-600 dark:text-sky-300/80", + dotClass: "bg-sky-500 dark:bg-sky-300/80", + pulse: true, + }; + } + + if (thread.session?.status === "connecting") { + return { + label: "Connecting", + colorClass: "text-sky-600 dark:text-sky-300/80", + dotClass: "bg-sky-500 dark:bg-sky-300/80", + pulse: true, + }; + } + + if (hasUnseenCompletion(thread)) { + return { + label: "Completed", + colorClass: "text-emerald-600 dark:text-emerald-300/90", + dotClass: "bg-emerald-500 dark:bg-emerald-300/90", + pulse: false, + }; + } + + return null; +} diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 894fde25e9..2a867cd4f0 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -28,8 +28,7 @@ import { APP_STAGE_LABEL } from "../branding"; import { newCommandId, newProjectId, newThreadId } from "../lib/utils"; import { useStore } from "../store"; import { isChatNewLocalShortcut, isChatNewShortcut, shortcutLabelForCommand } from "../keybindings"; -import { type Thread } from "../types"; -import { derivePendingApprovals } from "../session-logic"; +import { derivePendingApprovals, derivePendingUserInputs } from "../session-logic"; import { gitRemoveWorktreeMutationOptions, gitStatusQueryOptions } from "../lib/gitReactQuery"; import { serverConfigQueryOptions } from "../lib/serverReactQuery"; import { readNativeApi } from "../nativeApi"; @@ -66,6 +65,7 @@ import { SidebarSeparator, SidebarTrigger, } from "./ui/sidebar"; +import { resolveThreadStatusPill } from "./Sidebar.logic"; import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; import { isNonEmpty as isNonEmptyString } from "effect/String"; @@ -89,13 +89,6 @@ function formatRelativeTime(iso: string): string { return `${Math.floor(hours / 24)}d ago`; } -interface ThreadStatusPill { - label: "Working" | "Connecting" | "Completed" | "Pending Approval"; - colorClass: string; - dotClass: string; - pulse: boolean; -} - interface TerminalStatusIndicator { label: "Terminal process running"; colorClass: string; @@ -111,57 +104,6 @@ interface PrStatusIndicator { type ThreadPr = GitStatusResult["pr"]; -function hasUnseenCompletion(thread: Thread): boolean { - if (!thread.latestTurn?.completedAt) return false; - const completedAt = Date.parse(thread.latestTurn.completedAt); - if (Number.isNaN(completedAt)) return false; - if (!thread.lastVisitedAt) return true; - - const lastVisitedAt = Date.parse(thread.lastVisitedAt); - if (Number.isNaN(lastVisitedAt)) return true; - return completedAt > lastVisitedAt; -} - -function threadStatusPill(thread: Thread, hasPendingApprovals: boolean): ThreadStatusPill | null { - if (hasPendingApprovals) { - return { - label: "Pending Approval", - colorClass: "text-amber-600 dark:text-amber-300/90", - dotClass: "bg-amber-500 dark:bg-amber-300/90", - pulse: false, - }; - } - - if (thread.session?.status === "running") { - return { - label: "Working", - colorClass: "text-sky-600 dark:text-sky-300/80", - dotClass: "bg-sky-500 dark:bg-sky-300/80", - pulse: true, - }; - } - - if (thread.session?.status === "connecting") { - return { - label: "Connecting", - colorClass: "text-sky-600 dark:text-sky-300/80", - dotClass: "bg-sky-500 dark:bg-sky-300/80", - pulse: true, - }; - } - - if (hasUnseenCompletion(thread)) { - return { - label: "Completed", - colorClass: "text-emerald-600 dark:text-emerald-300/90", - dotClass: "bg-emerald-500 dark:bg-emerald-300/90", - pulse: false, - }; - } - - return null; -} - function terminalStatusFromRunningIds( runningTerminalIds: string[], ): TerminalStatusIndicator | null { @@ -319,6 +261,13 @@ export default function Sidebar() { } return map; }, [threads]); + const pendingUserInputByThreadId = useMemo(() => { + const map = new Map(); + for (const thread of threads) { + map.set(thread.id, derivePendingUserInputs(thread.activities).length > 0); + } + return map; + }, [threads]); const projectCwdById = useMemo( () => new Map(projects.map((project) => [project.id, project.cwd] as const)), [projects], @@ -1241,10 +1190,10 @@ export default function Sidebar() { {visibleThreads.map((thread) => { const isActive = routeThreadId === thread.id; - const threadStatus = threadStatusPill( - thread, - pendingApprovalByThreadId.get(thread.id) === true, - ); + const threadStatus = resolveThreadStatusPill(thread, { + hasPendingApproval: pendingApprovalByThreadId.get(thread.id) === true, + hasPendingUserInput: pendingUserInputByThreadId.get(thread.id) === true, + }); const prStatus = prStatusIndicator(prByThreadId.get(thread.id) ?? null); const terminalStatus = terminalStatusFromRunningIds( selectThreadTerminalState(terminalStateByThreadId, thread.id)