From 290592585988fef70d1e91552d86b28e1044f3a4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 29 Jul 2026 12:56:58 +0200 Subject: [PATCH 1/3] fix(web): preserve thread shell while detail loads - Render a stable loading shell with available project and thread metadata - Add coverage for populated and generic loading states --- .../ThreadDetailLoadingState.test.tsx | 28 +++++ .../components/ThreadDetailLoadingState.tsx | 115 ++++++++++++++++++ .../routes/_chat.$environmentId.$threadId.tsx | 27 +++- 3 files changed, 164 insertions(+), 6 deletions(-) create mode 100644 apps/web/src/components/ThreadDetailLoadingState.test.tsx create mode 100644 apps/web/src/components/ThreadDetailLoadingState.tsx diff --git a/apps/web/src/components/ThreadDetailLoadingState.test.tsx b/apps/web/src/components/ThreadDetailLoadingState.test.tsx new file mode 100644 index 00000000000..94f6d3e7157 --- /dev/null +++ b/apps/web/src/components/ThreadDetailLoadingState.test.tsx @@ -0,0 +1,28 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vite-plus/test"; + +import { ThreadDetailLoadingState } from "./ThreadDetailLoadingState"; + +describe("ThreadDetailLoadingState", () => { + it("renders shell metadata while the detail snapshot is loading", () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain('aria-busy="true"'); + expect(markup).toContain("Loading thread"); + expect(markup).toContain("T3 Code"); + expect(markup).toContain("Fix thread loading"); + expect(markup).toContain("chat-composer-glass-shell"); + }); + + it("renders a generic shell before thread metadata is available", () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain('role="status"'); + expect(markup).not.toContain("No active thread"); + expect(markup).not.toContain("animate-"); + }); +}); diff --git a/apps/web/src/components/ThreadDetailLoadingState.tsx b/apps/web/src/components/ThreadDetailLoadingState.tsx new file mode 100644 index 00000000000..a8727260038 --- /dev/null +++ b/apps/web/src/components/ThreadDetailLoadingState.tsx @@ -0,0 +1,115 @@ +import { FolderIcon } from "lucide-react"; + +import { isElectron } from "../env"; +import { cn } from "~/lib/utils"; +import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; + +interface ThreadDetailLoadingStateProps { + readonly projectTitle: string | null; + readonly threadTitle: string | null; +} + +/** + * Paints the stable chat chrome while the detail snapshot catches up with the + * shell snapshot. The placeholders are intentionally static so opening a large + * thread does not add a continuously repainting animation. + */ +export function ThreadDetailLoadingState({ + projectTitle, + threadTitle, +}: ThreadDetailLoadingStateProps) { + return ( +
+
+
+
+ {projectTitle ? ( + + + + {projectTitle} + + + / + + + ) : ( + + )} + {threadTitle ? ( +

+ {threadTitle} +

+ ) : ( + + )} +
+ + + +
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+ + +
+ +
+
+
+
+
+
+
+
+
+
+ ); +} diff --git a/apps/web/src/routes/_chat.$environmentId.$threadId.tsx b/apps/web/src/routes/_chat.$environmentId.$threadId.tsx index ce9de113beb..cf89801248a 100644 --- a/apps/web/src/routes/_chat.$environmentId.$threadId.tsx +++ b/apps/web/src/routes/_chat.$environmentId.$threadId.tsx @@ -1,13 +1,16 @@ +import { scopeProjectRef } from "@t3tools/client-runtime/environment"; import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { useEffect } from "react"; import ChatView from "../components/ChatView"; import { threadHasStarted } from "../components/ChatView.logic"; +import { ThreadDetailLoadingState } from "../components/ThreadDetailLoadingState"; import { finalizePromotedDraftThreadByRef, useComposerDraftStore } from "../composerDraftStore"; import { resolveThreadRouteRef, resolveThreadRouteRenderState } from "../threadRoutes"; import { SidebarInset } from "~/components/ui/sidebar"; import { useEnvironmentThreadRefs, + useProject, useThreadDetail, useThreadShell, useThreadStatus, @@ -24,6 +27,11 @@ function ChatThreadRouteView() { threadRef === null ? null : environmentShell.stateAtom(threadRef.environmentId), ); const serverThreadShell = useThreadShell(threadRef); + const serverThreadProject = useProject( + serverThreadShell === null + ? null + : scopeProjectRef(serverThreadShell.environmentId, serverThreadShell.projectId), + ); const serverThreadDetail = useThreadDetail(threadRef); const serverThreadStatus = useThreadStatus(threadRef); const environmentThreadRefs = useEnvironmentThreadRefs(threadRef?.environmentId ?? null); @@ -68,17 +76,24 @@ function ChatThreadRouteView() { finalizePromotedDraftThreadByRef(threadRef); }, [draftThread, serverThreadStarted, threadRef]); - if (!threadRef || renderState !== "ready") { + if (!threadRef) { return null; } return ( - + {renderState === "ready" ? ( + + ) : renderState === "loading" ? ( + + ) : null} ); } From 7dd91b5e9ed216f5bd90b5d9ed0fe8891b8eec4c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 29 Jul 2026 13:35:53 +0200 Subject: [PATCH 2/3] fix(web): preserve thread shell while details sync - Keep thread metadata visible during message loading - Show sync status and disable composer until details are ready --- .../web/src/components/ChatView.logic.test.ts | 48 +++++++- apps/web/src/components/ChatView.logic.ts | 13 +- apps/web/src/components/ChatView.tsx | 29 ++++- .../ThreadDetailLoadingState.test.tsx | 28 ----- .../components/ThreadDetailLoadingState.tsx | 115 ------------------ apps/web/src/components/chat/ChatComposer.tsx | 21 +++- .../chat/ComposerPrimaryActions.tsx | 35 ++++-- .../chat/ThreadSyncStatusPill.test.tsx | 17 +++ .../components/chat/ThreadSyncStatusPill.tsx | 18 +++ .../routes/_chat.$environmentId.$threadId.tsx | 22 ++-- apps/web/src/threadSync.test.ts | 49 ++++++++ apps/web/src/threadSync.ts | 27 ++++ 12 files changed, 245 insertions(+), 177 deletions(-) delete mode 100644 apps/web/src/components/ThreadDetailLoadingState.test.tsx delete mode 100644 apps/web/src/components/ThreadDetailLoadingState.tsx create mode 100644 apps/web/src/components/chat/ThreadSyncStatusPill.test.tsx create mode 100644 apps/web/src/components/chat/ThreadSyncStatusPill.tsx create mode 100644 apps/web/src/threadSync.test.ts create mode 100644 apps/web/src/threadSync.ts diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 1b2e537c35d..d48314e8be5 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"), diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 4fb007b85b5..ea563bc64d6 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,6 +95,17 @@ export function buildLocalDraftThread( }; } +export function buildLoadingThreadFromShell(shell: ThreadShell): Thread { + return { + ...shell, + messages: [], + proposedPlans: [], + activities: [], + checkpoints: [], + deletedAt: null, + }; +} + export function shouldWriteThreadErrorToCurrentServerThread(input: { serverThread: | { diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index bdde62a3e0d..8b623ee2143 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, @@ -275,6 +277,7 @@ import { 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 +468,7 @@ type ChatViewProps = onDiffPanelOpen?: () => void; reserveTitleBarControlInset?: boolean; forceExpandedMobileComposer?: boolean; + threadSyncPhase?: ThreadSyncPhase | null; routeKind: "server"; draftId?: never; } @@ -474,6 +478,7 @@ type ChatViewProps = onDiffPanelOpen?: () => void; reserveTitleBarControlInset?: boolean; forceExpandedMobileComposer?: boolean; + threadSyncPhase?: never; routeKind: "draft"; draftId: DraftId; }; @@ -1132,6 +1137,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 +1195,15 @@ 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 markThreadVisited = useUiStateStore((store) => store.markThreadVisited); const activeThreadLastVisitedAt = useUiStateStore( (store) => store.threadLastVisitedAtById[routeThreadKey], @@ -1421,10 +1436,11 @@ 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 activeServerThread = serverThread ?? loadingServerThread; + 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 = @@ -4471,6 +4487,7 @@ function ChatViewContent(props: ChatViewProps) { !activeThread || isSendBusy || isConnecting || + threadDetailLoading || activeEnvironmentUnavailable || sendInFlightRef.current ) @@ -5737,7 +5754,7 @@ function ChatViewContent(props: ChatViewProps) { contentInsetEndAdjustment={composerOverlayHeight} onIsAtEndChange={onIsAtEndChange} onManualNavigation={cancelTimelineLiveFollowForUserNavigation} - hideEmptyPlaceholder={isDraftHeroState} + hideEmptyPlaceholder={isDraftHeroState || threadDetailLoading} topFadeEnabled={!hasTimelineTopBanner} /> @@ -5798,6 +5815,9 @@ function ChatViewContent(props: ChatViewProps) { ) : ( )} + {threadSyncPhase && !activeEnvironmentUnavailable ? ( + + ) : null}
{ - it("renders shell metadata while the detail snapshot is loading", () => { - const markup = renderToStaticMarkup( - , - ); - - expect(markup).toContain('aria-busy="true"'); - expect(markup).toContain("Loading thread"); - expect(markup).toContain("T3 Code"); - expect(markup).toContain("Fix thread loading"); - expect(markup).toContain("chat-composer-glass-shell"); - }); - - it("renders a generic shell before thread metadata is available", () => { - const markup = renderToStaticMarkup( - , - ); - - expect(markup).toContain('role="status"'); - expect(markup).not.toContain("No active thread"); - expect(markup).not.toContain("animate-"); - }); -}); diff --git a/apps/web/src/components/ThreadDetailLoadingState.tsx b/apps/web/src/components/ThreadDetailLoadingState.tsx deleted file mode 100644 index a8727260038..00000000000 --- a/apps/web/src/components/ThreadDetailLoadingState.tsx +++ /dev/null @@ -1,115 +0,0 @@ -import { FolderIcon } from "lucide-react"; - -import { isElectron } from "../env"; -import { cn } from "~/lib/utils"; -import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; - -interface ThreadDetailLoadingStateProps { - readonly projectTitle: string | null; - readonly threadTitle: string | null; -} - -/** - * Paints the stable chat chrome while the detail snapshot catches up with the - * shell snapshot. The placeholders are intentionally static so opening a large - * thread does not add a continuously repainting animation. - */ -export function ThreadDetailLoadingState({ - projectTitle, - threadTitle, -}: ThreadDetailLoadingStateProps) { - return ( -
-
-
-
- {projectTitle ? ( - - - - {projectTitle} - - - / - - - ) : ( - - )} - {threadTitle ? ( -

- {threadTitle} -

- ) : ( - - )} -
- - - -
-
-
- -
-
-
-
-
-
-
-
-
-
-
-
-
-
- -
-
-
-
-
-
-
-
- - -
- -
-
-
-
-
-
-
-
-
-
- ); -} diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 5575916dd9a..b89521a932d 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -420,6 +420,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( showPlanFollowUpPrompt: boolean; promptHasText: boolean; isSendBusy: boolean; + sendDisabledReason: string | null; isConnecting: boolean; isEnvironmentUnavailable: boolean; hasSendableContent: boolean; @@ -446,6 +447,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( showPlanFollowUpPrompt={props.showPlanFollowUpPrompt} promptHasText={props.promptHasText} isSendBusy={props.isSendBusy} + sendDisabledReason={props.sendDisabledReason} isConnecting={props.isConnecting} isEnvironmentUnavailable={props.isEnvironmentUnavailable} isPreparingWorktree={props.isPreparingWorktree} @@ -526,6 +528,7 @@ export interface ChatComposerProps { phase: SessionPhase; isConnecting: boolean; isSendBusy: boolean; + sendDisabledReason: string | null; isPreparingWorktree: boolean; environmentUnavailable: { readonly label: string; @@ -637,6 +640,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) phase, isConnecting, isSendBusy, + sendDisabledReason, isPreparingWorktree, environmentUnavailable, activePendingApproval, @@ -690,6 +694,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) setThreadError, onExpandImage, } = props; + const isSendDisabled = sendDisabledReason !== null; // ------------------------------------------------------------------ // Store subscriptions (prompt / images / terminal contexts) @@ -1239,6 +1244,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const collapsedComposerPrimaryActionDisabled = phase === "running" || isSendBusy || + isSendDisabled || isConnecting || noProviderAvailable || projectSelectionRequired || @@ -1783,6 +1789,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) if (!isMobileViewport) return false; if ( isSendBusy || + isSendDisabled || isConnecting || noProviderAvailable || environmentUnavailable !== null || @@ -1802,6 +1809,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) isConnecting, isMobileViewport, isSendBusy, + isSendDisabled, noProviderAvailable, phase, showPlanFollowUpPrompt, @@ -1809,7 +1817,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const submitComposer = useCallback( (event?: { preventDefault: () => void }) => { - if (noProviderAvailable) { + if (noProviderAvailable || isSendDisabled) { event?.preventDefault(); return; } @@ -1818,7 +1826,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) blurMobileComposerAfterSend(); } }, - [blurMobileComposerAfterSend, noProviderAvailable, onSend, shouldBlurMobileComposerOnSubmit], + [ + blurMobileComposerAfterSend, + isSendDisabled, + noProviderAvailable, + onSend, + shouldBlurMobileComposerOnSubmit, + ], ); const expandMobileComposer = useCallback(() => { if (composerBlurFrameRef.current !== null) { @@ -2726,6 +2740,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) showPlanFollowUpPrompt={false} promptHasText={false} isSendBusy={isSendBusy} + sendDisabledReason={sendDisabledReason} isConnecting={isConnecting} isEnvironmentUnavailable={ environmentUnavailable !== null || @@ -3008,6 +3023,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) showPlanFollowUpPrompt={false} promptHasText={false} isSendBusy={isSendBusy} + sendDisabledReason={sendDisabledReason} isConnecting={isConnecting} isEnvironmentUnavailable={ environmentUnavailable !== null || @@ -3138,6 +3154,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) showPlanFollowUpPrompt={pendingUserInputs.length === 0 && showPlanFollowUpPrompt} promptHasText={prompt.trim().length > 0} isSendBusy={isSendBusy} + sendDisabledReason={sendDisabledReason} isConnecting={isConnecting} isEnvironmentUnavailable={ environmentUnavailable !== null || diff --git a/apps/web/src/components/chat/ComposerPrimaryActions.tsx b/apps/web/src/components/chat/ComposerPrimaryActions.tsx index 19c20e2abf2..504b7e1cc44 100644 --- a/apps/web/src/components/chat/ComposerPrimaryActions.tsx +++ b/apps/web/src/components/chat/ComposerPrimaryActions.tsx @@ -22,6 +22,7 @@ interface ComposerPrimaryActionsProps { showPlanFollowUpPrompt: boolean; promptHasText: boolean; isSendBusy: boolean; + sendDisabledReason: string | null; isConnecting: boolean; isEnvironmentUnavailable: boolean; isPreparingWorktree: boolean; @@ -61,6 +62,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ showPlanFollowUpPrompt, promptHasText, isSendBusy, + sendDisabledReason, isConnecting, isEnvironmentUnavailable, isPreparingWorktree, @@ -74,6 +76,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ ? { onPointerDown: preventPointerFocus } : undefined; const environmentIdentificationMode = useEnvironmentIdentificationMode(); + const isSendDisabled = sendDisabledReason !== null; const stageBackdropVariant = useSidebarStageBackdropVariant( environmentIdentificationMode === "artwork", ); @@ -153,7 +156,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ size="sm" className={cn("rounded-full", compact ? "h-9 px-3 sm:h-8" : "h-9 px-4 sm:h-8")} {...pointerFocusProps} - disabled={isSendBusy || isConnecting || isEnvironmentUnavailable} + disabled={isSendBusy || isSendDisabled || isConnecting || isEnvironmentUnavailable} > {isConnecting || isSendBusy ? "Sending..." : "Refine"} @@ -167,7 +170,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ size="sm" className="h-9 rounded-l-full rounded-r-none px-4 sm:h-8" {...pointerFocusProps} - disabled={isSendBusy || isConnecting || isEnvironmentUnavailable} + disabled={isSendBusy || isSendDisabled || isConnecting || isEnvironmentUnavailable} > {isConnecting || isSendBusy ? "Sending..." : "Implement"} @@ -180,7 +183,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ className="h-9 rounded-l-none rounded-r-full border-l-white/12 px-2 sm:h-8" aria-label="Implementation actions" {...pointerFocusProps} - disabled={isSendBusy || isConnecting || isEnvironmentUnavailable} + disabled={isSendBusy || isSendDisabled || isConnecting || isEnvironmentUnavailable} /> } > @@ -188,7 +191,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ void onImplementPlanInNewThread()} > Implement in a new thread @@ -209,17 +212,25 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ : "bg-primary/90 enabled:shadow-primary/24 hover:bg-primary", )} {...pointerFocusProps} - disabled={isSendBusy || isConnecting || isEnvironmentUnavailable || !hasSendableContent} + disabled={ + isSendBusy || + isSendDisabled || + isConnecting || + isEnvironmentUnavailable || + !hasSendableContent + } aria-label={ isEnvironmentUnavailable ? "Environment disconnected" - : isConnecting - ? "Connecting" - : isPreparingWorktree - ? "Preparing worktree" - : isSendBusy - ? "Sending" - : "Send message" + : sendDisabledReason + ? sendDisabledReason + : isConnecting + ? "Connecting" + : isPreparingWorktree + ? "Preparing worktree" + : isSendBusy + ? "Sending" + : "Send message" } > {stageBackdropVariant ? ( diff --git a/apps/web/src/components/chat/ThreadSyncStatusPill.test.tsx b/apps/web/src/components/chat/ThreadSyncStatusPill.test.tsx new file mode 100644 index 00000000000..2aa51cf7792 --- /dev/null +++ b/apps/web/src/components/chat/ThreadSyncStatusPill.test.tsx @@ -0,0 +1,17 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vite-plus/test"; + +import { ThreadSyncStatusPill } from "./ThreadSyncStatusPill"; + +describe("ThreadSyncStatusPill", () => { + it.each([ + ["loading", "Loading messages..."], + ["syncing", "Syncing messages..."], + ] as const)("renders the %s message sync phase", (phase, label) => { + const markup = renderToStaticMarkup(); + + expect(markup).toContain('role="status"'); + expect(markup).toContain(label); + expect(markup).not.toContain("animate-"); + }); +}); diff --git a/apps/web/src/components/chat/ThreadSyncStatusPill.tsx b/apps/web/src/components/chat/ThreadSyncStatusPill.tsx new file mode 100644 index 00000000000..d920a6d1953 --- /dev/null +++ b/apps/web/src/components/chat/ThreadSyncStatusPill.tsx @@ -0,0 +1,18 @@ +import { LoaderCircleIcon } from "lucide-react"; + +import { threadSyncLabel, type ThreadSyncPhase } from "../../threadSync"; + +export function ThreadSyncStatusPill({ phase }: { readonly phase: ThreadSyncPhase }) { + const label = threadSyncLabel(phase); + + return ( +
+ + {label} +
+ ); +} diff --git a/apps/web/src/routes/_chat.$environmentId.$threadId.tsx b/apps/web/src/routes/_chat.$environmentId.$threadId.tsx index cf89801248a..5ac4665cf8a 100644 --- a/apps/web/src/routes/_chat.$environmentId.$threadId.tsx +++ b/apps/web/src/routes/_chat.$environmentId.$threadId.tsx @@ -1,16 +1,14 @@ -import { scopeProjectRef } from "@t3tools/client-runtime/environment"; import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { useEffect } from "react"; import ChatView from "../components/ChatView"; import { threadHasStarted } from "../components/ChatView.logic"; -import { ThreadDetailLoadingState } from "../components/ThreadDetailLoadingState"; import { finalizePromotedDraftThreadByRef, useComposerDraftStore } from "../composerDraftStore"; import { resolveThreadRouteRef, resolveThreadRouteRenderState } from "../threadRoutes"; +import { resolveThreadSyncPhase } from "../threadSync"; import { SidebarInset } from "~/components/ui/sidebar"; import { useEnvironmentThreadRefs, - useProject, useThreadDetail, useThreadShell, useThreadStatus, @@ -27,11 +25,6 @@ function ChatThreadRouteView() { threadRef === null ? null : environmentShell.stateAtom(threadRef.environmentId), ); const serverThreadShell = useThreadShell(threadRef); - const serverThreadProject = useProject( - serverThreadShell === null - ? null - : scopeProjectRef(serverThreadShell.environmentId, serverThreadShell.projectId), - ); const serverThreadDetail = useThreadDetail(threadRef); const serverThreadStatus = useThreadStatus(threadRef); const environmentThreadRefs = useEnvironmentThreadRefs(threadRef?.environmentId ?? null); @@ -56,6 +49,11 @@ function ChatThreadRouteView() { serverThreadDetailDeleted: serverThreadStatus === "deleted", draftThreadExists, }); + const threadSyncPhase = resolveThreadSyncPhase({ + detailExists: serverThreadDetail !== null, + shellExists: serverThreadShell !== null, + status: serverThreadStatus, + }); const serverThreadStarted = threadHasStarted(serverThreadDetail); const environmentHasAnyThreads = environmentHasServerThreads || environmentHasDraftThreads; @@ -82,16 +80,12 @@ function ChatThreadRouteView() { return ( - {renderState === "ready" ? ( + {renderState === "ready" || (renderState === "loading" && serverThreadShell !== null) ? ( - ) : renderState === "loading" ? ( - ) : null} diff --git a/apps/web/src/threadSync.test.ts b/apps/web/src/threadSync.test.ts new file mode 100644 index 00000000000..ba91e33078f --- /dev/null +++ b/apps/web/src/threadSync.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { resolveThreadSyncPhase, threadSyncLabel } from "./threadSync"; + +describe("resolveThreadSyncPhase", () => { + it("loads when only shell data is available", () => { + expect( + resolveThreadSyncPhase({ + detailExists: false, + shellExists: true, + status: "synchronizing", + }), + ).toBe("loading"); + }); + + it("syncs when cached detail is already visible", () => { + expect( + resolveThreadSyncPhase({ + detailExists: true, + shellExists: true, + status: "cached", + }), + ).toBe("syncing"); + }); + + it("does not report a sync phase without a shell or after going live", () => { + expect( + resolveThreadSyncPhase({ + detailExists: false, + shellExists: false, + status: "empty", + }), + ).toBeNull(); + expect( + resolveThreadSyncPhase({ + detailExists: true, + shellExists: true, + status: "live", + }), + ).toBeNull(); + }); +}); + +describe("threadSyncLabel", () => { + it("uses the same loading and syncing language as mobile", () => { + expect(threadSyncLabel("loading")).toBe("Loading messages..."); + expect(threadSyncLabel("syncing")).toBe("Syncing messages..."); + }); +}); diff --git a/apps/web/src/threadSync.ts b/apps/web/src/threadSync.ts new file mode 100644 index 00000000000..a8b5446add2 --- /dev/null +++ b/apps/web/src/threadSync.ts @@ -0,0 +1,27 @@ +import type { EnvironmentThreadStatus } from "@t3tools/client-runtime/state/threads"; + +export type ThreadSyncPhase = "loading" | "syncing"; + +export function resolveThreadSyncPhase(input: { + readonly detailExists: boolean; + readonly shellExists: boolean; + readonly status: EnvironmentThreadStatus; +}): ThreadSyncPhase | null { + if (!input.shellExists) { + return null; + } + + switch (input.status) { + case "empty": + case "cached": + case "synchronizing": + return input.detailExists ? "syncing" : "loading"; + case "deleted": + case "live": + return null; + } +} + +export function threadSyncLabel(phase: ThreadSyncPhase): string { + return phase === "loading" ? "Loading messages..." : "Syncing messages..."; +} From 145f427c310d046af40be2ea07c540f6324ef139 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 29 Jul 2026 13:49:17 +0200 Subject: [PATCH 3/3] fix(web): preserve thread errors during shell loading - Treat the loading thread shell as the active server thread - Preserve error state across thread promotion and add focused coverage --- .../web/src/components/ChatView.logic.test.ts | 11 +++++++--- apps/web/src/components/ChatView.logic.ts | 8 ++++---- apps/web/src/components/ChatView.tsx | 20 ++++++++++--------- 3 files changed, 23 insertions(+), 16 deletions(-) diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index d48314e8be5..39285438d1a 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -472,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 ea563bc64d6..04b35fd4551 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -107,7 +107,7 @@ export function buildLoadingThreadFromShell(shell: ThreadShell): Thread { } export function shouldWriteThreadErrorToCurrentServerThread(input: { - serverThread: + activeServerThread: | { environmentId: EnvironmentId; id: ThreadId; @@ -118,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 8b623ee2143..d532c8b1233 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -274,6 +274,7 @@ import { resolveSendEnvMode, revokeBlobPreviewUrl, revokeUserMessagePreviewUrls, + shouldWriteThreadErrorToCurrentServerThread, startNewThreadForProject, waitForStartedServerThread, } from "./ChatView.logic"; @@ -1204,6 +1205,7 @@ function ChatViewContent(props: ChatViewProps) { : null, [routeServerThreadShell, threadDetailLoading], ); + const activeServerThread = serverThread ?? loadingServerThread; const markThreadVisited = useUiStateStore((store) => store.markThreadVisited); const activeThreadLastVisitedAt = useUiStateStore( (store) => store.threadLastVisitedAtById[routeThreadKey], @@ -1383,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; @@ -1392,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]; @@ -1421,7 +1423,7 @@ function ChatViewContent(props: ChatViewProps) { [routeThreadKey]: pendingDraftEntry, }; }); - }, [draftId, localDraftErrorsByDraftId, routeThreadKey, serverThread]); + }, [activeServerThread, draftId, localDraftErrorsByDraftId, routeThreadKey]); const localDraftThread = useMemo( () => draftThread @@ -1436,7 +1438,6 @@ 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 activeServerThread = serverThread ?? loadingServerThread; const isServerThread = activeServerThread !== null; const activeThread = activeServerThread ?? localDraftThread; const threadError = isServerThread @@ -2506,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) { @@ -2533,7 +2535,7 @@ function ChatViewContent(props: ChatViewProps) { }; }); }, - [draftId, routeThreadKey, routeThreadRef, serverThread], + [activeServerThread, draftId, routeThreadKey, routeThreadRef], ); const focusComposer = useCallback(() => {