authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-18 10:11:51-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-18 23:54:48-08:00
log1a315235b77d0a5b32057c700c4e5285d7dcd9b8
treec563be1436b37df2d5e57129cd3c8e537c79a87f
parentbcf8b14f3834aea98824f8b4688e2cdf5fbb6672

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`

14 files changed, 118 insertions(+), 219 deletions(-)

apps/web/src/components/ChatView.tsx+5-5
......@@ -307,7 +307,9 @@ const ComposerCommandMenu = memo(function ComposerCommandMenu(props: {
307307 <Command
308308 mode="none"
309309 onItemHighlighted={(highlightedValue) => {
310 props.onHighlightedItemChange(typeof highlightedValue === "string" ? highlightedValue : null);
310 props.onHighlightedItemChange(
311 typeof highlightedValue === "string" ? highlightedValue : null,
312 );
311313 }}
312314 >
313315 <div className="relative overflow-hidden rounded-xl border border-border/80 bg-popover/96 shadow-lg/8 backdrop-blur-xs">
......@@ -689,7 +691,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
689691 );
690692 const keybindings = keybindingsQuery.data ?? EMPTY_KEYBINDINGS;
691693 const threadTerminalRuntimeEnv = useMemo(() => {
692 if (!activeProject) return {};
694 if (!activeProject?.cwd) return {};
693695 return projectScriptRuntimeEnv({
694696 project: {
695697 cwd: activeProject.cwd,
......@@ -825,9 +827,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
825827 }
826828 const targetCwd = options?.cwd ?? gitCwd ?? activeProject.cwd;
827829 const baseTerminalId =
828 activeThread.activeTerminalId ||
829 activeThread.terminalIds[0] ||
830 DEFAULT_THREAD_TERMINAL_ID;
830 activeThread.activeTerminalId || activeThread.terminalIds[0] || DEFAULT_THREAD_TERMINAL_ID;
831831 const isBaseTerminalBusy = activeThread.runningTerminalIds.includes(baseTerminalId);
832832 const wantsNewTerminal = Boolean(options?.preferNewTerminal) || isBaseTerminalBusy;
833833 const shouldCreateNewTerminal =
apps/web/src/components/GitActionsControl.logic.ts+3-3
......@@ -67,13 +67,13 @@ export function buildGitActionProgressStages(input: {
6767 return [...commitStages, pushStage, "Creating PR..."];
6868}
6969
70const withDescription = (title: string, description: string | undefined) =>
71 description ? { title, description } : { title };
72
7073export 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 @@
22
33import { Toast } from "@base-ui/react/toast";
44import { useEffect } from "react";
5import { useRouterState } from "@tanstack/react-router";
56import {
67 CircleAlertIcon,
78 CircleCheckIcon,
......@@ -12,7 +13,6 @@ import {
1213
1314import { cn } from "~/lib/utils";
1415import { buttonVariants } from "~/components/ui/button";
15import { useStore } from "~/store";
1616import { shouldHideCollapsedToastContent } from "./toast.logic";
1717
1818type ThreadToastData = {
......@@ -55,6 +55,21 @@ function shouldRenderForActiveThread(
5555 return toastThreadId === activeThreadId;
5656}
5757
58function 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
5873function ThreadToastVisibleAutoDismiss({
5974 toastId,
6075 dismissAfterVisibleMs,
......@@ -143,9 +158,8 @@ function ToastProvider({ children, position = "top-right", ...props }: ToastProv
143158}
144159
145160function 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) {
292306}
293307
294308function 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
......@@ -28,6 +28,7 @@ function applyTheme(theme: Theme, suppressTransitions = false) {
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";
22import ReactDOM from "react-dom/client";
33import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
44import { RouterProvider } from "@tanstack/react-router";
5import { AnchoredToastProvider, ToastProvider } from "./components/ui/toast";
65import { createHashHistory, createRouter, createBrowserHistory } from "@tanstack/react-router";
76import { StoreProvider } from "./store";
87
......@@ -23,24 +22,21 @@ const router = createRouter({
2322 context: {
2423 queryClient,
2524 },
26});
27
28function 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
32declare module "@tanstack/react-router" {
33 interface Register {
34 router: typeof router;
35 }
4036}
4137
4238ReactDOM.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-2
......@@ -106,7 +106,6 @@ describe("hydratePersistedState", () => {
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
......@@ -442,7 +441,6 @@ describe("toPersistedState", () => {
442441 },
443442 ],
444443 threads: [thread],
445 activeThreadId: "t-1",
446444 runtimeMode: "full-access",
447445 });
448446
apps/web/src/persistenceSchema.ts+1-9
......@@ -90,7 +90,7 @@ const persistedThreadSchema = z.object({
9090const persistedStateBodySchema = z.object({
9191 projects: z.array(persistedProjectSchema),
9292 threads: z.array(persistedThreadSchema),
93 activeThreadId: z.string().min(1).nullable(),
93 activeThreadId: z.string().min(1).nullable().optional(),
9494});
9595
9696const runtimeModeSchema = z.enum(["approval-required", "full-access"]);
......@@ -141,7 +141,6 @@ const persistedStateSchema = z.union([
141141export interface PersistedStoreSnapshot {
142142 projects: Project[];
143143 threads: Thread[];
144 activeThreadId: string | null;
145144 runtimeMode: RuntimeMode;
146145}
147146
......@@ -332,15 +331,9 @@ export function hydratePersistedState(
332331 const threads = parsedState.data.threads
333332 .map((thread) => hydrateThread(thread, isLegacyPayload))
334333 .filter((thread) => projectIds.has(thread.projectId));
335 const hasActiveThread = Boolean(
336 parsedState.data.activeThreadId &&
337 threads.some((thread) => thread.id === parsedState.data.activeThreadId),
338 );
339
340334 return {
341335 projects,
342336 threads,
343 activeThreadId: hasActiveThread ? parsedState.data.activeThreadId : (threads[0]?.id ?? null),
344337 runtimeMode:
345338 "runtimeMode" in parsedState.data ? parsedState.data.runtimeMode : DEFAULT_RUNTIME_MODE,
346339 };
......@@ -402,7 +395,6 @@ export function toPersistedState(
402395 : {}),
403396 })),
404397 })),
405 activeThreadId: state.activeThreadId,
406398 runtimeMode: state.runtimeMode,
407399 };
408400}
apps/web/src/routes/__root.tsx+18-22
......@@ -1,12 +1,12 @@
1import { Outlet, createRootRouteWithContext } from "@tanstack/react-router";
1import { Outlet, createRootRouteWithContext, useParams } from "@tanstack/react-router";
22import { useEffect, useRef } from "react";
33import { QueryClient, useQueryClient } from "@tanstack/react-query";
44
5import { AnchoredToastProvider, ToastProvider } from "../components/ui/toast";
56import { isElectron } from "../env";
67import { useNativeApi } from "../hooks/useNativeApi";
78import { invalidateGitQueries } from "../lib/gitReactQuery";
89import { DEFAULT_MODEL } from "../model-logic";
9import { createThread } from "../threadFactory";
1010import { useStore } from "../store";
1111import { onServerWelcome } from "../wsNativeApi";
1212
......@@ -30,12 +30,14 @@ function RootRouteView() {
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() {
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() {
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() {
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() {
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";
55import { useStore } from "../store";
66
77function 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() {
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 @@
1import { createFileRoute, useNavigate } from "@tanstack/react-router";
2import { type FormEvent, useMemo, useState } from "react";
1import { createFileRoute } from "@tanstack/react-router";
32
4import { Button } from "../components/ui/button";
5import { Textarea } from "../components/ui/textarea";
6import { createThread } from "../threadFactory";
7import { truncateTitle } from "../truncateTitle";
8import { useStore } from "../store";
3import { isElectron } from "../env";
94
105function 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 @@
1import { Outlet, createFileRoute, useNavigate, useParams } from "@tanstack/react-router";
2import { useEffect } from "react";
1import { Outlet, createFileRoute } from "@tanstack/react-router";
32
43import DiffPanel from "../components/DiffPanel";
54import Sidebar from "../components/Sidebar";
......@@ -7,18 +6,6 @@ import { useStore } from "../store";
76
87function 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
......@@ -79,7 +79,6 @@ function makeState(thread: Thread): AppState {
7979 },
8080 ],
8181 threads: [thread],
82 activeThreadId: thread.id,
8382 threadsHydrated: true,
8483 runtimeMode: "full-access",
8584 diffOpen: false,
......@@ -617,7 +616,6 @@ describe("store reducer thread continuity", () => {
617616 projectId: "project-old-b",
618617 }),
619618 ],
620 activeThreadId: "thread-b",
621619 threadsHydrated: true,
622620 runtimeMode: "full-access",
623621 diffOpen: true,
......@@ -647,7 +645,6 @@ describe("store reducer thread continuity", () => {
647645 expect(next.threads).toHaveLength(1);
648646 expect(next.threads[0]?.id).toBe("thread-a");
649647 expect(next.threads[0]?.projectId).toBe("project-new-a");
650 expect(next.activeThreadId).toBe("thread-a");
651648 expect(next.diffOpen).toBe(false);
652649 expect(next.diffThreadId).toBeNull();
653650 expect(next.diffTurnId).toBeNull();
......@@ -675,7 +672,6 @@ describe("store reducer thread continuity", () => {
675672 },
676673 ],
677674 threads: [makeThread({ id: "thread-a", projectId: "project-old-a" })],
678 activeThreadId: "thread-a",
679675 threadsHydrated: true,
680676 runtimeMode: "full-access",
681677 diffOpen: false,
......@@ -768,7 +764,6 @@ describe("store reducer thread continuity", () => {
768764 projectId: "project-2",
769765 }),
770766 ],
771 activeThreadId: "thread-a",
772767 threadsHydrated: true,
773768 runtimeMode: "full-access",
774769 diffOpen: true,
......@@ -786,30 +781,12 @@ describe("store reducer thread continuity", () => {
786781 expect(next.projects[0]?.id).toBe("project-2");
787782 expect(next.threads).toHaveLength(1);
788783 expect(next.threads[0]?.id).toBe("thread-b");
789 expect(next.activeThreadId).toBe("thread-b");
790784 expect(next.diffOpen).toBe(false);
791785 expect(next.diffThreadId).toBeNull();
792786 expect(next.diffTurnId).toBeNull();
793787 expect(next.diffFilePath).toBeNull();
794788 });
795789
796 it("marks the active thread as visited when selected", () => {
797 const state = makeState(
798 makeThread({
799 lastVisitedAt: "2000-01-01T00:00:00.000Z",
800 }),
801 );
802
803 const next = reducer(state, {
804 type: "SET_ACTIVE_THREAD",
805 threadId: "thread-local-1",
806 });
807
808 expect(next.activeThreadId).toBe("thread-local-1");
809 expect(next.threads[0]?.lastVisitedAt).toBeDefined();
810 expect(next.threads[0]?.lastVisitedAt).not.toBe("2000-01-01T00:00:00.000Z");
811 });
812
813790 it("marks completion as seen immediately for the active thread", () => {
814791 const state = makeState(
815792 makeThread({
......@@ -831,6 +808,7 @@ describe("store reducer thread continuity", () => {
831808 payload: { turn: { id: "turn-1", status: "completed" } },
832809 }),
833810 activeAssistantItemRef: { current: null },
811 activeThreadId: "thread-local-1",
834812 });
835813
836814 expect(next.threads[0]?.latestTurnCompletedAt).toBe(completedAt);
apps/web/src/store.ts+5-42
......@@ -42,7 +42,6 @@ type Action =
4242 | { type: "TOGGLE_PROJECT"; projectId: string }
4343 | { type: "DELETE_PROJECT"; projectId: string }
4444 | { type: "ADD_THREAD"; thread: Thread }
45 | { type: "SET_ACTIVE_THREAD"; threadId: string }
4645 | { type: "TOGGLE_THREAD_TERMINAL"; threadId: string }
4746 | { type: "SET_THREAD_TERMINAL_OPEN"; threadId: string; open: boolean }
4847 | { type: "SET_THREAD_TERMINAL_HEIGHT"; threadId: string; height: number }
......@@ -68,6 +67,7 @@ type Action =
6867 type: "APPLY_EVENT";
6968 event: ProviderEvent;
7069 activeAssistantItemRef: { current: string | null };
70 activeThreadId?: string | null;
7171 }
7272 | { type: "APPLY_TERMINAL_EVENT"; event: TerminalEvent }
7373 | { type: "UPDATE_SESSION"; threadId: string; session: ProviderSession }
......@@ -113,7 +113,6 @@ type Action =
113113export interface AppState {
114114 projects: Project[];
115115 threads: Thread[];
116 activeThreadId: string | null;
117116 threadsHydrated: boolean;
118117 runtimeMode: RuntimeMode;
119118 diffOpen: boolean;
......@@ -137,7 +136,6 @@ const LEGACY_PERSISTED_STATE_KEYS = [
137136const initialState: AppState = {
138137 projects: [],
139138 threads: [],
140 activeThreadId: null,
141139 threadsHydrated: false,
142140 runtimeMode: DEFAULT_RUNTIME_MODE,
143141 diffOpen: false,
......@@ -162,14 +160,10 @@ function readPersistedState(): AppState {
162160 if (!hydrated) return initialState;
163161
164162 const threads = hydrated.threads.map((thread) => normalizeThreadTerminals(thread));
165 const activeThreadId = threads.some((thread) => thread.id === hydrated.activeThreadId)
166 ? hydrated.activeThreadId
167 : (threads[0]?.id ?? null);
168163
169164 return {
170165 ...hydrated,
171166 threads,
172 activeThreadId,
173167 threadsHydrated: threads.length > 0,
174168 diffOpen: false,
175169 diffThreadId: null,
......@@ -638,16 +632,12 @@ export function reducer(state: AppState, action: Action): AppState {
638632 });
639633 })
640634 .filter((thread): thread is Thread => thread !== null);
641 const activeThreadId = nextThreads.some((thread) => thread.id === state.activeThreadId)
642 ? state.activeThreadId
643 : (nextThreads[0]?.id ?? null);
644635 const diffState = resetDiffTargetIfMissing(state, nextThreads);
645636
646637 return {
647638 ...state,
648639 projects: nextProjects,
649640 threads: nextThreads,
650 activeThreadId,
651641 ...diffState,
652642 };
653643 }
......@@ -667,16 +657,12 @@ export function reducer(state: AppState, action: Action): AppState {
667657 }
668658
669659 const threads = state.threads.filter((thread) => thread.projectId !== action.projectId);
670 const activeThreadId = threads.some((thread) => thread.id === state.activeThreadId)
671 ? state.activeThreadId
672 : (threads[0]?.id ?? null);
673660 const diffState = resetDiffTargetIfMissing(state, threads);
674661
675662 return {
676663 ...state,
677664 projects,
678665 threads,
679 activeThreadId,
680666 ...diffState,
681667 };
682668 }
......@@ -691,19 +677,6 @@ export function reducer(state: AppState, action: Action): AppState {
691677 return {
692678 ...state,
693679 threads: [...state.threads, nextThread],
694 activeThreadId: action.thread.id,
695 };
696 }
697
698 case "SET_ACTIVE_THREAD": {
699 const visitedAt = new Date().toISOString();
700 return {
701 ...state,
702 activeThreadId: action.threadId,
703 threads: updateThread(state.threads, action.threadId, (t) => ({
704 ...t,
705 lastVisitedAt: visitedAt,
706 })),
707680 };
708681 }
709682
......@@ -926,7 +899,7 @@ export function reducer(state: AppState, action: Action): AppState {
926899 };
927900
928901 case "APPLY_EVENT": {
929 const { event, activeAssistantItemRef } = action;
902 const { event, activeAssistantItemRef, activeThreadId } = action;
930903 const target = findThreadBySessionId(state.threads, event.sessionId);
931904 if (!target) return state;
932905 if (shouldIgnoreForeignThreadEvent(target, event)) return state;
......@@ -959,7 +932,7 @@ export function reducer(state: AppState, action: Action): AppState {
959932 events: nextEvents,
960933 turnDiffSummaries,
961934 ...updateTurnFields(t, event),
962 ...(event.method === "turn/completed" && t.id === state.activeThreadId
935 ...(event.method === "turn/completed" && t.id === activeThreadId
963936 ? { lastVisitedAt: event.createdAt }
964937 : {}),
965938 };
......@@ -1176,21 +1149,11 @@ export function reducer(state: AppState, action: Action): AppState {
11761149
11771150 case "DELETE_THREAD": {
11781151 const threads = state.threads.filter((t) => t.id !== action.threadId);
1179 const activeThreadId =
1180 state.activeThreadId === action.threadId ? (threads[0]?.id ?? null) : state.activeThreadId;
1181 const shouldClearDiffTarget = state.diffThreadId === action.threadId;
1152 const diffState = resetDiffTargetIfMissing(state, threads);
11821153 return {
11831154 ...state,
11841155 threads,
1185 activeThreadId,
1186 ...(shouldClearDiffTarget
1187 ? {
1188 diffOpen: false,
1189 diffThreadId: null,
1190 diffTurnId: null,
1191 diffFilePath: null,
1192 }
1193 : {}),
1156 ...diffState,
11941157 };
11951158 }
11961159
apps/web/vite.config.js created+41
......@@ -0,0 +1,41 @@
1import tailwindcss from "@tailwindcss/vite";
2import react from "@vitejs/plugin-react";
3import { tanstackRouter } from "@tanstack/router-plugin/vite";
4import { defineConfig } from "vite";
5const port = Number(process.env.PORT ?? 5173);
6export 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});