History

Derive active thread from route and drop persisted selection

- remove `activeThreadId` from web store state and persistence schema - drive thread-scoped event handling and toast visibility from router params - simplify chat index/route flow and add explicit `apps/web/vite.config.js`

Julius Marminge committed 8 months ago

Commit de9c0591 parent 4444c8814 files · +118 −213Browse files

Unified · Split

14 files
  1. apps/web/src/components/ChatView.tsx +5 −5
  2. apps/web/src/components/GitActionsControl.logic.ts +3 −3
  3. apps/web/src/components/ui/toast.tsx +18 −5
  4. apps/web/src/hooks/useTheme.ts +1 −0
  5. apps/web/src/main.tsx +10 −14
  6. apps/web/src/persistenceSchema.test.ts +0 −2
  7. apps/web/src/persistenceSchema.ts +1 −9
  8. apps/web/src/routes/__root.tsx +18 −22
  9. apps/web/src/routes/_chat.$threadId.tsx +2 −8
  10. apps/web/src/routes/_chat.index.tsx +12 −72
  11. apps/web/src/routes/_chat.tsx +1 −14
  12. apps/web/src/store.test.ts +1 −23
  13. apps/web/src/store.ts +5 −36
  14. apps/web/vite.config.js +41 −0
apps/web/src/components/ChatView.tsx+5−5
@@ -300,7 +300,9 @@ const ComposerCommandMenu = memo(function ComposerCommandMenu(props: { · 299 unmodified lines
300300 <Command
301301 mode="none"
302302 onItemHighlighted={(highlightedValue) => {
303− props.onHighlightedItemChange(typeof highlightedValue === "string" ? highlightedValue : null);
303+ props.onHighlightedItemChange(
304+ typeof highlightedValue === "string" ? highlightedValue : null,
305+ );
304306 }}
305307 >
306308 <div className="relative overflow-hidden rounded-xl border border-border/80 bg-popover/96 shadow-lg/8 backdrop-blur-xs">
@@ -550,7 +552,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 243 unmodified lines
550552 );
551553 const keybindings = keybindingsQuery.data ?? EMPTY_KEYBINDINGS;
552554 const threadTerminalRuntimeEnv = useMemo(() => {
553− if (!activeProject) return {};
555+ if (!activeProject?.cwd) return {};
554556 return projectScriptRuntimeEnv({
555557 project: {
556558 cwd: activeProject.cwd,
@@ -686,9 +688,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 129 unmodified lines
686688 }
687689 const targetCwd = options?.cwd ?? gitCwd ?? activeProject.cwd;
688690 const baseTerminalId =
689− activeThread.activeTerminalId ||
690− activeThread.terminalIds[0] ||
691− DEFAULT_THREAD_TERMINAL_ID;
691+ activeThread.activeTerminalId || activeThread.terminalIds[0] || DEFAULT_THREAD_TERMINAL_ID;
692692 const isBaseTerminalBusy = activeThread.runningTerminalIds.includes(baseTerminalId);
693693 const wantsNewTerminal = Boolean(options?.preferNewTerminal) || isBaseTerminalBusy;
694694 const shouldCreateNewTerminal =
apps/web/src/components/GitActionsControl.logic.ts+3−3
@@ -67,13 +67,13 @@ export function buildGitActionProgressStages(input: { · 66 unmodified lines
6767 return [...commitStages, pushStage, "Creating PR..."];
6868 }
6969
70+const withDescription = (title: string, description: string | undefined) =>
71+ description ? { title, description } : { title };
72+
7073 export function summarizeGitResult(result: GitRunStackedActionResult): {
7174 title: string;
7275 description?: string;
7376 } {
74− const withDescription = (title: string, description: string | undefined) =>
75− description ? { title, description } : { title };
76−
7777 if (result.pr.status === "created" || result.pr.status === "opened_existing") {
7878 const prNumber = result.pr.number ? ` #${result.pr.number}` : "";
7979 const title = `${result.pr.status === "created" ? "Created PR" : "Opened PR"}${prNumber}`;
apps/web/src/components/ui/toast.tsx+18−5
@@ -2,6 +2,7 @@ · 1 unmodified line
22
33 import { Toast } from "@base-ui/react/toast";
44 import { useEffect } from "react";
5+import { useRouterState } from "@tanstack/react-router";
56 import {
67 CircleAlertIcon,
78 CircleCheckIcon,
@@ -12,7 +13,6 @@ import { · 4 unmodified lines
1213
1314 import { cn } from "~/lib/utils";
1415 import { buttonVariants } from "~/components/ui/button";
15−import { useStore } from "~/store";
1616 import { shouldHideCollapsedToastContent } from "./toast.logic";
1717
1818 type ThreadToastData = {
@@ -55,6 +55,21 @@ function shouldRenderForActiveThread( · 36 unmodified lines
5555 return toastThreadId === activeThreadId;
5656 }
5757
58+function useActiveThreadIdFromRoute(): string | null {
59+ return useRouterState({
60+ select: (routerState) => {
61+ for (let index = routerState.matches.length - 1; index >= 0; index -= 1) {
62+ const params = routerState.matches[index]?.params as Record<string, unknown> | undefined;
63+ const threadId = params?.threadId;
64+ if (typeof threadId === "string" && threadId.length > 0) {
65+ return threadId;
66+ }
67+ }
68+ return null;
69+ },
70+ });
71+}
72+
5873 function ThreadToastVisibleAutoDismiss({
5974 toastId,
6075 dismissAfterVisibleMs,
@@ -143,9 +158,8 @@ function ToastProvider({ children, position = "top-right", ...props }: ToastProv · 82 unmodified lines
143158 }
144159
145160 function Toasts({ position = "top-right" }: { position: ToastPosition }) {
146− const { state } = useStore();
147161 const { toasts } = Toast.useToastManager<ThreadToastData>();
148− const activeThreadId = state.activeThreadId;
162+ const activeThreadId = useActiveThreadIdFromRoute();
149163 const isTop = position.startsWith("top");
150164 const visibleToasts = toasts.filter((toast) =>
151165 shouldRenderForActiveThread(toast.data, activeThreadId),
@@ -292,9 +306,8 @@ function AnchoredToastProvider({ children, ...props }: Toast.Provider.Props) { · 140 unmodified lines
292306 }
293307
294308 function AnchoredToasts() {
295− const { state } = useStore();
296309 const { toasts } = Toast.useToastManager<ThreadToastData>();
297− const activeThreadId = state.activeThreadId;
310+ const activeThreadId = useActiveThreadIdFromRoute();
298311
299312 return (
300313 <Toast.Portal data-slot="toast-portal-anchored">
apps/web/src/hooks/useTheme.ts+1−0
@@ -28,6 +28,7 @@ function applyTheme(theme: Theme, suppressTransitions = false) { · 27 unmodified lines
2828 document.documentElement.classList.toggle("dark", isDark);
2929 if (suppressTransitions) {
3030 // Force a reflow so the no-transitions class takes effect before removal
31+ // oxlint-disable-next-line no-unused-expressions
3132 document.documentElement.offsetHeight;
3233 requestAnimationFrame(() => {
3334 document.documentElement.classList.remove("no-transitions");
apps/web/src/main.tsx+10−14
@@ -2,7 +2,6 @@ import React from "react"; · 1 unmodified line
22 import ReactDOM from "react-dom/client";
33 import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
44 import { RouterProvider } from "@tanstack/react-router";
5−import { AnchoredToastProvider, ToastProvider } from "./components/ui/toast";
65 import { createHashHistory, createRouter, createBrowserHistory } from "@tanstack/react-router";
76 import { StoreProvider } from "./store";
87
@@ -23,24 +22,21 @@ const router = createRouter({ · 14 unmodified lines
2322 context: {
2423 queryClient,
2524 },
26−});
27−
28−function App() {
29− return (
25+ Wrap: ({ children }) => (
3026 <QueryClientProvider client={queryClient}>
31− <StoreProvider>
32− <ToastProvider>
33− <AnchoredToastProvider>
34− <RouterProvider router={router} />
35− </AnchoredToastProvider>
36− </ToastProvider>
37− </StoreProvider>
27+ <StoreProvider>{children}</StoreProvider>
3828 </QueryClientProvider>
39− );
29+ ),
30+});
31+
32+declare module "@tanstack/react-router" {
33+ interface Register {
34+ router: typeof router;
35+ }
4036 }
4137
4238 ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
4339 <React.StrictMode>
44− <App />
40+ <RouterProvider router={router} />
4541 </React.StrictMode>,
4642 );
apps/web/src/persistenceSchema.test.ts+0−2
@@ -106,7 +106,6 @@ describe("hydratePersistedState", () => { · 105 unmodified lines
106106 { id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, terminalIds: [DEFAULT_THREAD_TERMINAL_ID] },
107107 ]);
108108 expect(hydrated?.threads[0]?.activeTerminalGroupId).toBe(`group-${DEFAULT_THREAD_TERMINAL_ID}`);
109− expect(hydrated?.activeThreadId).toBe("t-1");
110109 expect(hydrated?.runtimeMode).toBe("full-access");
111110 });
112111
@@ -323,7 +322,6 @@ describe("toPersistedState", () => { · 210 unmodified lines
323322 },
324323 ],
325324 threads: [thread],
326− activeThreadId: "t-1",
327325 runtimeMode: "full-access",
328326 });
329327
apps/web/src/persistenceSchema.ts+1−9
@@ -72,7 +72,7 @@ const persistedThreadSchema = z.object({ · 71 unmodified lines
7272 const persistedStateBodySchema = z.object({
7373 projects: z.array(persistedProjectSchema),
7474 threads: z.array(persistedThreadSchema),
75− activeThreadId: z.string().min(1).nullable(),
75+ activeThreadId: z.string().min(1).nullable().optional(),
7676 });
7777
7878 const runtimeModeSchema = z.enum(["approval-required", "full-access"]);
@@ -123,7 +123,6 @@ const persistedStateSchema = z.union([ · 44 unmodified lines
123123 export interface PersistedStoreSnapshot {
124124 projects: Project[];
125125 threads: Thread[];
126− activeThreadId: string | null;
127126 runtimeMode: RuntimeMode;
128127 }
129128
@@ -299,15 +298,9 @@ export function hydratePersistedState( · 169 unmodified lines
299298 const threads = parsedState.data.threads
300299 .map((thread) => hydrateThread(thread, isLegacyPayload))
301300 .filter((thread) => projectIds.has(thread.projectId));
302− const hasActiveThread = Boolean(
303− parsedState.data.activeThreadId &&
304− threads.some((thread) => thread.id === parsedState.data.activeThreadId),
305− );
306−
307301 return {
308302 projects,
309303 threads,
310− activeThreadId: hasActiveThread ? parsedState.data.activeThreadId : (threads[0]?.id ?? null),
311304 runtimeMode:
312305 "runtimeMode" in parsedState.data ? parsedState.data.runtimeMode : DEFAULT_RUNTIME_MODE,
313306 };
@@ -354,7 +347,6 @@ export function toPersistedState( · 40 unmodified lines
354347 branch: thread.branch,
355348 worktreePath: thread.worktreePath,
356349 })),
357− activeThreadId: state.activeThreadId,
358350 runtimeMode: state.runtimeMode,
359351 };
360352 }
apps/web/src/routes/__root.tsx+18−22
@@ -1,12 +1,12 @@
1−import { Outlet, createRootRouteWithContext } from "@tanstack/react-router";
1+import { Outlet, createRootRouteWithContext, useParams } from "@tanstack/react-router";
22 import { useEffect, useRef } from "react";
33 import { QueryClient, useQueryClient } from "@tanstack/react-query";
44
5+import { AnchoredToastProvider, ToastProvider } from "../components/ui/toast";
56 import { isElectron } from "../env";
67 import { useNativeApi } from "../hooks/useNativeApi";
78 import { invalidateGitQueries } from "../lib/gitReactQuery";
89 import { DEFAULT_MODEL } from "../model-logic";
9−import { createThread } from "../threadFactory";
1010 import { useStore } from "../store";
1111 import { onServerWelcome } from "../wsNativeApi";
1212
@@ -30,12 +30,14 @@ function RootRouteView() { · 17 unmodified lines
3030 }
3131
3232 return (
33− <>
34− <EventRouter />
35− <AutoProjectBootstrap />
36− <DesktopProjectBootstrap />
37− <Outlet />
38− </>
33+ <ToastProvider>
34+ <AnchoredToastProvider>
35+ <EventRouter />
36+ <AutoProjectBootstrap />
37+ <DesktopProjectBootstrap />
38+ <Outlet />
39+ </AnchoredToastProvider>
40+ </ToastProvider>
3941 );
4042 }
4143
@@ -44,6 +46,10 @@ function EventRouter() { · 2 unmodified lines
4446 const { dispatch } = useStore();
4547 const queryClient = useQueryClient();
4648 const activeAssistantItemRef = useRef<string | null>(null);
49+ const activeThreadId = useParams({
50+ strict: false,
51+ select: (params) => params.threadId,
52+ });
4753
4854 useEffect(() => {
4955 if (!api) return;
@@ -51,13 +57,15 @@ function EventRouter() { · 1 unmodified line
5157 if (event.method === "turn/completed") {
5258 void invalidateGitQueries(queryClient);
5359 }
60+ if (!activeThreadId) return;
5461 dispatch({
5562 type: "APPLY_EVENT",
5663 event,
5764 activeAssistantItemRef,
65+ activeThreadId,
5866 });
5967 });
60− }, [api, dispatch, queryClient]);
68+ }, [activeThreadId, api, dispatch, queryClient]);
6169
6270 useEffect(() => {
6371 if (!api) return;
@@ -88,14 +96,6 @@ function AutoProjectBootstrap() { · 24 unmodified lines
8896 const existing = state.projects.find((project) => project.cwd === payload.cwd);
8997 if (existing) {
9098 bootstrappedRef.current = true;
91− // Ensure a thread is active
92− const existingThread = state.threads.find((thread) => thread.projectId === existing.id);
93− if (existingThread && !state.activeThreadId) {
94− dispatch({
95− type: "SET_ACTIVE_THREAD",
96− threadId: existingThread.id,
97− });
98− }
9999 dispatch({ type: "SET_THREADS_HYDRATED", hydrated: true });
100100 return;
101101 }
@@ -115,13 +115,9 @@ function AutoProjectBootstrap() { · 13 unmodified lines
115115 scripts: [],
116116 },
117117 });
118− dispatch({
119− type: "ADD_THREAD",
120− thread: createThread(projectId),
121− });
122118 dispatch({ type: "SET_THREADS_HYDRATED", hydrated: true });
123119 });
124− }, [state.projects, state.threads, state.activeThreadId, dispatch]);
120+ }, [state.projects, dispatch]);
125121
126122 return null;
127123 }
apps/web/src/routes/_chat.$threadId.tsx+2−8
@@ -5,7 +5,7 @@ import ChatView from "../components/ChatView"; · 4 unmodified lines
55 import { useStore } from "../store";
66
77 function ChatThreadRouteView() {
8− const { state, dispatch } = useStore();
8+ const { state } = useStore();
99 const navigate = useNavigate();
1010 const { threadId } = Route.useParams();
1111 const threadExists = threadId ? state.threads.some((thread) => thread.id === threadId) : false;
@@ -24,13 +24,7 @@ function ChatThreadRouteView() { · 12 unmodified lines
2424 void navigate({ to: "/", replace: true });
2525 return;
2626 }
27−
28− if (state.activeThreadId === threadId) {
29− return;
30− }
31−
32− dispatch({ type: "SET_ACTIVE_THREAD", threadId });
33− }, [dispatch, navigate, state.activeThreadId, state.threadsHydrated, threadExists, threadId]);
27+ }, [navigate, state.threadsHydrated, threadExists, threadId]);
3428
3529 if (!threadId || !state.threadsHydrated || !threadExists) {
3630 return null;
apps/web/src/routes/_chat.index.tsx+12−72
@@ -1,82 +1,22 @@
1−import { createFileRoute, useNavigate } from "@tanstack/react-router";
2−import { type FormEvent, useMemo, useState } from "react";
1+import { createFileRoute } from "@tanstack/react-router";
32
4−import { Button } from "../components/ui/button";
5−import { Textarea } from "../components/ui/textarea";
6−import { createThread } from "../threadFactory";
7−import { truncateTitle } from "../truncateTitle";
8−import { useStore } from "../store";
3+import { isElectron } from "../env";
94
105 function ChatIndexRouteView() {
11− const { state, dispatch } = useStore();
12− const navigate = useNavigate();
13− const [draft, setDraft] = useState("");
14− const defaultProject = state.projects[0] ?? null;
15− const canCreateThread = defaultProject !== null;
16−
17− const placeholder = useMemo(() => {
18− if (!canCreateThread) {
19− return "Add a project in the sidebar to start chatting.";
20− }
21− return "Start with a goal, bug report, or implementation idea...";
22− }, [canCreateThread]);
23−
24− const onCreateThread = (event: FormEvent<HTMLFormElement>) => {
25− event.preventDefault();
26− if (!defaultProject) return;
27−
28− const threadTitle = truncateTitle(draft);
29− const thread = createThread(
30− defaultProject.id,
31− threadTitle
32− ? { model: defaultProject.model, title: threadTitle }
33− : { model: defaultProject.model },
34− );
35−
36− dispatch({
37− type: "ADD_THREAD",
38− thread,
39− });
40−
41− setDraft("");
42−
43− void navigate({
44− to: "/$threadId",
45− params: { threadId: thread.id },
46− });
47− };
48−
496 return (
50− <section className="flex min-h-0 min-w-0 flex-1 flex-col bg-background">
51− <div className="mx-auto flex h-full w-full max-w-3xl flex-col justify-center px-5 py-6">
52− <div className="mb-5">
53− <h1 className="text-xl font-semibold text-foreground">New chat</h1>
54− <p className="mt-1 text-sm text-muted-foreground/70">
55− Create a thread and continue in the full chat workspace.
56− </p>
7+ <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background text-muted-foreground/40">
8+ {isElectron && (
9+ <div className="drag-region flex h-[52px] shrink-0 items-center border-b border-border px-5">
10+ <span className="text-xs text-muted-foreground/50">No active thread</span>
5711 </div>
12+ )}
5813
59− <form onSubmit={onCreateThread} className="space-y-3">
60− <Textarea
61− value={draft}
62− onChange={(event) => setDraft(event.target.value)}
63− placeholder={placeholder}
64− className="min-h-36 resize-y"
65− disabled={!canCreateThread}
66− />
67− <div className="flex items-center justify-between">
68− <p className="text-xs text-muted-foreground/60">
69− {canCreateThread
70− ? "Create a thread first, then send your first prompt."
71− : "Projects are required before creating threads."}
72− </p>
73− <Button type="submit" disabled={!canCreateThread}>
74− Create thread
75− </Button>
76− </div>
77− </form>
14+ <div className="flex flex-1 items-center justify-center">
15+ <div className="text-center">
16+ <p className="text-sm">Select a thread or create a new one to get started.</p>
17+ </div>
7818 </div>
79− </section>
19+ </div>
8020 );
8121 }
8222
apps/web/src/routes/_chat.tsx+1−14
@@ -1,5 +1,4 @@
1−import { Outlet, createFileRoute, useNavigate, useParams } from "@tanstack/react-router";
2−import { useEffect } from "react";
1+import { Outlet, createFileRoute } from "@tanstack/react-router";
32
43 import DiffPanel from "../components/DiffPanel";
54 import Sidebar from "../components/Sidebar";
@@ -7,18 +6,6 @@ import { useStore } from "../store"; · 1 unmodified line
76
87 function ChatRouteLayout() {
98 const { state } = useStore();
10− const navigate = useNavigate();
11− const params = useParams({ strict: false });
12− const routeThreadId = typeof params.threadId === "string" ? params.threadId : null;
13−
14− useEffect(() => {
15− if (routeThreadId || !state.activeThreadId) return;
16− void navigate({
17− to: "/$threadId",
18− params: { threadId: state.activeThreadId },
19− replace: true,
20− });
21− }, [navigate, routeThreadId, state.activeThreadId]);
229
2310 return (
2411 <div className="flex h-screen overflow-hidden bg-background text-foreground isolate">
apps/web/src/store.test.ts+1−23
@@ -78,7 +78,6 @@ function makeState(thread: Thread): AppState { · 77 unmodified lines
7878 },
7979 ],
8080 threads: [thread],
81− activeThreadId: thread.id,
8281 threadsHydrated: true,
8382 runtimeMode: "full-access",
8483 diffOpen: false,
@@ -530,7 +529,6 @@ describe("store reducer thread continuity", () => { · 445 unmodified lines
530529 projectId: "project-old-b",
531530 }),
532531 ],
533− activeThreadId: "thread-b",
534532 threadsHydrated: true,
535533 runtimeMode: "full-access",
536534 diffOpen: false,
@@ -557,7 +555,6 @@ describe("store reducer thread continuity", () => { · 20 unmodified lines
557555 expect(next.threads).toHaveLength(1);
558556 expect(next.threads[0]?.id).toBe("thread-a");
559557 expect(next.threads[0]?.projectId).toBe("project-new-a");
560− expect(next.activeThreadId).toBe("thread-a");
561558 });
562559
563560 it("treats empty scripts from sync as authoritative", () => {
@@ -581,7 +578,6 @@ describe("store reducer thread continuity", () => { · 17 unmodified lines
581578 },
582579 ],
583580 threads: [makeThread({ id: "thread-a", projectId: "project-old-a" })],
584− activeThreadId: "thread-a",
585581 threadsHydrated: true,
586582 runtimeMode: "full-access",
587583 diffOpen: false,
@@ -671,7 +667,6 @@ describe("store reducer thread continuity", () => { · 83 unmodified lines
671667 projectId: "project-2",
672668 }),
673669 ],
674− activeThreadId: "thread-a",
675670 threadsHydrated: true,
676671 runtimeMode: "full-access",
677672 diffOpen: false,
@@ -686,24 +681,6 @@ describe("store reducer thread continuity", () => { · 8 unmodified lines
686681 expect(next.projects[0]?.id).toBe("project-2");
687682 expect(next.threads).toHaveLength(1);
688683 expect(next.threads[0]?.id).toBe("thread-b");
689− expect(next.activeThreadId).toBe("thread-b");
690− });
691−
692− it("marks the active thread as visited when selected", () => {
693− const state = makeState(
694− makeThread({
695− lastVisitedAt: "2000-01-01T00:00:00.000Z",
696− }),
697− );
698−
699− const next = reducer(state, {
700− type: "SET_ACTIVE_THREAD",
701− threadId: "thread-local-1",
702− });
703−
704− expect(next.activeThreadId).toBe("thread-local-1");
705− expect(next.threads[0]?.lastVisitedAt).toBeDefined();
706− expect(next.threads[0]?.lastVisitedAt).not.toBe("2000-01-01T00:00:00.000Z");
707684 });
708685
709686 it("marks completion as seen immediately for the active thread", () => {
@@ -727,6 +704,7 @@ describe("store reducer thread continuity", () => { · 17 unmodified lines
727704 payload: { turn: { id: "turn-1", status: "completed" } },
728705 }),
729706 activeAssistantItemRef: { current: null },
707+ activeThreadId: "thread-local-1",
730708 });
731709
732710 expect(next.threads[0]?.latestTurnCompletedAt).toBe(completedAt);
apps/web/src/store.ts+5−36
@@ -34,7 +34,6 @@ type Action = · 33 unmodified lines
3434 | { type: "TOGGLE_PROJECT"; projectId: string }
3535 | { type: "DELETE_PROJECT"; projectId: string }
3636 | { type: "ADD_THREAD"; thread: Thread }
37− | { type: "SET_ACTIVE_THREAD"; threadId: string }
3837 | { type: "TOGGLE_THREAD_TERMINAL"; threadId: string }
3938 | { type: "SET_THREAD_TERMINAL_OPEN"; threadId: string; open: boolean }
4039 | { type: "SET_THREAD_TERMINAL_HEIGHT"; threadId: string; height: number }
@@ -47,6 +46,7 @@ type Action = · 6 unmodified lines
4746 type: "APPLY_EVENT";
4847 event: ProviderEvent;
4948 activeAssistantItemRef: { current: string | null };
49+ activeThreadId?: string | null;
5050 }
5151 | { type: "APPLY_TERMINAL_EVENT"; event: TerminalEvent }
5252 | { type: "UPDATE_SESSION"; threadId: string; session: ProviderSession }
@@ -74,7 +74,6 @@ type Action = · 21 unmodified lines
7474 export interface AppState {
7575 projects: Project[];
7676 threads: Thread[];
77− activeThreadId: string | null;
7877 threadsHydrated: boolean;
7978 runtimeMode: RuntimeMode;
8079 diffOpen: boolean;
@@ -95,7 +94,6 @@ const LEGACY_PERSISTED_STATE_KEYS = [ · 14 unmodified lines
9594 const initialState: AppState = {
9695 projects: [],
9796 threads: [],
98− activeThreadId: null,
9997 threadsHydrated: false,
10098 runtimeMode: DEFAULT_RUNTIME_MODE,
10199 diffOpen: false,
@@ -117,14 +115,10 @@ function readPersistedState(): AppState { · 15 unmodified lines
117115 if (!hydrated) return initialState;
118116
119117 const threads = hydrated.threads.map((thread) => normalizeThreadTerminals(thread));
120− const activeThreadId = threads.some((thread) => thread.id === hydrated.activeThreadId)
121− ? hydrated.activeThreadId
122− : (threads[0]?.id ?? null);
123118
124119 return {
125120 ...hydrated,
126121 threads,
127− activeThreadId,
128122 threadsHydrated: threads.length > 0,
129123 diffOpen: false,
130124 };
@@ -494,15 +488,11 @@ export function reducer(state: AppState, action: Action): AppState { · 363 unmodified lines
494488 });
495489 })
496490 .filter((thread): thread is Thread => thread !== null);
497− const activeThreadId = nextThreads.some((thread) => thread.id === state.activeThreadId)
498− ? state.activeThreadId
499− : (nextThreads[0]?.id ?? null);
500491
501492 return {
502493 ...state,
503494 projects: nextProjects,
504495 threads: nextThreads,
505− activeThreadId,
506496 };
507497 }
508498
@@ -521,15 +511,11 @@ export function reducer(state: AppState, action: Action): AppState { · 12 unmodified lines
521511 }
522512
523513 const threads = state.threads.filter((thread) => thread.projectId !== action.projectId);
524− const activeThreadId = threads.some((thread) => thread.id === state.activeThreadId)
525− ? state.activeThreadId
526− : (threads[0]?.id ?? null);
527514
528515 return {
529516 ...state,
530517 projects,
531518 threads,
532− activeThreadId,
533519 };
534520 }
535521
@@ -542,19 +528,6 @@ export function reducer(state: AppState, action: Action): AppState { · 6 unmodified lines
542528 return {
543529 ...state,
544530 threads: [...state.threads, nextThread],
545− activeThreadId: action.thread.id,
546− };
547− }
548−
549− case "SET_ACTIVE_THREAD": {
550− const visitedAt = new Date().toISOString();
551− return {
552− ...state,
553− activeThreadId: action.threadId,
554− threads: updateThread(state.threads, action.threadId, (t) => ({
555− ...t,
556− lastVisitedAt: visitedAt,
557− })),
558531 };
559532 }
560533
@@ -757,7 +730,7 @@ export function reducer(state: AppState, action: Action): AppState { · 196 unmodified lines
757730 };
758731
759732 case "APPLY_EVENT": {
760− const { event, activeAssistantItemRef } = action;
733+ const { event, activeAssistantItemRef, activeThreadId } = action;
761734 const target = findThreadBySessionId(state.threads, event.sessionId);
762735 if (!target) return state;
763736 if (shouldIgnoreForeignThreadEvent(target, event)) return state;
@@ -781,7 +754,7 @@ export function reducer(state: AppState, action: Action): AppState { · 17 unmodified lines
781754 messages: applyEventToMessages(t.messages, event, activeAssistantItemRef),
782755 events: [event, ...t.events],
783756 ...updateTurnFields(t, event),
784− ...(event.method === "turn/completed" && t.id === state.activeThreadId
757+ ...(event.method === "turn/completed" && t.id === activeThreadId
785758 ? { lastVisitedAt: event.createdAt }
786759 : {}),
787760 })),
@@ -876,12 +849,8 @@ export function reducer(state: AppState, action: Action): AppState { · 88 unmodified lines
876849 runtimeMode: action.mode,
877850 };
878851
879− case "DELETE_THREAD": {
880− const threads = state.threads.filter((t) => t.id !== action.threadId);
881− const activeThreadId =
882− state.activeThreadId === action.threadId ? (threads[0]?.id ?? null) : state.activeThreadId;
883− return { ...state, threads, activeThreadId };
884− }
852+ case "DELETE_THREAD":
853+ return { ...state, threads: state.threads.filter((t) => t.id !== action.threadId) };
885854
886855 default:
887856 return state;
apps/web/vite.config.js+41−0
@@ -0,0 +1,41 @@
1+import tailwindcss from "@tailwindcss/vite";
2+import react from "@vitejs/plugin-react";
3+import { tanstackRouter } from "@tanstack/router-plugin/vite";
4+import { defineConfig } from "vite";
5+const port = Number(process.env.PORT ?? 5173);
6+export default defineConfig({
7+ plugins: [
8+ tanstackRouter(),
9+ react({
10+ babel: {
11+ plugins: [["babel-plugin-react-compiler", { target: "19" }]],
12+ },
13+ }),
14+ tailwindcss(),
15+ ],
16+ define: {
17+ // In dev mode, tell the web app where the WebSocket server lives
18+ "import.meta.env.VITE_WS_URL": JSON.stringify(process.env.VITE_WS_URL ?? ""),
19+ },
20+ experimental: {
21+ enableNativePlugin: true,
22+ },
23+ resolve: {
24+ tsconfigPaths: true,
25+ },
26+ server: {
27+ port,
28+ strictPort: true,
29+ hmr: {
30+ // Explicit config so Vite's HMR WebSocket connects reliably
31+ // inside Electron's BrowserWindow. Vite 8 uses console.debug for
32+ // connection logs — enable "Verbose" in DevTools to see them.
33+ protocol: "ws",
34+ host: "localhost",
35+ },
36+ },
37+ build: {
38+ outDir: "dist",
39+ emptyOutDir: true,
40+ },
41+});