From 5eb30c1ae0c4dfb005f9dfd813e2461cb24813f0 Mon Sep 17 00:00:00 2001 From: IskanVa Date: Mon, 9 Mar 2026 10:30:02 +0300 Subject: [PATCH 1/2] fix(web): show Awaiting response for blocked chats --- apps/web/src/components/ChatView.browser.tsx | 79 ++++++++++++++++ apps/web/src/components/Sidebar.tsx | 45 ++++----- apps/web/src/session-logic.test.ts | 98 +++++++++++++++++++- apps/web/src/session-logic.ts | 53 +++++++++++ 4 files changed, 249 insertions(+), 26 deletions(-) diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index e2fd573fe8..10197997e2 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -2,6 +2,7 @@ import "../index.css"; import { + EventId, ORCHESTRATION_WS_METHODS, type MessageId, type OrchestrationReadModel, @@ -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; @@ -803,6 +864,24 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); + it("shows Awaiting response 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("Awaiting response"); + }, + { 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, diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index ecfd526acb..0729af0c0a 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -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"; @@ -85,7 +84,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; @@ -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: "Awaiting response", 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", @@ -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", @@ -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", @@ -307,10 +295,18 @@ export default function Sidebar() { const renamingCommittedRef = useRef(false); const renamingInputRef = useRef(null); const [desktopUpdateState, setDesktopUpdateState] = useState(null); - const pendingApprovalByThreadId = useMemo(() => { - const map = new Map(); + const threadStatusStateByThreadId = useMemo(() => { + const map = new Map(); 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]); @@ -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( diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index 3d1d269d48..2100e90ff6 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -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: { @@ -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[] = [ diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index e9351ca2b2..b91d220ba7 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -63,6 +63,13 @@ export interface LatestProposedPlanState { planMarkdown: string; } +export type ThreadStatusState = + | "idle" + | "awaiting-response" + | "working" + | "connecting" + | "completed"; + export type TimelineEntry = | { id: string; @@ -115,6 +122,10 @@ export function formatElapsed(startIso: string, endIso: string | undefined): str type LatestTurnTiming = Pick; type SessionActivityState = Pick; +type ThreadStatusSessionState = Pick< + ThreadSession, + "status" | "orchestrationStatus" | "activeTurnId" +>; export function isLatestTurnSettled( latestTurn: LatestTurnTiming | null, @@ -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; + 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, latestTurnId: TurnId | undefined, From a5764d7e165e96e34e71d65614221da62c33f215 Mon Sep 17 00:00:00 2001 From: IskanVa Date: Mon, 9 Mar 2026 12:28:32 +0300 Subject: [PATCH 2/2] fix(web): shorten blocked thread status label --- apps/web/src/components/ChatView.browser.tsx | 4 ++-- apps/web/src/components/Sidebar.tsx | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 10197997e2..b80d7d49c9 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -864,7 +864,7 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); - it("shows Awaiting response in the sidebar when a thread is blocked on structured user input", async () => { + 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(), @@ -873,7 +873,7 @@ describe("ChatView timeline estimator parity (full app)", () => { try { await vi.waitFor( () => { - expect(document.body.textContent).toContain("Awaiting response"); + expect(document.body.textContent).toContain("Waiting"); }, { timeout: 8_000, interval: 16 }, ); diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 0729af0c0a..485239ae96 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -84,7 +84,7 @@ function formatRelativeTime(iso: string): string { } interface ThreadStatusPill { - label: "Working" | "Connecting" | "Completed" | "Awaiting response"; + label: "Working" | "Connecting" | "Completed" | "Waiting"; colorClass: string; dotClass: string; pulse: boolean; @@ -108,7 +108,7 @@ type ThreadPr = GitStatusResult["pr"]; function threadStatusPill(status: ThreadStatusState): ThreadStatusPill | null { if (status === "awaiting-response") { return { - label: "Awaiting response", + label: "Waiting", colorClass: "text-amber-600 dark:text-amber-300/90", dotClass: "bg-amber-500 dark:bg-amber-300/90", pulse: false,