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 {...@@ -8,12 +8,13 @@ import {
8} from "@t3tools/contracts";8} from "@t3tools/contracts";
9import { describe, expect, it } from "vite-plus/test";9import { describe, expect, it } from "vite-plus/test";
1010
11import type { Thread } from "../types";11import type { Thread, ThreadShell } from "../types";
12import {12import {
13 MAX_HIDDEN_MOUNTED_PREVIEW_THREADS,13 MAX_HIDDEN_MOUNTED_PREVIEW_THREADS,
14 MAX_HIDDEN_MOUNTED_TERMINAL_THREADS,14 MAX_HIDDEN_MOUNTED_TERMINAL_THREADS,
15 branchMismatchKey,15 branchMismatchKey,
16 buildExpiredTerminalContextToastCopy,16 buildExpiredTerminalContextToastCopy,
17 buildLoadingThreadFromShell,
17 buildThreadTurnInterruptInput,18 buildThreadTurnInterruptInput,
18 createLocalDispatchSnapshot,19 createLocalDispatchSnapshot,
19 deriveComposerSendState,20 deriveComposerSendState,
...@@ -85,6 +86,51 @@ const readySession = {...@@ -85,6 +86,51 @@ const readySession = {
85 updatedAt: "2026-03-29T00:00:10.000Z",86 updatedAt: "2026-03-29T00:00:10.000Z",
86};87};
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
88describe("resolveThreadMetadataUpdateForNextTurn", () => {134describe("resolveThreadMetadataUpdateForNextTurn", () => {
89 const modelSelection = {135 const modelSelection = {
90 instanceId: ProviderInstanceId.make("codex"),136 instanceId: ProviderInstanceId.make("codex"),
...@@ -426,19 +472,24 @@ describe("reconcileRetainedMountedThreadIds", () => {...@@ -426,19 +472,24 @@ describe("reconcileRetainedMountedThreadIds", () => {
426});472});
427473
428describe("shouldWriteThreadErrorToCurrentServerThread", () => {474describe("shouldWriteThreadErrorToCurrentServerThread", () => {
429 it("requires the environment, route thread, and target thread to match", () => {475 it("writes errors for a shell-derived active server thread", () => {
430 const routeThreadRef = { environmentId, threadId };476 const routeThreadRef = { environmentId, threadId };
431477
432 expect(478 expect(
433 shouldWriteThreadErrorToCurrentServerThread({479 shouldWriteThreadErrorToCurrentServerThread({
434 serverThread: { environmentId, id: threadId },480 activeServerThread: { environmentId, id: threadId },
435 routeThreadRef,481 routeThreadRef,
436 targetThreadId: threadId,482 targetThreadId: threadId,
437 }),483 }),
438 ).toBe(true);484 ).toBe(true);
485 });
486
487 it("requires an active server thread matching the environment, route, and target", () => {
488 const routeThreadRef = { environmentId, threadId };
489
439 expect(490 expect(
440 shouldWriteThreadErrorToCurrentServerThread({491 shouldWriteThreadErrorToCurrentServerThread({
441 serverThread: null,492 activeServerThread: null,
442 routeThreadRef,493 routeThreadRef,
443 targetThreadId: threadId,494 targetThreadId: threadId,
444 }),495 }),
apps/web/src/components/ChatView.logic.ts+16-5
...@@ -10,7 +10,7 @@ import {...@@ -10,7 +10,7 @@ import {
10 type ThreadId,10 type ThreadId,
11 type TurnId,11 type TurnId,
12} from "@t3tools/contracts";12} from "@t3tools/contracts";
13import { type ChatMessage, type SessionPhase, type Thread } from "../types";13import { type ChatMessage, type SessionPhase, type Thread, type ThreadShell } from "../types";
14import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore";14import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore";
15import * as Schema from "effect/Schema";15import * as Schema from "effect/Schema";
16import { appAtomRegistry } from "../rpc/atomRegistry";16import { appAtomRegistry } from "../rpc/atomRegistry";
...@@ -95,8 +95,19 @@ export function buildLocalDraftThread(...@@ -95,8 +95,19 @@ export function buildLocalDraftThread(
95 };95 };
96}96}
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
98export function shouldWriteThreadErrorToCurrentServerThread(input: {109export function shouldWriteThreadErrorToCurrentServerThread(input: {
99 serverThread:110 activeServerThread:
100 | {111 | {
101 environmentId: EnvironmentId;112 environmentId: EnvironmentId;
102 id: ThreadId;113 id: ThreadId;
...@@ -107,10 +118,10 @@ export function shouldWriteThreadErrorToCurrentServerThread(input: {...@@ -107,10 +118,10 @@ export function shouldWriteThreadErrorToCurrentServerThread(input: {
107 targetThreadId: ThreadId;118 targetThreadId: ThreadId;
108}): boolean {119}): boolean {
109 return Boolean(120 return Boolean(
110 input.serverThread &&121 input.activeServerThread &&
111 input.targetThreadId === input.routeThreadRef.threadId &&122 input.targetThreadId === input.routeThreadRef.threadId &&
112 input.serverThread.environmentId === input.routeThreadRef.environmentId &&123 input.activeServerThread.environmentId === input.routeThreadRef.environmentId &&
113 input.serverThread.id === input.targetThreadId,124 input.activeServerThread.id === input.targetThreadId,
114 );125 );
115}126}
116127
apps/web/src/components/ChatView.tsx+35-12
...@@ -238,6 +238,7 @@ import {...@@ -238,6 +238,7 @@ import {
238import { ThreadErrorBanner } from "./chat/ThreadErrorBanner";238import { ThreadErrorBanner } from "./chat/ThreadErrorBanner";
239import { resolveThreadPr } from "./ThreadStatusIndicators";239import { resolveThreadPr } from "./ThreadStatusIndicators";
240import { ComposerBannerStack, type ComposerBannerStackItem } from "./chat/ComposerBannerStack";240import { ComposerBannerStack, type ComposerBannerStackItem } from "./chat/ComposerBannerStack";
241import { ThreadSyncStatusPill } from "./chat/ThreadSyncStatusPill";
241import {242import {
242 DRAFT_HERO_TRANSITION_ANIMATION_ID,243 DRAFT_HERO_TRANSITION_ANIMATION_ID,
243 DRAFT_HERO_TRANSITION_DURATION_MS,244 DRAFT_HERO_TRANSITION_DURATION_MS,
...@@ -251,6 +252,7 @@ import {...@@ -251,6 +252,7 @@ import {
251 branchMismatchKey,252 branchMismatchKey,
252 buildExpiredTerminalContextToastCopy,253 buildExpiredTerminalContextToastCopy,
253 buildLocalDraftThread,254 buildLocalDraftThread,
255 buildLoadingThreadFromShell,
254 buildThreadTurnInterruptInput,256 buildThreadTurnInterruptInput,
255 collectUserMessageBlobPreviewUrls,257 collectUserMessageBlobPreviewUrls,
256 createLocalDispatchSnapshot,258 createLocalDispatchSnapshot,
...@@ -272,9 +274,11 @@ import {...@@ -272,9 +274,11 @@ import {
272 resolveSendEnvMode,274 resolveSendEnvMode,
273 revokeBlobPreviewUrl,275 revokeBlobPreviewUrl,
274 revokeUserMessagePreviewUrls,276 revokeUserMessagePreviewUrls,
277 shouldWriteThreadErrorToCurrentServerThread,
275 startNewThreadForProject,278 startNewThreadForProject,
276 waitForStartedServerThread,279 waitForStartedServerThread,
277} from "./ChatView.logic";280} from "./ChatView.logic";
281import type { ThreadSyncPhase } from "../threadSync";
278import { useLocalStorage } from "~/hooks/useLocalStorage";282import { useLocalStorage } from "~/hooks/useLocalStorage";
279import { useComposerHandleContext } from "../composerHandleContext";283import { useComposerHandleContext } from "../composerHandleContext";
280import { sanitizeThreadErrorMessage } from "~/rpc/transportError";284import { sanitizeThreadErrorMessage } from "~/rpc/transportError";
...@@ -465,6 +469,7 @@ type ChatViewProps =...@@ -465,6 +469,7 @@ type ChatViewProps =
465 onDiffPanelOpen?: () => void;469 onDiffPanelOpen?: () => void;
466 reserveTitleBarControlInset?: boolean;470 reserveTitleBarControlInset?: boolean;
467 forceExpandedMobileComposer?: boolean;471 forceExpandedMobileComposer?: boolean;
472 threadSyncPhase?: ThreadSyncPhase | null;
468 routeKind: "server";473 routeKind: "server";
469 draftId?: never;474 draftId?: never;
470 }475 }
...@@ -474,6 +479,7 @@ type ChatViewProps =...@@ -474,6 +479,7 @@ type ChatViewProps =
474 onDiffPanelOpen?: () => void;479 onDiffPanelOpen?: () => void;
475 reserveTitleBarControlInset?: boolean;480 reserveTitleBarControlInset?: boolean;
476 forceExpandedMobileComposer?: boolean;481 forceExpandedMobileComposer?: boolean;
482 threadSyncPhase?: never;
477 routeKind: "draft";483 routeKind: "draft";
478 draftId: DraftId;484 draftId: DraftId;
479 };485 };
...@@ -1132,6 +1138,8 @@ function ChatViewContent(props: ChatViewProps) {...@@ -1132,6 +1138,8 @@ function ChatViewContent(props: ChatViewProps) {
1132 forceExpandedMobileComposer = false,1138 forceExpandedMobileComposer = false,
1133 } = props;1139 } = props;
1134 const draftId = routeKind === "draft" ? props.draftId : null;1140 const draftId = routeKind === "draft" ? props.draftId : null;
1141 const threadSyncPhase = routeKind === "server" ? (props.threadSyncPhase ?? null) : null;
1142 const threadDetailLoading = threadSyncPhase === "loading";
1135 const handleNewThread = useNewThreadHandler();1143 const handleNewThread = useNewThreadHandler();
1136 const routeThreadRef = useMemo(1144 const routeThreadRef = useMemo(
1137 () => scopeThreadRef(environmentId, threadId),1145 () => scopeThreadRef(environmentId, threadId),
...@@ -1188,7 +1196,16 @@ function ChatViewContent(props: ChatViewProps) {...@@ -1188,7 +1196,16 @@ function ChatViewContent(props: ChatViewProps) {
1188 ? store.getDraftSession(draftId)1196 ? store.getDraftSession(draftId)
1189 : null,1197 : null,
1190 );1198 );
1199 const routeServerThreadShell = useThreadShell(routeKind === "server" ? routeThreadRef : null);
1191 const serverThread = useThread(routeThreadRef, { waitForShell: draftThread !== null });1200 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;
1192 const markThreadVisited = useUiStateStore((store) => store.markThreadVisited);1209 const markThreadVisited = useUiStateStore((store) => store.markThreadVisited);
1193 const activeThreadLastVisitedAt = useUiStateStore(1210 const activeThreadLastVisitedAt = useUiStateStore(
1194 (store) => store.threadLastVisitedAtById[routeThreadKey],1211 (store) => store.threadLastVisitedAtById[routeThreadKey],
...@@ -1368,7 +1385,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -1368,7 +1385,7 @@ function ChatViewContent(props: ChatViewProps) {
1368 ? scopeProjectRef(draftThread.environmentId, draftThread.projectId)1385 ? scopeProjectRef(draftThread.environmentId, draftThread.projectId)
1369 : null;1386 : null;
1370 const fallbackDraftProject = useProject(fallbackDraftProjectRef);1387 const fallbackDraftProject = useProject(fallbackDraftProjectRef);
1371 const localDraftError = serverThread1388 const localDraftError = activeServerThread
1372 ? null1389 ? null
1373 : ((draftId ? localDraftErrorsByDraftId[draftId]?.message : null) ?? null);1390 : ((draftId ? localDraftErrorsByDraftId[draftId]?.message : null) ?? null);
1374 const localServerError = localServerErrorsByThreadKey[routeThreadKey]?.message ?? null;1391 const localServerError = localServerErrorsByThreadKey[routeThreadKey]?.message ?? null;
...@@ -1377,7 +1394,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -1377,7 +1394,7 @@ function ChatViewContent(props: ChatViewProps) {
1377 // a failed send would silently disappear on promotion. When both keys hold1394 // a failed send would silently disappear on promotion. When both keys hold
1378 // an entry, the most recent write wins.1395 // an entry, the most recent write wins.
1379 useEffect(() => {1396 useEffect(() => {
1380 if (!serverThread || !draftId) {1397 if (!activeServerThread || !draftId) {
1381 return;1398 return;
1382 }1399 }
1383 const pendingDraftEntry = localDraftErrorsByDraftId[draftId];1400 const pendingDraftEntry = localDraftErrorsByDraftId[draftId];
...@@ -1406,7 +1423,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -1406,7 +1423,7 @@ function ChatViewContent(props: ChatViewProps) {
1406 [routeThreadKey]: pendingDraftEntry,1423 [routeThreadKey]: pendingDraftEntry,
1407 };1424 };
1408 });1425 });
1409 }, [draftId, localDraftErrorsByDraftId, routeThreadKey, serverThread]);1426 }, [activeServerThread, draftId, localDraftErrorsByDraftId, routeThreadKey]);
1410 const localDraftThread = useMemo(1427 const localDraftThread = useMemo(
1411 () =>1428 () =>
1412 draftThread1429 draftThread
...@@ -1421,10 +1438,10 @@ function ChatViewContent(props: ChatViewProps) {...@@ -1421,10 +1438,10 @@ function ChatViewContent(props: ChatViewProps) {
1421 // Promotion is data-driven: the draft route keeps rendering while the1438 // Promotion is data-driven: the draft route keeps rendering while the
1422 // server thread (same pre-allocated ref) starts, so live state must not1439 // server thread (same pre-allocated ref) starts, so live state must not
1423 // depend on which route is mounted.1440 // depend on which route is mounted.
1424 const isServerThread = serverThread !== null;1441 const isServerThread = activeServerThread !== null;
1425 const activeThread = isServerThread ? serverThread : localDraftThread;1442 const activeThread = activeServerThread ?? localDraftThread;
1426 const threadError = isServerThread1443 const threadError = isServerThread
1427 ? (localServerError ?? serverThread?.session?.lastError ?? null)1444 ? (localServerError ?? activeServerThread?.session?.lastError ?? null)
1428 : localDraftError;1445 : localDraftError;
1429 const runtimeMode = composerRuntimeMode ?? activeThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE;1446 const runtimeMode = composerRuntimeMode ?? activeThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE;
1430 const interactionMode =1447 const interactionMode =
...@@ -2490,10 +2507,11 @@ function ChatViewContent(props: ChatViewProps) {...@@ -2490,10 +2507,11 @@ function ChatViewContent(props: ChatViewProps) {
2490 const nextError = sanitizeThreadErrorMessage(error);2507 const nextError = sanitizeThreadErrorMessage(error);
2491 const nextEntry: LocalThreadErrorEntry = { message: nextError, at: Date.now() };2508 const nextEntry: LocalThreadErrorEntry = { message: nextError, at: Date.now() };
2492 if (2509 if (
2493 serverThread &&2510 shouldWriteThreadErrorToCurrentServerThread({
2494 targetThreadId === routeThreadRef.threadId &&2511 activeServerThread,
2495 serverThread.environmentId === routeThreadRef.environmentId &&2512 routeThreadRef,
2496 serverThread.id === targetThreadId2513 targetThreadId,
2514 })
2497 ) {2515 ) {
2498 setLocalServerErrorsByThreadKey((existing) => {2516 setLocalServerErrorsByThreadKey((existing) => {
2499 if ((existing[routeThreadKey]?.message ?? null) === nextError) {2517 if ((existing[routeThreadKey]?.message ?? null) === nextError) {
...@@ -2517,7 +2535,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -2517,7 +2535,7 @@ function ChatViewContent(props: ChatViewProps) {
2517 };2535 };
2518 });2536 });
2519 },2537 },
2520 [draftId, routeThreadKey, routeThreadRef, serverThread],2538 [activeServerThread, draftId, routeThreadKey, routeThreadRef],
2521 );2539 );
25222540
2523 const focusComposer = useCallback(() => {2541 const focusComposer = useCallback(() => {
...@@ -4471,6 +4489,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -4471,6 +4489,7 @@ function ChatViewContent(props: ChatViewProps) {
4471 !activeThread ||4489 !activeThread ||
4472 isSendBusy ||4490 isSendBusy ||
4473 isConnecting ||4491 isConnecting ||
4492 threadDetailLoading ||
4474 activeEnvironmentUnavailable ||4493 activeEnvironmentUnavailable ||
4475 sendInFlightRef.current4494 sendInFlightRef.current
4476 )4495 )
...@@ -5737,7 +5756,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -5737,7 +5756,7 @@ function ChatViewContent(props: ChatViewProps) {
5737 contentInsetEndAdjustment={composerOverlayHeight}5756 contentInsetEndAdjustment={composerOverlayHeight}
5738 onIsAtEndChange={onIsAtEndChange}5757 onIsAtEndChange={onIsAtEndChange}
5739 onManualNavigation={cancelTimelineLiveFollowForUserNavigation}5758 onManualNavigation={cancelTimelineLiveFollowForUserNavigation}
5740 hideEmptyPlaceholder={isDraftHeroState}5759 hideEmptyPlaceholder={isDraftHeroState || threadDetailLoading}
5741 topFadeEnabled={!hasTimelineTopBanner}5760 topFadeEnabled={!hasTimelineTopBanner}
5742 />5761 />
57435762
...@@ -5798,6 +5817,9 @@ function ChatViewContent(props: ChatViewProps) {...@@ -5798,6 +5817,9 @@ function ChatViewContent(props: ChatViewProps) {
5798 ) : (5817 ) : (
5799 <ComposerBannerStack className="relative z-0" items={composerBannerItems} />5818 <ComposerBannerStack className="relative z-0" items={composerBannerItems} />
5800 )}5819 )}
5820 {threadSyncPhase && !activeEnvironmentUnavailable ? (
5821 <ThreadSyncStatusPill phase={threadSyncPhase} />
5822 ) : null}
5801 <div5823 <div
5802 className="relative"5824 className="relative"
5803 style={5825 style={
...@@ -5831,6 +5853,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -5831,6 +5853,7 @@ function ChatViewContent(props: ChatViewProps) {
5831 phase={phase}5853 phase={phase}
5832 isConnecting={isConnecting}5854 isConnecting={isConnecting}
5833 isSendBusy={isSendBusy}5855 isSendBusy={isSendBusy}
5856 sendDisabledReason={threadDetailLoading ? "Messages loading" : null}
5834 isPreparingWorktree={isPreparingWorktree}5857 isPreparingWorktree={isPreparingWorktree}
5835 environmentUnavailable={activeEnvironmentUnavailableState}5858 environmentUnavailable={activeEnvironmentUnavailableState}
5836 activePendingApproval={activePendingApproval}5859 activePendingApproval={activePendingApproval}
apps/web/src/components/chat/ChatComposer.tsx+19-2
...@@ -420,6 +420,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(...@@ -420,6 +420,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(
420 showPlanFollowUpPrompt: boolean;420 showPlanFollowUpPrompt: boolean;
421 promptHasText: boolean;421 promptHasText: boolean;
422 isSendBusy: boolean;422 isSendBusy: boolean;
423 sendDisabledReason: string | null;
423 isConnecting: boolean;424 isConnecting: boolean;
424 isEnvironmentUnavailable: boolean;425 isEnvironmentUnavailable: boolean;
425 hasSendableContent: boolean;426 hasSendableContent: boolean;
...@@ -446,6 +447,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(...@@ -446,6 +447,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(
446 showPlanFollowUpPrompt={props.showPlanFollowUpPrompt}447 showPlanFollowUpPrompt={props.showPlanFollowUpPrompt}
447 promptHasText={props.promptHasText}448 promptHasText={props.promptHasText}
448 isSendBusy={props.isSendBusy}449 isSendBusy={props.isSendBusy}
450 sendDisabledReason={props.sendDisabledReason}
449 isConnecting={props.isConnecting}451 isConnecting={props.isConnecting}
450 isEnvironmentUnavailable={props.isEnvironmentUnavailable}452 isEnvironmentUnavailable={props.isEnvironmentUnavailable}
451 isPreparingWorktree={props.isPreparingWorktree}453 isPreparingWorktree={props.isPreparingWorktree}
...@@ -526,6 +528,7 @@ export interface ChatComposerProps {...@@ -526,6 +528,7 @@ export interface ChatComposerProps {
526 phase: SessionPhase;528 phase: SessionPhase;
527 isConnecting: boolean;529 isConnecting: boolean;
528 isSendBusy: boolean;530 isSendBusy: boolean;
531 sendDisabledReason: string | null;
529 isPreparingWorktree: boolean;532 isPreparingWorktree: boolean;
530 environmentUnavailable: {533 environmentUnavailable: {
531 readonly label: string;534 readonly label: string;
...@@ -637,6 +640,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -637,6 +640,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
637 phase,640 phase,
638 isConnecting,641 isConnecting,
639 isSendBusy,642 isSendBusy,
643 sendDisabledReason,
640 isPreparingWorktree,644 isPreparingWorktree,
641 environmentUnavailable,645 environmentUnavailable,
642 activePendingApproval,646 activePendingApproval,
...@@ -690,6 +694,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -690,6 +694,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
690 setThreadError,694 setThreadError,
691 onExpandImage,695 onExpandImage,
692 } = props;696 } = props;
697 const isSendDisabled = sendDisabledReason !== null;
693698
694 // ------------------------------------------------------------------699 // ------------------------------------------------------------------
695 // Store subscriptions (prompt / images / terminal contexts)700 // Store subscriptions (prompt / images / terminal contexts)
...@@ -1239,6 +1244,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -1239,6 +1244,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
1239 const collapsedComposerPrimaryActionDisabled =1244 const collapsedComposerPrimaryActionDisabled =
1240 phase === "running" ||1245 phase === "running" ||
1241 isSendBusy ||1246 isSendBusy ||
1247 isSendDisabled ||
1242 isConnecting ||1248 isConnecting ||
1243 noProviderAvailable ||1249 noProviderAvailable ||
1244 projectSelectionRequired ||1250 projectSelectionRequired ||
...@@ -1783,6 +1789,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -1783,6 +1789,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
1783 if (!isMobileViewport) return false;1789 if (!isMobileViewport) return false;
1784 if (1790 if (
1785 isSendBusy ||1791 isSendBusy ||
1792 isSendDisabled ||
1786 isConnecting ||1793 isConnecting ||
1787 noProviderAvailable ||1794 noProviderAvailable ||
1788 environmentUnavailable !== null ||1795 environmentUnavailable !== null ||
...@@ -1802,6 +1809,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -1802,6 +1809,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
1802 isConnecting,1809 isConnecting,
1803 isMobileViewport,1810 isMobileViewport,
1804 isSendBusy,1811 isSendBusy,
1812 isSendDisabled,
1805 noProviderAvailable,1813 noProviderAvailable,
1806 phase,1814 phase,
1807 showPlanFollowUpPrompt,1815 showPlanFollowUpPrompt,
...@@ -1809,7 +1817,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -1809,7 +1817,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
18091817
1810 const submitComposer = useCallback(1818 const submitComposer = useCallback(
1811 (event?: { preventDefault: () => void }) => {1819 (event?: { preventDefault: () => void }) => {
1812 if (noProviderAvailable) {1820 if (noProviderAvailable || isSendDisabled) {
1813 event?.preventDefault();1821 event?.preventDefault();
1814 return;1822 return;
1815 }1823 }
...@@ -1818,7 +1826,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -1818,7 +1826,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
1818 blurMobileComposerAfterSend();1826 blurMobileComposerAfterSend();
1819 }1827 }
1820 },1828 },
1821 [blurMobileComposerAfterSend, noProviderAvailable, onSend, shouldBlurMobileComposerOnSubmit],1829 [
1830 blurMobileComposerAfterSend,
1831 isSendDisabled,
1832 noProviderAvailable,
1833 onSend,
1834 shouldBlurMobileComposerOnSubmit,
1835 ],
1822 );1836 );
1823 const expandMobileComposer = useCallback(() => {1837 const expandMobileComposer = useCallback(() => {
1824 if (composerBlurFrameRef.current !== null) {1838 if (composerBlurFrameRef.current !== null) {
...@@ -2726,6 +2740,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2726,6 +2740,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
2726 showPlanFollowUpPrompt={false}2740 showPlanFollowUpPrompt={false}
2727 promptHasText={false}2741 promptHasText={false}
2728 isSendBusy={isSendBusy}2742 isSendBusy={isSendBusy}
2743 sendDisabledReason={sendDisabledReason}
2729 isConnecting={isConnecting}2744 isConnecting={isConnecting}
2730 isEnvironmentUnavailable={2745 isEnvironmentUnavailable={
2731 environmentUnavailable !== null ||2746 environmentUnavailable !== null ||
...@@ -3008,6 +3023,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -3008,6 +3023,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
3008 showPlanFollowUpPrompt={false}3023 showPlanFollowUpPrompt={false}
3009 promptHasText={false}3024 promptHasText={false}
3010 isSendBusy={isSendBusy}3025 isSendBusy={isSendBusy}
3026 sendDisabledReason={sendDisabledReason}
3011 isConnecting={isConnecting}3027 isConnecting={isConnecting}
3012 isEnvironmentUnavailable={3028 isEnvironmentUnavailable={
3013 environmentUnavailable !== null ||3029 environmentUnavailable !== null ||
...@@ -3138,6 +3154,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -3138,6 +3154,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
3138 showPlanFollowUpPrompt={pendingUserInputs.length === 0 && showPlanFollowUpPrompt}3154 showPlanFollowUpPrompt={pendingUserInputs.length === 0 && showPlanFollowUpPrompt}
3139 promptHasText={prompt.trim().length > 0}3155 promptHasText={prompt.trim().length > 0}
3140 isSendBusy={isSendBusy}3156 isSendBusy={isSendBusy}
3157 sendDisabledReason={sendDisabledReason}
3141 isConnecting={isConnecting}3158 isConnecting={isConnecting}
3142 isEnvironmentUnavailable={3159 isEnvironmentUnavailable={
3143 environmentUnavailable !== null ||3160 environmentUnavailable !== null ||
apps/web/src/components/chat/ComposerPrimaryActions.tsx+23-12
...@@ -22,6 +22,7 @@ interface ComposerPrimaryActionsProps {...@@ -22,6 +22,7 @@ interface ComposerPrimaryActionsProps {
22 showPlanFollowUpPrompt: boolean;22 showPlanFollowUpPrompt: boolean;
23 promptHasText: boolean;23 promptHasText: boolean;
24 isSendBusy: boolean;24 isSendBusy: boolean;
25 sendDisabledReason: string | null;
25 isConnecting: boolean;26 isConnecting: boolean;
26 isEnvironmentUnavailable: boolean;27 isEnvironmentUnavailable: boolean;
27 isPreparingWorktree: boolean;28 isPreparingWorktree: boolean;
...@@ -61,6 +62,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({...@@ -61,6 +62,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
61 showPlanFollowUpPrompt,62 showPlanFollowUpPrompt,
62 promptHasText,63 promptHasText,
63 isSendBusy,64 isSendBusy,
65 sendDisabledReason,
64 isConnecting,66 isConnecting,
65 isEnvironmentUnavailable,67 isEnvironmentUnavailable,
66 isPreparingWorktree,68 isPreparingWorktree,
...@@ -74,6 +76,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({...@@ -74,6 +76,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
74 ? { onPointerDown: preventPointerFocus }76 ? { onPointerDown: preventPointerFocus }
75 : undefined;77 : undefined;
76 const environmentIdentificationMode = useEnvironmentIdentificationMode();78 const environmentIdentificationMode = useEnvironmentIdentificationMode();
79 const isSendDisabled = sendDisabledReason !== null;
77 const stageBackdropVariant = useSidebarStageBackdropVariant(80 const stageBackdropVariant = useSidebarStageBackdropVariant(
78 environmentIdentificationMode === "artwork",81 environmentIdentificationMode === "artwork",
79 );82 );
...@@ -153,7 +156,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({...@@ -153,7 +156,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
153 size="sm"156 size="sm"
154 className={cn("rounded-full", compact ? "h-9 px-3 sm:h-8" : "h-9 px-4 sm:h-8")}157 className={cn("rounded-full", compact ? "h-9 px-3 sm:h-8" : "h-9 px-4 sm:h-8")}
155 {...pointerFocusProps}158 {...pointerFocusProps}
156 disabled={isSendBusy || isConnecting || isEnvironmentUnavailable}159 disabled={isSendBusy || isSendDisabled || isConnecting || isEnvironmentUnavailable}
157 >160 >
158 {isConnecting || isSendBusy ? "Sending..." : "Refine"}161 {isConnecting || isSendBusy ? "Sending..." : "Refine"}
159 </Button>162 </Button>
...@@ -167,7 +170,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({...@@ -167,7 +170,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
167 size="sm"170 size="sm"
168 className="h-9 rounded-l-full rounded-r-none px-4 sm:h-8"171 className="h-9 rounded-l-full rounded-r-none px-4 sm:h-8"
169 {...pointerFocusProps}172 {...pointerFocusProps}
170 disabled={isSendBusy || isConnecting || isEnvironmentUnavailable}173 disabled={isSendBusy || isSendDisabled || isConnecting || isEnvironmentUnavailable}
171 >174 >
172 {isConnecting || isSendBusy ? "Sending..." : "Implement"}175 {isConnecting || isSendBusy ? "Sending..." : "Implement"}
173 </Button>176 </Button>
...@@ -180,7 +183,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({...@@ -180,7 +183,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
180 className="h-9 rounded-l-none rounded-r-full border-l-white/12 px-2 sm:h-8"183 className="h-9 rounded-l-none rounded-r-full border-l-white/12 px-2 sm:h-8"
181 aria-label="Implementation actions"184 aria-label="Implementation actions"
182 {...pointerFocusProps}185 {...pointerFocusProps}
183 disabled={isSendBusy || isConnecting || isEnvironmentUnavailable}186 disabled={isSendBusy || isSendDisabled || isConnecting || isEnvironmentUnavailable}
184 />187 />
185 }188 }
186 >189 >
...@@ -188,7 +191,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({...@@ -188,7 +191,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
188 </MenuTrigger>191 </MenuTrigger>
189 <MenuPopup align="end" side="top">192 <MenuPopup align="end" side="top">
190 <MenuItem193 <MenuItem
191 disabled={isSendBusy || isConnecting || isEnvironmentUnavailable}194 disabled={isSendBusy || isSendDisabled || isConnecting || isEnvironmentUnavailable}
192 onClick={() => void onImplementPlanInNewThread()}195 onClick={() => void onImplementPlanInNewThread()}
193 >196 >
194 Implement in a new thread197 Implement in a new thread
...@@ -209,17 +212,25 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({...@@ -209,17 +212,25 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
209 : "bg-primary/90 enabled:shadow-primary/24 hover:bg-primary",212 : "bg-primary/90 enabled:shadow-primary/24 hover:bg-primary",
210 )}213 )}
211 {...pointerFocusProps}214 {...pointerFocusProps}
212 disabled={isSendBusy || isConnecting || isEnvironmentUnavailable || !hasSendableContent}215 disabled={
216 isSendBusy ||
217 isSendDisabled ||
218 isConnecting ||
219 isEnvironmentUnavailable ||
220 !hasSendableContent
221 }
213 aria-label={222 aria-label={
214 isEnvironmentUnavailable223 isEnvironmentUnavailable
215 ? "Environment disconnected"224 ? "Environment disconnected"
216 : isConnecting225 : sendDisabledReason
217 ? "Connecting"226 ? sendDisabledReason
218 : isPreparingWorktree227 : isConnecting
219 ? "Preparing worktree"228 ? "Connecting"
220 : isSendBusy229 : isPreparingWorktree
221 ? "Sending"230 ? "Preparing worktree"
222 : "Send message"231 : isSendBusy
232 ? "Sending"
233 : "Send message"
223 }234 }
224 >235 >
225 {stageBackdropVariant ? (236 {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";...@@ -5,6 +5,7 @@ import ChatView from "../components/ChatView";
5import { threadHasStarted } from "../components/ChatView.logic";5import { threadHasStarted } from "../components/ChatView.logic";
6import { finalizePromotedDraftThreadByRef, useComposerDraftStore } from "../composerDraftStore";6import { finalizePromotedDraftThreadByRef, useComposerDraftStore } from "../composerDraftStore";
7import { resolveThreadRouteRef, resolveThreadRouteRenderState } from "../threadRoutes";7import { resolveThreadRouteRef, resolveThreadRouteRenderState } from "../threadRoutes";
8import { resolveThreadSyncPhase } from "../threadSync";
8import { SidebarInset } from "~/components/ui/sidebar";9import { SidebarInset } from "~/components/ui/sidebar";
9import {10import {
10 useEnvironmentThreadRefs,11 useEnvironmentThreadRefs,
...@@ -48,6 +49,11 @@ function ChatThreadRouteView() {...@@ -48,6 +49,11 @@ function ChatThreadRouteView() {
48 serverThreadDetailDeleted: serverThreadStatus === "deleted",49 serverThreadDetailDeleted: serverThreadStatus === "deleted",
49 draftThreadExists,50 draftThreadExists,
50 });51 });
52 const threadSyncPhase = resolveThreadSyncPhase({
53 detailExists: serverThreadDetail !== null,
54 shellExists: serverThreadShell !== null,
55 status: serverThreadStatus,
56 });
51 const serverThreadStarted = threadHasStarted(serverThreadDetail);57 const serverThreadStarted = threadHasStarted(serverThreadDetail);
52 const environmentHasAnyThreads = environmentHasServerThreads || environmentHasDraftThreads;58 const environmentHasAnyThreads = environmentHasServerThreads || environmentHasDraftThreads;
5359
...@@ -68,17 +74,20 @@ function ChatThreadRouteView() {...@@ -68,17 +74,20 @@ function ChatThreadRouteView() {
68 finalizePromotedDraftThreadByRef(threadRef);74 finalizePromotedDraftThreadByRef(threadRef);
69 }, [draftThread, serverThreadStarted, threadRef]);75 }, [draftThread, serverThreadStarted, threadRef]);
7076
71 if (!threadRef || renderState !== "ready") {77 if (!threadRef) {
72 return null;78 return null;
73 }79 }
7480
75 return (81 return (
76 <SidebarInset className="h-svh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground md:h-dvh">82 <SidebarInset className="h-svh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground md:h-dvh">
77 <ChatView83 {renderState === "ready" || (renderState === "loading" && serverThreadShell !== null) ? (
78 environmentId={threadRef.environmentId}84 <ChatView
79 threadId={threadRef.threadId}85 environmentId={threadRef.environmentId}
80 routeKind="server"86 threadId={threadRef.threadId}
81 />87 routeKind="server"
88 threadSyncPhase={threadSyncPhase}
89 />
90 ) : null}
82 </SidebarInset>91 </SidebarInset>
83 );92 );
84}93}
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}