History

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

Julius Marminge committed 8 months ago

Commit b030f691 parent b0e1b334 files · +188 −64Browse files

Unified · Split

4 files
  1. apps/web/src/components/ChatView.tsx +7 −1
  2. apps/web/src/components/Sidebar.logic.test.ts +92 −0
  3. apps/web/src/components/Sidebar.logic.ts +76 −0
  4. apps/web/src/components/Sidebar.tsx +13 −63
apps/web/src/components/ChatView.tsx+7−1
@@ -1785,6 +1785,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 1784 unmodified lines
17851785 "button, summary, [role='button'], [data-scroll-anchor-target]",
17861786 );
17871787 if (!trigger || !scrollContainer.contains(trigger)) return;
1788+ if (trigger.closest("[data-scroll-anchor-ignore]")) return;
17881789
17891790 pendingInteractionAnchorRef.current = {
17901791 element: trigger,
@@ -4820,7 +4821,12 @@ const ProposedPlanCard = memo(function ProposedPlanCard({ · 3029 unmodified lines
48204821 </div>
48214822 {canCollapse ? (
48224823 <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+ >
48244830 {expanded ? "Collapse plan" : "Expand plan"}
48254831 </Button>
48264832 </div>
apps/web/src/components/Sidebar.logic.test.ts+92−0
@@ -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+76−0
@@ -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"; · 27 unmodified lines
2828 import { newCommandId, newProjectId, newThreadId } from "../lib/utils";
2929 import { useStore } from "../store";
3030 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";
3332 import { gitRemoveWorktreeMutationOptions, gitStatusQueryOptions } from "../lib/gitReactQuery";
3433 import { serverConfigQueryOptions } from "../lib/serverReactQuery";
3534 import { readNativeApi } from "../nativeApi";
@@ -68,6 +67,7 @@ import { · 32 unmodified lines
6867 } from "./ui/sidebar";
6968 import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup";
7069 import { isNonEmpty as isNonEmptyString } from "effect/String";
70+import { resolveThreadStatusPill } from "./Sidebar.logic";
7171
7272 const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = [];
7373 const THREAD_PREVIEW_LIMIT = 6;
@@ -89,13 +89,6 @@ function formatRelativeTime(iso: string): string { · 15 unmodified lines
8989 return `${Math.floor(hours / 24)}d ago`;
9090 }
9191
92−interface ThreadStatusPill {
93− label: "Working" | "Connecting" | "Completed" | "Pending Approval";
94− colorClass: string;
95− dotClass: string;
96− pulse: boolean;
97−}
98−
9992 interface TerminalStatusIndicator {
10093 label: "Terminal process running";
10194 colorClass: string;
@@ -111,57 +104,6 @@ interface PrStatusIndicator { · 9 unmodified lines
111104
112105 type ThreadPr = GitStatusResult["pr"];
113106
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−
165107 function terminalStatusFromRunningIds(
166108 runningTerminalIds: string[],
167109 ): TerminalStatusIndicator | null {
@@ -321,6 +263,13 @@ export default function Sidebar() { · 153 unmodified lines
321263 }
322264 return map;
323265 }, [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]);
324273 const projectCwdById = useMemo(
325274 () => new Map(projects.map((project) => [project.id, project.cwd] as const)),
326275 [projects],
@@ -1268,10 +1217,11 @@ export default function Sidebar() { · 941 unmodified lines
12681217 <SidebarMenuSub className="mx-1 my-0 w-full translate-x-0 gap-0 px-1.5 py-0">
12691218 {visibleThreads.map((thread) => {
12701219 const isActive = routeThreadId === thread.id;
1271− const threadStatus = threadStatusPill(
1220+ const threadStatus = resolveThreadStatusPill({
12721221 thread,
1273− pendingApprovalByThreadId.get(thread.id) === true,
1274− );
1222+ hasPendingApprovals: pendingApprovalByThreadId.get(thread.id) === true,
1223+ hasPendingUserInput: pendingUserInputByThreadId.get(thread.id) === true,
1224+ });
12751225 const prStatus = prStatusIndicator(prByThreadId.get(thread.id) ?? null);
12761226 const terminalStatus = terminalStatusFromRunningIds(
12771227 selectThreadTerminalState(terminalStateByThreadId, thread.id)

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t