| author | |
| committer | |
| log | 91dfe60a99b7bf6cc44138a237e663ba158062b7 |
| tree | e267b1c2e4dd543a9de1853192ec3888349a9093 |
| parent | edb12401e547be4806836077f336295c58097c9b |
| signature | Signed by PGP key B5690EEEBB952194 |
4 files changed, 119 insertions(+), 8 deletions(-)
apps/web/src/routes/_chat.$environmentId.$threadId.tsx+18-7| ... | @@ -4,9 +4,14 @@ import { useEffect } from "react"; | ... | @@ -4,9 +4,14 @@ import { useEffect } from "react"; |
| 4 | import ChatView from "../components/ChatView"; | 4 | 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 } from "../threadRoutes"; | 7 | import { resolveThreadRouteRef, resolveThreadRouteRenderState } from "../threadRoutes"; |
| 8 | import { SidebarInset } from "~/components/ui/sidebar"; | 8 | import { SidebarInset } from "~/components/ui/sidebar"; |
| 9 | import { useEnvironmentThreadRefs, useThreadDetail, useThreadShell } from "../state/entities"; | 9 | import { |
| 10 | useEnvironmentThreadRefs, | ||
| 11 | useThreadDetail, | ||
| 12 | useThreadShell, | ||
| 13 | useThreadStatus, | ||
| 14 | } from "../state/entities"; | ||
| 10 | import { useEnvironmentQuery } from "../state/query"; | 15 | import { useEnvironmentQuery } from "../state/query"; |
| 11 | import { environmentShell } from "../state/shell"; | 16 | import { environmentShell } from "../state/shell"; |
| 12 | 17 | ||
| ... | @@ -20,9 +25,9 @@ function ChatThreadRouteView() { | ... | @@ -20,9 +25,9 @@ function ChatThreadRouteView() { |
| 20 | ); | 25 | ); |
| 21 | const serverThreadShell = useThreadShell(threadRef); | 26 | const serverThreadShell = useThreadShell(threadRef); |
| 22 | const serverThreadDetail = useThreadDetail(threadRef); | 27 | const serverThreadDetail = useThreadDetail(threadRef); |
| 28 | const serverThreadStatus = useThreadStatus(threadRef); | ||
| 23 | const environmentThreadRefs = useEnvironmentThreadRefs(threadRef?.environmentId ?? null); | 29 | const environmentThreadRefs = useEnvironmentThreadRefs(threadRef?.environmentId ?? null); |
| 24 | const bootstrapComplete = shell.data?.snapshot._tag === "Some"; | 30 | const bootstrapComplete = shell.data?.snapshot._tag === "Some"; |
| 25 | const threadExists = serverThreadShell !== null || serverThreadDetail !== null; | ||
| 26 | const environmentHasServerThreads = environmentThreadRefs.length > 0; | 31 | const environmentHasServerThreads = environmentThreadRefs.length > 0; |
| 27 | const draftThreadExists = useComposerDraftStore((store) => | 32 | const draftThreadExists = useComposerDraftStore((store) => |
| 28 | threadRef ? store.getDraftThreadByRef(threadRef) !== null : false, | 33 | threadRef ? store.getDraftThreadByRef(threadRef) !== null : false, |
| ... | @@ -36,7 +41,13 @@ function ChatThreadRouteView() { | ... | @@ -36,7 +41,13 @@ function ChatThreadRouteView() { |
| 36 | } | 41 | } |
| 37 | return store.hasDraftThreadsInEnvironment(threadRef.environmentId); | 42 | return store.hasDraftThreadsInEnvironment(threadRef.environmentId); |
| 38 | }); | 43 | }); |
| 39 | const routeThreadExists = threadExists || draftThreadExists; | 44 | const renderState = resolveThreadRouteRenderState({ |
| 45 | bootstrapComplete, | ||
| 46 | serverThreadShellExists: serverThreadShell !== null, | ||
| 47 | serverThreadDetailExists: serverThreadDetail !== null, | ||
| 48 | serverThreadDetailDeleted: serverThreadStatus === "deleted", | ||
| 49 | draftThreadExists, | ||
| 50 | }); | ||
| 40 | const serverThreadStarted = threadHasStarted(serverThreadDetail); | 51 | const serverThreadStarted = threadHasStarted(serverThreadDetail); |
| 41 | const environmentHasAnyThreads = environmentHasServerThreads || environmentHasDraftThreads; | 52 | const environmentHasAnyThreads = environmentHasServerThreads || environmentHasDraftThreads; |
| 42 | 53 | ||
| ... | @@ -45,10 +56,10 @@ function ChatThreadRouteView() { | ... | @@ -45,10 +56,10 @@ function ChatThreadRouteView() { |
| 45 | return; | 56 | return; |
| 46 | } | 57 | } |
| 47 | 58 | ||
| 48 | if (!routeThreadExists && environmentHasAnyThreads) { | 59 | if (renderState === "missing" && environmentHasAnyThreads) { |
| 49 | void navigate({ to: "/", replace: true }); | 60 | void navigate({ to: "/", replace: true }); |
| 50 | } | 61 | } |
| 51 | }, [bootstrapComplete, environmentHasAnyThreads, navigate, routeThreadExists, threadRef]); | 62 | }, [bootstrapComplete, environmentHasAnyThreads, navigate, renderState, threadRef]); |
| 52 | 63 | ||
| 53 | useEffect(() => { | 64 | useEffect(() => { |
| 54 | if (!threadRef || !serverThreadStarted || !draftThread) { | 65 | if (!threadRef || !serverThreadStarted || !draftThread) { |
| ... | @@ -57,7 +68,7 @@ function ChatThreadRouteView() { | ... | @@ -57,7 +68,7 @@ function ChatThreadRouteView() { |
| 57 | finalizePromotedDraftThreadByRef(threadRef); | 68 | finalizePromotedDraftThreadByRef(threadRef); |
| 58 | }, [draftThread, serverThreadStarted, threadRef]); | 69 | }, [draftThread, serverThreadStarted, threadRef]); |
| 59 | 70 | ||
| 60 | if (!threadRef || !bootstrapComplete || !routeThreadExists) { | 71 | if (!threadRef || renderState !== "ready") { |
| 61 | return null; | 72 | return null; |
| 62 | } | 73 | } |
| 63 | 74 |
apps/web/src/state/entities.ts+13-1| ... | @@ -4,7 +4,10 @@ import type { | ... | @@ -4,7 +4,10 @@ import type { |
| 4 | EnvironmentThread, | 4 | EnvironmentThread, |
| 5 | EnvironmentThreadShell, | 5 | EnvironmentThreadShell, |
| 6 | } from "@t3tools/client-runtime/state/shell"; | 6 | } from "@t3tools/client-runtime/state/shell"; |
| 7 | import { mergeEnvironmentThread } from "@t3tools/client-runtime/state/threads"; | 7 | import { |
| 8 | type EnvironmentThreadStatus, | ||
| 9 | mergeEnvironmentThread, | ||
| 10 | } from "@t3tools/client-runtime/state/threads"; | ||
| 8 | import type { | 11 | import type { |
| 9 | OrchestrationMessage, | 12 | OrchestrationMessage, |
| 10 | OrchestrationProposedPlan, | 13 | OrchestrationProposedPlan, |
| ... | @@ -44,6 +47,9 @@ const EMPTY_THREAD_SHELL_ATOM = Atom.make<EnvironmentThreadShell | null>(null).p | ... | @@ -44,6 +47,9 @@ const EMPTY_THREAD_SHELL_ATOM = Atom.make<EnvironmentThreadShell | null>(null).p |
| 44 | const EMPTY_THREAD_DETAIL_ATOM = Atom.make<EnvironmentThread | null>(null).pipe( | 47 | const EMPTY_THREAD_DETAIL_ATOM = Atom.make<EnvironmentThread | null>(null).pipe( |
| 45 | Atom.withLabel("web-thread-detail:empty"), | 48 | Atom.withLabel("web-thread-detail:empty"), |
| 46 | ); | 49 | ); |
| 50 | const EMPTY_THREAD_STATUS_ATOM = Atom.make<EnvironmentThreadStatus>("empty").pipe( | ||
| 51 | Atom.withLabel("web-thread-status:empty"), | ||
| 52 | ); | ||
| 47 | const EMPTY_MESSAGES_ATOM = Atom.make(EMPTY_MESSAGES).pipe( | 53 | const EMPTY_MESSAGES_ATOM = Atom.make(EMPTY_MESSAGES).pipe( |
| 48 | Atom.withLabel("web-thread-messages:empty"), | 54 | Atom.withLabel("web-thread-messages:empty"), |
| 49 | ); | 55 | ); |
| ... | @@ -140,6 +146,12 @@ export function useThreadDetail(ref: ScopedThreadRef | null): EnvironmentThread | ... | @@ -140,6 +146,12 @@ export function useThreadDetail(ref: ScopedThreadRef | null): EnvironmentThread |
| 140 | ); | 146 | ); |
| 141 | } | 147 | } |
| 142 | 148 | ||
| 149 | export function useThreadStatus(ref: ScopedThreadRef | null): EnvironmentThreadStatus { | ||
| 150 | return useAtomValue( | ||
| 151 | ref === null ? EMPTY_THREAD_STATUS_ATOM : environmentThreadDetails.statusAtom(ref), | ||
| 152 | ); | ||
| 153 | } | ||
| 154 | |||
| 143 | /** Detail collections composed with shell-authoritative thread/workspace metadata. */ | 155 | /** Detail collections composed with shell-authoritative thread/workspace metadata. */ |
| 144 | export function useThread(ref: ScopedThreadRef | null): EnvironmentThread | null { | 156 | export function useThread(ref: ScopedThreadRef | null): EnvironmentThread | null { |
| 145 | const shell = useThreadShell(ref); | 157 | const shell = useThreadShell(ref); |
apps/web/src/threadRoutes.test.ts+67| ... | @@ -7,6 +7,7 @@ import { | ... | @@ -7,6 +7,7 @@ import { |
| 7 | buildDraftThreadRouteParams, | 7 | buildDraftThreadRouteParams, |
| 8 | buildThreadRouteParams, | 8 | buildThreadRouteParams, |
| 9 | resolveActiveThreadRouteRef, | 9 | resolveActiveThreadRouteRef, |
| 10 | resolveThreadRouteRenderState, | ||
| 10 | resolveThreadRouteRef, | 11 | resolveThreadRouteRef, |
| 11 | resolveThreadRouteTarget, | 12 | resolveThreadRouteTarget, |
| 12 | } from "./threadRoutes"; | 13 | } from "./threadRoutes"; |
| ... | @@ -92,4 +93,70 @@ describe("threadRoutes", () => { | ... | @@ -92,4 +93,70 @@ describe("threadRoutes", () => { |
| 92 | }), | 93 | }), |
| 93 | ).toBeNull(); | 94 | ).toBeNull(); |
| 94 | }); | 95 | }); |
| 96 | |||
| 97 | it("keeps shell-only server threads in the loading state", () => { | ||
| 98 | expect( | ||
| 99 | resolveThreadRouteRenderState({ | ||
| 100 | bootstrapComplete: true, | ||
| 101 | serverThreadShellExists: true, | ||
| 102 | serverThreadDetailExists: false, | ||
| 103 | serverThreadDetailDeleted: false, | ||
| 104 | draftThreadExists: false, | ||
| 105 | }), | ||
| 106 | ).toBe("loading"); | ||
| 107 | }); | ||
| 108 | |||
| 109 | it("renders server details and local drafts when they are ready", () => { | ||
| 110 | expect( | ||
| 111 | resolveThreadRouteRenderState({ | ||
| 112 | bootstrapComplete: true, | ||
| 113 | serverThreadShellExists: true, | ||
| 114 | serverThreadDetailExists: true, | ||
| 115 | serverThreadDetailDeleted: false, | ||
| 116 | draftThreadExists: false, | ||
| 117 | }), | ||
| 118 | ).toBe("ready"); | ||
| 119 | expect( | ||
| 120 | resolveThreadRouteRenderState({ | ||
| 121 | bootstrapComplete: true, | ||
| 122 | serverThreadShellExists: false, | ||
| 123 | serverThreadDetailExists: false, | ||
| 124 | serverThreadDetailDeleted: false, | ||
| 125 | draftThreadExists: true, | ||
| 126 | }), | ||
| 127 | ).toBe("ready"); | ||
| 128 | }); | ||
| 129 | |||
| 130 | it("distinguishes bootstrap loading from a missing thread", () => { | ||
| 131 | expect( | ||
| 132 | resolveThreadRouteRenderState({ | ||
| 133 | bootstrapComplete: false, | ||
| 134 | serverThreadShellExists: false, | ||
| 135 | serverThreadDetailExists: false, | ||
| 136 | serverThreadDetailDeleted: false, | ||
| 137 | draftThreadExists: false, | ||
| 138 | }), | ||
| 139 | ).toBe("loading"); | ||
| 140 | expect( | ||
| 141 | resolveThreadRouteRenderState({ | ||
| 142 | bootstrapComplete: true, | ||
| 143 | serverThreadShellExists: false, | ||
| 144 | serverThreadDetailExists: false, | ||
| 145 | serverThreadDetailDeleted: false, | ||
| 146 | draftThreadExists: false, | ||
| 147 | }), | ||
| 148 | ).toBe("missing"); | ||
| 149 | }); | ||
| 150 | |||
| 151 | it("redirects deleted shell-only threads", () => { | ||
| 152 | expect( | ||
| 153 | resolveThreadRouteRenderState({ | ||
| 154 | bootstrapComplete: true, | ||
| 155 | serverThreadShellExists: true, | ||
| 156 | serverThreadDetailExists: false, | ||
| 157 | serverThreadDetailDeleted: true, | ||
| 158 | draftThreadExists: false, | ||
| 159 | }), | ||
| 160 | ).toBe("missing"); | ||
| 161 | }); | ||
| 95 | }); | 162 | }); |
apps/web/src/threadRoutes.ts+21| ... | @@ -18,6 +18,27 @@ type DraftThreadRouteState = { | ... | @@ -18,6 +18,27 @@ type DraftThreadRouteState = { |
| 18 | promotedTo?: ScopedThreadRef | null; | 18 | promotedTo?: ScopedThreadRef | null; |
| 19 | }; | 19 | }; |
| 20 | 20 | ||
| 21 | export type ThreadRouteRenderState = "loading" | "ready" | "missing"; | ||
| 22 | |||
| 23 | export function resolveThreadRouteRenderState(input: { | ||
| 24 | bootstrapComplete: boolean; | ||
| 25 | serverThreadShellExists: boolean; | ||
| 26 | serverThreadDetailExists: boolean; | ||
| 27 | serverThreadDetailDeleted: boolean; | ||
| 28 | draftThreadExists: boolean; | ||
| 29 | }): ThreadRouteRenderState { | ||
| 30 | if (!input.bootstrapComplete) { | ||
| 31 | return "loading"; | ||
| 32 | } | ||
| 33 | if (input.serverThreadDetailExists || input.draftThreadExists) { | ||
| 34 | return "ready"; | ||
| 35 | } | ||
| 36 | if (input.serverThreadDetailDeleted) { | ||
| 37 | return "missing"; | ||
| 38 | } | ||
| 39 | return input.serverThreadShellExists ? "loading" : "missing"; | ||
| 40 | } | ||
| 41 | |||
| 21 | export function buildThreadRouteParams(ref: ScopedThreadRef): { | 42 | export function buildThreadRouteParams(ref: ScopedThreadRef): { |
| 22 | environmentId: EnvironmentId; | 43 | environmentId: EnvironmentId; |
| 23 | threadId: ThreadId; | 44 | threadId: ThreadId; |