diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 7169e1796bbfefb79447c42ba51ee9c5f77bf992..81b4e1fc057e75ef7da5ba7a8d2072d07edd2375 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -307,7 +307,9 @@ const ComposerCommandMenu = memo(function ComposerCommandMenu(props: { { - props.onHighlightedItemChange(typeof highlightedValue === "string" ? highlightedValue : null); + props.onHighlightedItemChange( + typeof highlightedValue === "string" ? highlightedValue : null, + ); }} >
@@ -689,7 +691,7 @@ export default function ChatView({ threadId }: ChatViewProps) { ); const keybindings = keybindingsQuery.data ?? EMPTY_KEYBINDINGS; const threadTerminalRuntimeEnv = useMemo(() => { - if (!activeProject) return {}; + if (!activeProject?.cwd) return {}; return projectScriptRuntimeEnv({ project: { cwd: activeProject.cwd, @@ -825,9 +827,7 @@ export default function ChatView({ threadId }: ChatViewProps) { } const targetCwd = options?.cwd ?? gitCwd ?? activeProject.cwd; const baseTerminalId = - activeThread.activeTerminalId || - activeThread.terminalIds[0] || - DEFAULT_THREAD_TERMINAL_ID; + activeThread.activeTerminalId || activeThread.terminalIds[0] || DEFAULT_THREAD_TERMINAL_ID; const isBaseTerminalBusy = activeThread.runningTerminalIds.includes(baseTerminalId); const wantsNewTerminal = Boolean(options?.preferNewTerminal) || isBaseTerminalBusy; const shouldCreateNewTerminal = diff --git a/apps/web/src/components/GitActionsControl.logic.ts b/apps/web/src/components/GitActionsControl.logic.ts index 50de7b6c5751294e9e3a05b6c8de7402b9589151..a740d6472245b40a9ac3039bbc22505727004bd3 100644 --- a/apps/web/src/components/GitActionsControl.logic.ts +++ b/apps/web/src/components/GitActionsControl.logic.ts @@ -67,13 +67,13 @@ export function buildGitActionProgressStages(input: { return [...commitStages, pushStage, "Creating PR..."]; } +const withDescription = (title: string, description: string | undefined) => + description ? { title, description } : { title }; + export function summarizeGitResult(result: GitRunStackedActionResult): { title: string; description?: string; } { - const withDescription = (title: string, description: string | undefined) => - description ? { title, description } : { title }; - if (result.pr.status === "created" || result.pr.status === "opened_existing") { const prNumber = result.pr.number ? ` #${result.pr.number}` : ""; const title = `${result.pr.status === "created" ? "Created PR" : "Opened PR"}${prNumber}`; diff --git a/apps/web/src/components/ui/toast.tsx b/apps/web/src/components/ui/toast.tsx index 7b4879e9f029b074bca0e4b887017c379f7a25c4..a8df9d0aca6aeb08bdec67e65c57befec3dd6fdc 100644 --- a/apps/web/src/components/ui/toast.tsx +++ b/apps/web/src/components/ui/toast.tsx @@ -2,6 +2,7 @@ import { Toast } from "@base-ui/react/toast"; import { useEffect } from "react"; +import { useRouterState } from "@tanstack/react-router"; import { CircleAlertIcon, CircleCheckIcon, @@ -12,7 +13,6 @@ import { import { cn } from "~/lib/utils"; import { buttonVariants } from "~/components/ui/button"; -import { useStore } from "~/store"; import { shouldHideCollapsedToastContent } from "./toast.logic"; type ThreadToastData = { @@ -55,6 +55,21 @@ function shouldRenderForActiveThread( return toastThreadId === activeThreadId; } +function useActiveThreadIdFromRoute(): string | null { + return useRouterState({ + select: (routerState) => { + for (let index = routerState.matches.length - 1; index >= 0; index -= 1) { + const params = routerState.matches[index]?.params as Record | undefined; + const threadId = params?.threadId; + if (typeof threadId === "string" && threadId.length > 0) { + return threadId; + } + } + return null; + }, + }); +} + function ThreadToastVisibleAutoDismiss({ toastId, dismissAfterVisibleMs, @@ -143,9 +158,8 @@ function ToastProvider({ children, position = "top-right", ...props }: ToastProv } function Toasts({ position = "top-right" }: { position: ToastPosition }) { - const { state } = useStore(); const { toasts } = Toast.useToastManager(); - const activeThreadId = state.activeThreadId; + const activeThreadId = useActiveThreadIdFromRoute(); const isTop = position.startsWith("top"); const visibleToasts = toasts.filter((toast) => shouldRenderForActiveThread(toast.data, activeThreadId), @@ -292,9 +306,8 @@ function AnchoredToastProvider({ children, ...props }: Toast.Provider.Props) { } function AnchoredToasts() { - const { state } = useStore(); const { toasts } = Toast.useToastManager(); - const activeThreadId = state.activeThreadId; + const activeThreadId = useActiveThreadIdFromRoute(); return ( diff --git a/apps/web/src/hooks/useTheme.ts b/apps/web/src/hooks/useTheme.ts index 4099cdc91212557eb630cd9d673d0f22c1ae018c..6a0e61d2d1b5de4500c75625ae8d3b6630846e6b 100644 --- a/apps/web/src/hooks/useTheme.ts +++ b/apps/web/src/hooks/useTheme.ts @@ -28,6 +28,7 @@ function applyTheme(theme: Theme, suppressTransitions = false) { document.documentElement.classList.toggle("dark", isDark); if (suppressTransitions) { // Force a reflow so the no-transitions class takes effect before removal + // oxlint-disable-next-line no-unused-expressions document.documentElement.offsetHeight; requestAnimationFrame(() => { document.documentElement.classList.remove("no-transitions"); diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index e11b2d8c35464196935df63622e5289993b46cb6..baa3fea4f4a3866c9dd5e156980d89a357964e65 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -2,7 +2,6 @@ import React from "react"; import ReactDOM from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { RouterProvider } from "@tanstack/react-router"; -import { AnchoredToastProvider, ToastProvider } from "./components/ui/toast"; import { createHashHistory, createRouter, createBrowserHistory } from "@tanstack/react-router"; import { StoreProvider } from "./store"; @@ -23,24 +22,21 @@ const router = createRouter({ context: { queryClient, }, -}); - -function App() { - return ( + Wrap: ({ children }) => ( - - - - - - - + {children} - ); + ), +}); + +declare module "@tanstack/react-router" { + interface Register { + router: typeof router; + } } ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( - + , ); diff --git a/apps/web/src/persistenceSchema.test.ts b/apps/web/src/persistenceSchema.test.ts index 2b7b951c8d856c5491138cd95cff312fe980fb96..365625033dd30b3efd71f15e8750694436b23a18 100644 --- a/apps/web/src/persistenceSchema.test.ts +++ b/apps/web/src/persistenceSchema.test.ts @@ -106,7 +106,6 @@ describe("hydratePersistedState", () => { { id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, terminalIds: [DEFAULT_THREAD_TERMINAL_ID] }, ]); expect(hydrated?.threads[0]?.activeTerminalGroupId).toBe(`group-${DEFAULT_THREAD_TERMINAL_ID}`); - expect(hydrated?.activeThreadId).toBe("t-1"); expect(hydrated?.runtimeMode).toBe("full-access"); }); @@ -442,7 +441,6 @@ describe("toPersistedState", () => { }, ], threads: [thread], - activeThreadId: "t-1", runtimeMode: "full-access", }); diff --git a/apps/web/src/persistenceSchema.ts b/apps/web/src/persistenceSchema.ts index 2f65a0f1987b40c406e9b283e005a46a123dac66..d2aeb3780a807e6b717ad2ef495b515b68179a2d 100644 --- a/apps/web/src/persistenceSchema.ts +++ b/apps/web/src/persistenceSchema.ts @@ -90,7 +90,7 @@ const persistedThreadSchema = z.object({ const persistedStateBodySchema = z.object({ projects: z.array(persistedProjectSchema), threads: z.array(persistedThreadSchema), - activeThreadId: z.string().min(1).nullable(), + activeThreadId: z.string().min(1).nullable().optional(), }); const runtimeModeSchema = z.enum(["approval-required", "full-access"]); @@ -141,7 +141,6 @@ const persistedStateSchema = z.union([ export interface PersistedStoreSnapshot { projects: Project[]; threads: Thread[]; - activeThreadId: string | null; runtimeMode: RuntimeMode; } @@ -332,15 +331,9 @@ export function hydratePersistedState( const threads = parsedState.data.threads .map((thread) => hydrateThread(thread, isLegacyPayload)) .filter((thread) => projectIds.has(thread.projectId)); - const hasActiveThread = Boolean( - parsedState.data.activeThreadId && - threads.some((thread) => thread.id === parsedState.data.activeThreadId), - ); - return { projects, threads, - activeThreadId: hasActiveThread ? parsedState.data.activeThreadId : (threads[0]?.id ?? null), runtimeMode: "runtimeMode" in parsedState.data ? parsedState.data.runtimeMode : DEFAULT_RUNTIME_MODE, }; @@ -402,7 +395,6 @@ export function toPersistedState( : {}), })), })), - activeThreadId: state.activeThreadId, runtimeMode: state.runtimeMode, }; } diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index a80424217fca556288d8040ddbfdc7a4a3e57fde..33b578674be2d612cc2b83a1a60de1a7136c770e 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -1,12 +1,12 @@ -import { Outlet, createRootRouteWithContext } from "@tanstack/react-router"; +import { Outlet, createRootRouteWithContext, useParams } from "@tanstack/react-router"; import { useEffect, useRef } from "react"; import { QueryClient, useQueryClient } from "@tanstack/react-query"; +import { AnchoredToastProvider, ToastProvider } from "../components/ui/toast"; import { isElectron } from "../env"; import { useNativeApi } from "../hooks/useNativeApi"; import { invalidateGitQueries } from "../lib/gitReactQuery"; import { DEFAULT_MODEL } from "../model-logic"; -import { createThread } from "../threadFactory"; import { useStore } from "../store"; import { onServerWelcome } from "../wsNativeApi"; @@ -30,12 +30,14 @@ function RootRouteView() { } return ( - <> - - - - - + + + + + + + + ); } @@ -44,6 +46,10 @@ function EventRouter() { const { dispatch } = useStore(); const queryClient = useQueryClient(); const activeAssistantItemRef = useRef(null); + const activeThreadId = useParams({ + strict: false, + select: (params) => params.threadId, + }); useEffect(() => { if (!api) return; @@ -51,13 +57,15 @@ function EventRouter() { if (event.method === "turn/completed") { void invalidateGitQueries(queryClient); } + if (!activeThreadId) return; dispatch({ type: "APPLY_EVENT", event, activeAssistantItemRef, + activeThreadId, }); }); - }, [api, dispatch, queryClient]); + }, [activeThreadId, api, dispatch, queryClient]); useEffect(() => { if (!api) return; @@ -88,14 +96,6 @@ function AutoProjectBootstrap() { const existing = state.projects.find((project) => project.cwd === payload.cwd); if (existing) { bootstrappedRef.current = true; - // Ensure a thread is active - const existingThread = state.threads.find((thread) => thread.projectId === existing.id); - if (existingThread && !state.activeThreadId) { - dispatch({ - type: "SET_ACTIVE_THREAD", - threadId: existingThread.id, - }); - } dispatch({ type: "SET_THREADS_HYDRATED", hydrated: true }); return; } @@ -115,13 +115,9 @@ function AutoProjectBootstrap() { scripts: [], }, }); - dispatch({ - type: "ADD_THREAD", - thread: createThread(projectId), - }); dispatch({ type: "SET_THREADS_HYDRATED", hydrated: true }); }); - }, [state.projects, state.threads, state.activeThreadId, dispatch]); + }, [state.projects, dispatch]); return null; } diff --git a/apps/web/src/routes/_chat.$threadId.tsx b/apps/web/src/routes/_chat.$threadId.tsx index 042fa0d37100d0f2820659724a82ad9d4a4300d4..066f657ea332294601206c030cfa1f4cc06edccc 100644 --- a/apps/web/src/routes/_chat.$threadId.tsx +++ b/apps/web/src/routes/_chat.$threadId.tsx @@ -5,7 +5,7 @@ import ChatView from "../components/ChatView"; import { useStore } from "../store"; function ChatThreadRouteView() { - const { state, dispatch } = useStore(); + const { state } = useStore(); const navigate = useNavigate(); const { threadId } = Route.useParams(); const threadExists = threadId ? state.threads.some((thread) => thread.id === threadId) : false; @@ -24,13 +24,7 @@ function ChatThreadRouteView() { void navigate({ to: "/", replace: true }); return; } - - if (state.activeThreadId === threadId) { - return; - } - - dispatch({ type: "SET_ACTIVE_THREAD", threadId }); - }, [dispatch, navigate, state.activeThreadId, state.threadsHydrated, threadExists, threadId]); + }, [navigate, state.threadsHydrated, threadExists, threadId]); if (!threadId || !state.threadsHydrated || !threadExists) { return null; diff --git a/apps/web/src/routes/_chat.index.tsx b/apps/web/src/routes/_chat.index.tsx index d8db92c20964c6f66893bdbf877b43304cfe8083..5e70383c485c3c5b7df2313db34913fb8ae97c8f 100644 --- a/apps/web/src/routes/_chat.index.tsx +++ b/apps/web/src/routes/_chat.index.tsx @@ -1,82 +1,22 @@ -import { createFileRoute, useNavigate } from "@tanstack/react-router"; -import { type FormEvent, useMemo, useState } from "react"; +import { createFileRoute } from "@tanstack/react-router"; -import { Button } from "../components/ui/button"; -import { Textarea } from "../components/ui/textarea"; -import { createThread } from "../threadFactory"; -import { truncateTitle } from "../truncateTitle"; -import { useStore } from "../store"; +import { isElectron } from "../env"; function ChatIndexRouteView() { - const { state, dispatch } = useStore(); - const navigate = useNavigate(); - const [draft, setDraft] = useState(""); - const defaultProject = state.projects[0] ?? null; - const canCreateThread = defaultProject !== null; - - const placeholder = useMemo(() => { - if (!canCreateThread) { - return "Add a project in the sidebar to start chatting."; - } - return "Start with a goal, bug report, or implementation idea..."; - }, [canCreateThread]); - - const onCreateThread = (event: FormEvent) => { - event.preventDefault(); - if (!defaultProject) return; - - const threadTitle = truncateTitle(draft); - const thread = createThread( - defaultProject.id, - threadTitle - ? { model: defaultProject.model, title: threadTitle } - : { model: defaultProject.model }, - ); - - dispatch({ - type: "ADD_THREAD", - thread, - }); - - setDraft(""); - - void navigate({ - to: "/$threadId", - params: { threadId: thread.id }, - }); - }; - return ( -
-
-
-

New chat

-

- Create a thread and continue in the full chat workspace. -

+
+ {isElectron && ( +
+ No active thread
+ )} -
-