| author | |
| committer | |
| log | 3137c2b162109640343a6ff88120609e061f3782 |
| tree | 9441851615f7bf7896e197dce662c048d208828b |
| parent | c3e8fb67d73955862ac312c801d45f2cc00c7178 |
| signature | Signed by PGP key B5690EEEBB952194 |
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"; |
| 9 | import { describe, expect, it } from "vite-plus/test"; | 9 | import { describe, expect, it } from "vite-plus/test"; |
| 10 | 10 | ||
| 11 | import type { Thread } from "../types"; | 11 | import type { Thread, ThreadShell } from "../types"; |
| 12 | import { | 12 | import { |
| 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 | }; |
| 87 | 88 | ||
| 89 | describe("buildLoadingThreadFromShell", () => { | ||
| 90 | it("preserves shell metadata and supplies empty detail collections", () => { | ||
| 91 | const shell = { | ||
| 92 | environmentId, | ||
| 93 | id: threadId, | ||
| 94 | projectId, | ||
| 95 | title: "Loading thread", | ||
| 96 | modelSelection: { | ||
| 97 | instanceId: ProviderInstanceId.make("codex"), | ||
| 98 | model: "gpt-5.4", | ||
| 99 | }, | ||
| 100 | runtimeMode: "full-access", | ||
| 101 | interactionMode: "default", | ||
| 102 | branch: "main", | ||
| 103 | worktreePath: null, | ||
| 104 | latestTurn: null, | ||
| 105 | createdAt: now, | ||
| 106 | updatedAt: now, | ||
| 107 | archivedAt: null, | ||
| 108 | settledOverride: null, | ||
| 109 | settledAt: null, | ||
| 110 | snoozedUntil: null, | ||
| 111 | snoozedAt: null, | ||
| 112 | session: null, | ||
| 113 | latestUserMessageAt: now, | ||
| 114 | hasPendingApprovals: false, | ||
| 115 | hasPendingUserInput: false, | ||
| 116 | hasActionableProposedPlan: false, | ||
| 117 | } satisfies ThreadShell; | ||
| 118 | |||
| 119 | expect(buildLoadingThreadFromShell(shell)).toMatchObject({ | ||
| 120 | environmentId, | ||
| 121 | id: threadId, | ||
| 122 | projectId, | ||
| 123 | title: "Loading thread", | ||
| 124 | branch: "main", | ||
| 125 | deletedAt: null, | ||
| 126 | messages: [], | ||
| 127 | proposedPlans: [], | ||
| 128 | activities: [], | ||
| 129 | checkpoints: [], | ||
| 130 | }); | ||
| 131 | }); | ||
| 132 | }); | ||
| 133 | |||
| 88 | describe("resolveThreadMetadataUpdateForNextTurn", () => { | 134 | describe("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 | }); |
| 427 | 473 | ||
| 428 | describe("shouldWriteThreadErrorToCurrentServerThread", () => { | 474 | describe("shouldWriteThreadErrorToCurrentServerThread", () => { |
| 429 | it("requires the environment, route thread, and target thread to match", () => { | 475 | it("writes errors for a shell-derived active server thread", () => { |
| 430 | const routeThreadRef = { environmentId, threadId }; | 476 | const routeThreadRef = { environmentId, threadId }; |
| 431 | 477 | ||
| 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"; |
| 13 | import { type ChatMessage, type SessionPhase, type Thread } from "../types"; | 13 | import { type ChatMessage, type SessionPhase, type Thread, type ThreadShell } from "../types"; |
| 14 | import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore"; | 14 | import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore"; |
| 15 | import * as Schema from "effect/Schema"; | 15 | import * as Schema from "effect/Schema"; |
| 16 | import { appAtomRegistry } from "../rpc/atomRegistry"; | 16 | import { appAtomRegistry } from "../rpc/atomRegistry"; |
| ... | @@ -95,8 +95,19 @@ export function buildLocalDraftThread( | ... | @@ -95,8 +95,19 @@ export function buildLocalDraftThread( |
| 95 | }; | 95 | }; |
| 96 | } | 96 | } |
| 97 | 97 | ||
| 98 | export function buildLoadingThreadFromShell(shell: ThreadShell): Thread { | ||
| 99 | return { | ||
| 100 | ...shell, | ||
| 101 | messages: [], | ||
| 102 | proposedPlans: [], | ||
| 103 | activities: [], | ||
| 104 | checkpoints: [], | ||
| 105 | deletedAt: null, | ||
| 106 | }; | ||
| 107 | } | ||
| 108 | |||
| 98 | export function shouldWriteThreadErrorToCurrentServerThread(input: { | 109 | export 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 | } |
| 116 | 127 |
apps/web/src/components/ChatView.tsx+35-12| ... | @@ -238,6 +238,7 @@ import { | ... | @@ -238,6 +238,7 @@ import { |
| 238 | import { ThreadErrorBanner } from "./chat/ThreadErrorBanner"; | 238 | import { ThreadErrorBanner } from "./chat/ThreadErrorBanner"; |
| 239 | import { resolveThreadPr } from "./ThreadStatusIndicators"; | 239 | import { resolveThreadPr } from "./ThreadStatusIndicators"; |
| 240 | import { ComposerBannerStack, type ComposerBannerStackItem } from "./chat/ComposerBannerStack"; | 240 | import { ComposerBannerStack, type ComposerBannerStackItem } from "./chat/ComposerBannerStack"; |
| 241 | import { ThreadSyncStatusPill } from "./chat/ThreadSyncStatusPill"; | ||
| 241 | import { | 242 | import { |
| 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"; |
| 281 | import type { ThreadSyncPhase } from "../threadSync"; | ||
| 278 | import { useLocalStorage } from "~/hooks/useLocalStorage"; | 282 | import { useLocalStorage } from "~/hooks/useLocalStorage"; |
| 279 | import { useComposerHandleContext } from "../composerHandleContext"; | 283 | import { useComposerHandleContext } from "../composerHandleContext"; |
| 280 | import { sanitizeThreadErrorMessage } from "~/rpc/transportError"; | 284 | import { 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 = serverThread | 1388 | const localDraftError = activeServerThread |
| 1372 | ? null | 1389 | ? 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 hold | 1394 | // 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 | draftThread | 1429 | 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 the | 1438 | // Promotion is data-driven: the draft route keeps rendering while the |
| 1422 | // server thread (same pre-allocated ref) starts, so live state must not | 1439 | // 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 = isServerThread | 1443 | 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 === targetThreadId | 2513 | 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 | ); |
| 2522 | 2540 | ||
| 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.current | 4494 | 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 | /> |
| 5743 | 5762 | ||
| ... | @@ -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 | <div | 5823 | <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; | ||
| 693 | 698 | ||
| 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) |
| 1809 | 1817 | ||
| 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 | <MenuItem | 193 | <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 thread | 197 | 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 | isEnvironmentUnavailable | 223 | isEnvironmentUnavailable |
| 215 | ? "Environment disconnected" | 224 | ? "Environment disconnected" |
| 216 | : isConnecting | 225 | : sendDisabledReason |
| 217 | ? "Connecting" | 226 | ? sendDisabledReason |
| 218 | : isPreparingWorktree | 227 | : isConnecting |
| 219 | ? "Preparing worktree" | 228 | ? "Connecting" |
| 220 | : isSendBusy | 229 | : 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 @@ | ||
| 1 | import { renderToStaticMarkup } from "react-dom/server"; | ||
| 2 | import { describe, expect, it } from "vite-plus/test"; | ||
| 3 | |||
| 4 | import { ThreadSyncStatusPill } from "./ThreadSyncStatusPill"; | ||
| 5 | |||
| 6 | describe("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 @@ | ||
| 1 | import { LoaderCircleIcon } from "lucide-react"; | ||
| 2 | |||
| 3 | import { threadSyncLabel, type ThreadSyncPhase } from "../../threadSync"; | ||
| 4 | |||
| 5 | export 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"; |
| 5 | import { threadHasStarted } from "../components/ChatView.logic"; | 5 | import { threadHasStarted } from "../components/ChatView.logic"; |
| 6 | import { finalizePromotedDraftThreadByRef, useComposerDraftStore } from "../composerDraftStore"; | 6 | import { finalizePromotedDraftThreadByRef, useComposerDraftStore } from "../composerDraftStore"; |
| 7 | import { resolveThreadRouteRef, resolveThreadRouteRenderState } from "../threadRoutes"; | 7 | import { resolveThreadRouteRef, resolveThreadRouteRenderState } from "../threadRoutes"; |
| 8 | import { resolveThreadSyncPhase } from "../threadSync"; | ||
| 8 | import { SidebarInset } from "~/components/ui/sidebar"; | 9 | import { SidebarInset } from "~/components/ui/sidebar"; |
| 9 | import { | 10 | import { |
| 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; |
| 53 | 59 | ||
| ... | @@ -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]); |
| 70 | 76 | ||
| 71 | if (!threadRef || renderState !== "ready") { | 77 | if (!threadRef) { |
| 72 | return null; | 78 | return null; |
| 73 | } | 79 | } |
| 74 | 80 | ||
| 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 | <ChatView | 83 | {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 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | |||
| 3 | import { resolveThreadSyncPhase, threadSyncLabel } from "./threadSync"; | ||
| 4 | |||
| 5 | describe("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 | |||
| 44 | describe("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 @@ | ||
| 1 | import type { EnvironmentThreadStatus } from "@t3tools/client-runtime/state/threads"; | ||
| 2 | |||
| 3 | export type ThreadSyncPhase = "loading" | "syncing"; | ||
| 4 | |||
| 5 | export 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 | |||
| 25 | export function threadSyncLabel(phase: ThreadSyncPhase): string { | ||
| 26 | return phase === "loading" ? "Loading messages..." : "Syncing messages..."; | ||
| 27 | } | ||