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
79 changes: 79 additions & 0 deletions apps/web/src/components/ChatView.browser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import "../index.css";

import {
EventId,
ORCHESTRATION_WS_METHODS,
type MessageId,
type OrchestrationReadModel,
Expand Down Expand Up @@ -256,6 +257,66 @@ function createDraftOnlySnapshot(): OrchestrationReadModel {
};
}

function createAwaitingResponseSnapshot(): OrchestrationReadModel {
const snapshot = createSnapshotForTargetUser({
targetMessageId: "msg-user-awaiting-response" as MessageId,
targetText: "status target",
});
const [thread] = snapshot.threads;
if (!thread) {
return snapshot;
}

return {
...snapshot,
threads: [
{
...thread,
activities: [
{
id: EventId.makeUnsafe("evt-user-input-requested"),
createdAt: isoAt(90),
kind: "user-input.requested",
summary: "Need user input",
tone: "info",
turnId: null,
payload: {
requestId: "req-awaiting-response",
questions: [
{
id: "scope",
header: "Scope",
question: "Which path should we take?",
options: [
{
label: "Small fix",
description: "Keep the change focused.",
},
],
},
],
},
},
],
session: thread.session
? {
...thread.session,
status: "running",
}
: {
threadId: THREAD_ID,
status: "running",
providerName: "codex",
runtimeMode: "full-access",
activeTurnId: null,
lastError: null,
updatedAt: NOW_ISO,
},
},
],
};
}

