Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -4820,7 +4821,12 @@ const ProposedPlanCard = memo(function ProposedPlanCard({
</div>
{canCollapse ? (
<div className="mt-4 flex justify-center">
<Button size="sm" variant="outline" onClick={() => setExpanded((value) => !value)}>
<Button
size="sm"
variant="outline"
data-scroll-anchor-ignore
onClick={() => setExpanded((value) => !value)}
>
{expanded ? "Collapse plan" : "Expand plan"}
</Button>
</div>
Expand Down
124 changes: 124 additions & 0 deletions apps/web/src/components/Sidebar.logic.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof hasUnseenCompletion>[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 });
});
});
100 changes: 100 additions & 0 deletions apps/web/src/components/Sidebar.logic.ts
Original file line number Diff line number Diff line change
@@ -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;
}
76 changes: 13 additions & 63 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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 {
Expand Down Expand Up @@ -319,6 +261,13 @@ export default function Sidebar() {
}
return map;
}, [threads]);
const pendingUserInputByThreadId = useMemo(() => {
const map = new Map<ThreadId, boolean>();
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],
Expand Down Expand Up @@ -1241,10 +1190,11 @@ export default function Sidebar() {
<SidebarMenuSub className="mx-1 my-0 w-full translate-x-0 gap-0 px-1.5 py-0">
{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)
Expand Down