Skip to content
Closed
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
142 changes: 142 additions & 0 deletions apps/web/src/components/Sidebar.logic.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import { ProjectId, ThreadId, TurnId } from "@t3tools/contracts";
import { describe, expect, it } from "vitest";
import type { Thread } from "../types";
import { resolveThreadStatusPill } from "./Sidebar.logic";

function makeThread(overrides: Partial<Thread> = {}): Thread {
return {
id: ThreadId.makeUnsafe("thread-1"),
codexThreadId: null,
projectId: ProjectId.makeUnsafe("project-1"),
title: "Thread",
model: "gpt-5-codex",
runtimeMode: "full-access",
interactionMode: "default",
session: null,
messages: [],
proposedPlans: [],
error: null,
createdAt: "2026-03-09T00:00:00.000Z",
latestTurn: null,
branch: null,
worktreePath: null,
turnDiffSummaries: [],
activities: [],
...overrides,
};
}

describe("resolveThreadStatusPill", () => {
it("returns Pending Approval when an approval request is open", () => {
expect(
resolveThreadStatusPill(
makeThread({
session: {
provider: "codex",
status: "running",
orchestrationStatus: "running",
createdAt: "2026-03-09T00:00:00.000Z",
updatedAt: "2026-03-09T00:00:00.000Z",
},
}),
{ hasPendingApproval: true, hasPendingUserInput: false },
),
).toMatchObject({ label: "Pending Approval", pulse: false });
});

it("returns Pending Approval when a user-input request is open", () => {
expect(
resolveThreadStatusPill(
makeThread({
session: {
provider: "codex",
status: "running",
orchestrationStatus: "running",
createdAt: "2026-03-09T00:00:00.000Z",
updatedAt: "2026-03-09T00:00:00.000Z",
},
}),
{ hasPendingApproval: false, hasPendingUserInput: true },
),
).toMatchObject({ label: "Pending Approval", pulse: false });
});

it("returns Pending Approval when both request types are open", () => {
expect(
resolveThreadStatusPill(
makeThread({
session: {
provider: "codex",
status: "running",
orchestrationStatus: "running",
createdAt: "2026-03-09T00:00:00.000Z",
updatedAt: "2026-03-09T00:00:00.000Z",
},
}),
{ hasPendingApproval: true, hasPendingUserInput: true },
),
).toMatchObject({ label: "Pending Approval", pulse: false });
});

it("returns Working when the session is running without blocking requests", () => {
expect(
resolveThreadStatusPill(
makeThread({
session: {
provider: "codex",
status: "running",
orchestrationStatus: "running",
createdAt: "2026-03-09T00:00:00.000Z",
updatedAt: "2026-03-09T00:00:00.000Z",
},
}),
{ hasPendingApproval: false, hasPendingUserInput: false },
),
).toMatchObject({ label: "Working", pulse: true });
});

it("returns Connecting when the session is connecting without blocking requests", () => {
expect(
resolveThreadStatusPill(
makeThread({
session: {
provider: "codex",
status: "connecting",
orchestrationStatus: "starting",
createdAt: "2026-03-09T00:00:00.000Z",
updatedAt: "2026-03-09T00:00:00.000Z",
},
}),
{ hasPendingApproval: false, hasPendingUserInput: false },
),
).toMatchObject({ label: "Connecting", pulse: true });
});

it("returns Completed when there is unseen completion and no stronger status", () => {
expect(
resolveThreadStatusPill(
makeThread({
latestTurn: {
turnId: TurnId.makeUnsafe("turn-1"),
state: "completed",
requestedAt: "2026-03-09T00:00:00.000Z",
startedAt: "2026-03-09T00:00:01.000Z",
completedAt: "2026-03-09T00:00:02.000Z",
assistantMessageId: null,
},
lastVisitedAt: "2026-03-09T00:00:01.500Z",
}),
{ hasPendingApproval: false, hasPendingUserInput: false },
),
).toMatchObject({ label: "Completed", pulse: false });
});

it("returns null when no status applies", () => {
expect(
resolveThreadStatusPill(makeThread(), {
hasPendingApproval: false,
hasPendingUserInput: false,
}),
).toBeNull();
});
});
67 changes: 67 additions & 0 deletions apps/web/src/components/Sidebar.logic.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import type { Thread } from "../types";

export interface ThreadStatusPill {
label: "Working" | "Connecting" | "Completed" | "Pending Approval";
colorClass: string;
dotClass: string;
pulse: boolean;
}

interface ResolveThreadStatusPillOptions {
hasPendingApproval: boolean;
hasPendingUserInput: boolean;
}

export 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;
}

export function resolveThreadStatusPill(
thread: Thread,
options: ResolveThreadStatusPillOptions,
): ThreadStatusPill | null {
if (options.hasPendingApproval || options.hasPendingUserInput) {
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;
}
77 changes: 13 additions & 64 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 @@ -66,6 +65,7 @@ import {
SidebarSeparator,
SidebarTrigger,
} from "./ui/sidebar";
import { resolveThreadStatusPill } from "./Sidebar.logic";
import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup";
import { isNonEmpty as isNonEmptyString } from "effect/String";

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,10 @@ 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(
thread,
pendingApprovalByThreadId.get(thread.id) === true,
);
const threadStatus = resolveThreadStatusPill(thread, {
hasPendingApproval: 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