Skip to content
Draft
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
9 changes: 8 additions & 1 deletion apps/web/src/components/LegacySidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import {
terminalStatusFromRunningIds,
ThreadStatusLabel,
ThreadWorktreeIndicator,
TurnCompletionTickIcon,
useTurnCompletionTick,
} from "./ThreadStatusIndicators";
import { ProjectFavicon } from "./ProjectFavicon";
import { useAtomValue } from "@effect/atom-react";
Expand Down Expand Up @@ -455,6 +457,7 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr
lastVisitedAt,
},
});
const showCompletionTick = useTurnCompletionTick(thread);
const pr = resolveThreadPr({
threadBranch: thread.branch,
gitStatus: gitStatus.data,
Expand Down Expand Up @@ -713,7 +716,11 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr
</TooltipPopup>
</Tooltip>
)}
{threadStatus && <ThreadStatusLabel status={threadStatus} />}
{threadStatus ? (
<ThreadStatusLabel status={threadStatus} completionTick={showCompletionTick} />
) : showCompletionTick ? (
<TurnCompletionTickIcon className="size-3" />
) : null}
{renamingThreadKey === threadKey ? (
<input
ref={handleRenameInputRef}
Expand Down
167 changes: 159 additions & 8 deletions apps/web/src/components/Sidebar.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
buildBulkTitleRegenerationContextMenuItem,
buildMultiSelectThreadContextMenuItems,
createThreadJumpHintVisibilityController,
EMPTY_TURN_COMPLETION_TICK_STATE,
getSidebarThreadIdsToPrewarm,
getVisibleSidebarThreadIds,
resolveAdjacentThreadId,
Expand All @@ -19,6 +20,7 @@ import {
resolveSidebarStageBadgeLabel,
resolveThreadRowClassName,
resolveSidebarThreadStatus,
reduceTurnCompletionTick,
resolveThreadStatusPill,
resolveWorkingStartedAt,
searchSidebarThreadsByTitle,
Expand Down Expand Up @@ -244,19 +246,15 @@ describe("resolveSidebarStageBadgeLabel", () => {
});
});

function makeLatestTurn(overrides?: {
completedAt?: string | null;
startedAt?: string | null;
}): OrchestrationLatestTurn {
function makeLatestTurn(overrides: Partial<OrchestrationLatestTurn> = {}): OrchestrationLatestTurn {
return {
turnId: "turn-1" as never,
state: "completed",
assistantMessageId: null,
requestedAt: "2026-03-09T10:00:00.000Z",
startedAt:
overrides?.startedAt !== undefined ? overrides.startedAt : "2026-03-09T10:00:00.000Z",
completedAt:
overrides?.completedAt !== undefined ? overrides.completedAt : "2026-03-09T10:05:00.000Z",
startedAt: "2026-03-09T10:00:00.000Z",
completedAt: "2026-03-09T10:05:00.000Z",
...overrides,
};
}

Expand Down Expand Up @@ -735,6 +733,159 @@ describe("resolveSidebarThreadStatus", () => {
});
});

describe("reduceTurnCompletionTick", () => {
const runningSession = {
threadId: ThreadId.make("thread-1"),
status: "running" as const,
providerName: "Codex",
providerInstanceId: ProviderInstanceId.make("codex"),
runtimeMode: DEFAULT_RUNTIME_MODE,
activeTurnId: "turn-1" as never,
lastError: null,
updatedAt: "2026-03-09T10:00:00.000Z",
};
const observe = (
state: typeof EMPTY_TURN_COMPLETION_TICK_STATE,
overrides: Partial<Parameters<typeof reduceTurnCompletionTick>[1]> = {},
) =>
reduceTurnCompletionTick(state, {
latestTurn: makeLatestTurn({ state: "running", completedAt: null }),
latestUserMessageAt: "2026-03-09T10:00:00.000Z",
session: runningSession,
canObserveLiveTransitions: true,
isForeground: true,
...overrides,
});

it("starts once for a live same-turn running to completed transition", () => {
const running = observe(EMPTY_TURN_COMPLETION_TICK_STATE);
expect(running.action).toBe("none");
expect(running.state.phase).toBe("running");

const completed = observe(running.state, {
latestTurn: makeLatestTurn(),
session: { ...runningSession, status: "ready", activeTurnId: null },
});
expect(completed.action).toBe("start");
expect(completed.state.phase).toBe("terminal");

expect(
observe(completed.state, {
latestTurn: makeLatestTurn(),
session: { ...runningSession, status: "ready", activeTurnId: null },
}).action,
).toBe("none");
expect(observe(completed.state).action).toBe("none");

const correctedToError = observe(completed.state, {
latestTurn: makeLatestTurn({ state: "error" }),
session: {
...runningSession,
status: "error",
activeTurnId: null,
lastError: "late failure",
},
});
expect(correctedToError.action).toBe("cancel");
});

it("does not start from completed hydration or remount state", () => {
const completed = observe(EMPTY_TURN_COMPLETION_TICK_STATE, {
latestTurn: makeLatestTurn(),
session: { ...runningSession, status: "ready", activeTurnId: null },
});

expect(completed.action).toBe("none");
expect(completed.state.phase).toBe("terminal");
});

it.each([
["error", "error"],
["interrupted", "interrupted"],
["interrupted", "stopped"],
] as const)("cancels for %s turn / %s session", (turnState, sessionStatus) => {
const running = observe(EMPTY_TURN_COMPLETION_TICK_STATE);
const ended = observe(running.state, {
latestTurn: makeLatestTurn({ state: turnState }),
session: {
...runningSession,
status: sessionStatus,
activeTurnId: null,
lastError: turnState === "error" ? "boom" : null,
},
});

expect(ended.action).toBe("cancel");
expect(ended.state.phase).toBe("terminal");
});

it.each([
{ canObserveLiveTransitions: false, isForeground: true },
{ canObserveLiveTransitions: true, isForeground: false },
])("does not replay a completion after the live foreground gate resets", (gate) => {
const running = observe(EMPTY_TURN_COMPLETION_TICK_STATE);
const reset = observe(running.state, gate);
expect(reset.action).toBe("cancel");
expect(reset.state).toBe(EMPTY_TURN_COMPLETION_TICK_STATE);

const completed = observe(reset.state, {
latestTurn: makeLatestTurn(),
session: { ...runningSession, status: "ready", activeTurnId: null },
});
expect(completed.action).toBe("none");
});

it("cancels an old tick for a new turn and ignores an older replay", () => {
const running = observe(EMPTY_TURN_COMPLETION_TICK_STATE);
const completed = observe(running.state, {
latestTurn: makeLatestTurn(),
session: { ...runningSession, status: "ready", activeTurnId: null },
});
expect(completed.action).toBe("start");

const nextRunning = observe(completed.state, {
latestTurn: makeLatestTurn({
turnId: "turn-2" as never,
state: "running",
requestedAt: "2026-03-09T11:00:00.000Z",
startedAt: "2026-03-09T11:00:00.000Z",
completedAt: null,
}),
session: {
...runningSession,
activeTurnId: "turn-2" as never,
updatedAt: "2026-03-09T11:00:00.000Z",
},
});
expect(nextRunning.action).toBe("cancel");
expect(nextRunning.state).toMatchObject({ turnId: "turn-2", phase: "running" });

const staleCompletion = observe(nextRunning.state, {
latestTurn: makeLatestTurn(),
session: { ...runningSession, status: "ready", activeTurnId: null },
});
expect(staleCompletion.action).toBe("none");
expect(staleCompletion.state).toBe(nextRunning.state);
});

it("cancels as soon as a newer user message queues the next turn", () => {
const running = observe(EMPTY_TURN_COMPLETION_TICK_STATE);
const completed = observe(running.state, {
latestTurn: makeLatestTurn(),
session: { ...runningSession, status: "ready", activeTurnId: null },
});
expect(completed.action).toBe("start");

const queued = observe(completed.state, {
latestTurn: makeLatestTurn(),
latestUserMessageAt: "2026-03-09T11:00:00.000Z",
session: { ...runningSession, status: "ready", activeTurnId: null },
});
expect(queued.action).toBe("cancel");
expect(queued.state).toBe(EMPTY_TURN_COMPLETION_TICK_STATE);
});
});

