import { useAtomValue } from "@effect/atom-react";
import { scopedThreadKey } from "@t3tools/client-runtime/environment";
import { parseThreadKey, threadKey } from "@t3tools/client-runtime/state/entities";
import { visibilityParentKey } from "@t3tools/client-runtime/state/threads";
import * as Option from "effect/Option";
import { Navigate, useNavigate } from "@tanstack/react-router";
import { useEffect, useState } from "react";
import ChatView from "./ChatView";
import { BackgroundThreadContext } from "./chat/backgroundThread";
import { SidebarInset } from "./ui/sidebar";
import { useComposerDraftStore } from "../composerDraftStore";
import { useSidebarPendingFileDropStore } from "../sidebarPendingFileDropStore";
import {
useEnvironmentThreadRefs,
useAnyEnvironmentHiding,
useQuietThreadShell,
} from "../state/entities";
import { useEnvironmentShellStatus } from "../state/shell";
import { environmentThreadShells, useEnvironmentThread } from "../state/threads";
import {
buildThreadRouteParams,
isThreadRouteSnapshotAuthoritative,
resolveThreadRouteRenderState,
type ThreadRouteTarget,
} from "../threadRoutes";
/**
* Unkeyed so navigating between server threads reuses one ChatView. Rendered by the `_chat`
* layout, since an element only survives a route swap when the same parent renders it. Draft
* promotion runs in the draft store; this only follows the redirect it leaves behind.
* `background` keeps the thread mounted, inert and in place under Settings or Usage.
*/
export function ThreadRouteView({
target,
background = false,
}: {
target: ThreadRouteTarget;
background?: boolean;
}) {
const navigate = useNavigate();
const draftId = target.kind === "draft" ? target.draftId : null;
const draftSession = useComposerDraftStore((store) =>
draftId === null ? null : store.getDraftSession(draftId),
);
const draftRedirect = useComposerDraftStore((store) =>
draftId === null ? null : store.getPromotedDraftRedirect(draftId),
);
const serverThreadRef = target.kind === "server" ? target.threadRef : null;
const serverThreadShell = useQuietThreadShell(serverThreadRef);
const shellStatus = useEnvironmentShellStatus(serverThreadRef?.environmentId ?? null);
const environmentThreadRefs = useEnvironmentThreadRefs(serverThreadRef?.environmentId ?? null);
const bootstrapComplete = isThreadRouteSnapshotAuthoritative(shellStatus);
const draftThread = useComposerDraftStore((store) =>
serverThreadRef ? store.getDraftThreadByRef(serverThreadRef) : null,
);
// Reached from the sidebar before finalize, the thread still has its draft.
const serverThreadDraftId = useComposerDraftStore((store) =>
serverThreadRef ? store.getDraftIdByRef(serverThreadRef) : null,
);
// The ChatView that carried a draft keeps its key through the redirect to its thread, and only
// until the route leaves that thread, so later visits reuse the shared unkeyed ChatView.
const [handoffKey, setHandoffKey] = useState<{ threadKey: string; key: string } | null>(null);
const nextHandoffKey =
draftId !== null && draftSession === null && draftRedirect
? handoffKey?.key === draftId
? handoffKey
: { threadKey: scopedThreadKey(draftRedirect), key: draftId }
: serverThreadRef && handoffKey?.threadKey === scopedThreadKey(serverThreadRef)
? handoffKey
: serverThreadRef && serverThreadDraftId
? { threadKey: scopedThreadKey(serverThreadRef), key: serverThreadDraftId }
: null;
if (nextHandoffKey !== handoffKey) {
setHandoffKey(nextHandoffKey);
}
const environmentHasDraftThreads = useComposerDraftStore((store) =>
serverThreadRef ? store.hasDraftThreadsInEnvironment(serverThreadRef.environmentId) : false,
);
// Until the shell lists the thread, its own load (HTTP, then the socket)
// decides whether it exists. Read only then: once listed, ChatView keeps
// this stream open and its updates need not re-render the route.
const unlistedThreadRef =
target.kind === "server" && serverThreadShell === null && draftThread === null
? target.threadRef
: null;
const unlistedThreadDetail = useEnvironmentThread(
unlistedThreadRef?.environmentId ?? null,
unlistedThreadRef?.threadId ?? null,
);
const hiddenKeys = useAtomValue(environmentThreadShells.hiddenThreadKeysAtom);
const unlistedThread = Option.getOrNull(Option.map(unlistedThreadDetail.data, (p) => p.thread));
const unlistedParentKey =
unlistedThreadRef === null || unlistedThread === null
? null
: visibilityParentKey(unlistedThreadRef.environmentId, unlistedThread);
const unlistedParentShell = useQuietThreadShell(
unlistedParentKey === null ? null : parseThreadKey(unlistedParentKey),
);
const hidingAnywhere = useAnyEnvironmentHiding();
const draftRedirectShell = useQuietThreadShell(draftRedirect ?? null);
const hiddenByTagFilter = serverThreadRef !== null && hiddenKeys.has(threadKey(serverThreadRef));
// Cached detail can predate a tag or an archive, so while hiding an off-shell thread opens only
// as the child of a loaded, visible parent (lineage never changes); anything else stays closed.
const offShellBlocked =
hidingAnywhere &&
unlistedThreadRef !== null &&
!(
unlistedParentKey !== null &&
unlistedParentShell !== null &&
!hiddenKeys.has(unlistedParentKey)
);
const renderState = resolveThreadRouteRenderState({
bootstrapComplete,
serverThreadExists: serverThreadShell !== null,
serverThreadDeleted: serverThreadShell?.deletedAt != null,
unlistedThreadDetail,
draftThreadExists: draftThread !== null,
});
const environmentHasAnyThreads = environmentThreadRefs.length > 0 || environmentHasDraftThreads;
// oxlint-disable-next-line t3code/no-unreasoned-state-sync -- clears pending file drops and navigates the router home once a server thread is confirmed missing
useEffect(() => {
if (target.kind !== "server" || !bootstrapComplete) {
return;
}
if (renderState === "missing") {
const { clearPendingFileDropsForThread } = useSidebarPendingFileDropStore.getState();
clearPendingFileDropsForThread(target.threadRef);
if (environmentHasAnyThreads && !background) {
void navigate({ to: "/", replace: true });
}
}
}, [background, bootstrapComplete, environmentHasAnyThreads, navigate, renderState, target]);
let view: React.ReactNode = null;
let redirect: React.ReactNode = null;
if (target.kind === "draft") {
if (
draftSession &&
hiddenKeys.has(
threadKey({ environmentId: draftSession.environmentId, threadId: draftSession.threadId }),
)
) {
// A draft whose thread is already live and hidden (mid-promotion): never mount it.
redirect = ;
} else if (draftSession) {
view = (
);
} else if (draftRedirect && hiddenKeys.has(threadKey(draftRedirect))) {
// Back to a promoted draft whose thread is now hidden: never mount it.
redirect = ;
} else if (draftRedirect && hidingAnywhere && draftRedirectShell === null) {
// While hiding, a thread outside the live list is judged by the server route, unmounted here.
redirect = (
);
} else if (draftRedirect) {
// Same slot and key as the server route renders, so the swap keeps this instance.
view = (
);
redirect = (
);
} else {
redirect = ;
}
} else if (hiddenByTagFilter) {
redirect = ;
} else if (offShellBlocked) {
// Before the shell bootstraps, a listed visible thread can still look off-shell.
if (bootstrapComplete && unlistedThread !== null) redirect = ;
} else if (renderState === "ready" || (renderState === "loading" && serverThreadShell !== null)) {
view = (
);
}
return (
{view}
{background ? null : redirect}
);
}