Skip to content

Commit f104741

Browse files
juliusmarmingemateus-nash
authored andcommitted
Preserve the thread shell while detail loads (pingdotgg#4830)
1 parent 83d29df commit f104741

10 files changed

Lines changed: 274 additions & 41 deletions

apps/web/src/components/ChatView.logic.test.ts

Lines changed: 55 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,13 @@ import {
88
} from "@t3tools/contracts";
99
import { describe, expect, it } from "vite-plus/test";
1010

11-
import type { Thread } from "../types";
11+
import type { Thread, ThreadShell } from "../types";
1212
import {
1313
MAX_HIDDEN_MOUNTED_PREVIEW_THREADS,
1414
MAX_HIDDEN_MOUNTED_TERMINAL_THREADS,
1515
branchMismatchKey,
1616
buildExpiredTerminalContextToastCopy,
17+
buildLoadingThreadFromShell,
1718
buildThreadTurnInterruptInput,
1819
createLocalDispatchSnapshot,
1920
deriveComposerSendState,
@@ -85,6 +86,51 @@ const readySession = {
8586
updatedAt: "2026-03-29T00:00:10.000Z",
8687
};
8788

89+
describe("buildLoadingThreadFromShell", () => {
90+
it("preserves shell metadata and supplies empty detail collections", () => {
91+
const shell = {
92+
environmentId,
93+
id: threadId,
94+
projectId,
95+
title: "Loading thread",
96+
modelSelection: {
97+
instanceId: ProviderInstanceId.make("codex"),
98+
model: "gpt-5.4",
99+
},
100+
runtimeMode: "full-access",
101+
interactionMode: "default",
102+
branch: "main",
103+
worktreePath: null,
104+
latestTurn: null,
105+
createdAt: now,
106+
updatedAt: now,
107+
archivedAt: null,
108+
settledOverride: null,
109+
settledAt: null,
110+
snoozedUntil: null,
111+
snoozedAt: null,
112+
session: null,
113+
latestUserMessageAt: now,
114+
hasPendingApprovals: false,
115+
hasPendingUserInput: false,
116+
hasActionableProposedPlan: false,
117+
} satisfies ThreadShell;
118+
119+
expect(buildLoadingThreadFromShell(shell)).toMatchObject({
120+
environmentId,
121+
id: threadId,
122+
projectId,
123+
title: "Loading thread",
124+
branch: "main",
125+
deletedAt: null,
126+
messages: [],
127+
proposedPlans: [],
128+
activities: [],
129+
checkpoints: [],
130+
});
131+
});
132+
});
133+
88134
describe("resolveThreadMetadataUpdateForNextTurn", () => {
89135
const modelSelection = {
90136
instanceId: ProviderInstanceId.make("codex"),
@@ -426,19 +472,24 @@ describe("reconcileRetainedMountedThreadIds", () => {
426472
});
427473

428474
describe("shouldWriteThreadErrorToCurrentServerThread", () => {
429-
it("requires the environment, route thread, and target thread to match", () => {
475+
it("writes errors for a shell-derived active server thread", () => {
430476
const routeThreadRef = { environmentId, threadId };
431477

432478
expect(
433479
shouldWriteThreadErrorToCurrentServerThread({
434-
serverThread: { environmentId, id: threadId },
480+
activeServerThread: { environmentId, id: threadId },
435481
routeThreadRef,
436482
targetThreadId: threadId,
437483
}),
438484
).toBe(true);
485+
});
486+
487+
it("requires an active server thread matching the environment, route, and target", () => {
488+
const routeThreadRef = { environmentId, threadId };
489+
439490
expect(
440491
shouldWriteThreadErrorToCurrentServerThread({
441-
serverThread: null,
492+
activeServerThread: null,
442493
routeThreadRef,
443494
targetThreadId: threadId,
444495
}),

apps/web/src/components/ChatView.logic.ts

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
type ThreadId,
1111
type TurnId,
1212
} from "@t3tools/contracts";
13-
import { type ChatMessage, type SessionPhase, type Thread } from "../types";
13+
import { type ChatMessage, type SessionPhase, type Thread, type ThreadShell } from "../types";
1414
import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore";
1515
import * as Schema from "effect/Schema";
1616
import { appAtomRegistry } from "../rpc/atomRegistry";
@@ -95,8 +95,19 @@ export function buildLocalDraftThread(
9595
};
9696
}
9797

98+
export function buildLoadingThreadFromShell(shell: ThreadShell): Thread {
99+
return {
100+
...shell,
101+
messages: [],
102+
proposedPlans: [],
103+
activities: [],
104+
checkpoints: [],
105+
deletedAt: null,
106+
};
107+
}
108+
98109
export function shouldWriteThreadErrorToCurrentServerThread(input: {
99-
serverThread:
110+
activeServerThread:
100111
| {
101112
environmentId: EnvironmentId;
102113
id: ThreadId;
@@ -107,10 +118,10 @@ export function shouldWriteThreadErrorToCurrentServerThread(input: {
107118
targetThreadId: ThreadId;
108119
}): boolean {
109120
return Boolean(
110-
input.serverThread &&
121+
input.activeServerThread &&
111122
input.targetThreadId === input.routeThreadRef.threadId &&
112-
input.serverThread.environmentId === input.routeThreadRef.environmentId &&
113-
input.serverThread.id === input.targetThreadId,
123+
input.activeServerThread.environmentId === input.routeThreadRef.environmentId &&
124+
input.activeServerThread.id === input.targetThreadId,
114125
);
115126
}
116127

apps/web/src/components/ChatView.tsx

Lines changed: 35 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,7 @@ import {
238238
import { ThreadErrorBanner } from "./chat/ThreadErrorBanner";
239239
import { resolveThreadPr } from "./ThreadStatusIndicators";
240240
import { ComposerBannerStack, type ComposerBannerStackItem } from "./chat/ComposerBannerStack";
241+
import { ThreadSyncStatusPill } from "./chat/ThreadSyncStatusPill";
241242
import {
242243
DRAFT_HERO_TRANSITION_ANIMATION_ID,
243244
DRAFT_HERO_TRANSITION_DURATION_MS,
@@ -251,6 +252,7 @@ import {
251252
branchMismatchKey,
252253
buildExpiredTerminalContextToastCopy,
253254
buildLocalDraftThread,
255+
buildLoadingThreadFromShell,
254256
buildThreadTurnInterruptInput,
255257
collectUserMessageBlobPreviewUrls,
256258
createLocalDispatchSnapshot,
@@ -272,9 +274,11 @@ import {
272274
resolveSendEnvMode,
273275
revokeBlobPreviewUrl,
274276
revokeUserMessagePreviewUrls,
277+
shouldWriteThreadErrorToCurrentServerThread,
275278
startNewThreadForProject,
276279
waitForStartedServerThread,
277280
} from "./ChatView.logic";
281+
import type { ThreadSyncPhase } from "../threadSync";
278282
import { useLocalStorage } from "~/hooks/useLocalStorage";
279283
import { useComposerHandleContext } from "../composerHandleContext";
280284
import { sanitizeThreadErrorMessage } from "~/rpc/transportError";
@@ -465,6 +469,7 @@ type ChatViewProps =
465469
onDiffPanelOpen?: () => void;
466470
reserveTitleBarControlInset?: boolean;
467471
forceExpandedMobileComposer?: boolean;
472+
threadSyncPhase?: ThreadSyncPhase | null;
468473
routeKind: "server";
469474
draftId?: never;
470475
}
@@ -474,6 +479,7 @@ type ChatViewProps =
474479
onDiffPanelOpen?: () => void;
475480
reserveTitleBarControlInset?: boolean;
476481
forceExpandedMobileComposer?: boolean;
482+
threadSyncPhase?: never;
477483
routeKind: "draft";
478484
draftId: DraftId;
479485
};
@@ -1132,6 +1138,8 @@ function ChatViewContent(props: ChatViewProps) {
11321138
forceExpandedMobileComposer = false,
11331139
} = props;
11341140
const draftId = routeKind === "draft" ? props.draftId : null;
1141+
const threadSyncPhase = routeKind === "server" ? (props.threadSyncPhase ?? null) : null;
1142+
const threadDetailLoading = threadSyncPhase === "loading";
11351143
const handleNewThread = useNewThreadHandler();
11361144
const routeThreadRef = useMemo(
11371145
() => scopeThreadRef(environmentId, threadId),
@@ -1188,7 +1196,16 @@ function ChatViewContent(props: ChatViewProps) {
11881196
? store.getDraftSession(draftId)
11891197
: null,
11901198
);
1199+
const routeServerThreadShell = useThreadShell(routeKind === "server" ? routeThreadRef : null);
11911200
const serverThread = useThread(routeThreadRef, { waitForShell: draftThread !== null });
1201+
const loadingServerThread = useMemo(
1202+
() =>
1203+
threadDetailLoading && routeServerThreadShell
1204+
? buildLoadingThreadFromShell(routeServerThreadShell)
1205+
: null,
1206+
[routeServerThreadShell, threadDetailLoading],
1207+
);
1208+
const activeServerThread = serverThread ?? loadingServerThread;
11921209
const markThreadVisited = useUiStateStore((store) => store.markThreadVisited);
11931210
const activeThreadLastVisitedAt = useUiStateStore(
11941211
(store) => store.threadLastVisitedAtById[routeThreadKey],
@@ -1368,7 +1385,7 @@ function ChatViewContent(props: ChatViewProps) {
13681385
? scopeProjectRef(draftThread.environmentId, draftThread.projectId)
13691386
: null;
13701387
const fallbackDraftProject = useProject(fallbackDraftProjectRef);
1371-
const localDraftError = serverThread
1388+
const localDraftError = activeServerThread
13721389
? null
13731390
: ((draftId ? localDraftErrorsByDraftId[draftId]?.message : null) ?? null);
13741391
const localServerError = localServerErrorsByThreadKey[routeThreadKey]?.message ?? null;
@@ -1377,7 +1394,7 @@ function ChatViewContent(props: ChatViewProps) {
13771394
// a failed send would silently disappear on promotion. When both keys hold
13781395
// an entry, the most recent write wins.
13791396
useEffect(() => {
1380-
if (!serverThread || !draftId) {
1397+
if (!activeServerThread || !draftId) {
13811398
return;
13821399
}
13831400
const pendingDraftEntry = localDraftErrorsByDraftId[draftId];
@@ -1406,7 +1423,7 @@ function ChatViewContent(props: ChatViewProps) {
14061423
[routeThreadKey]: pendingDraftEntry,
14071424
};
14081425
});
1409-
}, [draftId, localDraftErrorsByDraftId, routeThreadKey, serverThread]);
1426+
}, [activeServerThread, draftId, localDraftErrorsByDraftId, routeThreadKey]);
14101427
const localDraftThread = useMemo(
14111428
() =>
14121429
draftThread
@@ -1421,10 +1438,10 @@ function ChatViewContent(props: ChatViewProps) {
14211438
// Promotion is data-driven: the draft route keeps rendering while the
14221439
// server thread (same pre-allocated ref) starts, so live state must not
14231440
// depend on which route is mounted.
1424-
const isServerThread = serverThread !== null;
1425-
const activeThread = isServerThread ? serverThread : localDraftThread;
1441+
const isServerThread = activeServerThread !== null;
1442+
const activeThread = activeServerThread ?? localDraftThread;
14261443
const threadError = isServerThread
1427-
? (localServerError ?? serverThread?.session?.lastError ?? null)
1444+
? (localServerError ?? activeServerThread?.session?.lastError ?? null)
14281445
: localDraftError;
14291446
const runtimeMode = composerRuntimeMode ?? activeThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE;
14301447
const interactionMode =
@@ -2490,10 +2507,11 @@ function ChatViewContent(props: ChatViewProps) {
24902507
const nextError = sanitizeThreadErrorMessage(error);
24912508
const nextEntry: LocalThreadErrorEntry = { message: nextError, at: Date.now() };
24922509
if (
2493-
serverThread &&
2494-
targetThreadId === routeThreadRef.threadId &&
2495-
serverThread.environmentId === routeThreadRef.environmentId &&
2496-
serverThread.id === targetThreadId
2510+
shouldWriteThreadErrorToCurrentServerThread({
2511+
activeServerThread,
2512+
routeThreadRef,
2513+
targetThreadId,
2514+
})
24972515
) {
24982516
setLocalServerErrorsByThreadKey((existing) => {
24992517
if ((existing[routeThreadKey]?.message ?? null) === nextError) {
@@ -2517,7 +2535,7 @@ function ChatViewContent(props: ChatViewProps) {
25172535
};
25182536
});
25192537
},
2520-
[draftId, routeThreadKey, routeThreadRef, serverThread],
2538+
[activeServerThread, draftId, routeThreadKey, routeThreadRef],
25212539
);
25222540

25232541
const focusComposer = useCallback(() => {
@@ -4471,6 +4489,7 @@ function ChatViewContent(props: ChatViewProps) {
44714489
!activeThread ||
44724490
isSendBusy ||
44734491
isConnecting ||
4492+
threadDetailLoading ||
44744493
activeEnvironmentUnavailable ||
44754494
sendInFlightRef.current
44764495
)
@@ -5737,7 +5756,7 @@ function ChatViewContent(props: ChatViewProps) {
57375756
contentInsetEndAdjustment={composerOverlayHeight}
57385757
onIsAtEndChange={onIsAtEndChange}
57395758
onManualNavigation={cancelTimelineLiveFollowForUserNavigation}
5740-
hideEmptyPlaceholder={isDraftHeroState}
5759+
hideEmptyPlaceholder={isDraftHeroState || threadDetailLoading}
57415760
topFadeEnabled={!hasTimelineTopBanner}
57425761
/>
57435762

@@ -5798,6 +5817,9 @@ function ChatViewContent(props: ChatViewProps) {
57985817
) : (
57995818
<ComposerBannerStack className="relative z-0" items={composerBannerItems} />
58005819
)}
5820+
{threadSyncPhase && !activeEnvironmentUnavailable ? (
5821+
<ThreadSyncStatusPill phase={threadSyncPhase} />
5822+
) : null}
58015823
<div
58025824
className="relative"
58035825
style={
@@ -5831,6 +5853,7 @@ function ChatViewContent(props: ChatViewProps) {
58315853
phase={phase}
58325854
isConnecting={isConnecting}
58335855
isSendBusy={isSendBusy}
5856+
sendDisabledReason={threadDetailLoading ? "Messages loading" : null}
58345857
isPreparingWorktree={isPreparingWorktree}
58355858
environmentUnavailable={activeEnvironmentUnavailableState}
58365859
activePendingApproval={activePendingApproval}

0 commit comments

Comments
 (0)