| author | |
| committer | |
| log | 1a315235b77d0a5b32057c700c4e5285d7dcd9b8 |
| tree | c563be1436b37df2d5e57129cd3c8e537c79a87f |
| parent | bcf8b14f3834aea98824f8b4688e2cdf5fbb6672 |
- 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 | 307 | <Command |
| 308 | 308 | mode="none" |
| 309 | 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 | 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 | 691 | ); |
| 690 | 692 | const keybindings = keybindingsQuery.data ?? EMPTY_KEYBINDINGS; |
| 691 | 693 | const threadTerminalRuntimeEnv = useMemo(() => { |
| 692 | if (!activeProject) return {}; | |
| 694 | if (!activeProject?.cwd) return {}; | |
| 693 | 695 | return projectScriptRuntimeEnv({ |
| 694 | 696 | project: { |
| 695 | 697 | cwd: activeProject.cwd, |
| ... | ... | @@ -825,9 +827,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 825 | 827 | } |
| 826 | 828 | const targetCwd = options?.cwd ?? gitCwd ?? activeProject.cwd; |
| 827 | 829 | const baseTerminalId = |
| 828 | activeThread.activeTerminalId || | |
| 829 | activeThread.terminalIds[0] || | |
| 830 | DEFAULT_THREAD_TERMINAL_ID; | |
| 830 | activeThread.activeTerminalId || activeThread.terminalIds[0] || DEFAULT_THREAD_TERMINAL_ID; | |
| 831 | 831 | const isBaseTerminalBusy = activeThread.runningTerminalIds.includes(baseTerminalId); |
| 832 | 832 | const wantsNewTerminal = Boolean(options?.preferNewTerminal) || isBaseTerminalBusy; |
| 833 | 833 | const shouldCreateNewTerminal = |
apps/web/src/components/GitActionsControl.logic.ts+3-3| ... | ... | @@ -67,13 +67,13 @@ export function buildGitActionProgressStages(input: { |
| 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}`; |
apps/web/src/components/ui/toast.tsx+18-5| ... | ... | @@ -2,6 +2,7 @@ |
| 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 { |
| 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( |
| 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 |
| 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) { |
| 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"> |
apps/web/src/hooks/useTheme.ts+1| ... | ... | @@ -28,6 +28,7 @@ function applyTheme(theme: Theme, suppressTransitions = false) { |
| 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"); |
apps/web/src/main.tsx+10-14| ... | ... | @@ -2,7 +2,6 @@ import React from "react"; |
| 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({ |
| 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 | ); |
apps/web/src/persistenceSchema.test.ts-2| ... | ... | @@ -106,7 +106,6 @@ describe("hydratePersistedState", () => { |
| 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 | |
| ... | ... | @@ -442,7 +441,6 @@ describe("toPersistedState", () => { |
| 442 | 441 | }, |
| 443 | 442 | ], |
| 444 | 443 | threads: [thread], |
| 445 | activeThreadId: "t-1", | |
| 446 | 444 | runtimeMode: "full-access", |
| 447 | 445 | }); |
| 448 | 446 |
apps/web/src/persistenceSchema.ts+1-9| ... | ... | @@ -90,7 +90,7 @@ const persistedThreadSchema = z.object({ |
| 90 | 90 | const persistedStateBodySchema = z.object({ |
| 91 | 91 | projects: z.array(persistedProjectSchema), |
| 92 | 92 | threads: z.array(persistedThreadSchema), |
| 93 | activeThreadId: z.string().min(1).nullable(), | |
| 93 | activeThreadId: z.string().min(1).nullable().optional(), | |
| 94 | 94 | }); |
| 95 | 95 | |
| 96 | 96 | const runtimeModeSchema = z.enum(["approval-required", "full-access"]); |
| ... | ... | @@ -141,7 +141,6 @@ const persistedStateSchema = z.union([ |
| 141 | 141 | export interface PersistedStoreSnapshot { |
| 142 | 142 | projects: Project[]; |
| 143 | 143 | threads: Thread[]; |
| 144 | activeThreadId: string | null; | |
| 145 | 144 | runtimeMode: RuntimeMode; |
| 146 | 145 | } |
| 147 | 146 | |
| ... | ... | @@ -332,15 +331,9 @@ export function hydratePersistedState( |
| 332 | 331 | const threads = parsedState.data.threads |
| 333 | 332 | .map((thread) => hydrateThread(thread, isLegacyPayload)) |
| 334 | 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 | 334 | return { |
| 341 | 335 | projects, |
| 342 | 336 | threads, |
| 343 | activeThreadId: hasActiveThread ? parsedState.data.activeThreadId : (threads[0]?.id ?? null), | |
| 344 | 337 | runtimeMode: |
| 345 | 338 | "runtimeMode" in parsedState.data ? parsedState.data.runtimeMode : DEFAULT_RUNTIME_MODE, |
| 346 | 339 | }; |
| ... | ... | @@ -402,7 +395,6 @@ export function toPersistedState( |
| 402 | 395 | : {}), |
| 403 | 396 | })), |
| 404 | 397 | })), |
| 405 | activeThreadId: state.activeThreadId, | |
| 406 | 398 | runtimeMode: state.runtimeMode, |
| 407 | 399 | }; |
| 408 | 400 | } |
apps/web/src/routes/__root.tsx+18-22| ... | ... | @@ -1,12 +1,12 @@ |
| 1 | import { Outlet, createRootRouteWithContext } from "@tanstack/react-router"; | |
| 1 | import { Outlet, createRootRouteWithContext, useParams } from "@tanstack/react-router"; | |
| 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() { |
| 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() { |
| 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() { |
| 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() { |
| 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() { |
| 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 | } |
apps/web/src/routes/_chat.$threadId.tsx+2-8| ... | ... | @@ -5,7 +5,7 @@ import ChatView from "../components/ChatView"; |
| 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() { |
| 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; |
apps/web/src/routes/_chat.index.tsx+12-72| ... | ... | @@ -1,82 +1,22 @@ |
| 1 | import { createFileRoute, useNavigate } from "@tanstack/react-router"; | |
| 2 | import { type FormEvent, useMemo, useState } from "react"; | |
| 1 | import { createFileRoute } from "@tanstack/react-router"; | |
| 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 |
apps/web/src/routes/_chat.tsx+1-14| ... | ... | @@ -1,5 +1,4 @@ |
| 1 | import { Outlet, createFileRoute, useNavigate, useParams } from "@tanstack/react-router"; | |
| 2 | import { useEffect } from "react"; | |
| 1 | import { Outlet, createFileRoute } from "@tanstack/react-router"; | |
| 3 | 2 | |
| 4 | 3 | import DiffPanel from "../components/DiffPanel"; |
| 5 | 4 | import Sidebar from "../components/Sidebar"; |
| ... | ... | @@ -7,18 +6,6 @@ import { useStore } from "../store"; |
| 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"> |
apps/web/src/store.test.ts+1-23| ... | ... | @@ -79,7 +79,6 @@ function makeState(thread: Thread): AppState { |
| 79 | 79 | }, |
| 80 | 80 | ], |
| 81 | 81 | threads: [thread], |
| 82 | activeThreadId: thread.id, | |
| 83 | 82 | threadsHydrated: true, |
| 84 | 83 | runtimeMode: "full-access", |
| 85 | 84 | diffOpen: false, |
| ... | ... | @@ -617,7 +616,6 @@ describe("store reducer thread continuity", () => { |
| 617 | 616 | projectId: "project-old-b", |
| 618 | 617 | }), |
| 619 | 618 | ], |
| 620 | activeThreadId: "thread-b", | |
| 621 | 619 | threadsHydrated: true, |
| 622 | 620 | runtimeMode: "full-access", |
| 623 | 621 | diffOpen: true, |
| ... | ... | @@ -647,7 +645,6 @@ describe("store reducer thread continuity", () => { |
| 647 | 645 | expect(next.threads).toHaveLength(1); |
| 648 | 646 | expect(next.threads[0]?.id).toBe("thread-a"); |
| 649 | 647 | expect(next.threads[0]?.projectId).toBe("project-new-a"); |
| 650 | expect(next.activeThreadId).toBe("thread-a"); | |
| 651 | 648 | expect(next.diffOpen).toBe(false); |
| 652 | 649 | expect(next.diffThreadId).toBeNull(); |
| 653 | 650 | expect(next.diffTurnId).toBeNull(); |
| ... | ... | @@ -675,7 +672,6 @@ describe("store reducer thread continuity", () => { |
| 675 | 672 | }, |
| 676 | 673 | ], |
| 677 | 674 | threads: [makeThread({ id: "thread-a", projectId: "project-old-a" })], |
| 678 | activeThreadId: "thread-a", | |
| 679 | 675 | threadsHydrated: true, |
| 680 | 676 | runtimeMode: "full-access", |
| 681 | 677 | diffOpen: false, |
| ... | ... | @@ -768,7 +764,6 @@ describe("store reducer thread continuity", () => { |
| 768 | 764 | projectId: "project-2", |
| 769 | 765 | }), |
| 770 | 766 | ], |
| 771 | activeThreadId: "thread-a", | |
| 772 | 767 | threadsHydrated: true, |
| 773 | 768 | runtimeMode: "full-access", |
| 774 | 769 | diffOpen: true, |
| ... | ... | @@ -786,30 +781,12 @@ describe("store reducer thread continuity", () => { |
| 786 | 781 | expect(next.projects[0]?.id).toBe("project-2"); |
| 787 | 782 | expect(next.threads).toHaveLength(1); |
| 788 | 783 | expect(next.threads[0]?.id).toBe("thread-b"); |
| 789 | expect(next.activeThreadId).toBe("thread-b"); | |
| 790 | 784 | expect(next.diffOpen).toBe(false); |
| 791 | 785 | expect(next.diffThreadId).toBeNull(); |
| 792 | 786 | expect(next.diffTurnId).toBeNull(); |
| 793 | 787 | expect(next.diffFilePath).toBeNull(); |
| 794 | 788 | }); |
| 795 | 789 | |
| 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 | 790 | it("marks completion as seen immediately for the active thread", () => { |
| 814 | 791 | const state = makeState( |
| 815 | 792 | makeThread({ |
| ... | ... | @@ -831,6 +808,7 @@ describe("store reducer thread continuity", () => { |
| 831 | 808 | payload: { turn: { id: "turn-1", status: "completed" } }, |
| 832 | 809 | }), |
| 833 | 810 | activeAssistantItemRef: { current: null }, |
| 811 | activeThreadId: "thread-local-1", | |
| 834 | 812 | }); |
| 835 | 813 | |
| 836 | 814 | expect(next.threads[0]?.latestTurnCompletedAt).toBe(completedAt); |
apps/web/src/store.ts+5-42| ... | ... | @@ -42,7 +42,6 @@ type Action = |
| 42 | 42 | | { type: "TOGGLE_PROJECT"; projectId: string } |
| 43 | 43 | | { type: "DELETE_PROJECT"; projectId: string } |
| 44 | 44 | | { type: "ADD_THREAD"; thread: Thread } |
| 45 | | { type: "SET_ACTIVE_THREAD"; threadId: string } | |
| 46 | 45 | | { type: "TOGGLE_THREAD_TERMINAL"; threadId: string } |
| 47 | 46 | | { type: "SET_THREAD_TERMINAL_OPEN"; threadId: string; open: boolean } |
| 48 | 47 | | { type: "SET_THREAD_TERMINAL_HEIGHT"; threadId: string; height: number } |
| ... | ... | @@ -68,6 +67,7 @@ type Action = |
| 68 | 67 | type: "APPLY_EVENT"; |
| 69 | 68 | event: ProviderEvent; |
| 70 | 69 | activeAssistantItemRef: { current: string | null }; |
| 70 | activeThreadId?: string | null; | |
| 71 | 71 | } |
| 72 | 72 | | { type: "APPLY_TERMINAL_EVENT"; event: TerminalEvent } |
| 73 | 73 | | { type: "UPDATE_SESSION"; threadId: string; session: ProviderSession } |
| ... | ... | @@ -113,7 +113,6 @@ type Action = |
| 113 | 113 | export interface AppState { |
| 114 | 114 | projects: Project[]; |
| 115 | 115 | threads: Thread[]; |
| 116 | activeThreadId: string | null; | |
| 117 | 116 | threadsHydrated: boolean; |
| 118 | 117 | runtimeMode: RuntimeMode; |
| 119 | 118 | diffOpen: boolean; |
| ... | ... | @@ -137,7 +136,6 @@ const LEGACY_PERSISTED_STATE_KEYS = [ |
| 137 | 136 | const initialState: AppState = { |
| 138 | 137 | projects: [], |
| 139 | 138 | threads: [], |
| 140 | activeThreadId: null, | |
| 141 | 139 | threadsHydrated: false, |
| 142 | 140 | runtimeMode: DEFAULT_RUNTIME_MODE, |
| 143 | 141 | diffOpen: false, |
| ... | ... | @@ -162,14 +160,10 @@ function readPersistedState(): AppState { |
| 162 | 160 | if (!hydrated) return initialState; |
| 163 | 161 | |
| 164 | 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); | |
| 168 | 163 | |
| 169 | 164 | return { |
| 170 | 165 | ...hydrated, |
| 171 | 166 | threads, |
| 172 | activeThreadId, | |
| 173 | 167 | threadsHydrated: threads.length > 0, |
| 174 | 168 | diffOpen: false, |
| 175 | 169 | diffThreadId: null, |
| ... | ... | @@ -638,16 +632,12 @@ export function reducer(state: AppState, action: Action): AppState { |
| 638 | 632 | }); |
| 639 | 633 | }) |
| 640 | 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 | 635 | const diffState = resetDiffTargetIfMissing(state, nextThreads); |
| 645 | 636 | |
| 646 | 637 | return { |
| 647 | 638 | ...state, |
| 648 | 639 | projects: nextProjects, |
| 649 | 640 | threads: nextThreads, |
| 650 | activeThreadId, | |
| 651 | 641 | ...diffState, |
| 652 | 642 | }; |
| 653 | 643 | } |
| ... | ... | @@ -667,16 +657,12 @@ export function reducer(state: AppState, action: Action): AppState { |
| 667 | 657 | } |
| 668 | 658 | |
| 669 | 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 | 660 | const diffState = resetDiffTargetIfMissing(state, threads); |
| 674 | 661 | |
| 675 | 662 | return { |
| 676 | 663 | ...state, |
| 677 | 664 | projects, |
| 678 | 665 | threads, |
| 679 | activeThreadId, | |
| 680 | 666 | ...diffState, |
| 681 | 667 | }; |
| 682 | 668 | } |
| ... | ... | @@ -691,19 +677,6 @@ export function reducer(state: AppState, action: Action): AppState { |
| 691 | 677 | return { |
| 692 | 678 | ...state, |
| 693 | 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 | } |
| 709 | 682 | |
| ... | ... | @@ -926,7 +899,7 @@ export function reducer(state: AppState, action: Action): AppState { |
| 926 | 899 | }; |
| 927 | 900 | |
| 928 | 901 | case "APPLY_EVENT": { |
| 929 | const { event, activeAssistantItemRef } = action; | |
| 902 | const { event, activeAssistantItemRef, activeThreadId } = action; | |
| 930 | 903 | const target = findThreadBySessionId(state.threads, event.sessionId); |
| 931 | 904 | if (!target) return state; |
| 932 | 905 | if (shouldIgnoreForeignThreadEvent(target, event)) return state; |
| ... | ... | @@ -959,7 +932,7 @@ export function reducer(state: AppState, action: Action): AppState { |
| 959 | 932 | events: nextEvents, |
| 960 | 933 | turnDiffSummaries, |
| 961 | 934 | ...updateTurnFields(t, event), |
| 962 | ...(event.method === "turn/completed" && t.id === state.activeThreadId | |
| 935 | ...(event.method === "turn/completed" && t.id === activeThreadId | |
| 963 | 936 | ? { lastVisitedAt: event.createdAt } |
| 964 | 937 | : {}), |
| 965 | 938 | }; |
| ... | ... | @@ -1176,21 +1149,11 @@ export function reducer(state: AppState, action: Action): AppState { |
| 1176 | 1149 | |
| 1177 | 1150 | case "DELETE_THREAD": { |
| 1178 | 1151 | 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); | |
| 1182 | 1153 | return { |
| 1183 | 1154 | ...state, |
| 1184 | 1155 | threads, |
| 1185 | activeThreadId, | |
| 1186 | ...(shouldClearDiffTarget | |
| 1187 | ? { | |
| 1188 | diffOpen: false, | |
| 1189 | diffThreadId: null, | |
| 1190 | diffTurnId: null, | |
| 1191 | diffFilePath: null, | |
| 1192 | } | |
| 1193 | : {}), | |
| 1156 | ...diffState, | |
| 1194 | 1157 | }; |
| 1195 | 1158 | } |
| 1196 | 1159 |
apps/web/vite.config.js created+41| ... | ... | @@ -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 | }); |