Skip to content
Open
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
62 changes: 62 additions & 0 deletions apps/web/src/components/ChatView.browser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
type ProjectId,
type ServerConfig,
type ThreadId,
type TurnId,
type WsWelcomePayload,
WS_CHANNELS,
WS_METHODS,
Expand Down Expand Up @@ -514,6 +515,16 @@ async function waitForComposerEditor(): Promise<HTMLElement> {
);
}

async function waitForThreadSidebarItem(threadTitle: string): Promise<HTMLElement> {
return waitForElement(
() =>
Array.from(document.querySelectorAll<HTMLElement>("[data-thread-item]")).find((item) =>
item.textContent?.includes(threadTitle),
) ?? null,
`Unable to find sidebar item for thread "${threadTitle}".`,
);
}

async function waitForInteractionModeButton(
expectedLabel: "Chat" | "Plan",
): Promise<HTMLButtonElement> {
Expand Down Expand Up @@ -1048,6 +1059,57 @@ describe("ChatView timeline estimator parity (full app)", () => {
}
});

it("keeps the chat and sidebar working indicators visible when a resumed thread stays active", async () => {
const mounted = await mountChatView({
viewport: DEFAULT_VIEWPORT,
snapshot: createSnapshotForTargetUser({
targetMessageId: "msg-user-resume-working" as MessageId,
targetText: "resume working regression",
}),
});

try {
await vi.waitFor(
() => {
expect(useStore.getState().threadsHydrated).toBe(true);
expect(useStore.getState().threads).toHaveLength(1);
},
{ timeout: 8_000, interval: 16 },
);

useStore.setState((state) => ({
...state,
threads: state.threads.map((thread) =>
thread.id === THREAD_ID
? {
...thread,
session: thread.session
? {
...thread.session,
status: "ready",
orchestrationStatus: "running",
activeTurnId: "turn-resumed" as TurnId,
}
: thread.session,
}
: thread,
),
}));

await vi.waitFor(
() => {
expect(document.body.textContent).toContain("Working...");
},
{ timeout: 8_000, interval: 16 },
);

const threadItem = await waitForThreadSidebarItem("Browser test thread");
expect(threadItem.textContent).toContain("Working");
} finally {
await mounted.cleanup();
}
});

