From a211c7d5426a2826d9cdfa20898837725759d4b7 Mon Sep 17 00:00:00 2001 From: Mike Olson Date: Tue, 28 Jul 2026 18:26:57 -0400 Subject: [PATCH] fix(orchestrator): Keep active web timeline visible --- .../web/src/components/ChatView.logic.test.ts | 50 ++++++++++++++++++- apps/web/src/components/ChatView.logic.ts | 25 +++++++++- apps/web/src/components/ChatView.tsx | 39 ++++++++------- apps/web/src/components/Sidebar.logic.test.ts | 29 +++++++++++ apps/web/src/components/Sidebar.logic.ts | 7 ++- 5 files changed, 130 insertions(+), 20 deletions(-) diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 279b1381863..35378d3290c 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -12,19 +12,21 @@ import * as DateTime from "effect/DateTime"; import { describe, expect, it } from "vite-plus/test"; import type { Thread } from "../types"; -import { makeThreadFixture } from "../test-fixtures"; +import { makeThreadFixture, makeThreadProjectionFixture } from "../test-fixtures"; import { MAX_HIDDEN_MOUNTED_PREVIEW_THREADS, MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, branchMismatchKey, buildExpiredTerminalContextToastCopy, createLocalDispatchSnapshot, + deriveChatViewThreadExecution, deriveCommittedServerUserMessageIds, deriveComposerSendState, dismissBranchMismatchForSession, getStartedThreadModelChangeBlockReason, hasServerAcknowledgedLocalDispatch, isBranchMismatchDismissedForSession, + isChatViewThreadWorking, reconcileMountedTerminalThreadIds, reconcileRetainedMountedThreadIds, resolveThreadMetadataUpdateForNextTurn, @@ -40,6 +42,52 @@ const projectId = ProjectId.make("project-1"); const threadId = ThreadId.make("thread-1"); const now = "2026-03-29T00:00:00.000Z"; +describe("deriveChatViewThreadExecution", () => { + it("keeps web activity on a running turn when a newer turn is queued", () => { + const projection = makeThreadProjectionFixture(); + const timestamp = DateTime.makeUnsafe(now); + const runningRunId = RunId.make("run-running"); + const queuedRunId = RunId.make("run-queued"); + const makeRun = (runId: RunId, ordinal: number, status: "queued" | "running") => ({ + id: runId, + threadId: projection.thread.id, + ordinal, + providerInstanceId: projection.thread.providerInstanceId, + modelSelection: projection.thread.modelSelection, + providerThreadId: null, + userMessageId: MessageId.make(`message-${runId}`), + rootNodeId: null, + activeAttemptId: null, + status, + requestedAt: timestamp, + startedAt: status === "queued" ? null : timestamp, + completedAt: null, + checkpointId: null, + contextHandoffId: null, + }); + const execution = deriveChatViewThreadExecution({ + ...projection, + runs: [makeRun(queuedRunId, 2, "queued"), makeRun(runningRunId, 1, "running")], + updatedAt: timestamp, + }); + + expect(execution.latestRun).toMatchObject({ + runId: queuedRunId, + status: "queued", + }); + expect(execution.activityRun).toMatchObject({ + runId: runningRunId, + status: "running", + }); + expect(execution.runtime).toMatchObject({ + status: "queued", + activeRunId: runningRunId, + }); + expect(isChatViewThreadWorking("connecting", execution.runtime)).toBe(true); + expect(isChatViewThreadWorking("connecting", { activeRunId: null })).toBe(false); + }); +}); + function makeThread(overrides: Partial = {}): Thread { return makeThreadFixture({ id: threadId, diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 3264951bedd..84ba15b4451 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -3,6 +3,7 @@ import { isProviderDriverKind, ProjectId, type ModelSelection, + type OrchestrationV2ThreadProjection, type OrchestrationV2ProjectedTurnItem, type ProviderDriverKind, type ServerProvider, @@ -12,7 +13,12 @@ import { type RunId, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; -import { presentThreadShell } from "@t3tools/client-runtime/state/shell"; +import { presentThreadShell, type ThreadRuntimeSummary } from "@t3tools/client-runtime/state/shell"; +import { + deriveLatestThreadRun, + deriveThreadActivityRun, + deriveThreadRuntime, +} from "@t3tools/client-runtime/state/thread-execution"; import { type ChatMessage, type SessionPhase, type Thread } from "../types"; import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore"; import * as Schema from "effect/Schema"; @@ -32,6 +38,23 @@ export const MAX_HIDDEN_MOUNTED_PREVIEW_THREADS = 3; export const LastInvokedScriptByProjectSchema = Schema.Record(ProjectId, Schema.String); +export function deriveChatViewThreadExecution(projection: OrchestrationV2ThreadProjection) { + return { + activityRun: deriveThreadActivityRun(projection), + latestRun: deriveLatestThreadRun(projection), + runtime: deriveThreadRuntime(projection), + }; +} + +export function isChatViewThreadWorking( + phase: SessionPhase, + runtime: Pick | null, +): boolean { + return ( + phase === "running" || (runtime?.activeRunId !== null && runtime?.activeRunId !== undefined) + ); +} + export function startNewThreadForProject( projectRef: ScopedProjectRef | null, handleNewThread: (projectRef: ScopedProjectRef) => Promise, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 48e8b6bc9c5..47f1b4473da 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -26,10 +26,6 @@ import { type EnvironmentConnectionPresentation, } from "@t3tools/client-runtime/connection"; import { effectiveSettled, effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled"; -import { - deriveLatestThreadRun, - deriveThreadRuntime, -} from "@t3tools/client-runtime/state/thread-execution"; import { resolveThreadProviderSession } from "@t3tools/client-runtime/state/thread-workflows"; import { derivePendingThreadRequests } from "@t3tools/client-runtime/state/thread-requests"; import { @@ -277,9 +273,11 @@ import { collectUserMessageBlobPreviewUrls, createLocalDispatchSnapshot, deriveCommittedServerUserMessageIds, + deriveChatViewThreadExecution, deriveComposerSendState, dismissBranchMismatchForSession, hasServerAcknowledgedLocalDispatch, + isChatViewThreadWorking, isBranchMismatchDismissedForSession, shouldShowBranchMismatchBanner, getStartedThreadModelChangeBlockReason, @@ -1468,12 +1466,8 @@ function ChatViewContent(props: ChatViewProps) { ); const isServerThread = serverThread !== null; const activeThread = isServerThread ? serverThread : localDraftThread; - const serverLatestRun = useMemo( - () => (serverProjection === null ? null : deriveLatestThreadRun(serverProjection)), - [serverProjection], - ); - const serverRuntime = useMemo( - () => (serverProjection === null ? null : deriveThreadRuntime(serverProjection)), + const serverExecution = useMemo( + () => (serverProjection === null ? null : deriveChatViewThreadExecution(serverProjection)), [serverProjection], ); const activeProviderSession = useMemo( @@ -1482,8 +1476,15 @@ function ChatViewContent(props: ChatViewProps) { ); const supportsProviderSwitchingViaHandoff = activeProviderSession?.capabilities.sessions.supportsProviderSwitchingViaHandoff === true; - const activeLatestRun = isServerThread ? serverLatestRun : (activeThread?.latestRun ?? null); - const activeRuntime = isServerThread ? serverRuntime : (activeThread?.runtime ?? null); + const activeLatestRun = isServerThread + ? (serverExecution?.latestRun ?? null) + : (activeThread?.latestRun ?? null); + const activeActivityRun = isServerThread + ? (serverExecution?.activityRun ?? null) + : (activeThread?.latestRun ?? null); + const activeRuntime = isServerThread + ? (serverExecution?.runtime ?? null) + : (activeThread?.runtime ?? null); const parentSubagentThreadId = activeThread?.lineage.relationshipToParent === "subagent" ? activeThread.lineage.parentThreadId @@ -1507,7 +1508,7 @@ function ChatViewContent(props: ChatViewProps) { [parentSubagentThread?.title, parentSubagentThreadRef], ); const threadError = isServerThread - ? (localServerError ?? serverRuntime?.lastError ?? null) + ? (localServerError ?? serverExecution?.runtime?.lastError ?? null) : localDraftError; const runtimeMode = composerRuntimeMode ?? activeThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE; const interactionMode = @@ -1681,7 +1682,7 @@ function ChatViewContent(props: ChatViewProps) { : nextThreadIds; }); }, [activeThreadKey, existingOpenTerminalThreadKeys, terminalUiState.terminalOpen]); - const latestRunSettled = isLatestRunSettled(activeLatestRun, activeRuntime); + const latestRunSettled = isLatestRunSettled(activeActivityRun, activeRuntime); const activeProjectRef = activeThread ? scopeProjectRef(activeThread.environmentId, activeThread.projectId) : null; @@ -2161,9 +2162,13 @@ function ChatViewContent(props: ChatViewProps) { activePendingUserInput: activePendingUserInput?.requestId ?? null, threadError, }); - const isWorking = phase === "running" || isSendBusy || isConnecting || isRevertingCheckpoint; + const isWorking = + isChatViewThreadWorking(phase, activeRuntime) || + isSendBusy || + isConnecting || + isRevertingCheckpoint; const activeWorkStartedAt = deriveActiveWorkStartedAt( - activeLatestRun, + activeActivityRun, activeRuntime, localDispatchStartedAt, ); @@ -6020,7 +6025,7 @@ function ChatViewContent(props: ChatViewProps) { activeTurnStartedAt={activeWorkStartedAt} listRef={legendListRef} timelineEntries={timelineEntries} - latestRun={activeLatestRun} + latestRun={activeActivityRun} turnDiffSummaryByAssistantMessageId={turnDiffSummaryByAssistantMessageId} activeThreadEnvironmentId={activeThread.environmentId} routeThreadKey={routeThreadKey} diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index a5ab342b663..335e3575f8f 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -947,6 +947,35 @@ describe("resolveThreadStatusPill", () => { ).toMatchObject({ label: "Working", pulse: true }); }); + it("stays working when a newer run is queued behind the active run", () => { + expect( + resolveThreadStatusPill({ + thread: { + ...baseThread, + runtime: { + ...baseThread.runtime, + status: "queued", + }, + }, + }), + ).toMatchObject({ label: "Working", pulse: true }); + }); + + it("shows connecting when a queued run has no active provider work", () => { + expect( + resolveThreadStatusPill({ + thread: { + ...baseThread, + runtime: { + ...baseThread.runtime, + activeRunId: null, + status: "queued", + }, + }, + }), + ).toMatchObject({ label: "Connecting", pulse: true }); + }); + it("shows plan ready when a settled plan turn has a proposed plan ready for follow-up", () => { expect( resolveThreadStatusPill({ diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 4dcd8906a20..092cfc9cdec 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -592,7 +592,12 @@ export function resolveThreadStatusPill(input: { }; } - if (thread.runtime?.status === "running" || thread.runtime?.status === "waiting") { + if ( + thread.runtime !== null && + (thread.runtime.status === "running" || + thread.runtime.status === "waiting" || + (thread.runtime.status === "queued" && thread.runtime.activeRunId !== null)) + ) { return { label: "Working", colorClass: "text-sky-600 dark:text-sky-300/80",