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) { ) : ( )} + {threadSyncPhase && !activeEnvironmentUnavailable ? ( + + ) : null}
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 ce9de113beb..5ac4665cf8a 100644 --- a/apps/web/src/routes/_chat.$environmentId.$threadId.tsx +++ b/apps/web/src/routes/_chat.$environmentId.$threadId.tsx @@ -5,6 +5,7 @@ import ChatView from "../components/ChatView"; import { threadHasStarted } from "../components/ChatView.logic"; import { finalizePromotedDraftThreadByRef, useComposerDraftStore } from "../composerDraftStore"; import { resolveThreadRouteRef, resolveThreadRouteRenderState } from "../threadRoutes"; +import { resolveThreadSyncPhase } from "../threadSync"; import { SidebarInset } from "~/components/ui/sidebar"; import { useEnvironmentThreadRefs, @@ -48,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; @@ -68,17 +74,20 @@ function ChatThreadRouteView() { finalizePromotedDraftThreadByRef(threadRef); }, [draftThread, serverThreadStarted, threadRef]); - if (!threadRef || renderState !== "ready") { + if (!threadRef) { return null; } return ( - + {renderState === "ready" || (renderState === "loading" && serverThreadShell !== null) ? ( + + ) : 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..."; +}