diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts
index 1b2e537c35d..39285438d1a 100644
--- a/apps/web/src/components/ChatView.logic.test.ts
+++ b/apps/web/src/components/ChatView.logic.test.ts
@@ -8,12 +8,13 @@ import {
} from "@t3tools/contracts";
import { describe, expect, it } from "vite-plus/test";
-import type { Thread } from "../types";
+import type { Thread, ThreadShell } from "../types";
import {
MAX_HIDDEN_MOUNTED_PREVIEW_THREADS,
MAX_HIDDEN_MOUNTED_TERMINAL_THREADS,
branchMismatchKey,
buildExpiredTerminalContextToastCopy,
+ buildLoadingThreadFromShell,
buildThreadTurnInterruptInput,
createLocalDispatchSnapshot,
deriveComposerSendState,
@@ -85,6 +86,51 @@ const readySession = {
updatedAt: "2026-03-29T00:00:10.000Z",
};
+describe("buildLoadingThreadFromShell", () => {
+ it("preserves shell metadata and supplies empty detail collections", () => {
+ const shell = {
+ environmentId,
+ id: threadId,
+ projectId,
+ title: "Loading thread",
+ modelSelection: {
+ instanceId: ProviderInstanceId.make("codex"),
+ model: "gpt-5.4",
+ },
+ runtimeMode: "full-access",
+ interactionMode: "default",
+ branch: "main",
+ worktreePath: null,
+ latestTurn: null,
+ createdAt: now,
+ updatedAt: now,
+ archivedAt: null,
+ settledOverride: null,
+ settledAt: null,
+ snoozedUntil: null,
+ snoozedAt: null,
+ session: null,
+ latestUserMessageAt: now,
+ hasPendingApprovals: false,
+ hasPendingUserInput: false,
+ hasActionableProposedPlan: false,
+ } satisfies ThreadShell;
+
+ expect(buildLoadingThreadFromShell(shell)).toMatchObject({
+ environmentId,
+ id: threadId,
+ projectId,
+ title: "Loading thread",
+ branch: "main",
+ deletedAt: null,
+ messages: [],
+ proposedPlans: [],
+ activities: [],
+ checkpoints: [],
+ });
+ });
+});
+
describe("resolveThreadMetadataUpdateForNextTurn", () => {
const modelSelection = {
instanceId: ProviderInstanceId.make("codex"),
@@ -426,19 +472,24 @@ describe("reconcileRetainedMountedThreadIds", () => {
});
describe("shouldWriteThreadErrorToCurrentServerThread", () => {
- it("requires the environment, route thread, and target thread to match", () => {
+ it("writes errors for a shell-derived active server thread", () => {
const routeThreadRef = { environmentId, threadId };
expect(
shouldWriteThreadErrorToCurrentServerThread({
- serverThread: { environmentId, id: threadId },
+ activeServerThread: { environmentId, id: threadId },
routeThreadRef,
targetThreadId: threadId,
}),
).toBe(true);
+ });
+
+ it("requires an active server thread matching the environment, route, and target", () => {
+ const routeThreadRef = { environmentId, threadId };
+
expect(
shouldWriteThreadErrorToCurrentServerThread({
- serverThread: null,
+ activeServerThread: null,
routeThreadRef,
targetThreadId: threadId,
}),
diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts
index 4fb007b85b5..04b35fd4551 100644
--- a/apps/web/src/components/ChatView.logic.ts
+++ b/apps/web/src/components/ChatView.logic.ts
@@ -10,7 +10,7 @@ import {
type ThreadId,
type TurnId,
} from "@t3tools/contracts";
-import { type ChatMessage, type SessionPhase, type Thread } from "../types";
+import { type ChatMessage, type SessionPhase, type Thread, type ThreadShell } from "../types";
import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore";
import * as Schema from "effect/Schema";
import { appAtomRegistry } from "../rpc/atomRegistry";
@@ -95,8 +95,19 @@ export function buildLocalDraftThread(
};
}
+export function buildLoadingThreadFromShell(shell: ThreadShell): Thread {
+ return {
+ ...shell,
+ messages: [],
+ proposedPlans: [],
+ activities: [],
+ checkpoints: [],
+ deletedAt: null,
+ };
+}
+
export function shouldWriteThreadErrorToCurrentServerThread(input: {
- serverThread:
+ activeServerThread:
| {
environmentId: EnvironmentId;
id: ThreadId;
@@ -107,10 +118,10 @@ export function shouldWriteThreadErrorToCurrentServerThread(input: {
targetThreadId: ThreadId;
}): boolean {
return Boolean(
- input.serverThread &&
+ input.activeServerThread &&
input.targetThreadId === input.routeThreadRef.threadId &&
- input.serverThread.environmentId === input.routeThreadRef.environmentId &&
- input.serverThread.id === input.targetThreadId,
+ input.activeServerThread.environmentId === input.routeThreadRef.environmentId &&
+ input.activeServerThread.id === input.targetThreadId,
);
}
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx
index bdde62a3e0d..d532c8b1233 100644
--- a/apps/web/src/components/ChatView.tsx
+++ b/apps/web/src/components/ChatView.tsx
@@ -238,6 +238,7 @@ import {
import { ThreadErrorBanner } from "./chat/ThreadErrorBanner";
import { resolveThreadPr } from "./ThreadStatusIndicators";
import { ComposerBannerStack, type ComposerBannerStackItem } from "./chat/ComposerBannerStack";
+import { ThreadSyncStatusPill } from "./chat/ThreadSyncStatusPill";
import {
DRAFT_HERO_TRANSITION_ANIMATION_ID,
DRAFT_HERO_TRANSITION_DURATION_MS,
@@ -251,6 +252,7 @@ import {
branchMismatchKey,
buildExpiredTerminalContextToastCopy,
buildLocalDraftThread,
+ buildLoadingThreadFromShell,
buildThreadTurnInterruptInput,
collectUserMessageBlobPreviewUrls,
createLocalDispatchSnapshot,
@@ -272,9 +274,11 @@ import {
resolveSendEnvMode,
revokeBlobPreviewUrl,
revokeUserMessagePreviewUrls,
+ shouldWriteThreadErrorToCurrentServerThread,
startNewThreadForProject,
waitForStartedServerThread,
} from "./ChatView.logic";
+import type { ThreadSyncPhase } from "../threadSync";
import { useLocalStorage } from "~/hooks/useLocalStorage";
import { useComposerHandleContext } from "../composerHandleContext";
import { sanitizeThreadErrorMessage } from "~/rpc/transportError";
@@ -465,6 +469,7 @@ type ChatViewProps =
onDiffPanelOpen?: () => void;
reserveTitleBarControlInset?: boolean;
forceExpandedMobileComposer?: boolean;
+ threadSyncPhase?: ThreadSyncPhase | null;
routeKind: "server";
draftId?: never;
}
@@ -474,6 +479,7 @@ type ChatViewProps =
onDiffPanelOpen?: () => void;
reserveTitleBarControlInset?: boolean;
forceExpandedMobileComposer?: boolean;
+ threadSyncPhase?: never;
routeKind: "draft";
draftId: DraftId;
};
@@ -1132,6 +1138,8 @@ function ChatViewContent(props: ChatViewProps) {
forceExpandedMobileComposer = false,
} = props;
const draftId = routeKind === "draft" ? props.draftId : null;
+ const threadSyncPhase = routeKind === "server" ? (props.threadSyncPhase ?? null) : null;
+ const threadDetailLoading = threadSyncPhase === "loading";
const handleNewThread = useNewThreadHandler();
const routeThreadRef = useMemo(
() => scopeThreadRef(environmentId, threadId),
@@ -1188,7 +1196,16 @@ function ChatViewContent(props: ChatViewProps) {
? store.getDraftSession(draftId)
: null,
);
+ const routeServerThreadShell = useThreadShell(routeKind === "server" ? routeThreadRef : null);
const serverThread = useThread(routeThreadRef, { waitForShell: draftThread !== null });
+ const loadingServerThread = useMemo(
+ () =>
+ threadDetailLoading && routeServerThreadShell
+ ? buildLoadingThreadFromShell(routeServerThreadShell)
+ : null,
+ [routeServerThreadShell, threadDetailLoading],
+ );
+ const activeServerThread = serverThread ?? loadingServerThread;
const markThreadVisited = useUiStateStore((store) => store.markThreadVisited);
const activeThreadLastVisitedAt = useUiStateStore(
(store) => store.threadLastVisitedAtById[routeThreadKey],
@@ -1368,7 +1385,7 @@ function ChatViewContent(props: ChatViewProps) {
? scopeProjectRef(draftThread.environmentId, draftThread.projectId)
: null;
const fallbackDraftProject = useProject(fallbackDraftProjectRef);
- const localDraftError = serverThread
+ const localDraftError = activeServerThread
? null
: ((draftId ? localDraftErrorsByDraftId[draftId]?.message : null) ?? null);
const localServerError = localServerErrorsByThreadKey[routeThreadKey]?.message ?? null;
@@ -1377,7 +1394,7 @@ function ChatViewContent(props: ChatViewProps) {
// a failed send would silently disappear on promotion. When both keys hold
// an entry, the most recent write wins.
useEffect(() => {
- if (!serverThread || !draftId) {
+ if (!activeServerThread || !draftId) {
return;
}
const pendingDraftEntry = localDraftErrorsByDraftId[draftId];
@@ -1406,7 +1423,7 @@ function ChatViewContent(props: ChatViewProps) {
[routeThreadKey]: pendingDraftEntry,
};
});
- }, [draftId, localDraftErrorsByDraftId, routeThreadKey, serverThread]);
+ }, [activeServerThread, draftId, localDraftErrorsByDraftId, routeThreadKey]);
const localDraftThread = useMemo(
() =>
draftThread
@@ -1421,10 +1438,10 @@ function ChatViewContent(props: ChatViewProps) {
// Promotion is data-driven: the draft route keeps rendering while the
// server thread (same pre-allocated ref) starts, so live state must not
// depend on which route is mounted.
- const isServerThread = serverThread !== null;
- const activeThread = isServerThread ? serverThread : localDraftThread;
+ const isServerThread = activeServerThread !== null;
+ const activeThread = activeServerThread ?? localDraftThread;
const threadError = isServerThread
- ? (localServerError ?? serverThread?.session?.lastError ?? null)
+ ? (localServerError ?? activeServerThread?.session?.lastError ?? null)
: localDraftError;
const runtimeMode = composerRuntimeMode ?? activeThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE;
const interactionMode =
@@ -2490,10 +2507,11 @@ function ChatViewContent(props: ChatViewProps) {
const nextError = sanitizeThreadErrorMessage(error);
const nextEntry: LocalThreadErrorEntry = { message: nextError, at: Date.now() };
if (
- serverThread &&
- targetThreadId === routeThreadRef.threadId &&
- serverThread.environmentId === routeThreadRef.environmentId &&
- serverThread.id === targetThreadId
+ shouldWriteThreadErrorToCurrentServerThread({
+ activeServerThread,
+ routeThreadRef,
+ targetThreadId,
+ })
) {
setLocalServerErrorsByThreadKey((existing) => {
if ((existing[routeThreadKey]?.message ?? null) === nextError) {
@@ -2517,7 +2535,7 @@ function ChatViewContent(props: ChatViewProps) {
};
});
},
- [draftId, routeThreadKey, routeThreadRef, serverThread],
+ [activeServerThread, draftId, routeThreadKey, routeThreadRef],
);
const focusComposer = useCallback(() => {
@@ -4471,6 +4489,7 @@ function ChatViewContent(props: ChatViewProps) {
!activeThread ||
isSendBusy ||
isConnecting ||
+ threadDetailLoading ||
activeEnvironmentUnavailable ||
sendInFlightRef.current
)
@@ -5737,7 +5756,7 @@ function ChatViewContent(props: ChatViewProps) {
contentInsetEndAdjustment={composerOverlayHeight}
onIsAtEndChange={onIsAtEndChange}
onManualNavigation={cancelTimelineLiveFollowForUserNavigation}
- hideEmptyPlaceholder={isDraftHeroState}
+ hideEmptyPlaceholder={isDraftHeroState || threadDetailLoading}
topFadeEnabled={!hasTimelineTopBanner}
/>
@@ -5798,6 +5817,9 @@ function ChatViewContent(props: ChatViewProps) {
) : (