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: {...@@ -307,7 +307,9 @@ const ComposerCommandMenu = memo(function ComposerCommandMenu(props: {
307 <Command307 <Command
308 mode="none"308 mode="none"
309 onItemHighlighted={(highlightedValue) => {309 onItemHighlighted={(highlightedValue) => {
310 props.onHighlightedItemChange(typeof highlightedValue === "string" ? highlightedValue : null);310 props.onHighlightedItemChange(
311 typeof highlightedValue === "string" ? highlightedValue : null,
312 );
311 }}313 }}
312 >314 >
313 <div className="relative overflow-hidden rounded-xl border border-border/80 bg-popover/96 shadow-lg/8 backdrop-blur-xs">315 <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) {...@@ -689,7 +691,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
689 );691 );
690 const keybindings = keybindingsQuery.data ?? EMPTY_KEYBINDINGS;692 const keybindings = keybindingsQuery.data ?? EMPTY_KEYBINDINGS;
691 const threadTerminalRuntimeEnv = useMemo(() => {693 const threadTerminalRuntimeEnv = useMemo(() => {
692 if (!activeProject) return {};694 if (!activeProject?.cwd) return {};
693 return projectScriptRuntimeEnv({695 return projectScriptRuntimeEnv({
694 project: {696 project: {
695 cwd: activeProject.cwd,697 cwd: activeProject.cwd,
...@@ -825,9 +827,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -825,9 +827,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
825 }827 }
826 const targetCwd = options?.cwd ?? gitCwd ?? activeProject.cwd;828 const targetCwd = options?.cwd ?? gitCwd ?? activeProject.cwd;
827 const baseTerminalId =829 const baseTerminalId =
828 activeThread.activeTerminalId ||830 activeThread.activeTerminalId || activeThread.terminalIds[0] || DEFAULT_THREAD_TERMINAL_ID;
829 activeThread.terminalIds[0] ||
830 DEFAULT_THREAD_TERMINAL_ID;
831 const isBaseTerminalBusy = activeThread.runningTerminalIds.includes(baseTerminalId);831 const isBaseTerminalBusy = activeThread.runningTerminalIds.includes(baseTerminalId);
832 const wantsNewTerminal = Boolean(options?.preferNewTerminal) || isBaseTerminalBusy;832 const wantsNewTerminal = Boolean(options?.preferNewTerminal) || isBaseTerminalBusy;
833 const shouldCreateNewTerminal =833 const shouldCreateNewTerminal =
apps/web/src/components/GitActionsControl.logic.ts+3-3
...@@ -67,13 +67,13 @@ export function buildGitActionProgressStages(input: {...@@ -67,13 +67,13 @@ export function buildGitActionProgressStages(input: {
67 return [...commitStages, pushStage, "Creating PR..."];67 return [...commitStages, pushStage, "Creating PR..."];
68}68}
6969
70const withDescription = (title: string, description: string | undefined) =>
71 description ? { title, description } : { title };
72
70export function summarizeGitResult(result: GitRunStackedActionResult): {73export function summarizeGitResult(result: GitRunStackedActionResult): {
71 title: string;74 title: string;
72 description?: string;75 description?: string;
73} {76} {
74 const withDescription = (title: string, description: string | undefined) =>
75 description ? { title, description } : { title };
76
77 if (result.pr.status === "created" || result.pr.status === "opened_existing") {77 if (result.pr.status === "created" || result.pr.status === "opened_existing") {
78 const prNumber = result.pr.number ? ` #${result.pr.number}` : "";78 const prNumber = result.pr.number ? ` #${result.pr.number}` : "";
79 const title = `${result.pr.status === "created" ? "Created PR" : "Opened PR"}${prNumber}`;79 const title = `${result.pr.status === "created" ? "Created PR" : "Opened PR"}${prNumber}`;
apps/web/src/components/ui/toast.tsx+18-5
...@@ -2,6 +2,7 @@...@@ -2,6 +2,7 @@
22
3import { Toast } from "@base-ui/react/toast";3import { Toast } from "@base-ui/react/toast";
4import { useEffect } from "react";4import { useEffect } from "react";
5import { useRouterState } from "@tanstack/react-router";
5import {6import {
6 CircleAlertIcon,7 CircleAlertIcon,
7 CircleCheckIcon,8 CircleCheckIcon,
...@@ -12,7 +13,6 @@ import {...@@ -12,7 +13,6 @@ import {
1213
13import { cn } from "~/lib/utils";14import { cn } from "~/lib/utils";
14import { buttonVariants } from "~/components/ui/button";15import { buttonVariants } from "~/components/ui/button";
15import { useStore } from "~/store";
16import { shouldHideCollapsedToastContent } from "./toast.logic";16import { shouldHideCollapsedToastContent } from "./toast.logic";
1717
18type ThreadToastData = {18type ThreadToastData = {
...@@ -55,6 +55,21 @@ function shouldRenderForActiveThread(...@@ -55,6 +55,21 @@ function shouldRenderForActiveThread(
55 return toastThreadId === activeThreadId;55 return toastThreadId === activeThreadId;
56}56}
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
58function ThreadToastVisibleAutoDismiss({73function ThreadToastVisibleAutoDismiss({
59 toastId,74 toastId,
60 dismissAfterVisibleMs,75 dismissAfterVisibleMs,
...@@ -143,9 +158,8 @@ function ToastProvider({ children, position = "top-right", ...props }: ToastProv...@@ -143,9 +158,8 @@ function ToastProvider({ children, position = "top-right", ...props }: ToastProv
143}158}
144159
145function Toasts({ position = "top-right" }: { position: ToastPosition }) {160function Toasts({ position = "top-right" }: { position: ToastPosition }) {
146 const { state } = useStore();
147 const { toasts } = Toast.useToastManager<ThreadToastData>();161 const { toasts } = Toast.useToastManager<ThreadToastData>();
148 const activeThreadId = state.activeThreadId;162 const activeThreadId = useActiveThreadIdFromRoute();
149 const isTop = position.startsWith("top");163 const isTop = position.startsWith("top");
150 const visibleToasts = toasts.filter((toast) =>164 const visibleToasts = toasts.filter((toast) =>
151 shouldRenderForActiveThread(toast.data, activeThreadId),165 shouldRenderForActiveThread(toast.data, activeThreadId),
...@@ -292,9 +306,8 @@ function AnchoredToastProvider({ children, ...props }: Toast.Provider.Props) {...@@ -292,9 +306,8 @@ function AnchoredToastProvider({ children, ...props }: Toast.Provider.Props) {
292}306}
293307
294function AnchoredToasts() {308function AnchoredToasts() {
295 const { state } = useStore();
296 const { toasts } = Toast.useToastManager<ThreadToastData>();309 const { toasts } = Toast.useToastManager<ThreadToastData>();
297 const activeThreadId = state.activeThreadId;310 const activeThreadId = useActiveThreadIdFromRoute();
298311
299 return (312 return (
300 <Toast.Portal data-slot="toast-portal-anchored">313 <Toast.Portal data-slot="toast-portal-anchored">
apps/web/src/hooks/useTheme.ts+1
...@@ -28,6 +28,7 @@ function applyTheme(theme: Theme, suppressTransitions = false) {...@@ -28,6 +28,7 @@ function applyTheme(theme: Theme, suppressTransitions = false) {
28 document.documentElement.classList.toggle("dark", isDark);28 document.documentElement.classList.toggle("dark", isDark);
29 if (suppressTransitions) {29 if (suppressTransitions) {
30 // Force a reflow so the no-transitions class takes effect before removal30 // Force a reflow so the no-transitions class takes effect before removal
31 // oxlint-disable-next-line no-unused-expressions
31 document.documentElement.offsetHeight;32 document.documentElement.offsetHeight;
32 requestAnimationFrame(() => {33 requestAnimationFrame(() => {
33 document.documentElement.classList.remove("no-transitions");34 document.documentElement.classList.remove("no-transitions");
apps/web/src/main.tsx+10-14
...@@ -2,7 +2,6 @@ import React from "react";...@@ -2,7 +2,6 @@ import React from "react";
2import ReactDOM from "react-dom/client";2import ReactDOM from "react-dom/client";
3import { QueryClient, QueryClientProvider } from "@tanstack/react-query";3import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
4import { RouterProvider } from "@tanstack/react-router";4import { RouterProvider } from "@tanstack/react-router";
5import { AnchoredToastProvider, ToastProvider } from "./components/ui/toast";
6import { createHashHistory, createRouter, createBrowserHistory } from "@tanstack/react-router";5import { createHashHistory, createRouter, createBrowserHistory } from "@tanstack/react-router";
7import { StoreProvider } from "./store";6import { StoreProvider } from "./store";
87
...@@ -23,24 +22,21 @@ const router = createRouter({...@@ -23,24 +22,21 @@ const router = createRouter({
23 context: {22 context: {
24 queryClient,23 queryClient,
25 },24 },
26});25 Wrap: ({ children }) => (
27
28function App() {
29 return (
30 <QueryClientProvider client={queryClient}>26 <QueryClientProvider client={queryClient}>
31 <StoreProvider>27 <StoreProvider>{children}</StoreProvider>
32 <ToastProvider>
33 <AnchoredToastProvider>
34 <RouterProvider router={router} />
35 </AnchoredToastProvider>
36 </ToastProvider>
37 </StoreProvider>
38 </QueryClientProvider>28 </QueryClientProvider>
39 );29 ),
30});
31
32declare module "@tanstack/react-router" {
33 interface Register {
34 router: typeof router;
35 }
40}36}
4137
42ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(38ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
43 <React.StrictMode>39 <React.StrictMode>
44 <App />40 <RouterProvider router={router} />
45 </React.StrictMode>,41 </React.StrictMode>,
46);42);
apps/web/src/persistenceSchema.test.ts-2
...@@ -106,7 +106,6 @@ describe("hydratePersistedState", () => {...@@ -106,7 +106,6 @@ describe("hydratePersistedState", () => {
106 { id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, terminalIds: [DEFAULT_THREAD_TERMINAL_ID] },106 { id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, terminalIds: [DEFAULT_THREAD_TERMINAL_ID] },
107 ]);107 ]);
108 expect(hydrated?.threads[0]?.activeTerminalGroupId).toBe(`group-${DEFAULT_THREAD_TERMINAL_ID}`);108 expect(hydrated?.threads[0]?.activeTerminalGroupId).toBe(`group-${DEFAULT_THREAD_TERMINAL_ID}`);
109 expect(hydrated?.activeThreadId).toBe("t-1");
110 expect(hydrated?.runtimeMode).toBe("full-access");109 expect(hydrated?.runtimeMode).toBe("full-access");
111 });110 });
112111
...@@ -442,7 +441,6 @@ describe("toPersistedState", () => {...@@ -442,7 +441,6 @@ describe("toPersistedState", () => {
442 },441 },
443 ],442 ],
444 threads: [thread],443 threads: [thread],
445 activeThreadId: "t-1",
446 runtimeMode: "full-access",444 runtimeMode: "full-access",
447 });445 });
448446
apps/web/src/persistenceSchema.ts+1-9
...@@ -90,7 +90,7 @@ const persistedThreadSchema = z.object({...@@ -90,7 +90,7 @@ const persistedThreadSchema = z.object({
90const persistedStateBodySchema = z.object({90const persistedStateBodySchema = z.object({
91 projects: z.array(persistedProjectSchema),91 projects: z.array(persistedProjectSchema),
92 threads: z.array(persistedThreadSchema),92 threads: z.array(persistedThreadSchema),
93 activeThreadId: z.string().min(1).nullable(),93 activeThreadId: z.string().min(1).nullable().optional(),
94});94});
9595
96const runtimeModeSchema = z.enum(["approval-required", "full-access"]);96const runtimeModeSchema = z.enum(["approval-required", "full-access"]);
...@@ -141,7 +141,6 @@ const persistedStateSchema = z.union([...@@ -141,7 +141,6 @@ const persistedStateSchema = z.union([
141export interface PersistedStoreSnapshot {141export interface PersistedStoreSnapshot {
142 projects: Project[];142 projects: Project[];
143 threads: Thread[];143 threads: Thread[];
144 activeThreadId: string | null;
145 runtimeMode: RuntimeMode;144 runtimeMode: RuntimeMode;
146}145}
147146
...@@ -332,15 +331,9 @@ export function hydratePersistedState(...@@ -332,15 +331,9 @@ export function hydratePersistedState(
332 const threads = parsedState.data.threads331 const threads = parsedState.data.threads
333 .map((thread) => hydrateThread(thread, isLegacyPayload))332 .map((thread) => hydrateThread(thread, isLegacyPayload))
334 .filter((thread) => projectIds.has(thread.projectId));333 .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
340 return {334 return {
341 projects,335 projects,
342 threads,336 threads,
343 activeThreadId: hasActiveThread ? parsedState.data.activeThreadId : (threads[0]?.id ?? null),
344 runtimeMode:337 runtimeMode:
345 "runtimeMode" in parsedState.data ? parsedState.data.runtimeMode : DEFAULT_RUNTIME_MODE,338 "runtimeMode" in parsedState.data ? parsedState.data.runtimeMode : DEFAULT_RUNTIME_MODE,
346 };339 };
...@@ -402,7 +395,6 @@ export function toPersistedState(...@@ -402,7 +395,6 @@ export function toPersistedState(
402 : {}),395 : {}),
403 })),396 })),
404 })),397 })),
405 activeThreadId: state.activeThreadId,
406 runtimeMode: state.runtimeMode,398 runtimeMode: state.runtimeMode,
407 };399 };
408}400}
apps/web/src/routes/__root.tsx+18-22
...@@ -1,12 +1,12 @@...@@ -1,12 +1,12 @@
1import { Outlet, createRootRouteWithContext } from "@tanstack/react-router";1import { Outlet, createRootRouteWithContext, useParams } from "@tanstack/react-router";
2import { useEffect, useRef } from "react";2import { useEffect, useRef } from "react";
3import { QueryClient, useQueryClient } from "@tanstack/react-query";3import { QueryClient, useQueryClient } from "@tanstack/react-query";
44
5import { AnchoredToastProvider, ToastProvider } from "../components/ui/toast";
5import { isElectron } from "../env";6import { isElectron } from "../env";
6import { useNativeApi } from "../hooks/useNativeApi";7import { useNativeApi } from "../hooks/useNativeApi";
7import { invalidateGitQueries } from "../lib/gitReactQuery";8import { invalidateGitQueries } from "../lib/gitReactQuery";
8import { DEFAULT_MODEL } from "../model-logic";9import { DEFAULT_MODEL } from "../model-logic";
9import { createThread } from "../threadFactory";
10import { useStore } from "../store";10import { useStore } from "../store";
11import { onServerWelcome } from "../wsNativeApi";11import { onServerWelcome } from "../wsNativeApi";
1212
...@@ -30,12 +30,14 @@ function RootRouteView() {...@@ -30,12 +30,14 @@ function RootRouteView() {
30 }30 }
3131
32 return (32 return (
33 <>33 <ToastProvider>
34 <EventRouter />34 <AnchoredToastProvider>
35 <AutoProjectBootstrap />35 <EventRouter />
36 <DesktopProjectBootstrap />36 <AutoProjectBootstrap />
37 <Outlet />37 <DesktopProjectBootstrap />
38 </>38 <Outlet />
39 </AnchoredToastProvider>
40 </ToastProvider>
39 );41 );
40}42}
4143
...@@ -44,6 +46,10 @@ function EventRouter() {...@@ -44,6 +46,10 @@ function EventRouter() {
44 const { dispatch } = useStore();46 const { dispatch } = useStore();
45 const queryClient = useQueryClient();47 const queryClient = useQueryClient();
46 const activeAssistantItemRef = useRef<string | null>(null);48 const activeAssistantItemRef = useRef<string | null>(null);
49 const activeThreadId = useParams({
50 strict: false,
51 select: (params) => params.threadId,
52 });
4753
48 useEffect(() => {54 useEffect(() => {
49 if (!api) return;55 if (!api) return;
...@@ -51,13 +57,15 @@ function EventRouter() {...@@ -51,13 +57,15 @@ function EventRouter() {
51 if (event.method === "turn/completed") {57 if (event.method === "turn/completed") {
52 void invalidateGitQueries(queryClient);58 void invalidateGitQueries(queryClient);
53 }59 }
60 if (!activeThreadId) return;
54 dispatch({61 dispatch({
55 type: "APPLY_EVENT",62 type: "APPLY_EVENT",
56 event,63 event,
57 activeAssistantItemRef,64 activeAssistantItemRef,
65 activeThreadId,
58 });66 });
59 });67 });
60 }, [api, dispatch, queryClient]);68 }, [activeThreadId, api, dispatch, queryClient]);
6169
62 useEffect(() => {70 useEffect(() => {
63 if (!api) return;71 if (!api) return;
...@@ -88,14 +96,6 @@ function AutoProjectBootstrap() {...@@ -88,14 +96,6 @@ function AutoProjectBootstrap() {
88 const existing = state.projects.find((project) => project.cwd === payload.cwd);96 const existing = state.projects.find((project) => project.cwd === payload.cwd);
89 if (existing) {97 if (existing) {
90 bootstrappedRef.current = true;98 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 }
99 dispatch({ type: "SET_THREADS_HYDRATED", hydrated: true });99 dispatch({ type: "SET_THREADS_HYDRATED", hydrated: true });
100 return;100 return;
101 }101 }
...@@ -115,13 +115,9 @@ function AutoProjectBootstrap() {...@@ -115,13 +115,9 @@ function AutoProjectBootstrap() {
115 scripts: [],115 scripts: [],
116 },116 },
117 });117 });
118 dispatch({
119 type: "ADD_THREAD",
120 thread: createThread(projectId),
121 });
122 dispatch({ type: "SET_THREADS_HYDRATED", hydrated: true });118 dispatch({ type: "SET_THREADS_HYDRATED", hydrated: true });
123 });119 });
124 }, [state.projects, state.threads, state.activeThreadId, dispatch]);120 }, [state.projects, dispatch]);
125121
126 return null;122 return null;
127}123}
apps/web/src/routes/_chat.$threadId.tsx+2-8
...@@ -5,7 +5,7 @@ import ChatView from "../components/ChatView";...@@ -5,7 +5,7 @@ import ChatView from "../components/ChatView";
5import { useStore } from "../store";5import { useStore } from "../store";
66
7function ChatThreadRouteView() {7function ChatThreadRouteView() {
8 const { state, dispatch } = useStore();8 const { state } = useStore();
9 const navigate = useNavigate();9 const navigate = useNavigate();
10 const { threadId } = Route.useParams();10 const { threadId } = Route.useParams();
11 const threadExists = threadId ? state.threads.some((thread) => thread.id === threadId) : false;11 const threadExists = threadId ? state.threads.some((thread) => thread.id === threadId) : false;
...@@ -24,13 +24,7 @@ function ChatThreadRouteView() {...@@ -24,13 +24,7 @@ function ChatThreadRouteView() {
24 void navigate({ to: "/", replace: true });24 void navigate({ to: "/", replace: true });
25 return;25 return;
26 }26 }
2727 }, [navigate, state.threadsHydrated, threadExists, threadId]);
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]);
3428
35 if (!threadId || !state.threadsHydrated || !threadExists) {29 if (!threadId || !state.threadsHydrated || !threadExists) {
36 return null;30 return null;
apps/web/src/routes/_chat.index.tsx+12-72
...@@ -1,82 +1,22 @@...@@ -1,82 +1,22 @@
1import { createFileRoute, useNavigate } from "@tanstack/react-router";1import { createFileRoute } from "@tanstack/react-router";
2import { type FormEvent, useMemo, useState } from "react";
32
4import { Button } from "../components/ui/button";3import { isElectron } from "../env";
5import { Textarea } from "../components/ui/textarea";
6import { createThread } from "../threadFactory";
7import { truncateTitle } from "../truncateTitle";
8import { useStore } from "../store";
94
10function ChatIndexRouteView() {5function 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
49 return (6 return (
50 <section className="flex min-h-0 min-w-0 flex-1 flex-col bg-background">7 <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background text-muted-foreground/40">
51 <div className="mx-auto flex h-full w-full max-w-3xl flex-col justify-center px-5 py-6">8 {isElectron && (
52 <div className="mb-5">9 <div className="drag-region flex h-[52px] shrink-0 items-center border-b border-border px-5">
53 <h1 className="text-xl font-semibold text-foreground">New chat</h1>10 <span className="text-xs text-muted-foreground/50">No active thread</span>
54 <p className="mt-1 text-sm text-muted-foreground/70">
55 Create a thread and continue in the full chat workspace.
56 </p>
57 </div>11 </div>
12 )}
5813
59 <form onSubmit={onCreateThread} className="space-y-3">14 <div className="flex flex-1 items-center justify-center">
60 <Textarea15 <div className="text-center">
61 value={draft}16 <p className="text-sm">Select a thread or create a new one to get started.</p>
62 onChange={(event) => setDraft(event.target.value)}17 </div>
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>
78 </div>18 </div>
79 </section>19 </div>
80 );20 );
81}21}
8222
apps/web/src/routes/_chat.tsx+1-14
...@@ -1,5 +1,4 @@...@@ -1,5 +1,4 @@
1import { Outlet, createFileRoute, useNavigate, useParams } from "@tanstack/react-router";1import { Outlet, createFileRoute } from "@tanstack/react-router";
2import { useEffect } from "react";
32
4import DiffPanel from "../components/DiffPanel";3import DiffPanel from "../components/DiffPanel";
5import Sidebar from "../components/Sidebar";4import Sidebar from "../components/Sidebar";
...@@ -7,18 +6,6 @@ import { useStore } from "../store";...@@ -7,18 +6,6 @@ import { useStore } from "../store";
76
8function ChatRouteLayout() {7function ChatRouteLayout() {
9 const { state } = useStore();8 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
23 return (10 return (
24 <div className="flex h-screen overflow-hidden bg-background text-foreground isolate">11 <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 {...@@ -79,7 +79,6 @@ function makeState(thread: Thread): AppState {
79 },79 },
80 ],80 ],
81 threads: [thread],81 threads: [thread],
82 activeThreadId: thread.id,
83 threadsHydrated: true,82 threadsHydrated: true,
84 runtimeMode: "full-access",83 runtimeMode: "full-access",
85 diffOpen: false,84 diffOpen: false,
...@@ -617,7 +616,6 @@ describe("store reducer thread continuity", () => {...@@ -617,7 +616,6 @@ describe("store reducer thread continuity", () => {
617 projectId: "project-old-b",616 projectId: "project-old-b",
618 }),617 }),
619 ],618 ],
620 activeThreadId: "thread-b",
621 threadsHydrated: true,619 threadsHydrated: true,
622 runtimeMode: "full-access",620 runtimeMode: "full-access",
623 diffOpen: true,621 diffOpen: true,
...@@ -647,7 +645,6 @@ describe("store reducer thread continuity", () => {...@@ -647,7 +645,6 @@ describe("store reducer thread continuity", () => {
647 expect(next.threads).toHaveLength(1);645 expect(next.threads).toHaveLength(1);
648 expect(next.threads[0]?.id).toBe("thread-a");646 expect(next.threads[0]?.id).toBe("thread-a");
649 expect(next.threads[0]?.projectId).toBe("project-new-a");647 expect(next.threads[0]?.projectId).toBe("project-new-a");
650 expect(next.activeThreadId).toBe("thread-a");
651 expect(next.diffOpen).toBe(false);648 expect(next.diffOpen).toBe(false);
652 expect(next.diffThreadId).toBeNull();649 expect(next.diffThreadId).toBeNull();
653 expect(next.diffTurnId).toBeNull();650 expect(next.diffTurnId).toBeNull();
...@@ -675,7 +672,6 @@ describe("store reducer thread continuity", () => {...@@ -675,7 +672,6 @@ describe("store reducer thread continuity", () => {
675 },672 },
676 ],673 ],
677 threads: [makeThread({ id: "thread-a", projectId: "project-old-a" })],674 threads: [makeThread({ id: "thread-a", projectId: "project-old-a" })],
678 activeThreadId: "thread-a",
679 threadsHydrated: true,675 threadsHydrated: true,
680 runtimeMode: "full-access",676 runtimeMode: "full-access",
681 diffOpen: false,677 diffOpen: false,
...@@ -768,7 +764,6 @@ describe("store reducer thread continuity", () => {...@@ -768,7 +764,6 @@ describe("store reducer thread continuity", () => {
768 projectId: "project-2",764 projectId: "project-2",
769 }),765 }),
770 ],766 ],
771 activeThreadId: "thread-a",
772 threadsHydrated: true,767 threadsHydrated: true,
773 runtimeMode: "full-access",768 runtimeMode: "full-access",
774 diffOpen: true,769 diffOpen: true,
...@@ -786,30 +781,12 @@ describe("store reducer thread continuity", () => {...@@ -786,30 +781,12 @@ describe("store reducer thread continuity", () => {
786 expect(next.projects[0]?.id).toBe("project-2");781 expect(next.projects[0]?.id).toBe("project-2");
787 expect(next.threads).toHaveLength(1);782 expect(next.threads).toHaveLength(1);
788 expect(next.threads[0]?.id).toBe("thread-b");783 expect(next.threads[0]?.id).toBe("thread-b");
789 expect(next.activeThreadId).toBe("thread-b");
790 expect(next.diffOpen).toBe(false);784 expect(next.diffOpen).toBe(false);
791 expect(next.diffThreadId).toBeNull();785 expect(next.diffThreadId).toBeNull();
792 expect(next.diffTurnId).toBeNull();786 expect(next.diffTurnId).toBeNull();
793 expect(next.diffFilePath).toBeNull();787 expect(next.diffFilePath).toBeNull();
794 });788 });
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
813 it("marks completion as seen immediately for the active thread", () => {790 it("marks completion as seen immediately for the active thread", () => {
814 const state = makeState(791 const state = makeState(
815 makeThread({792 makeThread({
...@@ -831,6 +808,7 @@ describe("store reducer thread continuity", () => {...@@ -831,6 +808,7 @@ describe("store reducer thread continuity", () => {
831 payload: { turn: { id: "turn-1", status: "completed" } },808 payload: { turn: { id: "turn-1", status: "completed" } },
832 }),809 }),
833 activeAssistantItemRef: { current: null },810 activeAssistantItemRef: { current: null },
811 activeThreadId: "thread-local-1",
834 });812 });
835813
836 expect(next.threads[0]?.latestTurnCompletedAt).toBe(completedAt);814 expect(next.threads[0]?.latestTurnCompletedAt).toBe(completedAt);
apps/web/src/store.ts+5-42
...@@ -42,7 +42,6 @@ type Action =...@@ -42,7 +42,6 @@ type Action =
42 | { type: "TOGGLE_PROJECT"; projectId: string }42 | { type: "TOGGLE_PROJECT"; projectId: string }
43 | { type: "DELETE_PROJECT"; projectId: string }43 | { type: "DELETE_PROJECT"; projectId: string }
44 | { type: "ADD_THREAD"; thread: Thread }44 | { type: "ADD_THREAD"; thread: Thread }
45 | { type: "SET_ACTIVE_THREAD"; threadId: string }
46 | { type: "TOGGLE_THREAD_TERMINAL"; threadId: string }45 | { type: "TOGGLE_THREAD_TERMINAL"; threadId: string }
47 | { type: "SET_THREAD_TERMINAL_OPEN"; threadId: string; open: boolean }46 | { type: "SET_THREAD_TERMINAL_OPEN"; threadId: string; open: boolean }
48 | { type: "SET_THREAD_TERMINAL_HEIGHT"; threadId: string; height: number }47 | { type: "SET_THREAD_TERMINAL_HEIGHT"; threadId: string; height: number }
...@@ -68,6 +67,7 @@ type Action =...@@ -68,6 +67,7 @@ type Action =
68 type: "APPLY_EVENT";67 type: "APPLY_EVENT";
69 event: ProviderEvent;68 event: ProviderEvent;
70 activeAssistantItemRef: { current: string | null };69 activeAssistantItemRef: { current: string | null };
70 activeThreadId?: string | null;
71 }71 }
72 | { type: "APPLY_TERMINAL_EVENT"; event: TerminalEvent }72 | { type: "APPLY_TERMINAL_EVENT"; event: TerminalEvent }
73 | { type: "UPDATE_SESSION"; threadId: string; session: ProviderSession }73 | { type: "UPDATE_SESSION"; threadId: string; session: ProviderSession }
...@@ -113,7 +113,6 @@ type Action =...@@ -113,7 +113,6 @@ type Action =
113export interface AppState {113export interface AppState {
114 projects: Project[];114 projects: Project[];
115 threads: Thread[];115 threads: Thread[];
116 activeThreadId: string | null;
117 threadsHydrated: boolean;116 threadsHydrated: boolean;
118 runtimeMode: RuntimeMode;117 runtimeMode: RuntimeMode;
119 diffOpen: boolean;118 diffOpen: boolean;
...@@ -137,7 +136,6 @@ const LEGACY_PERSISTED_STATE_KEYS = [...@@ -137,7 +136,6 @@ const LEGACY_PERSISTED_STATE_KEYS = [
137const initialState: AppState = {136const initialState: AppState = {
138 projects: [],137 projects: [],
139 threads: [],138 threads: [],
140 activeThreadId: null,
141 threadsHydrated: false,139 threadsHydrated: false,
142 runtimeMode: DEFAULT_RUNTIME_MODE,140 runtimeMode: DEFAULT_RUNTIME_MODE,
143 diffOpen: false,141 diffOpen: false,
...@@ -162,14 +160,10 @@ function readPersistedState(): AppState {...@@ -162,14 +160,10 @@ function readPersistedState(): AppState {
162 if (!hydrated) return initialState;160 if (!hydrated) return initialState;
163161
164 const threads = hydrated.threads.map((thread) => normalizeThreadTerminals(thread));162 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
169 return {164 return {
170 ...hydrated,165 ...hydrated,
171 threads,166 threads,
172 activeThreadId,
173 threadsHydrated: threads.length > 0,167 threadsHydrated: threads.length > 0,
174 diffOpen: false,168 diffOpen: false,
175 diffThreadId: null,169 diffThreadId: null,
...@@ -638,16 +632,12 @@ export function reducer(state: AppState, action: Action): AppState {...@@ -638,16 +632,12 @@ export function reducer(state: AppState, action: Action): AppState {
638 });632 });
639 })633 })
640 .filter((thread): thread is Thread => thread !== null);634 .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);
644 const diffState = resetDiffTargetIfMissing(state, nextThreads);635 const diffState = resetDiffTargetIfMissing(state, nextThreads);
645636
646 return {637 return {
647 ...state,638 ...state,
648 projects: nextProjects,639 projects: nextProjects,
649 threads: nextThreads,640 threads: nextThreads,
650 activeThreadId,
651 ...diffState,641 ...diffState,
652 };642 };
653 }643 }
...@@ -667,16 +657,12 @@ export function reducer(state: AppState, action: Action): AppState {...@@ -667,16 +657,12 @@ export function reducer(state: AppState, action: Action): AppState {
667 }657 }
668658
669 const threads = state.threads.filter((thread) => thread.projectId !== action.projectId);659 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);
673 const diffState = resetDiffTargetIfMissing(state, threads);660 const diffState = resetDiffTargetIfMissing(state, threads);
674661
675 return {662 return {
676 ...state,663 ...state,
677 projects,664 projects,
678 threads,665 threads,
679 activeThreadId,
680 ...diffState,666 ...diffState,
681 };667 };
682 }668 }
...@@ -691,19 +677,6 @@ export function reducer(state: AppState, action: Action): AppState {...@@ -691,19 +677,6 @@ export function reducer(state: AppState, action: Action): AppState {
691 return {677 return {
692 ...state,678 ...state,
693 threads: [...state.threads, nextThread],679 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 })),
707 };680 };
708 }681 }
709682
...@@ -926,7 +899,7 @@ export function reducer(state: AppState, action: Action): AppState {...@@ -926,7 +899,7 @@ export function reducer(state: AppState, action: Action): AppState {
926 };899 };
927900
928 case "APPLY_EVENT": {901 case "APPLY_EVENT": {
929 const { event, activeAssistantItemRef } = action;902 const { event, activeAssistantItemRef, activeThreadId } = action;
930 const target = findThreadBySessionId(state.threads, event.sessionId);903 const target = findThreadBySessionId(state.threads, event.sessionId);
931 if (!target) return state;904 if (!target) return state;
932 if (shouldIgnoreForeignThreadEvent(target, event)) return state;905 if (shouldIgnoreForeignThreadEvent(target, event)) return state;
...@@ -959,7 +932,7 @@ export function reducer(state: AppState, action: Action): AppState {...@@ -959,7 +932,7 @@ export function reducer(state: AppState, action: Action): AppState {
959 events: nextEvents,932 events: nextEvents,
960 turnDiffSummaries,933 turnDiffSummaries,
961 ...updateTurnFields(t, event),934 ...updateTurnFields(t, event),
962 ...(event.method === "turn/completed" && t.id === state.activeThreadId935 ...(event.method === "turn/completed" && t.id === activeThreadId
963 ? { lastVisitedAt: event.createdAt }936 ? { lastVisitedAt: event.createdAt }
964 : {}),937 : {}),
965 };938 };
...@@ -1176,21 +1149,11 @@ export function reducer(state: AppState, action: Action): AppState {...@@ -1176,21 +1149,11 @@ export function reducer(state: AppState, action: Action): AppState {
11761149
1177 case "DELETE_THREAD": {1150 case "DELETE_THREAD": {
1178 const threads = state.threads.filter((t) => t.id !== action.threadId);1151 const threads = state.threads.filter((t) => t.id !== action.threadId);
1179 const activeThreadId =1152 const diffState = resetDiffTargetIfMissing(state, threads);
1180 state.activeThreadId === action.threadId ? (threads[0]?.id ?? null) : state.activeThreadId;
1181 const shouldClearDiffTarget = state.diffThreadId === action.threadId;
1182 return {1153 return {
1183 ...state,1154 ...state,
1184 threads,1155 threads,
1185 activeThreadId,1156 ...diffState,
1186 ...(shouldClearDiffTarget
1187 ? {
1188 diffOpen: false,
1189 diffThreadId: null,
1190 diffTurnId: null,
1191 diffFilePath: null,
1192 }
1193 : {}),
1194 };1157 };
1195 }1158 }
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});