- 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`
Unified · Split
| @@ -300,7 +300,9 @@ const ComposerCommandMenu = memo(function ComposerCommandMenu(props: { · 299 unmodified lines | ||
| 300 | 300 | <Command |
| 301 | 301 | mode="none" |
| 302 | 302 | onItemHighlighted={(highlightedValue) => { |
| 303 | − props.onHighlightedItemChange(typeof highlightedValue === "string" ? highlightedValue : null); | |
| 303 | + props.onHighlightedItemChange( | |
| 304 | + typeof highlightedValue === "string" ? highlightedValue : null, | |
| 305 | + ); | |
| 304 | 306 | }} |
| 305 | 307 | > |
| 306 | 308 | <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 | ||
| 550 | 552 | ); |
| 551 | 553 | const keybindings = keybindingsQuery.data ?? EMPTY_KEYBINDINGS; |
| 552 | 554 | const threadTerminalRuntimeEnv = useMemo(() => { |
| 553 | − if (!activeProject) return {}; | |
| 555 | + if (!activeProject?.cwd) return {}; | |
| 554 | 556 | return projectScriptRuntimeEnv({ |
| 555 | 557 | project: { |
| 556 | 558 | cwd: activeProject.cwd, |
| @@ -686,9 +688,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 129 unmodified lines | ||
| 686 | 688 | } |
| 687 | 689 | const targetCwd = options?.cwd ?? gitCwd ?? activeProject.cwd; |
| 688 | 690 | const baseTerminalId = |
| 689 | − activeThread.activeTerminalId || | |
| 690 | − activeThread.terminalIds[0] || | |
| 691 | − DEFAULT_THREAD_TERMINAL_ID; | |
| 691 | + activeThread.activeTerminalId || activeThread.terminalIds[0] || DEFAULT_THREAD_TERMINAL_ID; | |
| 692 | 692 | const isBaseTerminalBusy = activeThread.runningTerminalIds.includes(baseTerminalId); |
| 693 | 693 | const wantsNewTerminal = Boolean(options?.preferNewTerminal) || isBaseTerminalBusy; |
| 694 | 694 | const shouldCreateNewTerminal = |
| @@ -67,13 +67,13 @@ export function buildGitActionProgressStages(input: { · 66 unmodified lines | ||
| 67 | 67 | return [...commitStages, pushStage, "Creating PR..."]; |
| 68 | 68 | } |
| 69 | 69 | |
| 70 | +const withDescription = (title: string, description: string | undefined) => | |
| 71 | + description ? { title, description } : { title }; | |
| 72 | + | |
| 70 | 73 | export function summarizeGitResult(result: GitRunStackedActionResult): { |
| 71 | 74 | title: string; |
| 72 | 75 | description?: string; |
| 73 | 76 | } { |
| 74 | − const withDescription = (title: string, description: string | undefined) => | |
| 75 | − description ? { title, description } : { title }; | |
| 76 | − | |
| 77 | 77 | if (result.pr.status === "created" || result.pr.status === "opened_existing") { |
| 78 | 78 | const prNumber = result.pr.number ? ` #${result.pr.number}` : ""; |
| 79 | 79 | const title = `${result.pr.status === "created" ? "Created PR" : "Opened PR"}${prNumber}`; |
| @@ -2,6 +2,7 @@ · 1 unmodified line | ||
| 2 | 2 | |
| 3 | 3 | import { Toast } from "@base-ui/react/toast"; |
| 4 | 4 | import { useEffect } from "react"; |
| 5 | +import { useRouterState } from "@tanstack/react-router"; | |
| 5 | 6 | import { |
| 6 | 7 | CircleAlertIcon, |
| 7 | 8 | CircleCheckIcon, |
| @@ -12,7 +13,6 @@ import { · 4 unmodified lines | ||
| 12 | 13 | |
| 13 | 14 | import { cn } from "~/lib/utils"; |
| 14 | 15 | import { buttonVariants } from "~/components/ui/button"; |
| 15 | −import { useStore } from "~/store"; | |
| 16 | 16 | import { shouldHideCollapsedToastContent } from "./toast.logic"; |
| 17 | 17 | |
| 18 | 18 | type ThreadToastData = { |
| @@ -55,6 +55,21 @@ function shouldRenderForActiveThread( · 36 unmodified lines | ||
| 55 | 55 | return toastThreadId === activeThreadId; |
| 56 | 56 | } |
| 57 | 57 | |
| 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 | + | |
| 58 | 73 | function ThreadToastVisibleAutoDismiss({ |
| 59 | 74 | toastId, |
| 60 | 75 | dismissAfterVisibleMs, |
| @@ -143,9 +158,8 @@ function ToastProvider({ children, position = "top-right", ...props }: ToastProv · 82 unmodified lines | ||
| 143 | 158 | } |
| 144 | 159 | |
| 145 | 160 | function Toasts({ position = "top-right" }: { position: ToastPosition }) { |
| 146 | − const { state } = useStore(); | |
| 147 | 161 | const { toasts } = Toast.useToastManager<ThreadToastData>(); |
| 148 | − const activeThreadId = state.activeThreadId; | |
| 162 | + const activeThreadId = useActiveThreadIdFromRoute(); | |
| 149 | 163 | const isTop = position.startsWith("top"); |
| 150 | 164 | const visibleToasts = toasts.filter((toast) => |
| 151 | 165 | shouldRenderForActiveThread(toast.data, activeThreadId), |
| @@ -292,9 +306,8 @@ function AnchoredToastProvider({ children, ...props }: Toast.Provider.Props) { · 140 unmodified lines | ||
| 292 | 306 | } |
| 293 | 307 | |
| 294 | 308 | function AnchoredToasts() { |
| 295 | − const { state } = useStore(); | |
| 296 | 309 | const { toasts } = Toast.useToastManager<ThreadToastData>(); |
| 297 | − const activeThreadId = state.activeThreadId; | |
| 310 | + const activeThreadId = useActiveThreadIdFromRoute(); | |
| 298 | 311 | |
| 299 | 312 | return ( |
| 300 | 313 | <Toast.Portal data-slot="toast-portal-anchored"> |
| @@ -28,6 +28,7 @@ function applyTheme(theme: Theme, suppressTransitions = false) { · 27 unmodified lines | ||
| 28 | 28 | document.documentElement.classList.toggle("dark", isDark); |
| 29 | 29 | if (suppressTransitions) { |
| 30 | 30 | // Force a reflow so the no-transitions class takes effect before removal |
| 31 | + // oxlint-disable-next-line no-unused-expressions | |
| 31 | 32 | document.documentElement.offsetHeight; |
| 32 | 33 | requestAnimationFrame(() => { |
| 33 | 34 | document.documentElement.classList.remove("no-transitions"); |
| @@ -2,7 +2,6 @@ import React from "react"; · 1 unmodified line | ||
| 2 | 2 | import ReactDOM from "react-dom/client"; |
| 3 | 3 | import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; |
| 4 | 4 | import { RouterProvider } from "@tanstack/react-router"; |
| 5 | −import { AnchoredToastProvider, ToastProvider } from "./components/ui/toast"; | |
| 6 | 5 | import { createHashHistory, createRouter, createBrowserHistory } from "@tanstack/react-router"; |
| 7 | 6 | import { StoreProvider } from "./store"; |
| 8 | 7 | |
| @@ -23,24 +22,21 @@ const router = createRouter({ · 14 unmodified lines | ||
| 23 | 22 | context: { |
| 24 | 23 | queryClient, |
| 25 | 24 | }, |
| 26 | −}); | |
| 27 | − | |
| 28 | −function App() { | |
| 29 | − return ( | |
| 25 | + Wrap: ({ children }) => ( | |
| 30 | 26 | <QueryClientProvider client={queryClient}> |
| 31 | − <StoreProvider> | |
| 32 | − <ToastProvider> | |
| 33 | − <AnchoredToastProvider> | |
| 34 | − <RouterProvider router={router} /> | |
| 35 | − </AnchoredToastProvider> | |
| 36 | − </ToastProvider> | |
| 37 | − </StoreProvider> | |
| 27 | + <StoreProvider>{children}</StoreProvider> | |
| 38 | 28 | </QueryClientProvider> |
| 39 | − ); | |
| 29 | + ), | |
| 30 | +}); | |
| 31 | + | |
| 32 | +declare module "@tanstack/react-router" { | |
| 33 | + interface Register { | |
| 34 | + router: typeof router; | |
| 35 | + } | |
| 40 | 36 | } |
| 41 | 37 | |
| 42 | 38 | ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( |
| 43 | 39 | <React.StrictMode> |
| 44 | − <App /> | |
| 40 | + <RouterProvider router={router} /> | |
| 45 | 41 | </React.StrictMode>, |
| 46 | 42 | ); |
| @@ -106,7 +106,6 @@ describe("hydratePersistedState", () => { · 105 unmodified lines | ||
| 106 | 106 | { id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, terminalIds: [DEFAULT_THREAD_TERMINAL_ID] }, |
| 107 | 107 | ]); |
| 108 | 108 | expect(hydrated?.threads[0]?.activeTerminalGroupId).toBe(`group-${DEFAULT_THREAD_TERMINAL_ID}`); |
| 109 | − expect(hydrated?.activeThreadId).toBe("t-1"); | |
| 110 | 109 | expect(hydrated?.runtimeMode).toBe("full-access"); |
| 111 | 110 | }); |
| 112 | 111 | |
| @@ -323,7 +322,6 @@ describe("toPersistedState", () => { · 210 unmodified lines | ||
| 323 | 322 | }, |
| 324 | 323 | ], |
| 325 | 324 | threads: [thread], |
| 326 | − activeThreadId: "t-1", | |
| 327 | 325 | runtimeMode: "full-access", |
| 328 | 326 | }); |
| 329 | 327 | |
| @@ -72,7 +72,7 @@ const persistedThreadSchema = z.object({ · 71 unmodified lines | ||
| 72 | 72 | const persistedStateBodySchema = z.object({ |
| 73 | 73 | projects: z.array(persistedProjectSchema), |
| 74 | 74 | threads: z.array(persistedThreadSchema), |
| 75 | − activeThreadId: z.string().min(1).nullable(), | |
| 75 | + activeThreadId: z.string().min(1).nullable().optional(), | |
| 76 | 76 | }); |
| 77 | 77 | |
| 78 | 78 | const runtimeModeSchema = z.enum(["approval-required", "full-access"]); |
| @@ -123,7 +123,6 @@ const persistedStateSchema = z.union([ · 44 unmodified lines | ||
| 123 | 123 | export interface PersistedStoreSnapshot { |
| 124 | 124 | projects: Project[]; |
| 125 | 125 | threads: Thread[]; |
| 126 | − activeThreadId: string | null; | |
| 127 | 126 | runtimeMode: RuntimeMode; |
| 128 | 127 | } |
| 129 | 128 | |
| @@ -299,15 +298,9 @@ export function hydratePersistedState( · 169 unmodified lines | ||
| 299 | 298 | const threads = parsedState.data.threads |
| 300 | 299 | .map((thread) => hydrateThread(thread, isLegacyPayload)) |
| 301 | 300 | .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 | − | |
| 307 | 301 | return { |
| 308 | 302 | projects, |
| 309 | 303 | threads, |
| 310 | − activeThreadId: hasActiveThread ? parsedState.data.activeThreadId : (threads[0]?.id ?? null), | |
| 311 | 304 | runtimeMode: |
| 312 | 305 | "runtimeMode" in parsedState.data ? parsedState.data.runtimeMode : DEFAULT_RUNTIME_MODE, |
| 313 | 306 | }; |
| @@ -354,7 +347,6 @@ export function toPersistedState( · 40 unmodified lines | ||
| 354 | 347 | branch: thread.branch, |
| 355 | 348 | worktreePath: thread.worktreePath, |
| 356 | 349 | })), |
| 357 | − activeThreadId: state.activeThreadId, | |
| 358 | 350 | runtimeMode: state.runtimeMode, |
| 359 | 351 | }; |
| 360 | 352 | } |
| @@ -1,12 +1,12 @@ | ||
| 1 | −import { Outlet, createRootRouteWithContext } from "@tanstack/react-router"; | |
| 1 | +import { Outlet, createRootRouteWithContext, useParams } from "@tanstack/react-router"; | |
| 2 | 2 | import { useEffect, useRef } from "react"; |
| 3 | 3 | import { QueryClient, useQueryClient } from "@tanstack/react-query"; |
| 4 | 4 | |
| 5 | +import { AnchoredToastProvider, ToastProvider } from "../components/ui/toast"; | |
| 5 | 6 | import { isElectron } from "../env"; |
| 6 | 7 | import { useNativeApi } from "../hooks/useNativeApi"; |
| 7 | 8 | import { invalidateGitQueries } from "../lib/gitReactQuery"; |
| 8 | 9 | import { DEFAULT_MODEL } from "../model-logic"; |
| 9 | −import { createThread } from "../threadFactory"; | |
| 10 | 10 | import { useStore } from "../store"; |
| 11 | 11 | import { onServerWelcome } from "../wsNativeApi"; |
| 12 | 12 | |
| @@ -30,12 +30,14 @@ function RootRouteView() { · 17 unmodified lines | ||
| 30 | 30 | } |
| 31 | 31 | |
| 32 | 32 | 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> | |
| 39 | 41 | ); |
| 40 | 42 | } |
| 41 | 43 | |
| @@ -44,6 +46,10 @@ function EventRouter() { · 2 unmodified lines | ||
| 44 | 46 | const { dispatch } = useStore(); |
| 45 | 47 | const queryClient = useQueryClient(); |
| 46 | 48 | const activeAssistantItemRef = useRef<string | null>(null); |
| 49 | + const activeThreadId = useParams({ | |
| 50 | + strict: false, | |
| 51 | + select: (params) => params.threadId, | |
| 52 | + }); | |
| 47 | 53 | |
| 48 | 54 | useEffect(() => { |
| 49 | 55 | if (!api) return; |
| @@ -51,13 +57,15 @@ function EventRouter() { · 1 unmodified line | ||
| 51 | 57 | if (event.method === "turn/completed") { |
| 52 | 58 | void invalidateGitQueries(queryClient); |
| 53 | 59 | } |
| 60 | + if (!activeThreadId) return; | |
| 54 | 61 | dispatch({ |
| 55 | 62 | type: "APPLY_EVENT", |
| 56 | 63 | event, |
| 57 | 64 | activeAssistantItemRef, |
| 65 | + activeThreadId, | |
| 58 | 66 | }); |
| 59 | 67 | }); |
| 60 | − }, [api, dispatch, queryClient]); | |
| 68 | + }, [activeThreadId, api, dispatch, queryClient]); | |
| 61 | 69 | |
| 62 | 70 | useEffect(() => { |
| 63 | 71 | if (!api) return; |
| @@ -88,14 +96,6 @@ function AutoProjectBootstrap() { · 24 unmodified lines | ||
| 88 | 96 | const existing = state.projects.find((project) => project.cwd === payload.cwd); |
| 89 | 97 | if (existing) { |
| 90 | 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 | 99 | dispatch({ type: "SET_THREADS_HYDRATED", hydrated: true }); |
| 100 | 100 | return; |
| 101 | 101 | } |
| @@ -115,13 +115,9 @@ function AutoProjectBootstrap() { · 13 unmodified lines | ||
| 115 | 115 | scripts: [], |
| 116 | 116 | }, |
| 117 | 117 | }); |
| 118 | − dispatch({ | |
| 119 | − type: "ADD_THREAD", | |
| 120 | − thread: createThread(projectId), | |
| 121 | − }); | |
| 122 | 118 | dispatch({ type: "SET_THREADS_HYDRATED", hydrated: true }); |
| 123 | 119 | }); |
| 124 | − }, [state.projects, state.threads, state.activeThreadId, dispatch]); | |
| 120 | + }, [state.projects, dispatch]); | |
| 125 | 121 | |
| 126 | 122 | return null; |
| 127 | 123 | } |
| @@ -5,7 +5,7 @@ import ChatView from "../components/ChatView"; · 4 unmodified lines | ||
| 5 | 5 | import { useStore } from "../store"; |
| 6 | 6 | |
| 7 | 7 | function ChatThreadRouteView() { |
| 8 | − const { state, dispatch } = useStore(); | |
| 8 | + const { state } = useStore(); | |
| 9 | 9 | const navigate = useNavigate(); |
| 10 | 10 | const { threadId } = Route.useParams(); |
| 11 | 11 | const threadExists = threadId ? state.threads.some((thread) => thread.id === threadId) : false; |
| @@ -24,13 +24,7 @@ function ChatThreadRouteView() { · 12 unmodified lines | ||
| 24 | 24 | void navigate({ to: "/", replace: true }); |
| 25 | 25 | return; |
| 26 | 26 | } |
| 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]); | |
| 34 | 28 | |
| 35 | 29 | if (!threadId || !state.threadsHydrated || !threadExists) { |
| 36 | 30 | return null; |
| @@ -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"; | |
| 3 | 2 | |
| 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"; | |
| 9 | 4 | |
| 10 | 5 | 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 | − | |
| 49 | 6 | 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> | |
| 57 | 11 | </div> |
| 12 | + )} | |
| 58 | 13 | |
| 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> | |
| 78 | 18 | </div> |
| 79 | − </section> | |
| 19 | + </div> | |
| 80 | 20 | ); |
| 81 | 21 | } |
| 82 | 22 | |
| @@ -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"; | |
| 3 | 2 | |
| 4 | 3 | import DiffPanel from "../components/DiffPanel"; |
| 5 | 4 | import Sidebar from "../components/Sidebar"; |
| @@ -7,18 +6,6 @@ import { useStore } from "../store"; · 1 unmodified line | ||
| 7 | 6 | |
| 8 | 7 | function ChatRouteLayout() { |
| 9 | 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]); | |
| 22 | 9 | |
| 23 | 10 | return ( |
| 24 | 11 | <div className="flex h-screen overflow-hidden bg-background text-foreground isolate"> |
| @@ -78,7 +78,6 @@ function makeState(thread: Thread): AppState { · 77 unmodified lines | ||
| 78 | 78 | }, |
| 79 | 79 | ], |
| 80 | 80 | threads: [thread], |
| 81 | − activeThreadId: thread.id, | |
| 82 | 81 | threadsHydrated: true, |
| 83 | 82 | runtimeMode: "full-access", |
| 84 | 83 | diffOpen: false, |
| @@ -530,7 +529,6 @@ describe("store reducer thread continuity", () => { · 445 unmodified lines | ||
| 530 | 529 | projectId: "project-old-b", |
| 531 | 530 | }), |
| 532 | 531 | ], |
| 533 | − activeThreadId: "thread-b", | |
| 534 | 532 | threadsHydrated: true, |
| 535 | 533 | runtimeMode: "full-access", |
| 536 | 534 | diffOpen: false, |
| @@ -557,7 +555,6 @@ describe("store reducer thread continuity", () => { · 20 unmodified lines | ||
| 557 | 555 | expect(next.threads).toHaveLength(1); |
| 558 | 556 | expect(next.threads[0]?.id).toBe("thread-a"); |
| 559 | 557 | expect(next.threads[0]?.projectId).toBe("project-new-a"); |
| 560 | − expect(next.activeThreadId).toBe("thread-a"); | |
| 561 | 558 | }); |
| 562 | 559 | |
| 563 | 560 | it("treats empty scripts from sync as authoritative", () => { |
| @@ -581,7 +578,6 @@ describe("store reducer thread continuity", () => { · 17 unmodified lines | ||
| 581 | 578 | }, |
| 582 | 579 | ], |
| 583 | 580 | threads: [makeThread({ id: "thread-a", projectId: "project-old-a" })], |
| 584 | − activeThreadId: "thread-a", | |
| 585 | 581 | threadsHydrated: true, |
| 586 | 582 | runtimeMode: "full-access", |
| 587 | 583 | diffOpen: false, |
| @@ -671,7 +667,6 @@ describe("store reducer thread continuity", () => { · 83 unmodified lines | ||
| 671 | 667 | projectId: "project-2", |
| 672 | 668 | }), |
| 673 | 669 | ], |
| 674 | − activeThreadId: "thread-a", | |
| 675 | 670 | threadsHydrated: true, |
| 676 | 671 | runtimeMode: "full-access", |
| 677 | 672 | diffOpen: false, |
| @@ -686,24 +681,6 @@ describe("store reducer thread continuity", () => { · 8 unmodified lines | ||
| 686 | 681 | expect(next.projects[0]?.id).toBe("project-2"); |
| 687 | 682 | expect(next.threads).toHaveLength(1); |
| 688 | 683 | 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"); | |
| 707 | 684 | }); |
| 708 | 685 | |
| 709 | 686 | it("marks completion as seen immediately for the active thread", () => { |
| @@ -727,6 +704,7 @@ describe("store reducer thread continuity", () => { · 17 unmodified lines | ||
| 727 | 704 | payload: { turn: { id: "turn-1", status: "completed" } }, |
| 728 | 705 | }), |
| 729 | 706 | activeAssistantItemRef: { current: null }, |
| 707 | + activeThreadId: "thread-local-1", | |
| 730 | 708 | }); |
| 731 | 709 | |
| 732 | 710 | expect(next.threads[0]?.latestTurnCompletedAt).toBe(completedAt); |
| @@ -34,7 +34,6 @@ type Action = · 33 unmodified lines | ||
| 34 | 34 | | { type: "TOGGLE_PROJECT"; projectId: string } |
| 35 | 35 | | { type: "DELETE_PROJECT"; projectId: string } |
| 36 | 36 | | { type: "ADD_THREAD"; thread: Thread } |
| 37 | − | { type: "SET_ACTIVE_THREAD"; threadId: string } | |
| 38 | 37 | | { type: "TOGGLE_THREAD_TERMINAL"; threadId: string } |
| 39 | 38 | | { type: "SET_THREAD_TERMINAL_OPEN"; threadId: string; open: boolean } |
| 40 | 39 | | { type: "SET_THREAD_TERMINAL_HEIGHT"; threadId: string; height: number } |
| @@ -47,6 +46,7 @@ type Action = · 6 unmodified lines | ||
| 47 | 46 | type: "APPLY_EVENT"; |
| 48 | 47 | event: ProviderEvent; |
| 49 | 48 | activeAssistantItemRef: { current: string | null }; |
| 49 | + activeThreadId?: string | null; | |
| 50 | 50 | } |
| 51 | 51 | | { type: "APPLY_TERMINAL_EVENT"; event: TerminalEvent } |
| 52 | 52 | | { type: "UPDATE_SESSION"; threadId: string; session: ProviderSession } |
| @@ -74,7 +74,6 @@ type Action = · 21 unmodified lines | ||
| 74 | 74 | export interface AppState { |
| 75 | 75 | projects: Project[]; |
| 76 | 76 | threads: Thread[]; |
| 77 | − activeThreadId: string | null; | |
| 78 | 77 | threadsHydrated: boolean; |
| 79 | 78 | runtimeMode: RuntimeMode; |
| 80 | 79 | diffOpen: boolean; |
| @@ -95,7 +94,6 @@ const LEGACY_PERSISTED_STATE_KEYS = [ · 14 unmodified lines | ||
| 95 | 94 | const initialState: AppState = { |
| 96 | 95 | projects: [], |
| 97 | 96 | threads: [], |
| 98 | − activeThreadId: null, | |
| 99 | 97 | threadsHydrated: false, |
| 100 | 98 | runtimeMode: DEFAULT_RUNTIME_MODE, |
| 101 | 99 | diffOpen: false, |
| @@ -117,14 +115,10 @@ function readPersistedState(): AppState { · 15 unmodified lines | ||
| 117 | 115 | if (!hydrated) return initialState; |
| 118 | 116 | |
| 119 | 117 | 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); | |
| 123 | 118 | |
| 124 | 119 | return { |
| 125 | 120 | ...hydrated, |
| 126 | 121 | threads, |
| 127 | − activeThreadId, | |
| 128 | 122 | threadsHydrated: threads.length > 0, |
| 129 | 123 | diffOpen: false, |
| 130 | 124 | }; |
| @@ -494,15 +488,11 @@ export function reducer(state: AppState, action: Action): AppState { · 363 unmodified lines | ||
| 494 | 488 | }); |
| 495 | 489 | }) |
| 496 | 490 | .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); | |
| 500 | 491 | |
| 501 | 492 | return { |
| 502 | 493 | ...state, |
| 503 | 494 | projects: nextProjects, |
| 504 | 495 | threads: nextThreads, |
| 505 | − activeThreadId, | |
| 506 | 496 | }; |
| 507 | 497 | } |
| 508 | 498 | |
| @@ -521,15 +511,11 @@ export function reducer(state: AppState, action: Action): AppState { · 12 unmodified lines | ||
| 521 | 511 | } |
| 522 | 512 | |
| 523 | 513 | 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); | |
| 527 | 514 | |
| 528 | 515 | return { |
| 529 | 516 | ...state, |
| 530 | 517 | projects, |
| 531 | 518 | threads, |
| 532 | − activeThreadId, | |
| 533 | 519 | }; |
| 534 | 520 | } |
| 535 | 521 | |
| @@ -542,19 +528,6 @@ export function reducer(state: AppState, action: Action): AppState { · 6 unmodified lines | ||
| 542 | 528 | return { |
| 543 | 529 | ...state, |
| 544 | 530 | 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 | − })), | |
| 558 | 531 | }; |
| 559 | 532 | } |
| 560 | 533 | |
| @@ -757,7 +730,7 @@ export function reducer(state: AppState, action: Action): AppState { · 196 unmodified lines | ||
| 757 | 730 | }; |
| 758 | 731 | |
| 759 | 732 | case "APPLY_EVENT": { |
| 760 | − const { event, activeAssistantItemRef } = action; | |
| 733 | + const { event, activeAssistantItemRef, activeThreadId } = action; | |
| 761 | 734 | const target = findThreadBySessionId(state.threads, event.sessionId); |
| 762 | 735 | if (!target) return state; |
| 763 | 736 | if (shouldIgnoreForeignThreadEvent(target, event)) return state; |
| @@ -781,7 +754,7 @@ export function reducer(state: AppState, action: Action): AppState { · 17 unmodified lines | ||
| 781 | 754 | messages: applyEventToMessages(t.messages, event, activeAssistantItemRef), |
| 782 | 755 | events: [event, ...t.events], |
| 783 | 756 | ...updateTurnFields(t, event), |
| 784 | − ...(event.method === "turn/completed" && t.id === state.activeThreadId | |
| 757 | + ...(event.method === "turn/completed" && t.id === activeThreadId | |
| 785 | 758 | ? { lastVisitedAt: event.createdAt } |
| 786 | 759 | : {}), |
| 787 | 760 | })), |
| @@ -876,12 +849,8 @@ export function reducer(state: AppState, action: Action): AppState { · 88 unmodified lines | ||
| 876 | 849 | runtimeMode: action.mode, |
| 877 | 850 | }; |
| 878 | 851 | |
| 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) }; | |
| 885 | 854 | |
| 886 | 855 | default: |
| 887 | 856 | return state; |
| @@ -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 | +}); | |