it("keeps long proposed plans lightweight until the user expands them", async () => {
const mounted = await mountChatView({
viewport: DEFAULT_VIEWPORT,
Expand Down
51 changes: 51 additions & 0 deletions apps/web/src/components/Sidebar.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,57 @@ describe("resolveThreadStatusPill", () => {
).toMatchObject({ label: "Working", pulse: true });
});

it("shows working when orchestration resumed but the legacy session status is ready", () => {
expect(
resolveThreadStatusPill({
thread: {
...baseThread,
session: {
...baseThread.session,
status: "ready",
orchestrationStatus: "running",
},
},
hasPendingApprovals: false,
hasPendingUserInput: false,
}),
).toMatchObject({ label: "Working", pulse: true });
});

it("shows connecting when orchestration is still starting", () => {
expect(
resolveThreadStatusPill({
thread: {
...baseThread,
session: {
...baseThread.session,
status: "ready",
orchestrationStatus: "starting",
},
},
hasPendingApprovals: false,
hasPendingUserInput: false,
}),
).toMatchObject({ label: "Connecting", pulse: true });
});

it("matches the canonical phase precedence when legacy and orchestration states disagree", () => {
expect(
resolveThreadStatusPill({
thread: {
...baseThread,
session: {
...baseThread.session,
status: "connecting",
orchestrationStatus: "running",
},
},
hasPendingApprovals: false,
hasPendingUserInput: false,
}),
).toMatchObject({ label: "Connecting", pulse: true });
});

it("shows plan ready when a settled plan turn has a proposed plan ready for follow-up", () => {
expect(
resolveThreadStatusPill({
Expand Down
7 changes: 4 additions & 3 deletions apps/web/src/components/Sidebar.logic.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { Thread } from "../types";
import { findLatestProposedPlan, isLatestTurnSettled } from "../session-logic";
import { derivePhase, findLatestProposedPlan, isLatestTurnSettled } from "../session-logic";

export const THREAD_SELECTION_SAFE_SELECTOR = "[data-thread-item], [data-thread-selection-safe]";

Expand Down Expand Up @@ -43,6 +43,7 @@ export function resolveThreadStatusPill(input: {
hasPendingUserInput: boolean;
}): ThreadStatusPill | null {
const { hasPendingApprovals, hasPendingUserInput, thread } = input;
const phase = derivePhase(thread.session);

if (hasPendingApprovals) {
return {
Expand All @@ -62,7 +63,7 @@ export function resolveThreadStatusPill(input: {
};
}

if (thread.session?.status === "running") {
if (phase === "running") {
return {
label: "Working",
colorClass: "text-sky-600 dark:text-sky-300/80",
Expand All @@ -71,7 +72,7 @@ export function resolveThreadStatusPill(input: {
};
}

if (thread.session?.status === "connecting") {
if (phase === "connecting") {
return {
label: "Connecting",
colorClass: "text-sky-600 dark:text-sky-300/80",
Expand Down
28 changes: 28 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,
derivePhase,
PROVIDER_OPTIONS,
derivePendingApprovals,
derivePendingUserInputs,
Expand Down Expand Up @@ -639,6 +640,33 @@ describe("deriveActiveWorkStartedAt", () => {
});
});

describe("derivePhase", () => {
it("treats a resumed session as running when orchestration is still active", () => {
expect(
derivePhase({
provider: "codex",
status: "ready",
createdAt: "2026-03-09T10:00:00.000Z",
updatedAt: "2026-03-09T10:00:01.000Z",
orchestrationStatus: "running",
activeTurnId: TurnId.makeUnsafe("turn-1"),
}),
).toBe("running");
});

it("treats orchestration startup as connecting even before the legacy status catches up", () => {
expect(
derivePhase({
provider: "codex",
status: "ready",
createdAt: "2026-03-09T10:00:00.000Z",
updatedAt: "2026-03-09T10:00:01.000Z",
orchestrationStatus: "starting",
}),
).toBe("connecting");
});
});

describe("PROVIDER_OPTIONS", () => {
it("keeps Claude Code and Cursor visible as unavailable placeholders in the stack base", () => {
const claude = PROVIDER_OPTIONS.find((option) => option.value === "claudeCode");
Expand Down
15 changes: 13 additions & 2 deletions apps/web/src/session-logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,17 @@ export function formatElapsed(startIso: string, endIso: string | undefined): str

type LatestTurnTiming = Pick<OrchestrationLatestTurn, "turnId" | "startedAt" | "completedAt">;
type SessionActivityState = Pick<ThreadSession, "orchestrationStatus" | "activeTurnId">;
type SessionPhaseState = Pick<ThreadSession, "status" | "orchestrationStatus">;

export function isSessionConnecting(session: SessionPhaseState | null): boolean {
if (!session) return false;
return session.status === "connecting" || session.orchestrationStatus === "starting";
}

export function isSessionRunning(session: SessionPhaseState | null): boolean {
if (!session) return false;
return session.status === "running" || session.orchestrationStatus === "running";
}

export function isLatestTurnSettled(
latestTurn: LatestTurnTiming | null,
Expand Down Expand Up @@ -614,7 +625,7 @@ export function inferCheckpointTurnCountByTurnId(

export function derivePhase(session: ThreadSession | null): SessionPhase {
if (!session || session.status === "closed") return "disconnected";
if (session.status === "connecting") return "connecting";
if (session.status === "running") return "running";
if (isSessionConnecting(session)) return "connecting";
if (isSessionRunning(session)) return "running";
return "ready";
}