function resolveWsRpc(tag: string): unknown {
if (tag === ORCHESTRATION_WS_METHODS.getSnapshot) {
return fixture.snapshot;
Expand Down Expand Up @@ -803,6 +864,24 @@ describe("ChatView timeline estimator parity (full app)", () => {
}
});

it("shows Waiting in the sidebar when a thread is blocked on structured user input", async () => {
const mounted = await mountChatView({
viewport: DEFAULT_VIEWPORT,
snapshot: createAwaitingResponseSnapshot(),
});

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

it("toggles plan mode with Shift+Tab only while the composer is focused", async () => {
const mounted = await mountChatView({
viewport: DEFAULT_VIEWPORT,
Expand Down
45 changes: 20 additions & 25 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,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 { deriveThreadStatusState, type ThreadStatusState } from "../session-logic";
import { gitRemoveWorktreeMutationOptions, gitStatusQueryOptions } from "../lib/gitReactQuery";
import { serverConfigQueryOptions } from "../lib/serverReactQuery";
import { readNativeApi } from "../nativeApi";
Expand Down Expand Up @@ -85,7 +84,7 @@ function formatRelativeTime(iso: string): string {
}

interface ThreadStatusPill {
label: "Working" | "Connecting" | "Completed" | "Pending Approval";
label: "Working" | "Connecting" | "Completed" | "Waiting";
colorClass: string;
dotClass: string;
pulse: boolean;
Expand All @@ -106,28 +105,17 @@ 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) {
function threadStatusPill(status: ThreadStatusState): ThreadStatusPill | null {
if (status === "awaiting-response") {
return {
label: "Pending Approval",
label: "Waiting",
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") {
if (status === "working") {
return {
label: "Working",
colorClass: "text-sky-600 dark:text-sky-300/80",
Expand All @@ -136,7 +124,7 @@ function threadStatusPill(thread: Thread, hasPendingApprovals: boolean): ThreadS
};
}

if (thread.session?.status === "connecting") {
if (status === "connecting") {
return {
label: "Connecting",
colorClass: "text-sky-600 dark:text-sky-300/80",
Expand All @@ -145,7 +133,7 @@ function threadStatusPill(thread: Thread, hasPendingApprovals: boolean): ThreadS
};
}

if (hasUnseenCompletion(thread)) {
if (status === "completed") {
return {
label: "Completed",
colorClass: "text-emerald-600 dark:text-emerald-300/90",
Expand Down Expand Up @@ -307,10 +295,18 @@ 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>();
const threadStatusStateByThreadId = useMemo(() => {
const map = new Map<ThreadId, ThreadStatusState>();
for (const thread of threads) {
map.set(thread.id, derivePendingApprovals(thread.activities).length > 0);
map.set(
thread.id,
deriveThreadStatusState({
activities: thread.activities,
latestTurn: thread.latestTurn,
session: thread.session,
lastVisitedAt: thread.lastVisitedAt,
}),
);
}
return map;
}, [threads]);
Expand Down Expand Up @@ -1208,8 +1204,7 @@ export default function Sidebar() {
{visibleThreads.map((thread) => {
const isActive = routeThreadId === thread.id;
const threadStatus = threadStatusPill(
thread,
pendingApprovalByThreadId.get(thread.id) === true,
threadStatusStateByThreadId.get(thread.id) ?? "idle",
);
const prStatus = prStatusIndicator(prByThreadId.get(thread.id) ?? null);
const terminalStatus = terminalStatusFromRunningIds(
Expand Down
98 changes: 97 additions & 1 deletion apps/web/src/session-logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,15 @@ import { describe, expect, it } from "vitest";
import {
deriveActiveWorkStartedAt,
deriveActivePlanState,
PROVIDER_OPTIONS,
derivePendingApprovals,
derivePendingUserInputs,
deriveThreadStatusState,
deriveTimelineEntries,
deriveWorkLogEntries,
findLatestProposedPlan,
hasToolActivityForTurn,
isLatestTurnSettled,
PROVIDER_OPTIONS,
} from "./session-logic";

function makeActivity(overrides: {
Expand Down Expand Up @@ -222,6 +223,101 @@ describe("derivePendingUserInputs", () => {
});
});

describe("deriveThreadStatusState", () => {
it("surfaces awaiting-response when approvals are pending", () => {
expect(
deriveThreadStatusState({
activities: [
makeActivity({
id: "approval-open",
kind: "approval.requested",
tone: "approval",
payload: {
requestId: "req-approval",
requestKind: "command",
},
}),
],
latestTurn: null,
session: {
status: "running",
orchestrationStatus: "running",
activeTurnId: undefined,
},
}),
).toBe("awaiting-response");
});

it("surfaces awaiting-response when structured user input is pending", () => {
expect(
deriveThreadStatusState({
activities: [
makeActivity({
id: "user-input-open",
kind: "user-input.requested",
tone: "info",
payload: {
requestId: "req-user-input",
questions: [
{
id: "scope",
header: "Scope",
question: "Which path should we take?",
options: [
{
label: "Small fix",
description: "Keep the change focused.",
},
],
},
],
},
}),
],
latestTurn: null,
session: {
status: "running",
orchestrationStatus: "running",
activeTurnId: undefined,
},
}),
).toBe("awaiting-response");
});

it("falls back to working when the thread is not blocked", () => {
expect(
deriveThreadStatusState({
activities: [],
latestTurn: null,
session: {
status: "running",
orchestrationStatus: "running",
activeTurnId: undefined,
},
}),
).toBe("working");
});

it("marks unseen completions as completed", () => {
expect(
deriveThreadStatusState({
activities: [],
latestTurn: {
turnId: TurnId.makeUnsafe("turn-1"),
startedAt: "2026-02-23T00:00:00.000Z",
completedAt: "2026-02-23T00:00:05.000Z",
},
session: {
status: "ready",
orchestrationStatus: "ready",
activeTurnId: undefined,
},
lastVisitedAt: "2026-02-23T00:00:04.000Z",
}),
).toBe("completed");
});
});

describe("deriveActivePlanState", () => {
it("returns the latest plan update for the active turn", () => {
const activities: OrchestrationThreadActivity[] = [
Expand Down
53 changes: 53 additions & 0 deletions apps/web/src/session-logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,13 @@ export interface LatestProposedPlanState {
planMarkdown: string;
}

export type ThreadStatusState =
| "idle"
| "awaiting-response"
| "working"
| "connecting"
| "completed";

export type TimelineEntry =
| {
id: string;
Expand Down Expand Up @@ -115,6 +122,10 @@ export function formatElapsed(startIso: string, endIso: string | undefined): str

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

export function isLatestTurnSettled(
latestTurn: LatestTurnTiming | null,
Expand Down Expand Up @@ -299,6 +310,48 @@ export function derivePendingUserInputs(
);
}

function hasUnseenCompletion(input: {
latestTurn: LatestTurnTiming | null;
lastVisitedAt?: string | undefined;
}): boolean {
if (!input.latestTurn?.completedAt) return false;
const completedAt = Date.parse(input.latestTurn.completedAt);
if (Number.isNaN(completedAt)) return false;
if (!input.lastVisitedAt) return true;

const lastVisitedAt = Date.parse(input.lastVisitedAt);
if (Number.isNaN(lastVisitedAt)) return true;
return completedAt > lastVisitedAt;
}

export function deriveThreadStatusState(input: {
activities: ReadonlyArray<OrchestrationThreadActivity>;
latestTurn: LatestTurnTiming | null;
session: ThreadStatusSessionState | null;
lastVisitedAt?: string | undefined;
}): ThreadStatusState {
if (
derivePendingApprovals(input.activities).length > 0 ||
derivePendingUserInputs(input.activities).length > 0
) {
return "awaiting-response";
}

if (input.session?.status === "running") {
return "working";
}

if (input.session?.status === "connecting") {
return "connecting";
}

if (hasUnseenCompletion(input)) {
return "completed";
}

return "idle";
}

export function deriveActivePlanState(
activities: ReadonlyArray<OrchestrationThreadActivity>,
latestTurnId: TurnId | undefined,
Expand Down