- add `Sidebar.logic.ts` to centralize thread status pill resolution - show `Awaiting Input` when plan mode is blocked on user responses - add unit tests for unseen completion and status-priority behavior - prevent plan collapse/expand clicks from triggering chat scroll anchoring
Unified · Split
| @@ -1785,6 +1785,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 1784 unmodified lines | ||
| 1785 | 1785 | "button, summary, [role='button'], [data-scroll-anchor-target]", |
| 1786 | 1786 | ); |
| 1787 | 1787 | if (!trigger || !scrollContainer.contains(trigger)) return; |
| 1788 | + if (trigger.closest("[data-scroll-anchor-ignore]")) return; | |
| 1788 | 1789 | |
| 1789 | 1790 | pendingInteractionAnchorRef.current = { |
| 1790 | 1791 | element: trigger, |
| @@ -4820,7 +4821,12 @@ const ProposedPlanCard = memo(function ProposedPlanCard({ · 3029 unmodified lines | ||
| 4820 | 4821 | </div> |
| 4821 | 4822 | {canCollapse ? ( |
| 4822 | 4823 | <div className="mt-4 flex justify-center"> |
| 4823 | − <Button size="sm" variant="outline" onClick={() => setExpanded((value) => !value)}> | |
| 4824 | + <Button | |
| 4825 | + size="sm" | |
| 4826 | + variant="outline" | |
| 4827 | + data-scroll-anchor-ignore | |
| 4828 | + onClick={() => setExpanded((value) => !value)} | |
| 4829 | + > | |
| 4824 | 4830 | {expanded ? "Collapse plan" : "Expand plan"} |
| 4825 | 4831 | </Button> |
| 4826 | 4832 | </div> |
| @@ -0,0 +1,92 @@ | ||
| 1 | +import { describe, expect, it } from "vitest"; | |
| 2 | + | |
| 3 | +import { hasUnseenCompletion, resolveThreadStatusPill } from "./Sidebar.logic"; | |
| 4 | + | |
| 5 | +function makeLatestTurn(overrides?: { | |
| 6 | + completedAt?: string | null; | |
| 7 | + startedAt?: string | null; | |
| 8 | +}): Parameters<typeof hasUnseenCompletion>[0]["latestTurn"] { | |
| 9 | + return { | |
| 10 | + turnId: "turn-1" as never, | |
| 11 | + state: "completed", | |
| 12 | + assistantMessageId: null, | |
| 13 | + requestedAt: "2026-03-09T10:00:00.000Z", | |
| 14 | + startedAt: overrides?.startedAt ?? "2026-03-09T10:00:00.000Z", | |
| 15 | + completedAt: overrides?.completedAt ?? "2026-03-09T10:05:00.000Z", | |
| 16 | + }; | |
| 17 | +} | |
| 18 | + | |
| 19 | +describe("hasUnseenCompletion", () => { | |
| 20 | + it("returns true when a thread completed after its last visit", () => { | |
| 21 | + expect( | |
| 22 | + hasUnseenCompletion({ | |
| 23 | + latestTurn: makeLatestTurn(), | |
| 24 | + lastVisitedAt: "2026-03-09T10:04:00.000Z", | |
| 25 | + session: null, | |
| 26 | + }), | |
| 27 | + ).toBe(true); | |
| 28 | + }); | |
| 29 | +}); | |
| 30 | + | |
| 31 | +describe("resolveThreadStatusPill", () => { | |
| 32 | + const baseThread = { | |
| 33 | + latestTurn: null, | |
| 34 | + lastVisitedAt: undefined, | |
| 35 | + session: { | |
| 36 | + provider: "codex" as const, | |
| 37 | + status: "running" as const, | |
| 38 | + createdAt: "2026-03-09T10:00:00.000Z", | |
| 39 | + updatedAt: "2026-03-09T10:00:00.000Z", | |
| 40 | + orchestrationStatus: "running" as const, | |
| 41 | + }, | |
| 42 | + }; | |
| 43 | + | |
| 44 | + it("shows pending approval before all other statuses", () => { | |
| 45 | + expect( | |
| 46 | + resolveThreadStatusPill({ | |
| 47 | + thread: baseThread, | |
| 48 | + hasPendingApprovals: true, | |
| 49 | + hasPendingUserInput: true, | |
| 50 | + }), | |
| 51 | + ).toMatchObject({ label: "Pending Approval", pulse: false }); | |
| 52 | + }); | |
| 53 | + | |
| 54 | + it("shows awaiting input when plan mode is blocked on user answers", () => { | |
| 55 | + expect( | |
| 56 | + resolveThreadStatusPill({ | |
| 57 | + thread: baseThread, | |
| 58 | + hasPendingApprovals: false, | |
| 59 | + hasPendingUserInput: true, | |
| 60 | + }), | |
| 61 | + ).toMatchObject({ label: "Awaiting Input", pulse: false }); | |
| 62 | + }); | |
| 63 | + | |
| 64 | + it("falls back to working when the thread is actively running without blockers", () => { | |
| 65 | + expect( | |
| 66 | + resolveThreadStatusPill({ | |
| 67 | + thread: baseThread, | |
| 68 | + hasPendingApprovals: false, | |
| 69 | + hasPendingUserInput: false, | |
| 70 | + }), | |
| 71 | + ).toMatchObject({ label: "Working", pulse: true }); | |
| 72 | + }); | |
| 73 | + | |
| 74 | + it("shows completed when there is an unseen completion and no active blocker", () => { | |
| 75 | + expect( | |
| 76 | + resolveThreadStatusPill({ | |
| 77 | + thread: { | |
| 78 | + ...baseThread, | |
| 79 | + latestTurn: makeLatestTurn(), | |
| 80 | + lastVisitedAt: "2026-03-09T10:04:00.000Z", | |
| 81 | + session: { | |
| 82 | + ...baseThread.session, | |
| 83 | + status: "ready", | |
| 84 | + orchestrationStatus: "ready", | |
| 85 | + }, | |
| 86 | + }, | |
| 87 | + hasPendingApprovals: false, | |
| 88 | + hasPendingUserInput: false, | |
| 89 | + }), | |
| 90 | + ).toMatchObject({ label: "Completed", pulse: false }); | |
| 91 | + }); | |
| 92 | +}); | |
| @@ -0,0 +1,76 @@ | ||
| 1 | +import type { Thread } from "../types"; | |
| 2 | + | |
| 3 | +export interface ThreadStatusPill { | |
| 4 | + label: "Working" | "Connecting" | "Completed" | "Pending Approval" | "Awaiting Input"; | |
| 5 | + colorClass: string; | |
| 6 | + dotClass: string; | |
| 7 | + pulse: boolean; | |
| 8 | +} | |
| 9 | + | |
| 10 | +type ThreadStatusInput = Pick<Thread, "latestTurn" | "lastVisitedAt" | "session">; | |
| 11 | + | |
| 12 | +export function hasUnseenCompletion(thread: ThreadStatusInput): boolean { | |
| 13 | + if (!thread.latestTurn?.completedAt) return false; | |
| 14 | + const completedAt = Date.parse(thread.latestTurn.completedAt); | |
| 15 | + if (Number.isNaN(completedAt)) return false; | |
| 16 | + if (!thread.lastVisitedAt) return true; | |
| 17 | + | |
| 18 | + const lastVisitedAt = Date.parse(thread.lastVisitedAt); | |
| 19 | + if (Number.isNaN(lastVisitedAt)) return true; | |
| 20 | + return completedAt > lastVisitedAt; | |
| 21 | +} | |
| 22 | + | |
| 23 | +export function resolveThreadStatusPill(input: { | |
| 24 | + thread: ThreadStatusInput; | |
| 25 | + hasPendingApprovals: boolean; | |
| 26 | + hasPendingUserInput: boolean; | |
| 27 | +}): ThreadStatusPill | null { | |
| 28 | + const { hasPendingApprovals, hasPendingUserInput, thread } = input; | |
| 29 | + | |
| 30 | + if (hasPendingApprovals) { | |
| 31 | + return { | |
| 32 | + label: "Pending Approval", | |
| 33 | + colorClass: "text-amber-600 dark:text-amber-300/90", | |
| 34 | + dotClass: "bg-amber-500 dark:bg-amber-300/90", | |
| 35 | + pulse: false, | |
| 36 | + }; | |
| 37 | + } | |
| 38 | + | |
| 39 | + if (hasPendingUserInput) { | |
| 40 | + return { | |
| 41 | + label: "Awaiting Input", | |
| 42 | + colorClass: "text-indigo-600 dark:text-indigo-300/90", | |
| 43 | + dotClass: "bg-indigo-500 dark:bg-indigo-300/90", | |
| 44 | + pulse: false, | |
| 45 | + }; | |
| 46 | + } | |
| 47 | + | |
| 48 | + if (thread.session?.status === "running") { | |
| 49 | + return { | |
| 50 | + label: "Working", | |
| 51 | + colorClass: "text-sky-600 dark:text-sky-300/80", | |
| 52 | + dotClass: "bg-sky-500 dark:bg-sky-300/80", | |
| 53 | + pulse: true, | |
| 54 | + }; | |
| 55 | + } | |
| 56 | + | |
| 57 | + if (thread.session?.status === "connecting") { | |
| 58 | + return { | |
| 59 | + label: "Connecting", | |
| 60 | + colorClass: "text-sky-600 dark:text-sky-300/80", | |
| 61 | + dotClass: "bg-sky-500 dark:bg-sky-300/80", | |
| 62 | + pulse: true, | |
| 63 | + }; | |
| 64 | + } | |
| 65 | + | |
| 66 | + if (hasUnseenCompletion(thread)) { | |
| 67 | + return { | |
| 68 | + label: "Completed", | |
| 69 | + colorClass: "text-emerald-600 dark:text-emerald-300/90", | |
| 70 | + dotClass: "bg-emerald-500 dark:bg-emerald-300/90", | |
| 71 | + pulse: false, | |
| 72 | + }; | |
| 73 | + } | |
| 74 | + | |
| 75 | + return null; | |
| 76 | +} | |
| @@ -28,8 +28,7 @@ import { APP_STAGE_LABEL } from "../branding"; · 27 unmodified lines | ||
| 28 | 28 | import { newCommandId, newProjectId, newThreadId } from "../lib/utils"; |
| 29 | 29 | import { useStore } from "../store"; |
| 30 | 30 | import { isChatNewLocalShortcut, isChatNewShortcut, shortcutLabelForCommand } from "../keybindings"; |
| 31 | −import { type Thread } from "../types"; | |
| 32 | −import { derivePendingApprovals } from "../session-logic"; | |
| 31 | +import { derivePendingApprovals, derivePendingUserInputs } from "../session-logic"; | |
| 33 | 32 | import { gitRemoveWorktreeMutationOptions, gitStatusQueryOptions } from "../lib/gitReactQuery"; |
| 34 | 33 | import { serverConfigQueryOptions } from "../lib/serverReactQuery"; |
| 35 | 34 | import { readNativeApi } from "../nativeApi"; |
| @@ -68,6 +67,7 @@ import { · 32 unmodified lines | ||
| 68 | 67 | } from "./ui/sidebar"; |
| 69 | 68 | import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; |
| 70 | 69 | import { isNonEmpty as isNonEmptyString } from "effect/String"; |
| 70 | +import { resolveThreadStatusPill } from "./Sidebar.logic"; | |
| 71 | 71 | |
| 72 | 72 | const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; |
| 73 | 73 | const THREAD_PREVIEW_LIMIT = 6; |
| @@ -89,13 +89,6 @@ function formatRelativeTime(iso: string): string { · 15 unmodified lines | ||
| 89 | 89 | return `${Math.floor(hours / 24)}d ago`; |
| 90 | 90 | } |
| 91 | 91 | |
| 92 | −interface ThreadStatusPill { | |
| 93 | − label: "Working" | "Connecting" | "Completed" | "Pending Approval"; | |
| 94 | − colorClass: string; | |
| 95 | − dotClass: string; | |
| 96 | − pulse: boolean; | |
| 97 | −} | |
| 98 | − | |
| 99 | 92 | interface TerminalStatusIndicator { |
| 100 | 93 | label: "Terminal process running"; |
| 101 | 94 | colorClass: string; |
| @@ -111,57 +104,6 @@ interface PrStatusIndicator { · 9 unmodified lines | ||
| 111 | 104 | |
| 112 | 105 | type ThreadPr = GitStatusResult["pr"]; |
| 113 | 106 | |
| 114 | −function hasUnseenCompletion(thread: Thread): boolean { | |
| 115 | − if (!thread.latestTurn?.completedAt) return false; | |
| 116 | − const completedAt = Date.parse(thread.latestTurn.completedAt); | |
| 117 | − if (Number.isNaN(completedAt)) return false; | |
| 118 | − if (!thread.lastVisitedAt) return true; | |
| 119 | − | |
| 120 | − const lastVisitedAt = Date.parse(thread.lastVisitedAt); | |
| 121 | − if (Number.isNaN(lastVisitedAt)) return true; | |
| 122 | − return completedAt > lastVisitedAt; | |
| 123 | −} | |
| 124 | − | |
| 125 | −function threadStatusPill(thread: Thread, hasPendingApprovals: boolean): ThreadStatusPill | null { | |
| 126 | − if (hasPendingApprovals) { | |
| 127 | − return { | |
| 128 | − label: "Pending Approval", | |
| 129 | − colorClass: "text-amber-600 dark:text-amber-300/90", | |
| 130 | − dotClass: "bg-amber-500 dark:bg-amber-300/90", | |
| 131 | − pulse: false, | |
| 132 | − }; | |
| 133 | − } | |
| 134 | − | |
| 135 | − if (thread.session?.status === "running") { | |
| 136 | − return { | |
| 137 | − label: "Working", | |
| 138 | − colorClass: "text-sky-600 dark:text-sky-300/80", | |
| 139 | − dotClass: "bg-sky-500 dark:bg-sky-300/80", | |
| 140 | − pulse: true, | |
| 141 | − }; | |
| 142 | − } | |
| 143 | − | |
| 144 | − if (thread.session?.status === "connecting") { | |
| 145 | − return { | |
| 146 | − label: "Connecting", | |
| 147 | − colorClass: "text-sky-600 dark:text-sky-300/80", | |
| 148 | − dotClass: "bg-sky-500 dark:bg-sky-300/80", | |
| 149 | − pulse: true, | |
| 150 | − }; | |
| 151 | − } | |
| 152 | − | |
| 153 | − if (hasUnseenCompletion(thread)) { | |
| 154 | − return { | |
| 155 | − label: "Completed", | |
| 156 | − colorClass: "text-emerald-600 dark:text-emerald-300/90", | |
| 157 | − dotClass: "bg-emerald-500 dark:bg-emerald-300/90", | |
| 158 | − pulse: false, | |
| 159 | − }; | |
| 160 | − } | |
| 161 | − | |
| 162 | − return null; | |
| 163 | −} | |
| 164 | − | |
| 165 | 107 | function terminalStatusFromRunningIds( |
| 166 | 108 | runningTerminalIds: string[], |
| 167 | 109 | ): TerminalStatusIndicator | null { |
| @@ -321,6 +263,13 @@ export default function Sidebar() { · 153 unmodified lines | ||
| 321 | 263 | } |
| 322 | 264 | return map; |
| 323 | 265 | }, [threads]); |
| 266 | + const pendingUserInputByThreadId = useMemo(() => { | |
| 267 | + const map = new Map<ThreadId, boolean>(); | |
| 268 | + for (const thread of threads) { | |
| 269 | + map.set(thread.id, derivePendingUserInputs(thread.activities).length > 0); | |
| 270 | + } | |
| 271 | + return map; | |
| 272 | + }, [threads]); | |
| 324 | 273 | const projectCwdById = useMemo( |
| 325 | 274 | () => new Map(projects.map((project) => [project.id, project.cwd] as const)), |
| 326 | 275 | [projects], |
| @@ -1268,10 +1217,11 @@ export default function Sidebar() { · 941 unmodified lines | ||
| 1268 | 1217 | <SidebarMenuSub className="mx-1 my-0 w-full translate-x-0 gap-0 px-1.5 py-0"> |
| 1269 | 1218 | {visibleThreads.map((thread) => { |
| 1270 | 1219 | const isActive = routeThreadId === thread.id; |
| 1271 | − const threadStatus = threadStatusPill( | |
| 1220 | + const threadStatus = resolveThreadStatusPill({ | |
| 1272 | 1221 | thread, |
| 1273 | − pendingApprovalByThreadId.get(thread.id) === true, | |
| 1274 | − ); | |
| 1222 | + hasPendingApprovals: pendingApprovalByThreadId.get(thread.id) === true, | |
| 1223 | + hasPendingUserInput: pendingUserInputByThreadId.get(thread.id) === true, | |
| 1224 | + }); | |
| 1275 | 1225 | const prStatus = prStatusIndicator(prByThreadId.get(thread.id) ?? null); |
| 1276 | 1226 | const terminalStatus = terminalStatusFromRunningIds( |
| 1277 | 1227 | selectThreadTerminalState(terminalStateByThreadId, thread.id) |