History

Adopt TanStack Router for thread-based chat navigation

- add route-based chat layout with index and `/$threadId` views - navigate sidebar thread actions through router instead of store-only selection - extract shared thread creation into `threadFactory` and wire new router deps

Julius Marminge committed 8 months ago

Commit c79ef621 parent b236dcc10 files · +457 −247Browse files

Unified · Split

10 files
  1. apps/web/package.json +1 −0
  2. apps/web/src/App.tsx +5 −200
  3. apps/web/src/components/ChatView.tsx +6 −2
  4. apps/web/src/components/Sidebar.tsx +42 −45
  5. apps/web/src/router.tsx +37 −0
  6. apps/web/src/routes/_chat.$threadId.tsx +37 −0
  7. apps/web/src/routes/_chat.index.tsx +84 −0
  8. apps/web/src/routes/_chat.tsx +185 −0
  9. apps/web/src/threadFactory.ts +41 −0
  10. bun.lock +19 −0
apps/web/package.json+1−0
@@ -15,6 +15,7 @@ · 14 unmodified lines
15 "@t3tools/contracts": "workspace:*",15 "@t3tools/contracts": "workspace:*",
16 "@tanstack/react-pacer": "^0.19.4",16 "@tanstack/react-pacer": "^0.19.4",
17 "@tanstack/react-query": "^5.90.0",17 "@tanstack/react-query": "^5.90.0",
18 "@tanstack/react-router": "^1.160.2",
18 "@xterm/addon-fit": "^0.11.0",19 "@xterm/addon-fit": "^0.11.0",
19 "@xterm/xterm": "^6.0.0",20 "@xterm/xterm": "^6.0.0",
20 "class-variance-authority": "^0.7.1",21 "class-variance-authority": "^0.7.1",
apps/web/src/App.tsx+5−200
@@ -1,204 +1,9 @@
1import { QueryClient, QueryClientProvider, useQueryClient } from "@tanstack/react-query";1import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
2import { useEffect, useRef } from "react";2import { RouterProvider } from "@tanstack/react-router";
3 3
4import ChatView from "./components/ChatView";
5import DiffPanel from "./components/DiffPanel";
6import Sidebar from "./components/Sidebar";
7import { isElectron } from "./env";
8import { DEFAULT_MODEL } from "./model-logic";
9import { StoreProvider, useStore } from "./store";
10import { DEFAULT_THREAD_TERMINAL_HEIGHT, DEFAULT_THREAD_TERMINAL_ID } from "./types";
11import { onServerWelcome } from "./wsNativeApi";
12import { useNativeApi } from "./hooks/useNativeApi";
13import { AnchoredToastProvider, ToastProvider } from "./components/ui/toast";4import { AnchoredToastProvider, ToastProvider } from "./components/ui/toast";
14import { invalidateGitQueries } from "./lib/gitReactQuery";5import { router } from "./router";
15 6import { StoreProvider } from "./store";
16function EventRouter() {
17 const api = useNativeApi();
18 const { dispatch } = useStore();
19 const queryClient = useQueryClient();
20 const activeAssistantItemRef = useRef<string | null>(null);
21
22 useEffect(() => {
23 if (!api) return;
24 return api.providers.onEvent((event) => {
25 if (event.method === "turn/completed") {
26 void invalidateGitQueries(queryClient);
27 }
28 dispatch({
29 type: "APPLY_EVENT",
30 event,
31 activeAssistantItemRef,
32 });
33 });
34 }, [api, dispatch, queryClient]);
35
36 useEffect(() => {
37 if (!api) return;
38 return api.terminal.onEvent((event) => {
39 dispatch({
40 type: "APPLY_TERMINAL_EVENT",
41 event,
42 });
43 });
44 }, [api, dispatch]);
45
46 return null;
47}
48
49function AutoProjectBootstrap() {
50 const { state, dispatch } = useStore();
51 const bootstrappedRef = useRef(false);
52
53 useEffect(() => {
54 // Browser mode bootstraps from server welcome.
55 // Electron bootstraps from persisted projects via DesktopProjectBootstrap.
56 if (isElectron) return;
57
58 return onServerWelcome((payload) => {
59 if (bootstrappedRef.current) return;
60
61 // Don't create duplicate projects for the same cwd
62 const existing = state.projects.find((p) => p.cwd === payload.cwd);
63 if (existing) {
64 bootstrappedRef.current = true;
65 // Ensure a thread is active
66 const existingThread = state.threads.find((t) => t.projectId === existing.id);
67 if (existingThread && !state.activeThreadId) {
68 dispatch({
69 type: "SET_ACTIVE_THREAD",
70 threadId: existingThread.id,
71 });
72 }
73 return;
74 }
75
76 bootstrappedRef.current = true;
77
78 // Create project + thread from server cwd
79 const projectId = crypto.randomUUID();
80 dispatch({
81 type: "ADD_PROJECT",
82 project: {
83 id: projectId,
84 name: payload.projectName,
85 cwd: payload.cwd,
86 model: DEFAULT_MODEL,
87 expanded: true,
88 scripts: [],
89 },
90 });
91 dispatch({
92 type: "ADD_THREAD",
93 thread: {
94 id: crypto.randomUUID(),
95 codexThreadId: null,
96 projectId,
97 title: "New thread",
98 model: DEFAULT_MODEL,
99 terminalOpen: false,
100 terminalHeight: DEFAULT_THREAD_TERMINAL_HEIGHT,
101 terminalIds: [DEFAULT_THREAD_TERMINAL_ID],
102 runningTerminalIds: [],
103 activeTerminalId: DEFAULT_THREAD_TERMINAL_ID,
104 terminalGroups: [
105 {
106 id: `group-${DEFAULT_THREAD_TERMINAL_ID}`,
107 terminalIds: [DEFAULT_THREAD_TERMINAL_ID],
108 },
109 ],
110 activeTerminalGroupId: `group-${DEFAULT_THREAD_TERMINAL_ID}`,
111 session: null,
112 messages: [],
113 events: [],
114 error: null,
115 createdAt: new Date().toISOString(),
116 branch: null,
117 worktreePath: null,
118 },
119 });
120 });
121 }, [state.projects, state.threads, state.activeThreadId, dispatch]);
122
123 return null;
124}
125
126function DesktopProjectBootstrap() {
127 const api = useNativeApi();
128 const { dispatch } = useStore();
129 const bootstrappedRef = useRef(false);
130
131 useEffect(() => {
132 if (!isElectron || !api || bootstrappedRef.current) return;
133
134 let disposed = false;
135 let retryDelayMs = 500;
136 let retryTimer: ReturnType<typeof setTimeout> | null = null;
137
138 const attemptBootstrap = async () => {
139 try {
140 const projects = await api.projects.list();
141 if (disposed) return;
142 dispatch({
143 type: "SYNC_PROJECTS",
144 projects: projects.map((project) => ({
145 id: project.id,
146 name: project.name,
147 cwd: project.cwd,
148 model: DEFAULT_MODEL,
149 expanded: true,
150 scripts: project.scripts,
151 })),
152 });
153 bootstrappedRef.current = true;
154 } catch {
155 if (disposed) return;
156 retryTimer = setTimeout(() => {
157 retryTimer = null;
158 void attemptBootstrap();
159 }, retryDelayMs);
160 retryDelayMs = Math.min(retryDelayMs * 2, 5_000);
161 }
162 };
163
164 void attemptBootstrap();
165
166 return () => {
167 disposed = true;
168 if (retryTimer) {
169 clearTimeout(retryTimer);
170 }
171 };
172 }, [api, dispatch]);
173
174 return null;
175}
176
177function Layout() {
178 const api = useNativeApi();
179 const { state } = useStore();
180
181 if (!api) {
182 return (
183 <div className="flex h-screen flex-col bg-background text-foreground">
184 <div className="flex flex-1 items-center justify-center">
185 <p className="text-sm text-muted-foreground">Connecting to T3 Code server...</p>
186 </div>
187 </div>
188 );
189 }
190
191 return (
192 <div className="flex h-screen overflow-hidden bg-background text-foreground isolate">
193 <EventRouter />
194 <AutoProjectBootstrap />
195 <DesktopProjectBootstrap />
196 <Sidebar />
197 <ChatView />
198 {state.diffOpen && <DiffPanel />}
199 </div>
200 );
201}
202 7
203const queryClient = new QueryClient();8const queryClient = new QueryClient();
204 9
@@ -208,7 +13,7 @@ export default function App() { · 3 unmodified lines
208 <StoreProvider>13 <StoreProvider>
209 <ToastProvider>14 <ToastProvider>
210 <AnchoredToastProvider>15 <AnchoredToastProvider>
211 <Layout />16 <RouterProvider router={router} />
212 </AnchoredToastProvider>17 </AnchoredToastProvider>
213 </ToastProvider>18 </ToastProvider>
214 </StoreProvider>19 </StoreProvider>
apps/web/src/components/ChatView.tsx+6−2
@@ -330,7 +330,11 @@ const ComposerCommandMenu = memo(function ComposerCommandMenu(props: { · 329 unmodified lines
330 );330 );
331});331});
332 332
333export default function ChatView() {333interface ChatViewProps {
334 threadId: string;
335}
336
337export default function ChatView({ threadId }: ChatViewProps) {
334 const { state, dispatch } = useStore();338 const { state, dispatch } = useStore();
335 const api = useNativeApi();339 const api = useNativeApi();
336 const { resolvedTheme } = useTheme();340 const { resolvedTheme } = useTheme();
@@ -366,7 +370,7 @@ export default function ChatView() { · 29 unmodified lines
366 const dragDepthRef = useRef(0);370 const dragDepthRef = useRef(0);
367 const terminalOpenByThreadRef = useRef<Record<string, boolean>>({});371 const terminalOpenByThreadRef = useRef<Record<string, boolean>>({});
368 372
369 const activeThread = state.threads.find((t) => t.id === state.activeThreadId);373 const activeThread = state.threads.find((t) => t.id === threadId);
370 const activeThreadId = activeThread?.id ?? null;374 const activeThreadId = activeThread?.id ?? null;
371 const activeSessionId = activeThread?.session?.sessionId;375 const activeSessionId = activeThread?.session?.sessionId;
372 const activeProject = state.projects.find((p) => p.id === activeThread?.projectId);376 const activeProject = state.projects.find((p) => p.id === activeThread?.projectId);
apps/web/src/components/Sidebar.tsx+42−45
@@ -2,23 +2,20 @@ import { MonitorIcon, MoonIcon, SunIcon, TerminalIcon } from "lucide-react"; · 1 unmodified line
2import { useCallback, useEffect, useMemo, useState } from "react";2import { useCallback, useEffect, useMemo, useState } from "react";
3import type { ResolvedKeybindingsConfig } from "@t3tools/contracts";3import type { ResolvedKeybindingsConfig } from "@t3tools/contracts";
4import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";4import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
5import { useNavigate, useParams } from "@tanstack/react-router";
5import { isElectron } from "../env";6import { isElectron } from "../env";
6import { useTheme } from "../hooks/useTheme";7import { useTheme } from "../hooks/useTheme";
7import { DEFAULT_MODEL } from "../model-logic";8import { DEFAULT_MODEL } from "../model-logic";
8import { derivePendingApprovals } from "../session-logic";9import { derivePendingApprovals } from "../session-logic";
9import { useStore } from "../store";10import { useStore } from "../store";
10import { isChatNewLocalShortcut, isChatNewShortcut } from "../keybindings";11import { isChatNewLocalShortcut, isChatNewShortcut } from "../keybindings";
11import {12import { type Project, type Thread } from "../types";
12 DEFAULT_THREAD_TERMINAL_HEIGHT,
13 DEFAULT_THREAD_TERMINAL_ID,
14 type Project,
15 type Thread,
16} from "../types";
17import { useNativeApi } from "../hooks/useNativeApi";13import { useNativeApi } from "../hooks/useNativeApi";
18import { gitRemoveWorktreeMutationOptions } from "../lib/gitReactQuery";14import { gitRemoveWorktreeMutationOptions } from "../lib/gitReactQuery";
19import { serverConfigQueryOptions } from "../lib/serverReactQuery";15import { serverConfigQueryOptions } from "../lib/serverReactQuery";
20import { toastManager } from "./ui/toast";16import { toastManager } from "./ui/toast";
21import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup";17import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup";
18import { createThread } from "../threadFactory";
22 19
23const THEME_CYCLE = { system: "light", light: "dark", dark: "system" } as const;20const THEME_CYCLE = { system: "light", light: "dark", dark: "system" } as const;
24const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = [];21const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = [];
@@ -118,6 +115,9 @@ function terminalStatusIndicator(thread: Thread): TerminalStatusIndicator | null · 93 unmodified lines
118export default function Sidebar() {115export default function Sidebar() {
119 const { state, dispatch } = useStore();116 const { state, dispatch } = useStore();
120 const api = useNativeApi();117 const api = useNativeApi();
118 const navigate = useNavigate();
119 const params = useParams({ strict: false });
120 const routeThreadId = typeof params.threadId === "string" ? params.threadId : null;
121 const { data: keybindings = EMPTY_KEYBINDINGS } = useQuery({121 const { data: keybindings = EMPTY_KEYBINDINGS } = useQuery({
122 ...serverConfigQueryOptions(api),122 ...serverConfigQueryOptions(api),
123 select: (config) => config.keybindings,123 select: (config) => config.keybindings,
@@ -147,37 +147,21 @@ export default function Sidebar() { · 23 unmodified lines
147 worktreePath?: string | null;147 worktreePath?: string | null;
148 },148 },
149 ) => {149 ) => {
150 const thread = createThread(projectId, {
151 model: state.projects.find((project) => project.id === projectId)?.model ?? DEFAULT_MODEL,
152 branch: options?.branch ?? null,
153 worktreePath: options?.worktreePath ?? null,
154 });
150 dispatch({155 dispatch({
151 type: "ADD_THREAD",156 type: "ADD_THREAD",
152 thread: {157 thread,
153 id: crypto.randomUUID(),158 });
154 codexThreadId: null,159 void navigate({
155 projectId,160 to: "/$threadId",
156 title: "New thread",161 params: { threadId: thread.id },
157 model: state.projects.find((p) => p.id === projectId)?.model ?? DEFAULT_MODEL,
158 terminalOpen: false,
159 terminalHeight: DEFAULT_THREAD_TERMINAL_HEIGHT,
160 terminalIds: [DEFAULT_THREAD_TERMINAL_ID],
161 runningTerminalIds: [],
162 activeTerminalId: DEFAULT_THREAD_TERMINAL_ID,
163 terminalGroups: [
164 {
165 id: `group-${DEFAULT_THREAD_TERMINAL_ID}`,
166 terminalIds: [DEFAULT_THREAD_TERMINAL_ID],
167 },
168 ],
169 activeTerminalGroupId: `group-${DEFAULT_THREAD_TERMINAL_ID}`,
170 session: null,
171 messages: [],
172 events: [],
173 error: null,
174 createdAt: new Date().toISOString(),
175 branch: options?.branch ?? null,
176 worktreePath: options?.worktreePath ?? null,
177 },
178 });162 });
179 },163 },
180 [dispatch, state.projects],164 [dispatch, navigate, state.projects],
181 );165 );
182 166
183 const focusMostRecentThreadForProject = useCallback(167 const focusMostRecentThreadForProject = useCallback(
@@ -191,12 +175,12 @@ export default function Sidebar() { · 7 unmodified lines
191 })[0];175 })[0];
192 if (!latestThread) return;176 if (!latestThread) return;
193 177
194 dispatch({178 void navigate({
195 type: "SET_ACTIVE_THREAD",179 to: "/$threadId",
196 threadId: latestThread.id,180 params: { threadId: latestThread.id },
197 });181 });
198 },182 },
199 [dispatch, state.threads],183 [navigate, state.threads],
200 );184 );
201 185
202 const addProjectFromPath = useCallback(186 const addProjectFromPath = useCallback(
@@ -324,7 +308,20 @@ export default function Sidebar() { · 121 unmodified lines
324 // Terminal may already be closed308 // Terminal may already be closed
325 }309 }
326 310
311 const shouldNavigateToFallback = routeThreadId === threadId;
312 const fallbackThreadId = state.threads.find((entry) => entry.id !== threadId)?.id ?? null;
327 dispatch({ type: "DELETE_THREAD", threadId });313 dispatch({ type: "DELETE_THREAD", threadId });
314 if (shouldNavigateToFallback) {
315 if (fallbackThreadId) {
316 void navigate({
317 to: "/$threadId",
318 params: { threadId: fallbackThreadId },
319 replace: true,
320 });
321 } else {
322 void navigate({ to: "/", replace: true });
323 }
324 }
328 325
329 if (!shouldDeleteWorktree || !orphanedWorktreePath || !threadProject) {326 if (!shouldDeleteWorktree || !orphanedWorktreePath || !threadProject) {
330 return;327 return;
@@ -339,7 +336,7 @@ export default function Sidebar() { · 8 unmodified lines
339 // Worktree deletion is best-effort and should not block thread deletion.336 // Worktree deletion is best-effort and should not block thread deletion.
340 }337 }
341 },338 },
342 [api, dispatch, removeWorktreeMutation, state.projects, state.threads],339 [api, dispatch, navigate, removeWorktreeMutation, routeThreadId, state.projects, state.threads],
343 );340 );
344 341
345 const handleProjectContextMenu = useCallback(342 const handleProjectContextMenu = useCallback(
@@ -497,7 +494,7 @@ export default function Sidebar() { · 151 unmodified lines
497 {project.expanded && (494 {project.expanded && (
498 <div className="ml-2 border-l border-border/80 pl-2">495 <div className="ml-2 border-l border-border/80 pl-2">
499 {threads.map((thread) => {496 {threads.map((thread) => {
500 const isActive = state.activeThreadId === thread.id;497 const isActive = routeThreadId === thread.id;
501 const threadStatus = threadStatusPill(498 const threadStatus = threadStatusPill(
502 thread,499 thread,
503 pendingApprovalByThreadId.get(thread.id) === true,500 pendingApprovalByThreadId.get(thread.id) === true,
@@ -512,12 +509,12 @@ export default function Sidebar() { · 8 unmodified lines
512 ? "bg-accent text-foreground"509 ? "bg-accent text-foreground"
513 : "text-muted-foreground hover:bg-secondary"510 : "text-muted-foreground hover:bg-secondary"
514 }`}511 }`}
515 onClick={() =>512 onClick={() => {
516 dispatch({513 void navigate({
517 type: "SET_ACTIVE_THREAD",514 to: "/$threadId",
518 threadId: thread.id,515 params: { threadId: thread.id },
519 })516 });
520 }517 }}
521 onContextMenu={(e) => {518 onContextMenu={(e) => {
522 e.preventDefault();519 e.preventDefault();
523 void handleThreadContextMenu(thread.id, {520 void handleThreadContextMenu(thread.id, {
apps/web/src/router.tsx+37−0
@@ -0,0 +1,37 @@
1import { Outlet, createRootRoute, createRoute, createRouter } from "@tanstack/react-router";
2
3import { ChatThreadRouteView } from "./routes/_chat.$threadId";
4import { ChatIndexRouteView } from "./routes/_chat.index";
5import { ChatRouteLayout } from "./routes/_chat";
6
7const rootRoute = createRootRoute({
8 component: Outlet,
9});
10
11const chatRoute = createRoute({
12 getParentRoute: () => rootRoute,
13 path: "/",
14 component: ChatRouteLayout,
15});
16
17const chatIndexRoute = createRoute({
18 getParentRoute: () => chatRoute,
19 path: "/",
20 component: ChatIndexRouteView,
21});
22
23const chatThreadRoute = createRoute({
24 getParentRoute: () => chatRoute,
25 path: "$threadId",
26 component: ChatThreadRouteView,
27});
28
29const routeTree = rootRoute.addChildren([chatRoute.addChildren([chatIndexRoute, chatThreadRoute])]);
30
31export const router = createRouter({ routeTree });
32
33declare module "@tanstack/react-router" {
34 interface Register {
35 router: typeof router;
36 }
37}
apps/web/src/routes/_chat.$threadId.tsx+37−0
@@ -0,0 +1,37 @@
1import { useNavigate, useParams } from "@tanstack/react-router";
2import { useEffect } from "react";
3
4import ChatView from "../components/ChatView";
5import { useStore } from "../store";
6
7export function ChatThreadRouteView() {
8 const { state, dispatch } = useStore();
9 const navigate = useNavigate();
10 const params = useParams({ strict: false });
11 const threadId = typeof params.threadId === "string" ? params.threadId : null;
12 const threadExists = threadId ? state.threads.some((thread) => thread.id === threadId) : false;
13
14 useEffect(() => {
15 if (!threadId) {
16 void navigate({ to: "/", replace: true });
17 return;
18 }
19
20 if (!threadExists) {
21 void navigate({ to: "/", replace: true });
22 return;
23 }
24
25 if (state.activeThreadId === threadId) {
26 return;
27 }
28
29 dispatch({ type: "SET_ACTIVE_THREAD", threadId });
30 }, [dispatch, navigate, state.activeThreadId, threadExists, threadId]);
31
32 if (!threadId || !threadExists) {
33 return null;
34 }
35
36 return <ChatView threadId={threadId} />;
37}
apps/web/src/routes/_chat.index.tsx+84−0
@@ -0,0 +1,84 @@
1import { useNavigate } from "@tanstack/react-router";
2import { type FormEvent, useMemo, useState } from "react";
3
4import { Button } from "../components/ui/button";
5import { Textarea } from "../components/ui/textarea";
6import { createThread } from "../threadFactory";
7import { useStore } from "../store";
8
9export function ChatIndexRouteView() {
10 const { state, dispatch } = useStore();
11 const navigate = useNavigate();
12 const [draft, setDraft] = useState("");
13 const defaultProject = state.projects[0] ?? null;
14 const canCreateThread = defaultProject !== null;
15
16 const placeholder = useMemo(() => {
17 if (!canCreateThread) {
18 return "Add a project in the sidebar to start chatting.";
19 }
20 return "Start with a goal, bug report, or implementation idea...";
21 }, [canCreateThread]);
22
23 const onCreateThread = (event: FormEvent<HTMLFormElement>) => {
24 event.preventDefault();
25 if (!defaultProject) return;
26
27 const titleSeed = draft.trim();
28 const threadTitle =
29 titleSeed.length > 0
30 ? (titleSeed.length > 50 ? `${titleSeed.slice(0, 50)}...` : titleSeed)
31 : undefined;
32 const thread = createThread(
33 defaultProject.id,
34 threadTitle
35 ? { model: defaultProject.model, title: threadTitle }
36 : { model: defaultProject.model },
37 );
38
39 dispatch({
40 type: "ADD_THREAD",
41 thread,
42 });
43
44 setDraft("");
45
46 void navigate({
47 to: "/$threadId",
48 params: { threadId: thread.id },
49 });
50 };
51
52 return (
53 <section className="flex min-h-0 min-w-0 flex-1 flex-col bg-background">
54 <div className="mx-auto flex h-full w-full max-w-3xl flex-col justify-center px-5 py-6">
55 <div className="mb-5">
56 <h1 className="text-xl font-semibold text-foreground">New chat</h1>
57 <p className="mt-1 text-sm text-muted-foreground/70">
58 Create a thread and continue in the full chat workspace.
59 </p>
60 </div>
61
62 <form onSubmit={onCreateThread} className="space-y-3">
63 <Textarea
64 value={draft}
65 onChange={(event) => setDraft(event.target.value)}
66 placeholder={placeholder}
67 className="min-h-36 resize-y"
68 disabled={!canCreateThread}
69 />
70 <div className="flex items-center justify-between">
71 <p className="text-xs text-muted-foreground/60">
72 {canCreateThread
73 ? "Create a thread first, then send your first prompt."
74 : "Projects are required before creating threads."}
75 </p>
76 <Button type="submit" disabled={!canCreateThread}>
77 Create thread
78 </Button>
79 </div>
80 </form>
81 </div>
82 </section>
83 );
84}
apps/web/src/routes/_chat.tsx+185−0
@@ -0,0 +1,185 @@
1import { Outlet, useNavigate, useParams } from "@tanstack/react-router";
2import { useEffect, useRef } from "react";
3import { useQueryClient } from "@tanstack/react-query";
4
5import DiffPanel from "../components/DiffPanel";
6import Sidebar from "../components/Sidebar";
7import { isElectron } from "../env";
8import { useNativeApi } from "../hooks/useNativeApi";
9import { invalidateGitQueries } from "../lib/gitReactQuery";
10import { DEFAULT_MODEL } from "../model-logic";
11import { createThread } from "../threadFactory";
12import { useStore } from "../store";
13import { onServerWelcome } from "../wsNativeApi";
14
15function EventRouter() {
16 const api = useNativeApi();
17 const { dispatch } = useStore();
18 const queryClient = useQueryClient();
19 const activeAssistantItemRef = useRef<string | null>(null);
20
21 useEffect(() => {
22 if (!api) return;
23 return api.providers.onEvent((event) => {
24 if (event.method === "turn/completed") {
25 void invalidateGitQueries(queryClient);
26 }
27 dispatch({
28 type: "APPLY_EVENT",
29 event,
30 activeAssistantItemRef,
31 });
32 });
33 }, [api, dispatch, queryClient]);
34
35 useEffect(() => {
36 if (!api) return;
37 return api.terminal.onEvent((event) => {
38 dispatch({
39 type: "APPLY_TERMINAL_EVENT",
40 event,
41 });
42 });
43 }, [api, dispatch]);
44
45 return null;
46}
47
48function AutoProjectBootstrap() {
49 const { state, dispatch } = useStore();
50 const bootstrappedRef = useRef(false);
51
52 useEffect(() => {
53 // Browser mode bootstraps from server welcome.
54 // Electron bootstraps from persisted projects via DesktopProjectBootstrap.
55 if (isElectron) return;
56
57 return onServerWelcome((payload) => {
58 if (bootstrappedRef.current) return;
59
60 // Don't create duplicate projects for the same cwd
61 const existing = state.projects.find((project) => project.cwd === payload.cwd);
62 if (existing) {
63 bootstrappedRef.current = true;
64 // Ensure a thread is active
65 const existingThread = state.threads.find((thread) => thread.projectId === existing.id);
66 if (existingThread && !state.activeThreadId) {
67 dispatch({
68 type: "SET_ACTIVE_THREAD",
69 threadId: existingThread.id,
70 });
71 }
72 return;
73 }
74
75 bootstrappedRef.current = true;
76
77 // Create project + thread from server cwd
78 const projectId = crypto.randomUUID();
79 dispatch({
80 type: "ADD_PROJECT",
81 project: {
82 id: projectId,
83 name: payload.projectName,
84 cwd: payload.cwd,
85 model: DEFAULT_MODEL,
86 expanded: true,
87 },
88 });
89 dispatch({
90 type: "ADD_THREAD",
91 thread: createThread(projectId),
92 });
93 });
94 }, [state.projects, state.threads, state.activeThreadId, dispatch]);
95
96 return null;
97}
98
99function DesktopProjectBootstrap() {
100 const api = useNativeApi();
101 const { dispatch } = useStore();
102 const bootstrappedRef = useRef(false);
103
104 useEffect(() => {
105 if (!isElectron || !api || bootstrappedRef.current) return;
106
107 let disposed = false;
108 let retryDelayMs = 500;
109 let retryTimer: ReturnType<typeof setTimeout> | null = null;
110
111 const attemptBootstrap = async () => {
112 try {
113 const projects = await api.projects.list();
114 if (disposed) return;
115 dispatch({
116 type: "SYNC_PROJECTS",
117 projects: projects.map((project) => ({
118 id: project.id,
119 name: project.name,
120 cwd: project.cwd,
121 model: DEFAULT_MODEL,
122 expanded: true,
123 })),
124 });
125 bootstrappedRef.current = true;
126 } catch {
127 if (disposed) return;
128 retryTimer = setTimeout(() => {
129 retryTimer = null;
130 void attemptBootstrap();
131 }, retryDelayMs);
132 retryDelayMs = Math.min(retryDelayMs * 2, 5_000);
133 }
134 };
135
136 void attemptBootstrap();
137
138 return () => {
139 disposed = true;
140 if (retryTimer) {
141 clearTimeout(retryTimer);
142 }
143 };
144 }, [api, dispatch]);
145
146 return null;
147}
148
149export function ChatRouteLayout() {
150 const api = useNativeApi();
151 const { state } = useStore();
152 const navigate = useNavigate();
153 const params = useParams({ strict: false });
154 const routeThreadId = typeof params.threadId === "string" ? params.threadId : null;
155
156 useEffect(() => {
157 if (routeThreadId || !state.activeThreadId) return;
158 void navigate({
159 to: "/$threadId",
160 params: { threadId: state.activeThreadId },
161 replace: true,
162 });
163 }, [navigate, routeThreadId, state.activeThreadId]);
164
165 if (!api) {
166 return (
167 <div className="flex h-screen flex-col bg-background text-foreground">
168 <div className="flex flex-1 items-center justify-center">
169 <p className="text-sm text-muted-foreground">Connecting to T3 Code server...</p>
170 </div>
171 </div>
172 );
173 }
174
175 return (
176 <div className="flex h-screen overflow-hidden bg-background text-foreground isolate">
177 <EventRouter />
178 <AutoProjectBootstrap />
179 <DesktopProjectBootstrap />
180 <Sidebar />
181 <Outlet />
182 {state.diffOpen && <DiffPanel />}
183 </div>
184 );
185}
apps/web/src/threadFactory.ts+41−0
@@ -0,0 +1,41 @@
1import { DEFAULT_MODEL } from "./model-logic";
2import { DEFAULT_THREAD_TERMINAL_HEIGHT, DEFAULT_THREAD_TERMINAL_ID, type Thread } from "./types";
3
4interface CreateThreadOptions {
5 branch?: string | null;
6 createdAt?: string;
7 model?: string;
8 title?: string;
9 worktreePath?: string | null;
10}
11
12export function createThread(projectId: string, options: CreateThreadOptions = {}): Thread {
13 const createdAt = options.createdAt ?? new Date().toISOString();
14
15 return {
16 id: crypto.randomUUID(),
17 codexThreadId: null,
18 projectId,
19 title: options.title ?? "New thread",
20 model: options.model ?? DEFAULT_MODEL,
21 terminalOpen: false,
22 terminalHeight: DEFAULT_THREAD_TERMINAL_HEIGHT,
23 terminalIds: [DEFAULT_THREAD_TERMINAL_ID],
24 runningTerminalIds: [],
25 activeTerminalId: DEFAULT_THREAD_TERMINAL_ID,
26 terminalGroups: [
27 {
28 id: `group-${DEFAULT_THREAD_TERMINAL_ID}`,
29 terminalIds: [DEFAULT_THREAD_TERMINAL_ID],
30 },
31 ],
32 activeTerminalGroupId: `group-${DEFAULT_THREAD_TERMINAL_ID}`,
33 session: null,
34 messages: [],
35 events: [],
36 error: null,
37 createdAt,
38 branch: options.branch ?? null,
39 worktreePath: options.worktreePath ?? null,
40 };
41}
bun.lock+19−0
@@ -55,6 +55,7 @@ · 54 unmodified lines
55 "@t3tools/contracts": "workspace:*",55 "@t3tools/contracts": "workspace:*",
56 "@tanstack/react-pacer": "^0.19.4",56 "@tanstack/react-pacer": "^0.19.4",
57 "@tanstack/react-query": "^5.90.0",57 "@tanstack/react-query": "^5.90.0",
58 "@tanstack/react-router": "^1.160.2",
58 "@xterm/addon-fit": "^0.11.0",59 "@xterm/addon-fit": "^0.11.0",
59 "@xterm/xterm": "^6.0.0",60 "@xterm/xterm": "^6.0.0",
60 "class-variance-authority": "^0.7.1",61 "class-variance-authority": "^0.7.1",
@@ -428,6 +429,8 @@ · 367 unmodified lines
428 429
429 "@tanstack/devtools-event-client": ["@tanstack/devtools-event-client@0.4.0", "", {}, "sha512-RPfGuk2bDZgcu9bAJodvO2lnZeHuz4/71HjZ0bGb/SPg8+lyTA+RLSKQvo7fSmPSi8/vcH3aKQ8EM9ywf1olaw=="],430 "@tanstack/devtools-event-client": ["@tanstack/devtools-event-client@0.4.0", "", {}, "sha512-RPfGuk2bDZgcu9bAJodvO2lnZeHuz4/71HjZ0bGb/SPg8+lyTA+RLSKQvo7fSmPSi8/vcH3aKQ8EM9ywf1olaw=="],
430 431
432 "@tanstack/history": ["@tanstack/history@1.154.14", "", {}, "sha512-xyIfof8eHBuub1CkBnbKNKQXeRZC4dClhmzePHVOEel4G7lk/dW+TQ16da7CFdeNLv6u6Owf5VoBQxoo6DFTSA=="],
433
431 "@tanstack/pacer": ["@tanstack/pacer@0.18.0", "", { "dependencies": { "@tanstack/devtools-event-client": "^0.4.0", "@tanstack/store": "^0.8.0" } }, "sha512-qhCRSFei0hokQr3xYcQXqxsRD/LKlgHCxHXtKHrQoImp4x2Zu6tUOpUGVH4y2qexIrzSu3aibQBNNfC3Eay6Mg=="],434 "@tanstack/pacer": ["@tanstack/pacer@0.18.0", "", { "dependencies": { "@tanstack/devtools-event-client": "^0.4.0", "@tanstack/store": "^0.8.0" } }, "sha512-qhCRSFei0hokQr3xYcQXqxsRD/LKlgHCxHXtKHrQoImp4x2Zu6tUOpUGVH4y2qexIrzSu3aibQBNNfC3Eay6Mg=="],
432 435
433 "@tanstack/query-core": ["@tanstack/query-core@5.90.20", "", {}, "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg=="],436 "@tanstack/query-core": ["@tanstack/query-core@5.90.20", "", {}, "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg=="],
@@ -436,8 +439,12 @@ · 2 unmodified lines
436 439
437 "@tanstack/react-query": ["@tanstack/react-query@5.90.21", "", { "dependencies": { "@tanstack/query-core": "5.90.20" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg=="],440 "@tanstack/react-query": ["@tanstack/react-query@5.90.21", "", { "dependencies": { "@tanstack/query-core": "5.90.20" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg=="],
438 441
442 "@tanstack/react-router": ["@tanstack/react-router@1.160.2", "", { "dependencies": { "@tanstack/history": "1.154.14", "@tanstack/react-store": "^0.8.0", "@tanstack/router-core": "1.160.0", "isbot": "^5.1.22", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-EJWAMS4qCfWKNCzzYGy6ZuWTdBATYEEWieaQdmM7zUesyOQ01j7o6aKXdmCp9rWuSKjPHXagWubEnEo+Puhi3w=="],
443
439 "@tanstack/react-store": ["@tanstack/react-store@0.8.1", "", { "dependencies": { "@tanstack/store": "0.8.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-XItJt+rG8c5Wn/2L/bnxys85rBpm0BfMbhb4zmPVLXAKY9POrp1xd6IbU4PKoOI+jSEGc3vntPRfLGSgXfE2Ig=="],444 "@tanstack/react-store": ["@tanstack/react-store@0.8.1", "", { "dependencies": { "@tanstack/store": "0.8.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-XItJt+rG8c5Wn/2L/bnxys85rBpm0BfMbhb4zmPVLXAKY9POrp1xd6IbU4PKoOI+jSEGc3vntPRfLGSgXfE2Ig=="],
440 445
446 "@tanstack/router-core": ["@tanstack/router-core@1.160.0", "", { "dependencies": { "@tanstack/history": "1.154.14", "@tanstack/store": "^0.8.0", "cookie-es": "^2.0.0", "seroval": "^1.4.2", "seroval-plugins": "^1.4.2", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" } }, "sha512-vbh6OsE0MG+0c+SKh2uk5yEEZlWsxT96Ub2JaTs7ixOvZp3Wu9PTEIe2BA3cShNZhEsDI0Le4NqgY4XIaHLLvA=="],
447
441 "@tanstack/store": ["@tanstack/store@0.8.1", "", {}, "sha512-PtOisLjUZPz5VyPRSCGjNOlwTvabdTBQ2K80DpVL1chGVr35WRxfeavAPdNq6pm/t7F8GhoR2qtmkkqtCEtHYw=="],448 "@tanstack/store": ["@tanstack/store@0.8.1", "", {}, "sha512-PtOisLjUZPz5VyPRSCGjNOlwTvabdTBQ2K80DpVL1chGVr35WRxfeavAPdNq6pm/t7F8GhoR2qtmkkqtCEtHYw=="],
442 449
443 "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],450 "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
@@ -590,6 +597,8 @@ · 146 unmodified lines
590 597
591 "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],598 "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
592 599
600 "cookie-es": ["cookie-es@2.0.0", "", {}, "sha512-RAj4E421UYRgqokKUmotqAwuplYw15qtdXfY+hGzgCJ/MBjCVZcSoHK/kH9kocfjRjcDME7IiDWR/1WX1TM2Pg=="],
601
593 "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],602 "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
594 603
595 "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],604 "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
@@ -768,6 +777,8 @@ · 172 unmodified lines
768 777
769 "is-wsl": ["is-wsl@3.1.0", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw=="],778 "is-wsl": ["is-wsl@3.1.0", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw=="],
770 779
780 "isbot": ["isbot@5.1.35", "", {}, "sha512-waFfC72ZNfwLLuJ2iLaoVaqcNo+CAaLR7xCpAn0Y5WfGzkNHv7ZN39Vbi1y+kb+Zs46XHOX3tZNExroFUPX+Kg=="],
781
771 "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],782 "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
772 783
773 "joi": ["joi@18.0.2", "", { "dependencies": { "@hapi/address": "^5.1.1", "@hapi/formula": "^3.0.2", "@hapi/hoek": "^11.0.7", "@hapi/pinpoint": "^2.0.1", "@hapi/tlds": "^1.1.1", "@hapi/topo": "^6.0.2", "@standard-schema/spec": "^1.0.0" } }, "sha512-RuCOQMIt78LWnktPoeBL0GErkNaJPTBGcYuyaBvUOQSpcpcLfWrHPPihYdOGbV5pam9VTWbeoF7TsGiHugcjGA=="],784 "joi": ["joi@18.0.2", "", { "dependencies": { "@hapi/address": "^5.1.1", "@hapi/formula": "^3.0.2", "@hapi/hoek": "^11.0.7", "@hapi/pinpoint": "^2.0.1", "@hapi/tlds": "^1.1.1", "@hapi/topo": "^6.0.2", "@standard-schema/spec": "^1.0.0" } }, "sha512-RuCOQMIt78LWnktPoeBL0GErkNaJPTBGcYuyaBvUOQSpcpcLfWrHPPihYdOGbV5pam9VTWbeoF7TsGiHugcjGA=="],
@@ -1046,6 +1057,10 @@ · 272 unmodified lines
1046 1057
1047 "serialize-error": ["serialize-error@7.0.1", "", { "dependencies": { "type-fest": "^0.13.1" } }, "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw=="],1058 "serialize-error": ["serialize-error@7.0.1", "", { "dependencies": { "type-fest": "^0.13.1" } }, "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw=="],
1048 1059
1060 "seroval": ["seroval@1.5.0", "", {}, "sha512-OE4cvmJ1uSPrKorFIH9/w/Qwuvi/IMcGbv5RKgcJ/zjA/IohDLU6SVaxFN9FwajbP7nsX0dQqMDes1whk3y+yw=="],
1061
1062 "seroval-plugins": ["seroval-plugins@1.5.0", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-EAHqADIQondwRZIdeW2I636zgsODzoBDwb3PT/+7TLDWyw1Dy/Xv7iGUIEXXav7usHDE9HVhOU61irI3EnyyHA=="],
1063
1049 "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="],1064 "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="],
1050 1065
1051 "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],1066 "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
@@ -1090,6 +1105,10 @@ · 38 unmodified lines
1090 1105
1091 "tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],1106 "tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
1092 1107
1108 "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
1109
1110 "tiny-warning": ["tiny-warning@1.0.3", "", {}, "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA=="],
1111
1093 "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="],1112 "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="],
1094 1113
1095 "tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="],1114 "tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="],