authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-07-29 13:54:17+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-29 13:54:17+02:00
log3137c2b162109640343a6ff88120609e061f3782
tree9441851615f7bf7896e197dce662c048d208828b
parentc3e8fb67d73955862ac312c801d45f2cc00c7178
signature Signed by PGP key B5690EEEBB952194

Preserve the thread shell while detail loads (#4830)


10 files changed, 274 insertions(+), 41 deletions(-)

apps/web/src/components/ChatView.logic.test.ts+55-4
......@@ -8,12 +8,13 @@ import {
88} from "@t3tools/contracts";
99import { describe, expect, it } from "vite-plus/test";
1010
11import type { Thread } from "../types";
11import type { Thread, ThreadShell } from "../types";
1212import {
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
89describe("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
88134describe("resolveThreadMetadataUpdateForNextTurn", () => {
89135 const modelSelection = {
90136 instanceId: ProviderInstanceId.make("codex"),
......@@ -426,19 +472,24 @@ describe("reconcileRetainedMountedThreadIds", () => {
426472});
427473
428474describe("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+16-5
......@@ -10,7 +10,7 @@ import {
1010 type ThreadId,
1111 type TurnId,
1212} from "@t3tools/contracts";
13import { type ChatMessage, type SessionPhase, type Thread } from "../types";
13import { type ChatMessage, type SessionPhase, type Thread, type ThreadShell } from "../types";
1414import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore";
1515import * as Schema from "effect/Schema";
1616import { appAtomRegistry } from "../rpc/atomRegistry";
......@@ -95,8 +95,19 @@ export function buildLocalDraftThread(
9595 };
9696}
9797
98export function buildLoadingThreadFromShell(shell: ThreadShell): Thread {
99 return {
100 ...shell,
101 messages: [],
102 proposedPlans: [],
103 activities: [],
104 checkpoints: [],
105 deletedAt: null,
106 };
107}
108
98109export 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+35-12
......@@ -238,6 +238,7 @@ import {
238238import { ThreadErrorBanner } from "./chat/ThreadErrorBanner";
239239import { resolveThreadPr } from "./ThreadStatusIndicators";
240240import { ComposerBannerStack, type ComposerBannerStackItem } from "./chat/ComposerBannerStack";
241import { ThreadSyncStatusPill } from "./chat/ThreadSyncStatusPill";
241242import {
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";
281import type { ThreadSyncPhase } from "../threadSync";
278282import { useLocalStorage } from "~/hooks/useLocalStorage";
279283import { useComposerHandleContext } from "../composerHandleContext";
280284import { 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}
apps/web/src/components/chat/ChatComposer.tsx+19-2
......@@ -420,6 +420,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(
420420 showPlanFollowUpPrompt: boolean;
421421 promptHasText: boolean;
422422 isSendBusy: boolean;
423 sendDisabledReason: string | null;
423424 isConnecting: boolean;
424425 isEnvironmentUnavailable: boolean;
425426 hasSendableContent: boolean;
......@@ -446,6 +447,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(
446447 showPlanFollowUpPrompt={props.showPlanFollowUpPrompt}
447448 promptHasText={props.promptHasText}
448449 isSendBusy={props.isSendBusy}
450 sendDisabledReason={props.sendDisabledReason}
449451 isConnecting={props.isConnecting}
450452 isEnvironmentUnavailable={props.isEnvironmentUnavailable}
451453 isPreparingWorktree={props.isPreparingWorktree}
......@@ -526,6 +528,7 @@ export interface ChatComposerProps {
526528 phase: SessionPhase;
527529 isConnecting: boolean;
528530 isSendBusy: boolean;
531 sendDisabledReason: string | null;
529532 isPreparingWorktree: boolean;
530533 environmentUnavailable: {
531534 readonly label: string;
......@@ -637,6 +640,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
637640 phase,
638641 isConnecting,
639642 isSendBusy,
643 sendDisabledReason,
640644 isPreparingWorktree,
641645 environmentUnavailable,
642646 activePendingApproval,
......@@ -690,6 +694,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
690694 setThreadError,
691695 onExpandImage,
692696 } = props;
697 const isSendDisabled = sendDisabledReason !== null;
693698
694699 // ------------------------------------------------------------------
695700 // Store subscriptions (prompt / images / terminal contexts)
......@@ -1239,6 +1244,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
12391244 const collapsedComposerPrimaryActionDisabled =
12401245 phase === "running" ||
12411246 isSendBusy ||
1247 isSendDisabled ||
12421248 isConnecting ||
12431249 noProviderAvailable ||
12441250 projectSelectionRequired ||
......@@ -1783,6 +1789,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
17831789 if (!isMobileViewport) return false;
17841790 if (
17851791 isSendBusy ||
1792 isSendDisabled ||
17861793 isConnecting ||
17871794 noProviderAvailable ||
17881795 environmentUnavailable !== null ||
......@@ -1802,6 +1809,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
18021809 isConnecting,
18031810 isMobileViewport,
18041811 isSendBusy,
1812 isSendDisabled,
18051813 noProviderAvailable,
18061814 phase,
18071815 showPlanFollowUpPrompt,
......@@ -1809,7 +1817,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
18091817
18101818 const submitComposer = useCallback(
18111819 (event?: { preventDefault: () => void }) => {
1812 if (noProviderAvailable) {
1820 if (noProviderAvailable || isSendDisabled) {
18131821 event?.preventDefault();
18141822 return;
18151823 }
......@@ -1818,7 +1826,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
18181826 blurMobileComposerAfterSend();
18191827 }
18201828 },
1821 [blurMobileComposerAfterSend, noProviderAvailable, onSend, shouldBlurMobileComposerOnSubmit],
1829 [
1830 blurMobileComposerAfterSend,
1831 isSendDisabled,
1832 noProviderAvailable,
1833 onSend,
1834 shouldBlurMobileComposerOnSubmit,
1835 ],
18221836 );
18231837 const expandMobileComposer = useCallback(() => {
18241838 if (composerBlurFrameRef.current !== null) {
......@@ -2726,6 +2740,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
27262740 showPlanFollowUpPrompt={false}
27272741 promptHasText={false}
27282742 isSendBusy={isSendBusy}
2743 sendDisabledReason={sendDisabledReason}
27292744 isConnecting={isConnecting}
27302745 isEnvironmentUnavailable={
27312746 environmentUnavailable !== null ||
......@@ -3008,6 +3023,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
30083023 showPlanFollowUpPrompt={false}
30093024 promptHasText={false}
30103025 isSendBusy={isSendBusy}
3026 sendDisabledReason={sendDisabledReason}
30113027 isConnecting={isConnecting}
30123028 isEnvironmentUnavailable={
30133029 environmentUnavailable !== null ||
......@@ -3138,6 +3154,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
31383154 showPlanFollowUpPrompt={pendingUserInputs.length === 0 && showPlanFollowUpPrompt}
31393155 promptHasText={prompt.trim().length > 0}
31403156 isSendBusy={isSendBusy}
3157 sendDisabledReason={sendDisabledReason}
31413158 isConnecting={isConnecting}
31423159 isEnvironmentUnavailable={
31433160 environmentUnavailable !== null ||
apps/web/src/components/chat/ComposerPrimaryActions.tsx+23-12
......@@ -22,6 +22,7 @@ interface ComposerPrimaryActionsProps {
2222 showPlanFollowUpPrompt: boolean;
2323 promptHasText: boolean;
2424 isSendBusy: boolean;
25 sendDisabledReason: string | null;
2526 isConnecting: boolean;
2627 isEnvironmentUnavailable: boolean;
2728 isPreparingWorktree: boolean;
......@@ -61,6 +62,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
6162 showPlanFollowUpPrompt,
6263 promptHasText,
6364 isSendBusy,
65 sendDisabledReason,
6466 isConnecting,
6567 isEnvironmentUnavailable,
6668 isPreparingWorktree,
......@@ -74,6 +76,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
7476 ? { onPointerDown: preventPointerFocus }
7577 : undefined;
7678 const environmentIdentificationMode = useEnvironmentIdentificationMode();
79 const isSendDisabled = sendDisabledReason !== null;
7780 const stageBackdropVariant = useSidebarStageBackdropVariant(
7881 environmentIdentificationMode === "artwork",
7982 );
......@@ -153,7 +156,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
153156 size="sm"
154157 className={cn("rounded-full", compact ? "h-9 px-3 sm:h-8" : "h-9 px-4 sm:h-8")}
155158 {...pointerFocusProps}
156 disabled={isSendBusy || isConnecting || isEnvironmentUnavailable}
159 disabled={isSendBusy || isSendDisabled || isConnecting || isEnvironmentUnavailable}
157160 >
158161 {isConnecting || isSendBusy ? "Sending..." : "Refine"}
159162 </Button>
......@@ -167,7 +170,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
167170 size="sm"
168171 className="h-9 rounded-l-full rounded-r-none px-4 sm:h-8"
169172 {...pointerFocusProps}
170 disabled={isSendBusy || isConnecting || isEnvironmentUnavailable}
173 disabled={isSendBusy || isSendDisabled || isConnecting || isEnvironmentUnavailable}
171174 >
172175 {isConnecting || isSendBusy ? "Sending..." : "Implement"}
173176 </Button>
......@@ -180,7 +183,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
180183 className="h-9 rounded-l-none rounded-r-full border-l-white/12 px-2 sm:h-8"
181184 aria-label="Implementation actions"
182185 {...pointerFocusProps}
183 disabled={isSendBusy || isConnecting || isEnvironmentUnavailable}
186 disabled={isSendBusy || isSendDisabled || isConnecting || isEnvironmentUnavailable}
184187 />
185188 }
186189 >
......@@ -188,7 +191,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
188191 </MenuTrigger>
189192 <MenuPopup align="end" side="top">
190193 <MenuItem
191 disabled={isSendBusy || isConnecting || isEnvironmentUnavailable}
194 disabled={isSendBusy || isSendDisabled || isConnecting || isEnvironmentUnavailable}
192195 onClick={() => void onImplementPlanInNewThread()}
193196 >
194197 Implement in a new thread
......@@ -209,17 +212,25 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
209212 : "bg-primary/90 enabled:shadow-primary/24 hover:bg-primary",
210213 )}
211214 {...pointerFocusProps}
212 disabled={isSendBusy || isConnecting || isEnvironmentUnavailable || !hasSendableContent}
215 disabled={
216 isSendBusy ||
217 isSendDisabled ||
218 isConnecting ||
219 isEnvironmentUnavailable ||
220 !hasSendableContent
221 }
213222 aria-label={
214223 isEnvironmentUnavailable
215224 ? "Environment disconnected"
216 : isConnecting
217 ? "Connecting"
218 : isPreparingWorktree
219 ? "Preparing worktree"
220 : isSendBusy
221 ? "Sending"
222 : "Send message"
225 : sendDisabledReason
226 ? sendDisabledReason
227 : isConnecting
228 ? "Connecting"
229 : isPreparingWorktree
230 ? "Preparing worktree"
231 : isSendBusy
232 ? "Sending"
233 : "Send message"
223234 }
224235 >
225236 {stageBackdropVariant ? (
apps/web/src/components/chat/ThreadSyncStatusPill.test.tsx created+17
......@@ -0,0 +1,17 @@
1import { renderToStaticMarkup } from "react-dom/server";
2import { describe, expect, it } from "vite-plus/test";
3
4import { ThreadSyncStatusPill } from "./ThreadSyncStatusPill";
5
6describe("ThreadSyncStatusPill", () => {
7 it.each([
8 ["loading", "Loading messages..."],
9 ["syncing", "Syncing messages..."],
10 ] as const)("renders the %s message sync phase", (phase, label) => {
11 const markup = renderToStaticMarkup(<ThreadSyncStatusPill phase={phase} />);
12
13 expect(markup).toContain('role="status"');
14 expect(markup).toContain(label);
15 expect(markup).not.toContain("animate-");
16 });
17});
apps/web/src/components/chat/ThreadSyncStatusPill.tsx created+18
......@@ -0,0 +1,18 @@
1import { LoaderCircleIcon } from "lucide-react";
2
3import { threadSyncLabel, type ThreadSyncPhase } from "../../threadSync";
4
5export function ThreadSyncStatusPill({ phase }: { readonly phase: ThreadSyncPhase }) {
6 const label = threadSyncLabel(phase);
7
8 return (
9 <div
10 aria-label={label}
11 className="pointer-events-none mx-auto mb-2 flex w-fit max-w-full items-center gap-2 rounded-full border border-border/60 bg-card/95 px-3 py-1.5 text-foreground text-xs font-medium shadow-sm"
12 role="status"
13 >
14 <LoaderCircleIcon aria-hidden className="size-3.5 shrink-0 text-muted-foreground" />
15 <span className="truncate">{label}</span>
16 </div>
17 );
18}
apps/web/src/routes/_chat.$environmentId.$threadId.tsx+15-6
......@@ -5,6 +5,7 @@ import ChatView from "../components/ChatView";
55import { threadHasStarted } from "../components/ChatView.logic";
66import { finalizePromotedDraftThreadByRef, useComposerDraftStore } from "../composerDraftStore";
77import { resolveThreadRouteRef, resolveThreadRouteRenderState } from "../threadRoutes";
8import { resolveThreadSyncPhase } from "../threadSync";
89import { SidebarInset } from "~/components/ui/sidebar";
910import {
1011 useEnvironmentThreadRefs,
......@@ -48,6 +49,11 @@ function ChatThreadRouteView() {
4849 serverThreadDetailDeleted: serverThreadStatus === "deleted",
4950 draftThreadExists,
5051 });
52 const threadSyncPhase = resolveThreadSyncPhase({
53 detailExists: serverThreadDetail !== null,
54 shellExists: serverThreadShell !== null,
55 status: serverThreadStatus,
56 });
5157 const serverThreadStarted = threadHasStarted(serverThreadDetail);
5258 const environmentHasAnyThreads = environmentHasServerThreads || environmentHasDraftThreads;
5359
......@@ -68,17 +74,20 @@ function ChatThreadRouteView() {
6874 finalizePromotedDraftThreadByRef(threadRef);
6975 }, [draftThread, serverThreadStarted, threadRef]);
7076
71 if (!threadRef || renderState !== "ready") {
77 if (!threadRef) {
7278 return null;
7379 }
7480
7581 return (
7682 <SidebarInset className="h-svh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground md:h-dvh">
77 <ChatView
78 environmentId={threadRef.environmentId}
79 threadId={threadRef.threadId}
80 routeKind="server"
81 />
83 {renderState === "ready" || (renderState === "loading" && serverThreadShell !== null) ? (
84 <ChatView
85 environmentId={threadRef.environmentId}
86 threadId={threadRef.threadId}
87 routeKind="server"
88 threadSyncPhase={threadSyncPhase}
89 />
90 ) : null}
8291 </SidebarInset>
8392 );
8493}
apps/web/src/threadSync.test.ts created+49
......@@ -0,0 +1,49 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { resolveThreadSyncPhase, threadSyncLabel } from "./threadSync";
4
5describe("resolveThreadSyncPhase", () => {
6 it("loads when only shell data is available", () => {
7 expect(
8 resolveThreadSyncPhase({
9 detailExists: false,
10 shellExists: true,
11 status: "synchronizing",
12 }),
13 ).toBe("loading");
14 });
15
16 it("syncs when cached detail is already visible", () => {
17 expect(
18 resolveThreadSyncPhase({
19 detailExists: true,
20 shellExists: true,
21 status: "cached",
22 }),
23 ).toBe("syncing");
24 });
25
26 it("does not report a sync phase without a shell or after going live", () => {
27 expect(
28 resolveThreadSyncPhase({
29 detailExists: false,
30 shellExists: false,
31 status: "empty",
32 }),
33 ).toBeNull();
34 expect(
35 resolveThreadSyncPhase({
36 detailExists: true,
37 shellExists: true,
38 status: "live",
39 }),
40 ).toBeNull();
41 });
42});
43
44describe("threadSyncLabel", () => {
45 it("uses the same loading and syncing language as mobile", () => {
46 expect(threadSyncLabel("loading")).toBe("Loading messages...");
47 expect(threadSyncLabel("syncing")).toBe("Syncing messages...");
48 });
49});
apps/web/src/threadSync.ts created+27
......@@ -0,0 +1,27 @@
1import type { EnvironmentThreadStatus } from "@t3tools/client-runtime/state/threads";
2
3export type ThreadSyncPhase = "loading" | "syncing";
4
5export function resolveThreadSyncPhase(input: {
6 readonly detailExists: boolean;
7 readonly shellExists: boolean;
8 readonly status: EnvironmentThreadStatus;
9}): ThreadSyncPhase | null {
10 if (!input.shellExists) {
11 return null;
12 }
13
14 switch (input.status) {
15 case "empty":
16 case "cached":
17 case "synchronizing":
18 return input.detailExists ? "syncing" : "loading";
19 case "deleted":
20 case "live":
21 return null;
22 }
23}
24
25export function threadSyncLabel(phase: ThreadSyncPhase): string {
26 return phase === "loading" ? "Loading messages..." : "Syncing messages...";
27}