From 744842168cde27e9b4e886b8b3ee6787505079ca Mon Sep 17 00:00:00 2001 From: UtkarshUsername Date: Sat, 22 Aug 2026 15:42:15 +0530 Subject: [PATCH 1/5] feat(web): sidebar thread rows accept file drops Dropping files from the OS onto a sidebar thread row now opens that thread and attaches the files in its composer, without sending. Rows reuse the workspace drop handlers (Files-type gate only), so pinned reordering and mention drags are unaffected. Files dropped on a row that is not the open thread are stashed and handed to the composer once the navigation lands; if the route bounces the stash is cleared. Ends with model/harness info: ox-alpha (opencode) --- apps/web/src/components/ChatView.tsx | 29 ++++++++ apps/web/src/components/Sidebar.tsx | 60 +++++++++++++++- .../src/sidebarPendingFileDropStore.test.ts | 70 +++++++++++++++++++ apps/web/src/sidebarPendingFileDropStore.ts | 47 +++++++++++++ docs/user/thread-sidebar.md | 3 + 5 files changed, 208 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/sidebarPendingFileDropStore.test.ts create mode 100644 apps/web/src/sidebarPendingFileDropStore.ts diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 8dcb6b8ed932..a72eca4f5c86 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -149,6 +149,7 @@ import { ThreadPreviewMiniPlayer } from "./preview/ThreadPreviewMiniPlayer"; import { subscribePreviewAction } from "./preview/previewActionBus"; import { getConfiguredPreviewUrls } from "./preview/previewEmptyStateLogic"; import { makeWorkspaceFileDropHandlers } from "./chat/workspaceFileDrop"; +import { useSidebarPendingFileDropStore } from "../sidebarPendingFileDropStore"; import { selectThreadPreviewMiniPlayer, usePreviewMiniPlayerStore, @@ -6409,6 +6410,34 @@ function ChatViewContent(props: ChatViewProps) { ) : null ) : null; + const pendingSidebarFileDrop = useSidebarPendingFileDropStore((state) => state.pending); + const consumePendingFileDrop = useSidebarPendingFileDropStore( + (state) => state.consumePendingFileDrop, + ); + // Files dropped on a sidebar row land here once the dropped-on thread is + // actually open, then take the exact same path as a workspace drop: + // validate, compress, focus the composer, never send. + useEffect(() => { + if (pendingSidebarFileDrop === null) return; + if ( + activeThreadId === null || + pendingSidebarFileDrop.threadRef.threadId !== activeThreadId || + activeThreadEnvironmentId !== pendingSidebarFileDrop.threadRef.environmentId + ) { + return; + } + const files = consumePendingFileDrop(pendingSidebarFileDrop.threadRef); + if (files !== null) { + composerRef.current?.addDroppedFiles(files); + } + }, [ + activeThreadId, + activeThreadEnvironmentId, + composerRef, + consumePendingFileDrop, + pendingSidebarFileDrop, + ]); + const workspaceFileDropHandlers = makeWorkspaceFileDropHandlers({ setDragActive: setIsWorkspaceFileDragActive, addFiles: (files) => composerRef.current?.addDroppedFiles(files), diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index af7cdc8a94a2..8dcccc0eff21 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -90,6 +90,7 @@ import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../termina import { isMacPlatform } from "~/lib/utils"; import { useOpenPrLink } from "../lib/openPullRequestLink"; import { readLocalApi } from "../localApi"; +import { useSidebarPendingFileDropStore } from "../sidebarPendingFileDropStore"; import { getProjectOrderKey, selectProjectGroupingSettings } from "../logicalProject"; import { buildSidebarProjectSnapshots, @@ -163,6 +164,7 @@ import { type SnoozePreset, } from "./Sidebar.snooze"; import { ProjectFavicon } from "./ProjectFavicon"; +import { makeWorkspaceFileDropHandlers } from "./chat/workspaceFileDrop"; import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon"; import { getTriggerDisplayModelLabel } from "./chat/providerIconUtils"; import { @@ -744,6 +746,12 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { onUnsnooze: (threadRef: ScopedThreadRef) => void; onUnpin: (threadRef: ScopedThreadRef) => void; onAcknowledgeWoke: (threadRef: ScopedThreadRef, visitedAt: string) => void; + /** + * External files dropped onto this row. The row highlights while the drag + * is over it; the callback opens the thread and hands the files to its + * composer. Absent when the sidebar cannot open server threads. + */ + onFileDropThreads?: ((threadRef: ScopedThreadRef, files: File[]) => void) | undefined; changeRequestSnapshot: ThreadChangeRequestSnapshot | null; onChangeRequestSnapshot: ( threadKey: string, @@ -1000,6 +1008,27 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { }, [isRenaming, onStartRename, thread.title, threadRef], ); + const [isFileDragOver, setIsFileDragOver] = useState(false); + const fileDropHandlers = useMemo( + () => + props.onFileDropThreads + ? makeWorkspaceFileDropHandlers({ + setDragActive: setIsFileDragOver, + addFiles: (files) => { + props.onFileDropThreads?.(threadRef, files); + }, + }) + : null, + [props.onFileDropThreads, threadRef], + ); + // A drop lands on a child or outside the window entirely, so dragend is + // the reset of last resort for the row's highlight. + useEffect(() => { + if (!isFileDragOver) return; + const clearFileDrag = () => setIsFileDragOver(false); + window.addEventListener("dragend", clearFileDrag); + return () => window.removeEventListener("dragend", clearFileDrag); + }, [isFileDragOver]); const renameCommittedRef = useRef(false); useEffect(() => { if (isRenaming) renameCommittedRef.current = false; @@ -1111,6 +1140,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { !props.isActive && !isSelected && "opacity-70 transition-opacity hover:opacity-100", + isFileDragOver && "bg-sidebar-row-hover ring-1 ring-sidebar-ring", ); const title = isRenaming ? ( @@ -1197,6 +1227,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { return (
  • @@ -1354,6 +1385,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { : undefined } {...(sortable?.listeners ?? {})} + {...(fileDropHandlers ?? {})} className={cn( "list-none py-0.5 [content-visibility:auto] [contain-intrinsic-size:auto_96px]", sortable?.isDragging && "z-20 opacity-80", @@ -2314,7 +2346,7 @@ export default function Sidebar() { if (isMobile) { setOpenMobile(false); } - void router.navigate({ + return router.navigate({ to: "/$environmentId/$threadId", params: buildThreadRouteParams(threadRef), }); @@ -2322,6 +2354,31 @@ export default function Sidebar() { [clearSelection, isMobile, router, setOpenMobile, setSelectionAnchor], ); + // Dropping files on a row opens that thread and attaches the files there. + // The composer only accepts drops for its OWN thread, so when the row is + // not the open thread we stash the files and let ChatView hand them over + // once the navigation actually lands; if the route bounced (thread gone), + // nothing will consume them, so clear instead of surprising the user later. + const setPendingFileDrop = useSidebarPendingFileDropStore((s) => s.setPendingFileDrop); + const clearPendingFileDrop = useSidebarPendingFileDropStore((s) => s.clearPendingFileDrop); + const handleThreadFileDrop = useCallback( + async (threadRef: ScopedThreadRef, files: File[]) => { + setPendingFileDrop({ threadRef, files }); + const threadKey = scopedThreadKey(threadRef); + if (routeThreadKeyRef.current === threadKey) return; + await navigateToThread(threadRef); + if ( + router.buildLocation({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(threadRef), + }).pathname !== router.state.location.pathname + ) { + clearPendingFileDrop(); + } + }, + [clearPendingFileDrop, navigateToThread, router, setPendingFileDrop], + ); + const navigateToDraft = useCallback( (draftId: DraftId) => { // Unconditional: also drops a stale selection anchor left by @@ -3751,6 +3808,7 @@ export default function Sidebar() { onAcknowledgeWoke={acknowledgeWoke} changeRequestSnapshot={changeRequestSnapshotByKey.get(threadKey) ?? null} onChangeRequestSnapshot={setThreadChangeRequestSnapshot} + onFileDropThreads={handleThreadFileDrop} /> ); }; diff --git a/apps/web/src/sidebarPendingFileDropStore.test.ts b/apps/web/src/sidebarPendingFileDropStore.test.ts new file mode 100644 index 000000000000..cae5398fa153 --- /dev/null +++ b/apps/web/src/sidebarPendingFileDropStore.test.ts @@ -0,0 +1,70 @@ +import { beforeEach, describe, expect, it } from "vite-plus/test"; + +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { type EnvironmentId, ThreadId } from "@t3tools/contracts"; + +import { + useSidebarPendingFileDropStore, + type SidebarPendingFileDrop, +} from "./sidebarPendingFileDropStore"; + +function makeFiles(count: number): File[] { + return Array.from({ length: count }, (_, index) => new File(["x"], `f${index}.png`)); +} + +function makeEntry(environmentId: string, threadId: string, files: File[]): SidebarPendingFileDrop { + return { + threadRef: scopeThreadRef(environmentId as EnvironmentId, ThreadId.make(threadId)), + files, + }; +} + +beforeEach(() => { + useSidebarPendingFileDropStore.setState({ pending: null }); +}); + +describe("sidebarPendingFileDropStore", () => { + it("starts empty", () => { + expect(useSidebarPendingFileDropStore.getState().pending).toBeNull(); + }); + + it("stashes and consumes a drop for the matching thread", () => { + const files = makeFiles(2); + const entry = makeEntry("env-1", "thread-1", files); + useSidebarPendingFileDropStore.getState().setPendingFileDrop(entry); + + expect( + useSidebarPendingFileDropStore.getState().consumePendingFileDrop(entry.threadRef), + ).toEqual(files); + expect(useSidebarPendingFileDropStore.getState().pending).toBeNull(); + }); + + it("refuses to consume for a different thread without clearing the stash", () => { + const entry = makeEntry("env-1", "thread-1", makeFiles(1)); + useSidebarPendingFileDropStore.getState().setPendingFileDrop(entry); + + const otherThread = makeEntry("env-1", "thread-2", []).threadRef; + expect( + useSidebarPendingFileDropStore.getState().consumePendingFileDrop(otherThread), + ).toBeNull(); + expect(useSidebarPendingFileDropStore.getState().pending).toEqual(entry); + + useSidebarPendingFileDropStore.getState().clearPendingFileDrop(); + expect(useSidebarPendingFileDropStore.getState().pending).toBeNull(); + }); + + it("replaces an earlier pending drop with a newer one", () => { + const first = makeEntry("env-1", "thread-1", makeFiles(1)); + const second = makeEntry("env-2", "thread-2", makeFiles(3)); + useSidebarPendingFileDropStore.getState().setPendingFileDrop(first); + useSidebarPendingFileDropStore.getState().setPendingFileDrop(second); + + expect(useSidebarPendingFileDropStore.getState().pending).toEqual(second); + expect( + useSidebarPendingFileDropStore.getState().consumePendingFileDrop(first.threadRef), + ).toBeNull(); + expect( + useSidebarPendingFileDropStore.getState().consumePendingFileDrop(second.threadRef), + ).not.toBeNull(); + }); +}); diff --git a/apps/web/src/sidebarPendingFileDropStore.ts b/apps/web/src/sidebarPendingFileDropStore.ts new file mode 100644 index 000000000000..eb8ebe72a1a6 --- /dev/null +++ b/apps/web/src/sidebarPendingFileDropStore.ts @@ -0,0 +1,47 @@ +import { create } from "zustand"; + +import { scopedThreadKey } from "@t3tools/client-runtime/environment"; +import type { ScopedThreadRef } from "@t3tools/contracts"; + +/** + * Files dropped onto a sidebar thread row while another thread was open. + * The row hands these off and navigates; ChatView attaches them through the + * composer's normal drop path once the dropped-on thread actually becomes + * active, so they can never land on the wrong thread's draft. + */ +export interface SidebarPendingFileDrop { + threadRef: ScopedThreadRef; + files: File[]; +} + +interface SidebarPendingFileDropStoreState { + pending: SidebarPendingFileDrop | null; + /** Replaces any earlier pending drop: only the latest handoff matters. */ + setPendingFileDrop: (entry: SidebarPendingFileDrop) => void; + clearPendingFileDrop: () => void; + /** + * Returns the stashed files when `threadRef` matches the drop target and + * clears the entry; returns null (leaving state untouched) otherwise. + */ + consumePendingFileDrop: (threadRef: ScopedThreadRef) => File[] | null; +} + +export const useSidebarPendingFileDropStore = create()( + (set, get) => ({ + pending: null, + setPendingFileDrop: (entry) => { + set({ pending: entry }); + }, + clearPendingFileDrop: () => { + set({ pending: null }); + }, + consumePendingFileDrop: (threadRef) => { + const pending = get().pending; + if (pending === null || scopedThreadKey(pending.threadRef) !== scopedThreadKey(threadRef)) { + return null; + } + set({ pending: null }); + return pending.files; + }, + }), +); diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 70b3cccc962a..3fb74070f4c8 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -12,6 +12,9 @@ If reordering is unavailable for one environment, update the T3 Code server runn environment. Older servers can still pin and unpin threads, but do not understand synced ordering; their pinned threads keep the default newest-first order below the ones you have arranged. +On web and desktop, you can also drag files from your computer onto any thread row: the thread opens +and the files are attached as images in its composer, ready for your next message. + ## Environment artwork Dev and Nightly environments can identify themselves with artwork at the top of the sidebar and in From 38543d28bd82d7e5baf2722a5d4876740d7b77ec Mon Sep 17 00:00:00 2001 From: UtkarshUsername Date: Sat, 22 Aug 2026 16:27:42 +0530 Subject: [PATCH 2/5] fix(web): address review findings on sidebar file drop Move the pending-drop hooks above ChatView's no-active-thread early return so hook order cannot change when a thread opens or closes. Only clear the pending stash when it still belongs to the drop that missed its landing, so a newer drop is never wiped by an older navigation. Use the existing ring-ring token for the row highlight instead of the nonexistent ring-sidebar-ring. Ends with: ox-alpha (opencode) --- apps/web/src/components/ChatView.tsx | 57 ++++++++++++++-------------- apps/web/src/components/Sidebar.tsx | 11 ++++-- 2 files changed, 36 insertions(+), 32 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index a72eca4f5c86..0bfce028b224 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -6257,6 +6257,35 @@ function ChatViewContent(props: ChatViewProps) { void onRevertToTurnCountRef.current(targetTurnCount); }, []); + // Files dropped on a sidebar row land here once the dropped-on thread is + // actually open, then take the exact same path as a workspace drop: + // validate, compress, focus the composer, never send. Kept above the + // no-active-thread early return so hook order never changes. + const pendingSidebarFileDrop = useSidebarPendingFileDropStore((state) => state.pending); + const consumePendingFileDrop = useSidebarPendingFileDropStore( + (state) => state.consumePendingFileDrop, + ); + useEffect(() => { + if (pendingSidebarFileDrop === null) return; + if ( + activeThreadId === null || + pendingSidebarFileDrop.threadRef.threadId !== activeThreadId || + activeThreadEnvironmentId !== pendingSidebarFileDrop.threadRef.environmentId + ) { + return; + } + const files = consumePendingFileDrop(pendingSidebarFileDrop.threadRef); + if (files !== null) { + composerRef.current?.addDroppedFiles(files); + } + }, [ + activeThreadId, + activeThreadEnvironmentId, + composerRef, + consumePendingFileDrop, + pendingSidebarFileDrop, + ]); + // Empty state: no active thread if (!activeThread) { return ; @@ -6410,34 +6439,6 @@ function ChatViewContent(props: ChatViewProps) { ) : null ) : null; - const pendingSidebarFileDrop = useSidebarPendingFileDropStore((state) => state.pending); - const consumePendingFileDrop = useSidebarPendingFileDropStore( - (state) => state.consumePendingFileDrop, - ); - // Files dropped on a sidebar row land here once the dropped-on thread is - // actually open, then take the exact same path as a workspace drop: - // validate, compress, focus the composer, never send. - useEffect(() => { - if (pendingSidebarFileDrop === null) return; - if ( - activeThreadId === null || - pendingSidebarFileDrop.threadRef.threadId !== activeThreadId || - activeThreadEnvironmentId !== pendingSidebarFileDrop.threadRef.environmentId - ) { - return; - } - const files = consumePendingFileDrop(pendingSidebarFileDrop.threadRef); - if (files !== null) { - composerRef.current?.addDroppedFiles(files); - } - }, [ - activeThreadId, - activeThreadEnvironmentId, - composerRef, - consumePendingFileDrop, - pendingSidebarFileDrop, - ]); - const workspaceFileDropHandlers = makeWorkspaceFileDropHandlers({ setDragActive: setIsWorkspaceFileDragActive, addFiles: (files) => composerRef.current?.addDroppedFiles(files), diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 8dcccc0eff21..96de519874e1 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1140,7 +1140,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { !props.isActive && !isSelected && "opacity-70 transition-opacity hover:opacity-100", - isFileDragOver && "bg-sidebar-row-hover ring-1 ring-sidebar-ring", + isFileDragOver && "bg-sidebar-row-hover ring-1 ring-ring", ); const title = isRenaming ? ( @@ -2367,12 +2367,15 @@ export default function Sidebar() { const threadKey = scopedThreadKey(threadRef); if (routeThreadKeyRef.current === threadKey) return; await navigateToThread(threadRef); - if ( + // A newer drop may have replaced ours while the navigation was in + // flight; only clear the stash if it still belongs to this drop. + const landed = router.buildLocation({ to: "/$environmentId/$threadId", params: buildThreadRouteParams(threadRef), - }).pathname !== router.state.location.pathname - ) { + }).pathname === router.state.location.pathname; + const pending = useSidebarPendingFileDropStore.getState().pending; + if (!landed && pending !== null && scopedThreadKey(pending.threadRef) === threadKey) { clearPendingFileDrop(); } }, From 7bd2cdc3bff313cd152609e335a9a4789309bc7f Mon Sep 17 00:00:00 2001 From: UtkarshUsername Date: Sat, 22 Aug 2026 16:36:15 +0530 Subject: [PATCH 3/5] fix(web): sidebar drop highlight visible inside clipped rows The row li's content-visibility paint containment clips outer rings, so the drag-over affordance read as plain hover. Use an inset ring, the sidebar's own ring convention, tinted with primary to match the other file-drop surfaces instead of the focus-ring token. Ends with: ox-alpha (opencode) --- apps/web/src/components/Sidebar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 96de519874e1..2747551cce68 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1140,7 +1140,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { !props.isActive && !isSelected && "opacity-70 transition-opacity hover:opacity-100", - isFileDragOver && "bg-sidebar-row-hover ring-1 ring-ring", + isFileDragOver && "bg-sidebar-row-hover ring-1 ring-inset ring-primary/70", ); const title = isRenaming ? ( From 16be90439026010803a7bfe787ac023f8d031094 Mon Sep 17 00:00:00 2001 From: UtkarshUsername Date: Sat, 22 Aug 2026 20:39:01 +0530 Subject: [PATCH 4/5] fix(web): release pending drop when dropped-on thread is missing The missing-thread redirect fires in an effect after navigation resolves, so the drop's landing check had already passed and the stash was never consumed or cleared. Clear it here, scoped to the dead thread, before navigating home. Ends with: ox-alpha (opencode) --- apps/web/src/routes/_chat.$environmentId.$threadId.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/apps/web/src/routes/_chat.$environmentId.$threadId.tsx b/apps/web/src/routes/_chat.$environmentId.$threadId.tsx index 5ac4665cf8a3..2a2bed2861d0 100644 --- a/apps/web/src/routes/_chat.$environmentId.$threadId.tsx +++ b/apps/web/src/routes/_chat.$environmentId.$threadId.tsx @@ -6,6 +6,8 @@ import { threadHasStarted } from "../components/ChatView.logic"; import { finalizePromotedDraftThreadByRef, useComposerDraftStore } from "../composerDraftStore"; import { resolveThreadRouteRef, resolveThreadRouteRenderState } from "../threadRoutes"; import { resolveThreadSyncPhase } from "../threadSync"; +import { useSidebarPendingFileDropStore } from "../sidebarPendingFileDropStore"; +import { scopedThreadKey } from "@t3tools/client-runtime/environment"; import { SidebarInset } from "~/components/ui/sidebar"; import { useEnvironmentThreadRefs, @@ -63,6 +65,12 @@ function ChatThreadRouteView() { } if (renderState === "missing" && environmentHasAnyThreads) { + // Navigation already resolved onto this path, so a drop aimed here + // passed its landing check; it can never be attached now, release it. + const { pending, clearPendingFileDrop } = useSidebarPendingFileDropStore.getState(); + if (pending && scopedThreadKey(pending.threadRef) === scopedThreadKey(threadRef)) { + clearPendingFileDrop(); + } void navigate({ to: "/", replace: true }); } }, [bootstrapComplete, environmentHasAnyThreads, navigate, renderState, threadRef]); From 5d437c4da1989b503769444c1ed89e35409bfcbb Mon Sep 17 00:00:00 2001 From: UtkarshUsername Date: Sat, 22 Aug 2026 20:46:12 +0530 Subject: [PATCH 5/5] fix(web): release pending drop even when missing thread cannot redirect An environment with no threads left leaves the route parked on the missing thread instead of redirecting home, so the stash survived. Move the release out of the redirect condition. Ends with: ox-alpha (opencode) --- .../web/src/routes/_chat.$environmentId.$threadId.tsx | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/apps/web/src/routes/_chat.$environmentId.$threadId.tsx b/apps/web/src/routes/_chat.$environmentId.$threadId.tsx index 2a2bed2861d0..bf6a7744b7fb 100644 --- a/apps/web/src/routes/_chat.$environmentId.$threadId.tsx +++ b/apps/web/src/routes/_chat.$environmentId.$threadId.tsx @@ -64,14 +64,17 @@ function ChatThreadRouteView() { return; } - if (renderState === "missing" && environmentHasAnyThreads) { - // Navigation already resolved onto this path, so a drop aimed here - // passed its landing check; it can never be attached now, release it. + // Navigation already resolved onto this path, so a drop aimed here + // passed its landing check; once the thread reads as missing it can + // never be attached, release it even when there is nowhere to redirect. + if (renderState === "missing") { const { pending, clearPendingFileDrop } = useSidebarPendingFileDropStore.getState(); if (pending && scopedThreadKey(pending.threadRef) === scopedThreadKey(threadRef)) { clearPendingFileDrop(); } - void navigate({ to: "/", replace: true }); + if (environmentHasAnyThreads) { + void navigate({ to: "/", replace: true }); + } } }, [bootstrapComplete, environmentHasAnyThreads, navigate, renderState, threadRef]);