diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index e3a3ce02ef..8436f450c0 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -25,7 +25,7 @@ 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 { deriveThreadStatusState } from "../session-logic"; import { gitRemoveWorktreeMutationOptions, gitStatusQueryOptions } from "../lib/gitReactQuery"; import { serverConfigQueryOptions } from "../lib/serverReactQuery"; import { readNativeApi } from "../nativeApi"; @@ -82,7 +82,7 @@ function formatRelativeTime(iso: string): string { } interface ThreadStatusPill { - label: "Working" | "Connecting" | "Completed" | "Pending Approval"; + label: "Working" | "Connecting" | "Completed" | "Awaiting response"; colorClass: string; dotClass: string; pulse: boolean; @@ -103,55 +103,46 @@ 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, - }; - } +function threadStatusPill(thread: Thread): ThreadStatusPill | null { + const state = deriveThreadStatusState({ + session: thread.session, + latestTurn: thread.latestTurn, + lastVisitedAt: thread.lastVisitedAt, + activities: thread.activities, + }); - 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, - }; + switch (state) { + case "awaiting-response": + return { + label: "Awaiting response", + colorClass: "text-amber-600 dark:text-amber-300/90", + dotClass: "bg-amber-500 dark:bg-amber-300/90", + pulse: false, + }; + case "working": + return { + label: "Working", + colorClass: "text-sky-600 dark:text-sky-300/80", + dotClass: "bg-sky-500 dark:bg-sky-300/80", + pulse: true, + }; + case "connecting": + return { + label: "Connecting", + colorClass: "text-sky-600 dark:text-sky-300/80", + dotClass: "bg-sky-500 dark:bg-sky-300/80", + pulse: true, + }; + case "completed": + return { + label: "Completed", + colorClass: "text-emerald-600 dark:text-emerald-300/90", + dotClass: "bg-emerald-500 dark:bg-emerald-300/90", + pulse: false, + }; + default: + return null; } - - return null; } function terminalStatusFromRunningIds( @@ -301,13 +292,6 @@ export default function Sidebar() { const renamingCommittedRef = useRef(false); const renamingInputRef = useRef(null); const [desktopUpdateState, setDesktopUpdateState] = useState(null); - const pendingApprovalByThreadId = useMemo(() => { - const map = new Map(); - for (const thread of threads) { - map.set(thread.id, derivePendingApprovals(thread.activities).length > 0); - } - return map; - }, [threads]); const projectCwdById = useMemo( () => new Map(projects.map((project) => [project.id, project.cwd] as const)), [projects], @@ -1112,10 +1096,7 @@ export default function Sidebar() { {visibleThreads.map((thread) => { const isActive = routeThreadId === thread.id; - const threadStatus = threadStatusPill( - thread, - pendingApprovalByThreadId.get(thread.id) === true, - ); + const threadStatus = threadStatusPill(thread); const prStatus = prStatusIndicator(prByThreadId.get(thread.id) ?? null); const terminalStatus = terminalStatusFromRunningIds( selectThreadTerminalState(terminalStateByThreadId, thread.id) diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index 3d1d269d48..674762158e 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from "vitest"; import { deriveActiveWorkStartedAt, deriveActivePlanState, + deriveThreadStatusState, PROVIDER_OPTIONS, derivePendingApprovals, derivePendingUserInputs, @@ -13,6 +14,7 @@ import { hasToolActivityForTurn, isLatestTurnSettled, } from "./session-logic"; +import type { ThreadSession } from "./types"; function makeActivity(overrides: { id?: string; @@ -260,6 +262,244 @@ describe("deriveActivePlanState", () => { }); }); +function makeSession( + overrides: Partial = {}, +): ThreadSession { + return { + provider: "codex", + status: "ready", + createdAt: "2026-02-23T00:00:00.000Z", + updatedAt: "2026-02-23T00:00:00.000Z", + orchestrationStatus: "ready", + ...overrides, + }; +} + +describe("deriveThreadStatusState", () => { + it("returns awaiting-response for unresolved approvals", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + kind: "approval.requested", + tone: "approval", + summary: "Command approval requested", + payload: { + requestId: "req-approval-1", + requestKind: "command", + }, + }), + ]; + + expect( + deriveThreadStatusState({ + session: makeSession({ status: "running", orchestrationStatus: "running" }), + latestTurn: null, + activities, + }), + ).toBe("awaiting-response"); + }); + + it("returns awaiting-response for unresolved user input", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + kind: "user-input.requested", + tone: "info", + summary: "User input requested", + payload: { + requestId: "req-user-input-1", + questions: [ + { + id: "sandbox_mode", + header: "Sandbox", + question: "Which mode should be used?", + options: [ + { + label: "workspace-write", + description: "Allow workspace writes only", + }, + ], + }, + ], + }, + }), + ]; + + expect( + deriveThreadStatusState({ + session: makeSession({ status: "running", orchestrationStatus: "running" }), + latestTurn: null, + activities, + }), + ).toBe("awaiting-response"); + }); + + it("returns awaiting-response when approvals and user input are both open", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "approval-open", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "approval.requested", + tone: "approval", + summary: "Command approval requested", + payload: { + requestId: "req-approval-1", + requestKind: "command", + }, + }), + makeActivity({ + id: "user-input-open", + createdAt: "2026-02-23T00:00:02.000Z", + kind: "user-input.requested", + tone: "info", + summary: "User input requested", + payload: { + requestId: "req-user-input-1", + questions: [ + { + id: "approval", + header: "Approval", + question: "Continue?", + options: [ + { + label: "yes", + description: "Continue execution", + }, + ], + }, + ], + }, + }), + ]; + + expect( + deriveThreadStatusState({ + session: makeSession({ status: "running", orchestrationStatus: "running" }), + latestTurn: null, + activities, + }), + ).toBe("awaiting-response"); + }); + + it("falls back to working after blocked activity resolves", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "approval-open", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "approval.requested", + tone: "approval", + summary: "Command approval requested", + payload: { + requestId: "req-approval-1", + requestKind: "command", + }, + }), + makeActivity({ + id: "approval-resolved", + createdAt: "2026-02-23T00:00:02.000Z", + kind: "approval.resolved", + tone: "info", + summary: "Approval resolved", + payload: { + requestId: "req-approval-1", + }, + }), + ]; + + expect( + deriveThreadStatusState({ + session: makeSession({ status: "running", orchestrationStatus: "running" }), + latestTurn: null, + activities, + }), + ).toBe("working"); + }); + + it("returns connecting when there is no blocked activity", () => { + expect( + deriveThreadStatusState({ + session: makeSession({ status: "connecting", orchestrationStatus: "starting" }), + latestTurn: null, + activities: [], + }), + ).toBe("connecting"); + }); + + it("returns completed for unseen completed turns", () => { + expect( + deriveThreadStatusState({ + session: makeSession(), + latestTurn: { + turnId: TurnId.makeUnsafe("turn-1"), + startedAt: "2026-02-23T00:00:01.000Z", + completedAt: "2026-02-23T00:00:02.000Z", + }, + lastVisitedAt: "2026-02-23T00:00:01.500Z", + activities: [], + }), + ).toBe("completed"); + }); + + it("prefers awaiting-response over completed", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + kind: "user-input.requested", + tone: "info", + summary: "User input requested", + payload: { + requestId: "req-user-input-1", + questions: [ + { + id: "sandbox_mode", + header: "Sandbox", + question: "Which mode should be used?", + options: [ + { + label: "workspace-write", + description: "Allow workspace writes only", + }, + ], + }, + ], + }, + }), + ]; + + expect( + deriveThreadStatusState({ + session: makeSession(), + latestTurn: { + turnId: TurnId.makeUnsafe("turn-1"), + startedAt: "2026-02-23T00:00:01.000Z", + completedAt: "2026-02-23T00:00:02.000Z", + }, + lastVisitedAt: "2026-02-23T00:00:01.500Z", + activities, + }), + ).toBe("awaiting-response"); + }); + + it("prefers awaiting-response over running", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + kind: "approval.requested", + tone: "approval", + summary: "Command approval requested", + payload: { + requestId: "req-approval-1", + requestKind: "command", + }, + }), + ]; + + expect( + deriveThreadStatusState({ + session: makeSession({ status: "running", orchestrationStatus: "running" }), + latestTurn: null, + activities, + }), + ).toBe("awaiting-response"); + }); +}); + describe("findLatestProposedPlan", () => { it("prefers the latest proposed plan for the active turn", () => { expect( diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index e9351ca2b2..ccc74dec87 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -63,6 +63,12 @@ export interface LatestProposedPlanState { planMarkdown: string; } +export type ThreadStatusState = + | "awaiting-response" + | "working" + | "connecting" + | "completed"; + export type TimelineEntry = | { id: string; @@ -116,6 +122,20 @@ export function formatElapsed(startIso: string, endIso: string | undefined): str type LatestTurnTiming = Pick; type SessionActivityState = Pick; +function hasUnseenCompletion(input: { + latestTurn: LatestTurnTiming | null; + lastVisitedAt?: string | undefined; +}): boolean { + if (!input.latestTurn?.completedAt) return false; + const completedAt = Date.parse(input.latestTurn.completedAt); + if (Number.isNaN(completedAt)) return false; + if (!input.lastVisitedAt) return true; + + const lastVisitedAt = Date.parse(input.lastVisitedAt); + if (Number.isNaN(lastVisitedAt)) return true; + return completedAt > lastVisitedAt; +} + export function isLatestTurnSettled( latestTurn: LatestTurnTiming | null, session: SessionActivityState | null, @@ -299,6 +319,39 @@ export function derivePendingUserInputs( ); } +export function deriveThreadStatusState(input: { + session: ThreadSession | null; + latestTurn: LatestTurnTiming | null; + lastVisitedAt?: string | undefined; + activities: ReadonlyArray; +}): ThreadStatusState | null { + if ( + derivePendingApprovals(input.activities).length > 0 || + derivePendingUserInputs(input.activities).length > 0 + ) { + return "awaiting-response"; + } + + if (input.session?.status === "running") { + return "working"; + } + + if (input.session?.status === "connecting") { + return "connecting"; + } + + if ( + hasUnseenCompletion({ + latestTurn: input.latestTurn, + lastVisitedAt: input.lastVisitedAt, + }) + ) { + return "completed"; + } + + return null; +} + export function deriveActivePlanState( activities: ReadonlyArray, latestTurnId: TurnId | undefined,