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
101 changes: 41 additions & 60 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ 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 { deriveThreadStatusState } from "../session-logic";
import { gitRemoveWorktreeMutationOptions, gitStatusQueryOptions } from "../lib/gitReactQuery";
import { serverConfigQueryOptions } from "../lib/serverReactQuery";
import { readNativeApi } from "../nativeApi";
Expand Down Expand Up @@ -82,7 +82,7 @@ function formatRelativeTime(iso: string): string {
}

interface ThreadStatusPill {
label: "Working" | "Connecting" | "Completed" | "Pending Approval";
label: "Working" | "Connecting" | "Completed" | "Awaiting response";
colorClass: string;
dotClass: string;
pulse: boolean;
Expand All @@ -103,55 +103,46 @@ 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,
};
}
function threadStatusPill(thread: Thread): ThreadStatusPill | null {
const state = deriveThreadStatusState({
session: thread.session,
latestTurn: thread.latestTurn,
lastVisitedAt: thread.lastVisitedAt,
activities: thread.activities,
});

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,
};
switch (state) {
case "awaiting-response":
return {
label: "Awaiting response",
colorClass: "text-amber-600 dark:text-amber-300/90",
dotClass: "bg-amber-500 dark:bg-amber-300/90",
pulse: false,
};
case "working":
return {
label: "Working",
colorClass: "text-sky-600 dark:text-sky-300/80",
dotClass: "bg-sky-500 dark:bg-sky-300/80",
pulse: true,
};
case "connecting":
return {
label: "Connecting",
colorClass: "text-sky-600 dark:text-sky-300/80",
dotClass: "bg-sky-500 dark:bg-sky-300/80",
pulse: true,
};
case "completed":
return {
label: "Completed",
colorClass: "text-emerald-600 dark:text-emerald-300/90",
dotClass: "bg-emerald-500 dark:bg-emerald-300/90",
pulse: false,
};
default:
return null;
}

return null;
}

