authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-09 09:14:08-07:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-03-09 09:46:33-07:00
logb030f69a833802fae9222781ef2dc822dfe81aac
tree0bdcaecd3a1d77b38f7f80a670e72b8426893c7f
parentb0e1b3368e47c8b470f988312309a9784421b69a

Show Awaiting Input status in sidebar and extract status logic

- 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

4 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;
17881789
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 @@
1import { describe, expect, it } from "vitest";
2
3import { hasUnseenCompletion, resolveThreadStatusPill } from "./Sidebar.logic";
4
5function 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
19describe("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
31describe("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 @@
1import type { Thread } from "../types";
2
3export interface ThreadStatusPill {
4 label: "Working" | "Connecting" | "Completed" | "Pending Approval" | "Awaiting Input";
5 colorClass: string;
6 dotClass: string;
7 pulse: boolean;
8}
9
10type ThreadStatusInput = Pick<Thread, "latestTurn" | "lastVisitedAt" | "session">;
11
12export 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
23export 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";
28import { newCommandId, newProjectId, newThreadId } from "../lib/utils";28import { newCommandId, newProjectId, newThreadId } from "../lib/utils";
29import { useStore } from "../store";29import { useStore } from "../store";
30import { isChatNewLocalShortcut, isChatNewShortcut, shortcutLabelForCommand } from "../keybindings";30import { isChatNewLocalShortcut, isChatNewShortcut, shortcutLabelForCommand } from "../keybindings";
31import { type Thread } from "../types";31import { derivePendingApprovals, derivePendingUserInputs } from "../session-logic";
32import { derivePendingApprovals } from "../session-logic";
33import { gitRemoveWorktreeMutationOptions, gitStatusQueryOptions } from "../lib/gitReactQuery";32import { gitRemoveWorktreeMutationOptions, gitStatusQueryOptions } from "../lib/gitReactQuery";
34import { serverConfigQueryOptions } from "../lib/serverReactQuery";33import { serverConfigQueryOptions } from "../lib/serverReactQuery";
35import { readNativeApi } from "../nativeApi";34import { readNativeApi } from "../nativeApi";
...@@ -68,6 +67,7 @@ import {...@@ -68,6 +67,7 @@ import {
68} from "./ui/sidebar";67} from "./ui/sidebar";
69import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup";68import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup";
70import { isNonEmpty as isNonEmptyString } from "effect/String";69import { isNonEmpty as isNonEmptyString } from "effect/String";
70import { resolveThreadStatusPill } from "./Sidebar.logic";
7171
72const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = [];72const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = [];
73const THREAD_PREVIEW_LIMIT = 6;73const 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}
9191
92interface ThreadStatusPill {
93 label: "Working" | "Connecting" | "Completed" | "Pending Approval";
94 colorClass: string;
95 dotClass: string;
96 pulse: boolean;
97}
98
99interface TerminalStatusIndicator {92interface 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 {
111104
112type ThreadPr = GitStatusResult["pr"];105type ThreadPr = GitStatusResult["pr"];
113106
114function 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
125function 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
165function terminalStatusFromRunningIds(107function 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)