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)