describe("searchSidebarThreadsByTitle", () => {
const threads = [
{ id: "thread-1", title: "Fix workspace search", project: "Alpha" },
Expand Down
141 changes: 141 additions & 0 deletions apps/web/src/components/Sidebar.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,147 @@ export type SidebarThreadStatus =
| "failed"
| "ready";

export interface TurnCompletionTickState {
readonly turnId: string | null;
readonly requestedAtMs: number | null;
readonly phase: "idle" | "running" | "terminal";
}

export const EMPTY_TURN_COMPLETION_TICK_STATE: TurnCompletionTickState = {
turnId: null,
requestedAtMs: null,
phase: "idle",
};

export interface TurnCompletionTickTransition {
readonly state: TurnCompletionTickState;
readonly action: "none" | "start" | "cancel";
}

type TurnCompletionTickInput = Pick<
SidebarThreadSummary,
"latestTurn" | "latestUserMessageAt" | "session"
> & {
readonly canObserveLiveTransitions: boolean;
readonly isForeground: boolean;
};

function turnRequestedAtMs(value: string): number | null {
const parsed = Date.parse(value);
return Number.isNaN(parsed) ? null : parsed;
}

function latestTurnActivityAtMs(turn: SidebarThreadSummary["latestTurn"]): number | null {
if (turn === null) return null;
let latest: number | null = null;
for (const value of [turn.requestedAt, turn.startedAt, turn.completedAt]) {
if (value === null) continue;
const parsed = turnRequestedAtMs(value);
if (parsed !== null && (latest === null || parsed > latest)) latest = parsed;
}
return latest;
}

/**
* Tracks one normalized latest-turn edge. The caller supplies the shell's
* live/synchronized provenance; without it, a running snapshot followed by
* replayed completion would be indistinguishable from new work.
*/
export function reduceTurnCompletionTick(
previous: TurnCompletionTickState,
input: TurnCompletionTickInput,
): TurnCompletionTickTransition {
if (!input.canObserveLiveTransitions || !input.isForeground) {
return { state: EMPTY_TURN_COMPLETION_TICK_STATE, action: "cancel" };
}

const latestTurn = input.latestTurn;
const latestUserMessageAtMs =
input.latestUserMessageAt === null ? null : turnRequestedAtMs(input.latestUserMessageAt);
const latestTurnActivityMs = latestTurnActivityAtMs(latestTurn);
if (
previous.turnId !== null &&
latestTurn?.turnId === previous.turnId &&
latestUserMessageAtMs !== null &&
latestTurnActivityMs !== null &&
latestUserMessageAtMs > latestTurnActivityMs
) {
return { state: EMPTY_TURN_COMPLETION_TICK_STATE, action: "cancel" };
}
const runningSessionTurnId =
input.session?.status === "running" ? input.session.activeTurnId : null;
const turnId = runningSessionTurnId ?? latestTurn?.turnId ?? null;
if (turnId === null) {
return { state: EMPTY_TURN_COMPLETION_TICK_STATE, action: "cancel" };
}

const requestedAt =
latestTurn?.turnId === turnId ? latestTurn.requestedAt : (input.session?.updatedAt ?? "");
const requestedAtMs = turnRequestedAtMs(requestedAt);
if (previous.turnId !== null && previous.turnId !== turnId) {
if (
previous.requestedAtMs !== null &&
requestedAtMs !== null &&
requestedAtMs < previous.requestedAtMs
) {
return { state: previous, action: "none" };
}
const isRunning =
latestTurn?.turnId === turnId &&
latestTurn.state === "running" &&
input.session?.status === "running" &&
input.session.activeTurnId === turnId;
return {
state: {
turnId,
requestedAtMs,
phase: isRunning ? "running" : "idle",
},
action: "cancel",
};
}

const current =
previous.turnId === turnId ? previous : { turnId, requestedAtMs, phase: "idle" as const };

const isUnsuccessfulTerminal =
(latestTurn?.turnId === turnId &&
(latestTurn.state === "error" || latestTurn.state === "interrupted")) ||
input.session?.status === "error" ||
input.session?.status === "interrupted" ||
input.session?.status === "stopped";
if (isUnsuccessfulTerminal) {
return { state: { ...current, phase: "terminal" }, action: "cancel" };
}

if (current.phase === "terminal") {
return { state: current, action: "none" };
}

const isRunning =
latestTurn?.turnId === turnId &&
latestTurn.state === "running" &&
input.session?.status === "running" &&
input.session.activeTurnId === turnId;
if (isRunning) {
return { state: { ...current, phase: "running" }, action: "none" };
}

const isSuccessfulCompletion =
latestTurn?.turnId === turnId &&
latestTurn.state === "completed" &&
latestTurn.completedAt !== null &&
(input.session?.status === "idle" || input.session?.status === "ready");
if (isSuccessfulCompletion) {
return {
state: { ...current, phase: "terminal" },
action: current.phase === "running" ? "start" : "none",
};
}

return { state: current, action: "none" };
}

type SidebarThreadStatusInput = Pick<
SidebarThreadSummary,
"hasPendingApprovals" | "hasPendingUserInput" | "session" | "backgroundLiveness"
Expand Down
Loading
Loading