| 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 | 4 | import ChatView from "../components/ChatView"; |
| 5 | 5 | import { threadHasStarted } from "../components/ChatView.logic"; |
| 6 | 6 | import { finalizePromotedDraftThreadByRef, useComposerDraftStore } from "../composerDraftStore"; |
| 7 | import { resolveThreadRouteRef } from "../threadRoutes"; | |
| 7 | import { resolveThreadRouteRef, resolveThreadRouteRenderState } from "../threadRoutes"; | |
| 8 | 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 | 15 | import { useEnvironmentQuery } from "../state/query"; |
| 11 | 16 | import { environmentShell } from "../state/shell"; |
| 12 | 17 | |
| ... | ... | @@ -20,9 +25,9 @@ function ChatThreadRouteView() { |
| 20 | 25 | ); |
| 21 | 26 | const serverThreadShell = useThreadShell(threadRef); |
| 22 | 27 | const serverThreadDetail = useThreadDetail(threadRef); |
| 28 | const serverThreadStatus = useThreadStatus(threadRef); | |
| 23 | 29 | const environmentThreadRefs = useEnvironmentThreadRefs(threadRef?.environmentId ?? null); |
| 24 | 30 | const bootstrapComplete = shell.data?.snapshot._tag === "Some"; |
| 25 | const threadExists = serverThreadShell !== null || serverThreadDetail !== null; | |
| 26 | 31 | const environmentHasServerThreads = environmentThreadRefs.length > 0; |
| 27 | 32 | const draftThreadExists = useComposerDraftStore((store) => |
| 28 | 33 | threadRef ? store.getDraftThreadByRef(threadRef) !== null : false, |
| ... | ... | @@ -36,7 +41,13 @@ function ChatThreadRouteView() { |
| 36 | 41 | } |
| 37 | 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 | 51 | const serverThreadStarted = threadHasStarted(serverThreadDetail); |
| 41 | 52 | const environmentHasAnyThreads = environmentHasServerThreads || environmentHasDraftThreads; |
| 42 | 53 | |
| ... | ... | @@ -45,10 +56,10 @@ function ChatThreadRouteView() { |
| 45 | 56 | return; |
| 46 | 57 | } |
| 47 | 58 | |
| 48 | if (!routeThreadExists && environmentHasAnyThreads) { | |
| 59 | if (renderState === "missing" && environmentHasAnyThreads) { | |
| 49 | 60 | void navigate({ to: "/", replace: true }); |
| 50 | 61 | } |
| 51 | }, [bootstrapComplete, environmentHasAnyThreads, navigate, routeThreadExists, threadRef]); | |
| 62 | }, [bootstrapComplete, environmentHasAnyThreads, navigate, renderState, threadRef]); | |
| 52 | 63 | |
| 53 | 64 | useEffect(() => { |
| 54 | 65 | if (!threadRef || !serverThreadStarted || !draftThread) { |
| ... | ... | @@ -57,7 +68,7 @@ function ChatThreadRouteView() { |
| 57 | 68 | finalizePromotedDraftThreadByRef(threadRef); |
| 58 | 69 | }, [draftThread, serverThreadStarted, threadRef]); |
| 59 | 70 | |
| 60 | if (!threadRef || !bootstrapComplete || !routeThreadExists) { | |
| 71 | if (!threadRef || renderState !== "ready") { | |
| 61 | 72 | return null; |
| 62 | 73 | } |
| 63 | 74 |
apps/web/src/state/entities.ts+13-1| ... | ... | @@ -4,7 +4,10 @@ import type { |
| 4 | 4 | EnvironmentThread, |
| 5 | 5 | EnvironmentThreadShell, |
| 6 | 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 | 11 | import type { |
| 9 | 12 | OrchestrationMessage, |
| 10 | 13 | OrchestrationProposedPlan, |
| ... | ... | @@ -44,6 +47,9 @@ const EMPTY_THREAD_SHELL_ATOM = Atom.make<EnvironmentThreadShell | null>(null).p |
| 44 | 47 | const EMPTY_THREAD_DETAIL_ATOM = Atom.make<EnvironmentThread | null>(null).pipe( |
| 45 | 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 | 53 | const EMPTY_MESSAGES_ATOM = Atom.make(EMPTY_MESSAGES).pipe( |
| 48 | 54 | Atom.withLabel("web-thread-messages:empty"), |
| 49 | 55 | ); |
| ... | ... | @@ -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 | 155 | /** Detail collections composed with shell-authoritative thread/workspace metadata. */ |
| 144 | 156 | export function useThread(ref: ScopedThreadRef | null): EnvironmentThread | null { |
| 145 | 157 | const shell = useThreadShell(ref); |
apps/web/src/threadRoutes.test.ts+67| ... | ... | @@ -7,6 +7,7 @@ import { |
| 7 | 7 | buildDraftThreadRouteParams, |
| 8 | 8 | buildThreadRouteParams, |
| 9 | 9 | resolveActiveThreadRouteRef, |
| 10 | resolveThreadRouteRenderState, | |
| 10 | 11 | resolveThreadRouteRef, |
| 11 | 12 | resolveThreadRouteTarget, |
| 12 | 13 | } from "./threadRoutes"; |
| ... | ... | @@ -92,4 +93,70 @@ describe("threadRoutes", () => { |
| 92 | 93 | }), |
| 93 | 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 | 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 | 42 | export function buildThreadRouteParams(ref: ScopedThreadRef): { |
| 22 | 43 | environmentId: EnvironmentId; |
| 23 | 44 | threadId: ThreadId; |