diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 51b300ba8e..196dfebacc 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1785,6 +1785,7 @@ export default function ChatView({ threadId }: ChatViewProps) { "button, summary, [role='button'], [data-scroll-anchor-target]", ); if (!trigger || !scrollContainer.contains(trigger)) return; + if (trigger.closest("[data-scroll-anchor-ignore]")) return; pendingInteractionAnchorRef.current = { element: trigger, @@ -4820,7 +4821,12 @@ const ProposedPlanCard = memo(function ProposedPlanCard({ {canCollapse ? (
-
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..d0216d0e40 --- /dev/null +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from "vitest"; + +import { hasUnseenCompletion, resolveThreadStatusPill } from "./Sidebar.logic"; + +function makeLatestTurn(overrides?: { + completedAt?: string | null; + startedAt?: string | null; +}): Parameters[0]["latestTurn"] { + return { + turnId: "turn-1" as never, + state: "completed", + assistantMessageId: null, + requestedAt: "2026-03-09T10:00:00.000Z", + startedAt: overrides?.startedAt ?? "2026-03-09T10:00:00.000Z", + completedAt: overrides?.completedAt ?? "2026-03-09T10:05:00.000Z", + }; +} + +describe("hasUnseenCompletion", () => { + it("returns true when a thread completed after its last visit", () => { + expect( + hasUnseenCompletion({ + interactionMode: "default", + latestTurn: makeLatestTurn(), + lastVisitedAt: "2026-03-09T10:04:00.000Z", + proposedPlans: [], + session: null, + }), + ).toBe(true); + }); +}); + +describe("resolveThreadStatusPill", () => { + const baseThread = { + interactionMode: "plan" as const, + latestTurn: null, + lastVisitedAt: undefined, + proposedPlans: [], + session: { + provider: "codex" as const, + status: "running" as const, + createdAt: "2026-03-09T10:00:00.000Z", + updatedAt: "2026-03-09T10:00:00.000Z", + orchestrationStatus: "running" as const, + }, + }; + + it("shows pending approval before all other statuses", () => { + expect( + resolveThreadStatusPill({ + thread: baseThread, + hasPendingApprovals: true, + hasPendingUserInput: true, + }), + ).toMatchObject({ label: "Pending Approval", pulse: false }); + }); + + it("shows awaiting input when plan mode is blocked on user answers", () => { + expect( + resolveThreadStatusPill({ + thread: baseThread, + hasPendingApprovals: false, + hasPendingUserInput: true, + }), + ).toMatchObject({ label: "Awaiting Input", pulse: false }); + }); + + it("falls back to working when the thread is actively running without blockers", () => { + expect( + resolveThreadStatusPill({ + thread: baseThread, + hasPendingApprovals: false, + hasPendingUserInput: false, + }), + ).toMatchObject({ label: "Working", pulse: true }); + }); + + it("shows plan ready when a settled plan turn has a proposed plan ready for follow-up", () => { + expect( + resolveThreadStatusPill({ + thread: { + ...baseThread, + latestTurn: makeLatestTurn(), + proposedPlans: [ + { + id: "plan-1" as never, + turnId: "turn-1" as never, + createdAt: "2026-03-09T10:00:00.000Z", + updatedAt: "2026-03-09T10:05:00.000Z", + planMarkdown: "# Plan", + }, + ], + session: { + ...baseThread.session, + status: "ready", + orchestrationStatus: "ready", + }, + }, + hasPendingApprovals: false, + hasPendingUserInput: false, + }), + ).toMatchObject({ label: "Plan Ready", pulse: false }); + }); + + it("shows completed when there is an unseen completion and no active blocker", () => { + expect( + resolveThreadStatusPill({ + thread: { + ...baseThread, + interactionMode: "default", + latestTurn: makeLatestTurn(), + lastVisitedAt: "2026-03-09T10:04:00.000Z", + session: { + ...baseThread.session, + status: "ready", + orchestrationStatus: "ready", + }, + }, + hasPendingApprovals: false, + hasPendingUserInput: false, + }), + ).toMatchObject({ label: "Completed", pulse: false }); + }); +}); diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts new file mode 100644 index 0000000000..e950d8de6e --- /dev/null +++ b/apps/web/src/components/Sidebar.logic.ts @@ -0,0 +1,100 @@ +import type { Thread } from "../types"; +import { findLatestProposedPlan, isLatestTurnSettled } from "../session-logic"; + +export interface ThreadStatusPill { + label: + | "Working" + | "Connecting" + | "Completed" + | "Pending Approval" + | "Awaiting Input" + | "Plan Ready"; + colorClass: string; + dotClass: string; + pulse: boolean; +} + +type ThreadStatusInput = Pick< + Thread, + "interactionMode" | "latestTurn" | "lastVisitedAt" | "proposedPlans" | "session" +>; + +export function hasUnseenCompletion(thread: ThreadStatusInput): 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(input: { + thread: ThreadStatusInput; + hasPendingApprovals: boolean; + hasPendingUserInput: boolean; +}): ThreadStatusPill | null { + const { hasPendingApprovals, hasPendingUserInput, thread } = input; + + 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 (hasPendingUserInput) { + return { + label: "Awaiting Input", + colorClass: "text-indigo-600 dark:text-indigo-300/90", + dotClass: "bg-indigo-500 dark:bg-indigo-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, + }; + } + + const hasPlanReadyPrompt = + !hasPendingUserInput && + thread.interactionMode === "plan" && + isLatestTurnSettled(thread.latestTurn, thread.session) && + findLatestProposedPlan(thread.proposedPlans, thread.latestTurn?.turnId ?? null) !== null; + if (hasPlanReadyPrompt) { + return { + label: "Plan Ready", + colorClass: "text-violet-600 dark:text-violet-300/90", + dotClass: "bg-violet-500 dark:bg-violet-300/90", + pulse: false, + }; + } + + 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..956c656bdb 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"; @@ -68,6 +67,7 @@ import { } from "./ui/sidebar"; import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; import { isNonEmpty as isNonEmptyString } from "effect/String"; +import { resolveThreadStatusPill } from "./Sidebar.logic"; const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; const THREAD_PREVIEW_LIMIT = 6; @@ -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,11 @@ export default function Sidebar() { {visibleThreads.map((thread) => { const isActive = routeThreadId === thread.id; - const threadStatus = threadStatusPill( + const threadStatus = resolveThreadStatusPill({ thread, - pendingApprovalByThreadId.get(thread.id) === true, - ); + hasPendingApprovals: 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)