function terminalStatusFromRunningIds(
Expand Down Expand Up @@ -301,13 +292,6 @@ export default function Sidebar() {
const renamingCommittedRef = useRef(false);
const renamingInputRef = useRef<HTMLInputElement | null>(null);
const [desktopUpdateState, setDesktopUpdateState] = useState<DesktopUpdateState | null>(null);
const pendingApprovalByThreadId = useMemo(() => {
const map = new Map<ThreadId, boolean>();
for (const thread of threads) {
map.set(thread.id, derivePendingApprovals(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 @@ -1112,10 +1096,7 @@ 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 = threadStatusPill(thread);
const prStatus = prStatusIndicator(prByThreadId.get(thread.id) ?? null);
const terminalStatus = terminalStatusFromRunningIds(
selectThreadTerminalState(terminalStateByThreadId, thread.id)
Expand Down
240 changes: 240 additions & 0 deletions apps/web/src/session-logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { describe, expect, it } from "vitest";
import {
deriveActiveWorkStartedAt,
deriveActivePlanState,
deriveThreadStatusState,
PROVIDER_OPTIONS,
derivePendingApprovals,
derivePendingUserInputs,
Expand All @@ -13,6 +14,7 @@ import {
hasToolActivityForTurn,
isLatestTurnSettled,
} from "./session-logic";
import type { ThreadSession } from "./types";

function makeActivity(overrides: {
id?: string;
Expand Down Expand Up @@ -260,6 +262,244 @@ describe("deriveActivePlanState", () => {
});
});

function makeSession(
overrides: Partial<ThreadSession> = {},
): ThreadSession {
return {
provider: "codex",
status: "ready",
createdAt: "2026-02-23T00:00:00.000Z",
updatedAt: "2026-02-23T00:00:00.000Z",
orchestrationStatus: "ready",
...overrides,
};
}

describe("deriveThreadStatusState", () => {
it("returns awaiting-response for unresolved approvals", () => {
const activities: OrchestrationThreadActivity[] = [
makeActivity({
kind: "approval.requested",
tone: "approval",
summary: "Command approval requested",
payload: {
requestId: "req-approval-1",
requestKind: "command",
},
}),
];

expect(
deriveThreadStatusState({
session: makeSession({ status: "running", orchestrationStatus: "running" }),
latestTurn: null,
activities,
}),
).toBe("awaiting-response");
});

it("returns awaiting-response for unresolved user input", () => {
const activities: OrchestrationThreadActivity[] = [
makeActivity({
kind: "user-input.requested",
tone: "info",
summary: "User input requested",
payload: {
requestId: "req-user-input-1",
questions: [
{
id: "sandbox_mode",
header: "Sandbox",
question: "Which mode should be used?",
options: [
{
label: "workspace-write",
description: "Allow workspace writes only",
},
],
},
],
},
}),
];

expect(
deriveThreadStatusState({
session: makeSession({ status: "running", orchestrationStatus: "running" }),
latestTurn: null,
activities,
}),
).toBe("awaiting-response");
});

it("returns awaiting-response when approvals and user input are both open", () => {
const activities: OrchestrationThreadActivity[] = [
makeActivity({
id: "approval-open",
createdAt: "2026-02-23T00:00:01.000Z",
kind: "approval.requested",
tone: "approval",
summary: "Command approval requested",
payload: {
requestId: "req-approval-1",
requestKind: "command",
},
}),
makeActivity({
id: "user-input-open",
createdAt: "2026-02-23T00:00:02.000Z",
kind: "user-input.requested",
tone: "info",
summary: "User input requested",
payload: {
requestId: "req-user-input-1",
questions: [
{
id: "approval",
header: "Approval",
question: "Continue?",
options: [
{
label: "yes",
description: "Continue execution",
},
],
},
],
},
}),
];

expect(
deriveThreadStatusState({
session: makeSession({ status: "running", orchestrationStatus: "running" }),
latestTurn: null,
activities,
}),
).toBe("awaiting-response");
});

it("falls back to working after blocked activity resolves", () => {
const activities: OrchestrationThreadActivity[] = [
makeActivity({
id: "approval-open",
createdAt: "2026-02-23T00:00:01.000Z",
kind: "approval.requested",
tone: "approval",
summary: "Command approval requested",
payload: {
requestId: "req-approval-1",
requestKind: "command",
},
}),
makeActivity({
id: "approval-resolved",
createdAt: "2026-02-23T00:00:02.000Z",
kind: "approval.resolved",
tone: "info",
summary: "Approval resolved",
payload: {
requestId: "req-approval-1",
},
}),
];

expect(
deriveThreadStatusState({
session: makeSession({ status: "running", orchestrationStatus: "running" }),
latestTurn: null,
activities,
}),
).toBe("working");
});

it("returns connecting when there is no blocked activity", () => {
expect(
deriveThreadStatusState({
session: makeSession({ status: "connecting", orchestrationStatus: "starting" }),
latestTurn: null,
activities: [],
}),
).toBe("connecting");
});

it("returns completed for unseen completed turns", () => {
expect(
deriveThreadStatusState({
session: makeSession(),
latestTurn: {
turnId: TurnId.makeUnsafe("turn-1"),
startedAt: "2026-02-23T00:00:01.000Z",
completedAt: "2026-02-23T00:00:02.000Z",
},
lastVisitedAt: "2026-02-23T00:00:01.500Z",
activities: [],
}),
).toBe("completed");
});

it("prefers awaiting-response over completed", () => {
const activities: OrchestrationThreadActivity[] = [
makeActivity({
kind: "user-input.requested",
tone: "info",
summary: "User input requested",
payload: {
requestId: "req-user-input-1",
questions: [
{
id: "sandbox_mode",
header: "Sandbox",
question: "Which mode should be used?",
options: [
{
label: "workspace-write",
description: "Allow workspace writes only",
},
],
},
],
},
}),
];

expect(
deriveThreadStatusState({
session: makeSession(),
latestTurn: {
turnId: TurnId.makeUnsafe("turn-1"),
startedAt: "2026-02-23T00:00:01.000Z",
completedAt: "2026-02-23T00:00:02.000Z",
},
lastVisitedAt: "2026-02-23T00:00:01.500Z",
activities,
}),
).toBe("awaiting-response");
});

it("prefers awaiting-response over running", () => {
const activities: OrchestrationThreadActivity[] = [
makeActivity({
kind: "approval.requested",
tone: "approval",
summary: "Command approval requested",
payload: {
requestId: "req-approval-1",
requestKind: "command",
},
}),
];

expect(
deriveThreadStatusState({
session: makeSession({ status: "running", orchestrationStatus: "running" }),
latestTurn: null,
activities,
}),
).toBe("awaiting-response");
});
});

describe("findLatestProposedPlan", () => {
it("prefers the latest proposed plan for the active turn", () => {
expect(
Expand Down
Loading