- 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 | <Command | 300 | <Command |
| 301 | mode="none" | 301 | mode="none" |
| 302 | onItemHighlighted={(highlightedValue) => { | 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 | <div className="relative overflow-hidden rounded-xl border border-border/80 bg-popover/96 shadow-lg/8 backdrop-blur-xs"> | 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 | const keybindings = keybindingsQuery.data ?? EMPTY_KEYBINDINGS; | 553 | const keybindings = keybindingsQuery.data ?? EMPTY_KEYBINDINGS; |
| 552 | const threadTerminalRuntimeEnv = useMemo(() => { | 554 | const threadTerminalRuntimeEnv = useMemo(() => { |
| 553 | if (!activeProject) return {}; | 555 | if (!activeProject?.cwd) return {}; |
| 554 | return projectScriptRuntimeEnv({ | 556 | return projectScriptRuntimeEnv({ |
| 555 | project: { | 557 | project: { |
| 556 | cwd: activeProject.cwd, | 558 | cwd: activeProject.cwd, |
| @@ -686,9 +688,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 129 unmodified lines | |||
| 686 | } | 688 | } |
| 687 | const targetCwd = options?.cwd ?? gitCwd ?? activeProject.cwd; | 689 | const targetCwd = options?.cwd ?? gitCwd ?? activeProject.cwd; |
| 688 | const baseTerminalId = | 690 | const baseTerminalId = |
| 689 | activeThread.activeTerminalId || | 691 | activeThread.activeTerminalId || activeThread.terminalIds[0] || DEFAULT_THREAD_TERMINAL_ID; |
| 690 | activeThread.terminalIds[0] || | ||
| 691 | DEFAULT_THREAD_TERMINAL_ID; | ||
| 692 | const isBaseTerminalBusy = activeThread.runningTerminalIds.includes(baseTerminalId); | 692 | const isBaseTerminalBusy = activeThread.runningTerminalIds.includes(baseTerminalId); |
| 693 | const wantsNewTerminal = Boolean(options?.preferNewTerminal) || isBaseTerminalBusy; | 693 | const wantsNewTerminal = Boolean(options?.preferNewTerminal) || isBaseTerminalBusy; |
| 694 | const shouldCreateNewTerminal = | 694 | const shouldCreateNewTerminal = |
| @@ -67,13 +67,13 @@ export function buildGitActionProgressStages(input: { · 66 unmodified lines | |||
| 67 | return [...commitStages, pushStage, "Creating PR..."]; | 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 | export function summarizeGitResult(result: GitRunStackedActionResult): { | 73 | export 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}`; |
| @@ -2,6 +2,7 @@ · 1 unmodified line | |||
| 2 | 2 | ||
| 3 | import { Toast } from "@base-ui/react/toast"; | 3 | import { Toast } from "@base-ui/react/toast"; |
| 4 | import { useEffect } from "react"; | 4 | import { useEffect } from "react"; |
| 5 | import { useRouterState } from "@tanstack/react-router"; | ||
| 5 | import { | 6 | import { |
| 6 | CircleAlertIcon, | 7 | CircleAlertIcon, |
| 7 | CircleCheckIcon, | 8 | CircleCheckIcon, |
| @@ -12,7 +13,6 @@ import { · 4 unmodified lines | |||
| 12 | 13 | ||
| 13 | import { cn } from "~/lib/utils"; | 14 | import { cn } from "~/lib/utils"; |
| 14 | import { buttonVariants } from "~/components/ui/button"; | 15 | import { buttonVariants } from "~/components/ui/button"; |
| 15 | import { useStore } from "~/store"; | ||
| 16 | import { shouldHideCollapsedToastContent } from "./toast.logic"; | 16 | import { shouldHideCollapsedToastContent } from "./toast.logic"; |
| 17 | 17 | ||
| 18 | type ThreadToastData = { | 18 | type ThreadToastData = { |
| @@ -55,6 +55,21 @@ function shouldRenderForActiveThread( · 36 unmodified lines | |||
| 55 | return toastThreadId === activeThreadId; | 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 | function ThreadToastVisibleAutoDismiss({ | 73 | function ThreadToastVisibleAutoDismiss({ |
| 59 | toastId, | 74 | toastId, |
| 60 | dismissAfterVisibleMs, | 75 | dismissAfterVisibleMs, |
| @@ -143,9 +158,8 @@ function ToastProvider({ children, position = "top-right", ...props }: ToastProv · 82 unmodified lines | |||
| 143 | } | 158 | } |
| 144 | 159 | ||
| 145 | function Toasts({ position = "top-right" }: { position: ToastPosition }) { | 160 | function 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) { · 140 unmodified lines | |||
| 292 | } | 306 | } |
| 293 | 307 | ||
| 294 | function AnchoredToasts() { | 308 | function 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(); |
| 298 | 311 | ||
| 299 | return ( | 312 | return ( |
| 300 | <Toast.Portal data-slot="toast-portal-anchored"> | 313 | <Toast.Portal data-slot="toast-portal-anchored"> |
| @@ -28,6 +28,7 @@ function applyTheme(theme: Theme, suppressTransitions = false) { · 27 unmodified lines | |||
| 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 removal | 30 | // 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"); |
| @@ -2,7 +2,6 @@ import React from "react"; · 1 unmodified line | |||
| 2 | import ReactDOM from "react-dom/client"; | 2 | import ReactDOM from "react-dom/client"; |
| 3 | import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; | 3 | import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; |
| 4 | import { RouterProvider } from "@tanstack/react-router"; | 4 | import { RouterProvider } from "@tanstack/react-router"; |
| 5 | import { AnchoredToastProvider, ToastProvider } from "./components/ui/toast"; | ||
| 6 | import { createHashHistory, createRouter, createBrowserHistory } from "@tanstack/react-router"; | 5 | import { createHashHistory, createRouter, createBrowserHistory } from "@tanstack/react-router"; |
| 7 | import { StoreProvider } from "./store"; | 6 | import { StoreProvider } from "./store"; |
| 8 | 7 | ||
| @@ -23,24 +22,21 @@ const router = createRouter({ · 14 unmodified lines | |||
| 23 | context: { | 22 | context: { |
| 24 | queryClient, | 23 | queryClient, |
| 25 | }, | 24 | }, |
| 26 | }); | 25 | Wrap: ({ children }) => ( |
| 27 | |||
| 28 | function 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 | |||
| 32 | declare module "@tanstack/react-router" { | ||
| 33 | interface Register { | ||
| 34 | router: typeof router; | ||
| 35 | } | ||
| 40 | } | 36 | } |
| 41 | 37 | ||
| 42 | ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( | 38 | ReactDOM.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 | ); |
| @@ -106,7 +106,6 @@ describe("hydratePersistedState", () => { · 105 unmodified lines | |||
| 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 | }); |
| 112 | 111 | ||
| @@ -323,7 +322,6 @@ describe("toPersistedState", () => { · 210 unmodified lines | |||
| 323 | }, | 322 | }, |
| 324 | ], | 323 | ], |
| 325 | threads: [thread], | 324 | threads: [thread], |
| 326 | activeThreadId: "t-1", | ||
| 327 | runtimeMode: "full-access", | 325 | runtimeMode: "full-access", |
| 328 | }); | 326 | }); |
| 329 | 327 | ||
| @@ -72,7 +72,7 @@ const persistedThreadSchema = z.object({ · 71 unmodified lines | |||
| 72 | const persistedStateBodySchema = z.object({ | 72 | const persistedStateBodySchema = z.object({ |
| 73 | projects: z.array(persistedProjectSchema), | 73 | projects: z.array(persistedProjectSchema), |
| 74 | threads: z.array(persistedThreadSchema), | 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 | const runtimeModeSchema = z.enum(["approval-required", "full-access"]); | 78 | const runtimeModeSchema = z.enum(["approval-required", "full-access"]); |
| @@ -123,7 +123,6 @@ const persistedStateSchema = z.union([ · 44 unmodified lines | |||
| 123 | export interface PersistedStoreSnapshot { | 123 | export interface PersistedStoreSnapshot { |
| 124 | projects: Project[]; | 124 | projects: Project[]; |
| 125 | threads: Thread[]; | 125 | threads: Thread[]; |
| 126 | activeThreadId: string | null; | ||
| 127 | runtimeMode: RuntimeMode; | 126 | runtimeMode: RuntimeMode; |
| 128 | } | 127 | } |
| 129 | 128 | ||
| @@ -299,15 +298,9 @@ export function hydratePersistedState( · 169 unmodified lines | |||
| 299 | const threads = parsedState.data.threads | 298 | const threads = parsedState.data.threads |
| 300 | .map((thread) => hydrateThread(thread, isLegacyPayload)) | 299 | .map((thread) => hydrateThread(thread, isLegacyPayload)) |
| 301 | .filter((thread) => projectIds.has(thread.projectId)); | 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 | return { | 301 | return { |
| 308 | projects, | 302 | projects, |
| 309 | threads, | 303 | threads, |
| 310 | activeThreadId: hasActiveThread ? parsedState.data.activeThreadId : (threads[0]?.id ?? null), | ||
| 311 | runtimeMode: | 304 | runtimeMode: |
| 312 | "runtimeMode" in parsedState.data ? parsedState.data.runtimeMode : DEFAULT_RUNTIME_MODE, | 305 | "runtimeMode" in parsedState.data ? parsedState.data.runtimeMode : DEFAULT_RUNTIME_MODE, |
| 313 | }; | 306 | }; |
| @@ -354,7 +347,6 @@ export function toPersistedState( · 40 unmodified lines | |||
| 354 | branch: thread.branch, | 347 | branch: thread.branch, |
| 355 | worktreePath: thread.worktreePath, | 348 | worktreePath: thread.worktreePath, |
| 356 | })), | 349 | })), |
| 357 | activeThreadId: state.activeThreadId, | ||
| 358 | runtimeMode: state.runtimeMode, | 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 | import { useEffect, useRef } from "react"; | 2 | import { useEffect, useRef } from "react"; |
| 3 | import { QueryClient, useQueryClient } from "@tanstack/react-query"; | 3 | import { QueryClient, useQueryClient } from "@tanstack/react-query"; |
| 4 | 4 | ||
| 5 | import { AnchoredToastProvider, ToastProvider } from "../components/ui/toast"; | ||
| 5 | import { isElectron } from "../env"; | 6 | import { isElectron } from "../env"; |
| 6 | import { useNativeApi } from "../hooks/useNativeApi"; | 7 | import { useNativeApi } from "../hooks/useNativeApi"; |
| 7 | import { invalidateGitQueries } from "../lib/gitReactQuery"; | 8 | import { invalidateGitQueries } from "../lib/gitReactQuery"; |
| 8 | import { DEFAULT_MODEL } from "../model-logic"; | 9 | import { DEFAULT_MODEL } from "../model-logic"; |
| 9 | import { createThread } from "../threadFactory"; | ||
| 10 | import { useStore } from "../store"; | 10 | import { useStore } from "../store"; |
| 11 | import { onServerWelcome } from "../wsNativeApi"; | 11 | import { onServerWelcome } from "../wsNativeApi"; |
| 12 | 12 | ||
| @@ -30,12 +30,14 @@ function RootRouteView() { · 17 unmodified lines | |||
| 30 | } | 30 | } |
| 31 | 31 | ||
| 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 | } |
| 41 | 43 | ||
| @@ -44,6 +46,10 @@ function EventRouter() { · 2 unmodified lines | |||
| 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 | }); | ||
| 47 | 53 | ||
| 48 | useEffect(() => { | 54 | useEffect(() => { |
| 49 | if (!api) return; | 55 | if (!api) return; |
| @@ -51,13 +57,15 @@ function EventRouter() { · 1 unmodified line | |||
| 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]); |
| 61 | 69 | ||
| 62 | useEffect(() => { | 70 | useEffect(() => { |
| 63 | if (!api) return; | 71 | if (!api) return; |
| @@ -88,14 +96,6 @@ function AutoProjectBootstrap() { · 24 unmodified lines | |||
| 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() { · 13 unmodified lines | |||
| 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]); |
| 125 | 121 | ||
| 126 | return null; | 122 | return null; |
| 127 | } | 123 | } |
| @@ -5,7 +5,7 @@ import ChatView from "../components/ChatView"; · 4 unmodified lines | |||
| 5 | import { useStore } from "../store"; | 5 | import { useStore } from "../store"; |
| 6 | 6 | ||
| 7 | function ChatThreadRouteView() { | 7 | function 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() { · 12 unmodified lines | |||
| 24 | void navigate({ to: "/", replace: true }); | 24 | void navigate({ to: "/", replace: true }); |
| 25 | return; | 25 | return; |
| 26 | } | 26 | } |
| 27 | 27 | }, [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]); | ||
| 34 | 28 | ||
| 35 | if (!threadId || !state.threadsHydrated || !threadExists) { | 29 | if (!threadId || !state.threadsHydrated || !threadExists) { |
| 36 | return null; | 30 | return null; |
| @@ -1,82 +1,22 @@ | |||
| 1 | import { createFileRoute, useNavigate } from "@tanstack/react-router"; | 1 | import { createFileRoute } from "@tanstack/react-router"; |
| 2 | import { type FormEvent, useMemo, useState } from "react"; | ||
| 3 | 2 | ||
| 4 | import { Button } from "../components/ui/button"; | 3 | import { isElectron } from "../env"; |
| 5 | import { Textarea } from "../components/ui/textarea"; | ||
| 6 | import { createThread } from "../threadFactory"; | ||
| 7 | import { truncateTitle } from "../truncateTitle"; | ||
| 8 | import { useStore } from "../store"; | ||
| 9 | 4 | ||
| 10 | function ChatIndexRouteView() { | 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 | 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 | )} | ||
| 58 | 13 | ||
| 59 | <form onSubmit={onCreateThread} className="space-y-3"> | 14 | <div className="flex flex-1 items-center justify-center"> |
| 60 | <Textarea | 15 | <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 | } |
| 82 | 22 | ||
| @@ -1,5 +1,4 @@ | |||
| 1 | import { Outlet, createFileRoute, useNavigate, useParams } from "@tanstack/react-router"; | 1 | import { Outlet, createFileRoute } from "@tanstack/react-router"; |
| 2 | import { useEffect } from "react"; | ||
| 3 | 2 | ||
| 4 | import DiffPanel from "../components/DiffPanel"; | 3 | import DiffPanel from "../components/DiffPanel"; |
| 5 | import Sidebar from "../components/Sidebar"; | 4 | import Sidebar from "../components/Sidebar"; |
| @@ -7,18 +6,6 @@ import { useStore } from "../store"; · 1 unmodified line | |||
| 7 | 6 | ||
| 8 | function ChatRouteLayout() { | 7 | function 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]); | ||
| 22 | 9 | ||
| 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"> |
| @@ -78,7 +78,6 @@ function makeState(thread: Thread): AppState { · 77 unmodified lines | |||
| 78 | }, | 78 | }, |
| 79 | ], | 79 | ], |
| 80 | threads: [thread], | 80 | threads: [thread], |
| 81 | activeThreadId: thread.id, | ||
| 82 | threadsHydrated: true, | 81 | threadsHydrated: true, |
| 83 | runtimeMode: "full-access", | 82 | runtimeMode: "full-access", |
| 84 | diffOpen: false, | 83 | diffOpen: false, |
| @@ -530,7 +529,6 @@ describe("store reducer thread continuity", () => { · 445 unmodified lines | |||
| 530 | projectId: "project-old-b", | 529 | projectId: "project-old-b", |
| 531 | }), | 530 | }), |
| 532 | ], | 531 | ], |
| 533 | activeThreadId: "thread-b", | ||
| 534 | threadsHydrated: true, | 532 | threadsHydrated: true, |
| 535 | runtimeMode: "full-access", | 533 | runtimeMode: "full-access", |
| 536 | diffOpen: false, | 534 | diffOpen: false, |
| @@ -557,7 +555,6 @@ describe("store reducer thread continuity", () => { · 20 unmodified lines | |||
| 557 | expect(next.threads).toHaveLength(1); | 555 | expect(next.threads).toHaveLength(1); |
| 558 | expect(next.threads[0]?.id).toBe("thread-a"); | 556 | expect(next.threads[0]?.id).toBe("thread-a"); |
| 559 | expect(next.threads[0]?.projectId).toBe("project-new-a"); | 557 | expect(next.threads[0]?.projectId).toBe("project-new-a"); |
| 560 | expect(next.activeThreadId).toBe("thread-a"); | ||
| 561 | }); | 558 | }); |
| 562 | 559 | ||
| 563 | it("treats empty scripts from sync as authoritative", () => { | 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 | threads: [makeThread({ id: "thread-a", projectId: "project-old-a" })], | 580 | threads: [makeThread({ id: "thread-a", projectId: "project-old-a" })], |
| 584 | activeThreadId: "thread-a", | ||
| 585 | threadsHydrated: true, | 581 | threadsHydrated: true, |
| 586 | runtimeMode: "full-access", | 582 | runtimeMode: "full-access", |
| 587 | diffOpen: false, | 583 | diffOpen: false, |
| @@ -671,7 +667,6 @@ describe("store reducer thread continuity", () => { · 83 unmodified lines | |||
| 671 | projectId: "project-2", | 667 | projectId: "project-2", |
| 672 | }), | 668 | }), |
| 673 | ], | 669 | ], |
| 674 | activeThreadId: "thread-a", | ||
| 675 | threadsHydrated: true, | 670 | threadsHydrated: true, |
| 676 | runtimeMode: "full-access", | 671 | runtimeMode: "full-access", |
| 677 | diffOpen: false, | 672 | diffOpen: false, |
| @@ -686,24 +681,6 @@ describe("store reducer thread continuity", () => { · 8 unmodified lines | |||
| 686 | expect(next.projects[0]?.id).toBe("project-2"); | 681 | expect(next.projects[0]?.id).toBe("project-2"); |
| 687 | expect(next.threads).toHaveLength(1); | 682 | expect(next.threads).toHaveLength(1); |
| 688 | expect(next.threads[0]?.id).toBe("thread-b"); | 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 | it("marks completion as seen immediately for the active thread", () => { | 686 | it("marks completion as seen immediately for the active thread", () => { |
| @@ -727,6 +704,7 @@ describe("store reducer thread continuity", () => { · 17 unmodified lines | |||
| 727 | payload: { turn: { id: "turn-1", status: "completed" } }, | 704 | payload: { turn: { id: "turn-1", status: "completed" } }, |
| 728 | }), | 705 | }), |
| 729 | activeAssistantItemRef: { current: null }, | 706 | activeAssistantItemRef: { current: null }, |
| 707 | activeThreadId: "thread-local-1", | ||
| 730 | }); | 708 | }); |
| 731 | 709 | ||
| 732 | expect(next.threads[0]?.latestTurnCompletedAt).toBe(completedAt); | 710 | expect(next.threads[0]?.latestTurnCompletedAt).toBe(completedAt); |
| @@ -34,7 +34,6 @@ type Action = · 33 unmodified lines | |||
| 34 | | { type: "TOGGLE_PROJECT"; projectId: string } | 34 | | { type: "TOGGLE_PROJECT"; projectId: string } |
| 35 | | { type: "DELETE_PROJECT"; projectId: string } | 35 | | { type: "DELETE_PROJECT"; projectId: string } |
| 36 | | { type: "ADD_THREAD"; thread: Thread } | 36 | | { type: "ADD_THREAD"; thread: Thread } |
| 37 | | { type: "SET_ACTIVE_THREAD"; threadId: string } | ||
| 38 | | { type: "TOGGLE_THREAD_TERMINAL"; threadId: string } | 37 | | { type: "TOGGLE_THREAD_TERMINAL"; threadId: string } |
| 39 | | { type: "SET_THREAD_TERMINAL_OPEN"; threadId: string; open: boolean } | 38 | | { type: "SET_THREAD_TERMINAL_OPEN"; threadId: string; open: boolean } |
| 40 | | { type: "SET_THREAD_TERMINAL_HEIGHT"; threadId: string; height: number } | 39 | | { type: "SET_THREAD_TERMINAL_HEIGHT"; threadId: string; height: number } |
| @@ -47,6 +46,7 @@ type Action = · 6 unmodified lines | |||
| 47 | type: "APPLY_EVENT"; | 46 | type: "APPLY_EVENT"; |
| 48 | event: ProviderEvent; | 47 | event: ProviderEvent; |
| 49 | activeAssistantItemRef: { current: string | null }; | 48 | activeAssistantItemRef: { current: string | null }; |
| 49 | activeThreadId?: string | null; | ||
| 50 | } | 50 | } |
| 51 | | { type: "APPLY_TERMINAL_EVENT"; event: TerminalEvent } | 51 | | { type: "APPLY_TERMINAL_EVENT"; event: TerminalEvent } |
| 52 | | { type: "UPDATE_SESSION"; threadId: string; session: ProviderSession } | 52 | | { type: "UPDATE_SESSION"; threadId: string; session: ProviderSession } |
| @@ -74,7 +74,6 @@ type Action = · 21 unmodified lines | |||
| 74 | export interface AppState { | 74 | export interface AppState { |
| 75 | projects: Project[]; | 75 | projects: Project[]; |
| 76 | threads: Thread[]; | 76 | threads: Thread[]; |
| 77 | activeThreadId: string | null; | ||
| 78 | threadsHydrated: boolean; | 77 | threadsHydrated: boolean; |
| 79 | runtimeMode: RuntimeMode; | 78 | runtimeMode: RuntimeMode; |
| 80 | diffOpen: boolean; | 79 | diffOpen: boolean; |
| @@ -95,7 +94,6 @@ const LEGACY_PERSISTED_STATE_KEYS = [ · 14 unmodified lines | |||
| 95 | const initialState: AppState = { | 94 | const initialState: AppState = { |
| 96 | projects: [], | 95 | projects: [], |
| 97 | threads: [], | 96 | threads: [], |
| 98 | activeThreadId: null, | ||
| 99 | threadsHydrated: false, | 97 | threadsHydrated: false, |
| 100 | runtimeMode: DEFAULT_RUNTIME_MODE, | 98 | runtimeMode: DEFAULT_RUNTIME_MODE, |
| 101 | diffOpen: false, | 99 | diffOpen: false, |
| @@ -117,14 +115,10 @@ function readPersistedState(): AppState { · 15 unmodified lines | |||
| 117 | if (!hydrated) return initialState; | 115 | if (!hydrated) return initialState; |
| 118 | 116 | ||
| 119 | const threads = hydrated.threads.map((thread) => normalizeThreadTerminals(thread)); | 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 | return { | 119 | return { |
| 125 | ...hydrated, | 120 | ...hydrated, |
| 126 | threads, | 121 | threads, |
| 127 | activeThreadId, | ||
| 128 | threadsHydrated: threads.length > 0, | 122 | threadsHydrated: threads.length > 0, |
| 129 | diffOpen: false, | 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 | .filter((thread): thread is Thread => thread !== null); | 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 | return { | 492 | return { |
| 502 | ...state, | 493 | ...state, |
| 503 | projects: nextProjects, | 494 | projects: nextProjects, |
| 504 | threads: nextThreads, | 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 | const threads = state.threads.filter((thread) => thread.projectId !== action.projectId); | 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 | return { | 515 | return { |
| 529 | ...state, | 516 | ...state, |
| 530 | projects, | 517 | projects, |
| 531 | threads, | 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 | return { | 528 | return { |
| 543 | ...state, | 529 | ...state, |
| 544 | threads: [...state.threads, nextThread], | 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 | case "APPLY_EVENT": { | 732 | case "APPLY_EVENT": { |
| 760 | const { event, activeAssistantItemRef } = action; | 733 | const { event, activeAssistantItemRef, activeThreadId } = action; |
| 761 | const target = findThreadBySessionId(state.threads, event.sessionId); | 734 | const target = findThreadBySessionId(state.threads, event.sessionId); |
| 762 | if (!target) return state; | 735 | if (!target) return state; |
| 763 | if (shouldIgnoreForeignThreadEvent(target, event)) return state; | 736 | if (shouldIgnoreForeignThreadEvent(target, event)) return state; |
| @@ -781,7 +754,7 @@ export function reducer(state: AppState, action: Action): AppState { · 17 unmodified lines | |||
| 781 | messages: applyEventToMessages(t.messages, event, activeAssistantItemRef), | 754 | messages: applyEventToMessages(t.messages, event, activeAssistantItemRef), |
| 782 | events: [event, ...t.events], | 755 | events: [event, ...t.events], |
| 783 | ...updateTurnFields(t, event), | 756 | ...updateTurnFields(t, event), |
| 784 | ...(event.method === "turn/completed" && t.id === state.activeThreadId | 757 | ...(event.method === "turn/completed" && t.id === activeThreadId |
| 785 | ? { lastVisitedAt: event.createdAt } | 758 | ? { lastVisitedAt: event.createdAt } |
| 786 | : {}), | 759 | : {}), |
| 787 | })), | 760 | })), |
| @@ -876,12 +849,8 @@ export function reducer(state: AppState, action: Action): AppState { · 88 unmodified lines | |||
| 876 | runtimeMode: action.mode, | 849 | runtimeMode: action.mode, |
| 877 | }; | 850 | }; |
| 878 | 851 | ||
| 879 | case "DELETE_THREAD": { | 852 | case "DELETE_THREAD": |
| 880 | const threads = state.threads.filter((t) => t.id !== action.threadId); | 853 | return { ...state, 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 | } | ||
| 885 | 854 | ||
| 886 | default: | 855 | default: |
| 887 | return state; | 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 | }); | ||