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
50 changes: 49 additions & 1 deletion apps/web/src/components/ChatView.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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> = {}): Thread {
return makeThreadFixture({
id: threadId,
Expand Down
25 changes: 24 additions & 1 deletion apps/web/src/components/ChatView.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
isProviderDriverKind,
ProjectId,
type ModelSelection,
type OrchestrationV2ThreadProjection,
type OrchestrationV2ProjectedTurnItem,
type ProviderDriverKind,
type ServerProvider,
Expand All @@ -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";
Expand All @@ -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<ThreadRuntimeSummary, "activeRunId"> | null,
): boolean {
return (
phase === "running" || (runtime?.activeRunId !== null && runtime?.activeRunId !== undefined)
);
}

export function startNewThreadForProject(
projectRef: ScopedProjectRef | null,
handleNewThread: (projectRef: ScopedProjectRef) => Promise<void>,
Expand Down
39 changes: 22 additions & 17 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -277,9 +273,11 @@ import {
collectUserMessageBlobPreviewUrls,
createLocalDispatchSnapshot,
deriveCommittedServerUserMessageIds,
deriveChatViewThreadExecution,
deriveComposerSendState,
dismissBranchMismatchForSession,
hasServerAcknowledgedLocalDispatch,
isChatViewThreadWorking,
isBranchMismatchDismissedForSession,
shouldShowBranchMismatchBanner,
getStartedThreadModelChangeBlockReason,
Expand Down Expand Up @@ -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(
Expand All @@ -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
Expand All @@ -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 =
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
);
Expand Down Expand Up @@ -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}
Expand Down
29 changes: 29 additions & 0 deletions apps/web/src/components/Sidebar.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
7 changes: 6 additions & 1 deletion apps/web/src/components/Sidebar.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading