| author | |
| committer | |
| log | b030f69a833802fae9222781ef2dc822dfe81aac |
| tree | 0bdcaecd3a1d77b38f7f80a670e72b8426893c7f |
| parent | b0e1b3368e47c8b470f988312309a9784421b69a |
- 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 anchoring4 files changed, 188 insertions(+), 64 deletions(-)
apps/web/src/components/ChatView.tsx+7-1| ... | @@ -1785,6 +1785,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1785,6 +1785,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1785 | "button, summary, [role='button'], [data-scroll-anchor-target]", | 1785 | "button, summary, [role='button'], [data-scroll-anchor-target]", |
| 1786 | ); | 1786 | ); |
| 1787 | if (!trigger || !scrollContainer.contains(trigger)) return; | 1787 | if (!trigger || !scrollContainer.contains(trigger)) return; |
| 1788 | if (trigger.closest("[data-scroll-anchor-ignore]")) return; | ||
| 1788 | 1789 | ||
| 1789 | pendingInteractionAnchorRef.current = { | 1790 | pendingInteractionAnchorRef.current = { |
| 1790 | element: trigger, | 1791 | element: trigger, |
| ... | @@ -4820,7 +4821,12 @@ const ProposedPlanCard = memo(function ProposedPlanCard({ | ... | @@ -4820,7 +4821,12 @@ const ProposedPlanCard = memo(function ProposedPlanCard({ |
| 4820 | </div> | 4821 | </div> |
| 4821 | {canCollapse ? ( | 4822 | {canCollapse ? ( |
| 4822 | <div className="mt-4 flex justify-center"> | 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 | {expanded ? "Collapse plan" : "Expand plan"} | 4830 | {expanded ? "Collapse plan" : "Expand plan"} |
| 4825 | </Button> | 4831 | </Button> |
| 4826 | </div> | 4832 | </div> |
apps/web/src/components/Sidebar.logic.test.ts created+92| ... | @@ -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 | }); | ||
apps/web/src/components/Sidebar.logic.ts created+76| ... | @@ -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 | } | ||
apps/web/src/components/Sidebar.tsx+13-63| ... | @@ -28,8 +28,7 @@ import { APP_STAGE_LABEL } from "../branding"; | ... | @@ -28,8 +28,7 @@ import { APP_STAGE_LABEL } from "../branding"; |
| 28 | import { newCommandId, newProjectId, newThreadId } from "../lib/utils"; | 28 | import { newCommandId, newProjectId, newThreadId } from "../lib/utils"; |
| 29 | import { useStore } from "../store"; | 29 | import { useStore } from "../store"; |
| 30 | import { isChatNewLocalShortcut, isChatNewShortcut, shortcutLabelForCommand } from "../keybindings"; | 30 | import { isChatNewLocalShortcut, isChatNewShortcut, shortcutLabelForCommand } from "../keybindings"; |
| 31 | import { type Thread } from "../types"; | 31 | import { derivePendingApprovals, derivePendingUserInputs } from "../session-logic"; |
| 32 | import { derivePendingApprovals } from "../session-logic"; | ||
| 33 | import { gitRemoveWorktreeMutationOptions, gitStatusQueryOptions } from "../lib/gitReactQuery"; | 32 | import { gitRemoveWorktreeMutationOptions, gitStatusQueryOptions } from "../lib/gitReactQuery"; |
| 34 | import { serverConfigQueryOptions } from "../lib/serverReactQuery"; | 33 | import { serverConfigQueryOptions } from "../lib/serverReactQuery"; |
| 35 | import { readNativeApi } from "../nativeApi"; | 34 | import { readNativeApi } from "../nativeApi"; |
| ... | @@ -68,6 +67,7 @@ import { | ... | @@ -68,6 +67,7 @@ import { |
| 68 | } from "./ui/sidebar"; | 67 | } from "./ui/sidebar"; |
| 69 | import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; | 68 | import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; |
| 70 | import { isNonEmpty as isNonEmptyString } from "effect/String"; | 69 | import { isNonEmpty as isNonEmptyString } from "effect/String"; |
| 70 | import { resolveThreadStatusPill } from "./Sidebar.logic"; | ||
| 71 | 71 | ||
| 72 | const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; | 72 | const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; |
| 73 | const THREAD_PREVIEW_LIMIT = 6; | 73 | const THREAD_PREVIEW_LIMIT = 6; |
| ... | @@ -89,13 +89,6 @@ function formatRelativeTime(iso: string): string { | ... | @@ -89,13 +89,6 @@ function formatRelativeTime(iso: string): string { |
| 89 | return `${Math.floor(hours / 24)}d ago`; | 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 | interface TerminalStatusIndicator { | 92 | interface TerminalStatusIndicator { |
| 100 | label: "Terminal process running"; | 93 | label: "Terminal process running"; |
| 101 | colorClass: string; | 94 | colorClass: string; |
| ... | @@ -111,57 +104,6 @@ interface PrStatusIndicator { | ... | @@ -111,57 +104,6 @@ interface PrStatusIndicator { |
| 111 | 104 | ||
| 112 | type ThreadPr = GitStatusResult["pr"]; | 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 | function terminalStatusFromRunningIds( | 107 | function terminalStatusFromRunningIds( |
| 166 | runningTerminalIds: string[], | 108 | runningTerminalIds: string[], |
| 167 | ): TerminalStatusIndicator | null { | 109 | ): TerminalStatusIndicator | null { |
| ... | @@ -321,6 +263,13 @@ export default function Sidebar() { | ... | @@ -321,6 +263,13 @@ export default function Sidebar() { |
| 321 | } | 263 | } |
| 322 | return map; | 264 | return map; |
| 323 | }, [threads]); | 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 | const projectCwdById = useMemo( | 273 | const projectCwdById = useMemo( |
| 325 | () => new Map(projects.map((project) => [project.id, project.cwd] as const)), | 274 | () => new Map(projects.map((project) => [project.id, project.cwd] as const)), |
| 326 | [projects], | 275 | [projects], |
| ... | @@ -1268,10 +1217,11 @@ export default function Sidebar() { | ... | @@ -1268,10 +1217,11 @@ export default function Sidebar() { |
| 1268 | <SidebarMenuSub className="mx-1 my-0 w-full translate-x-0 gap-0 px-1.5 py-0"> | 1217 | <SidebarMenuSub className="mx-1 my-0 w-full translate-x-0 gap-0 px-1.5 py-0"> |
| 1269 | {visibleThreads.map((thread) => { | 1218 | {visibleThreads.map((thread) => { |
| 1270 | const isActive = routeThreadId === thread.id; | 1219 | const isActive = routeThreadId === thread.id; |
| 1271 | const threadStatus = threadStatusPill( | 1220 | const threadStatus = resolveThreadStatusPill({ |
| 1272 | thread, | 1221 | thread, |
| 1273 | pendingApprovalByThreadId.get(thread.id) === true, | 1222 | hasPendingApprovals: pendingApprovalByThreadId.get(thread.id) === true, |
| 1274 | ); | 1223 | hasPendingUserInput: pendingUserInputByThreadId.get(thread.id) === true, |
| 1224 | }); | ||
| 1275 | const prStatus = prStatusIndicator(prByThreadId.get(thread.id) ?? null); | 1225 | const prStatus = prStatusIndicator(prByThreadId.get(thread.id) ?? null); |
| 1276 | const terminalStatus = terminalStatusFromRunningIds( | 1226 | const terminalStatus = terminalStatusFromRunningIds( |
| 1277 | selectThreadTerminalState(terminalStateByThreadId, thread.id) | 1227 | selectThreadTerminalState(terminalStateByThreadId, thread.id) |