| 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,7 +307,9 @@ const ComposerCommandMenu = memo(function ComposerCommandMenu(props: { |
| 307 | <Command | 307 | <Command |
| 308 | mode="none" | 308 | mode="none" |
| 309 | onItemHighlighted={(highlightedValue) => { | 309 | onItemHighlighted={(highlightedValue) => { |
| 310 | props.onHighlightedItemChange(typeof highlightedValue === "string" ? highlightedValue : null); | 310 | props.onHighlightedItemChange( |
| 311 | typeof highlightedValue === "string" ? highlightedValue : null, | ||
| 312 | ); | ||
| 311 | }} | 313 | }} |
| 312 | > | 314 | > |
| 313 | <div className="relative overflow-hidden rounded-xl border border-border/80 bg-popover/96 shadow-lg/8 backdrop-blur-xs"> | 315 | <div className="relative overflow-hidden rounded-xl border border-border/80 bg-popover/96 shadow-lg/8 backdrop-blur-xs"> |
| ... | @@ -689,7 +691,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -689,7 +691,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 689 | ); | 691 | ); |
| 690 | const keybindings = keybindingsQuery.data ?? EMPTY_KEYBINDINGS; | 692 | const keybindings = keybindingsQuery.data ?? EMPTY_KEYBINDINGS; |
| 691 | const threadTerminalRuntimeEnv = useMemo(() => { | 693 | const threadTerminalRuntimeEnv = useMemo(() => { |
| 692 | if (!activeProject) return {}; | 694 | if (!activeProject?.cwd) return {}; |
| 693 | return projectScriptRuntimeEnv({ | 695 | return projectScriptRuntimeEnv({ |
| 694 | project: { | 696 | project: { |
| 695 | cwd: activeProject.cwd, | 697 | cwd: activeProject.cwd, |
| ... | @@ -825,9 +827,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -825,9 +827,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 825 | } | 827 | } |
| 826 | const targetCwd = options?.cwd ?? gitCwd ?? activeProject.cwd; | 828 | const targetCwd = options?.cwd ?? gitCwd ?? activeProject.cwd; |
| 827 | const baseTerminalId = | 829 | const baseTerminalId = |
| 828 | activeThread.activeTerminalId || | 830 | activeThread.activeTerminalId || activeThread.terminalIds[0] || DEFAULT_THREAD_TERMINAL_ID; |
| 829 | activeThread.terminalIds[0] || | ||
| 830 | DEFAULT_THREAD_TERMINAL_ID; | ||
| 831 | const isBaseTerminalBusy = activeThread.runningTerminalIds.includes(baseTerminalId); | 831 | const isBaseTerminalBusy = activeThread.runningTerminalIds.includes(baseTerminalId); |
| 832 | const wantsNewTerminal = Boolean(options?.preferNewTerminal) || isBaseTerminalBusy; | 832 | const wantsNewTerminal = Boolean(options?.preferNewTerminal) || isBaseTerminalBusy; |
| 833 | const shouldCreateNewTerminal = | 833 | const shouldCreateNewTerminal = |
apps/web/src/components/GitActionsControl.logic.ts+3-3| ... | @@ -67,13 +67,13 @@ export function buildGitActionProgressStages(input: { | ... | @@ -67,13 +67,13 @@ export function buildGitActionProgressStages(input: { |
| 67 | return [...commitStages, pushStage, "Creating PR..."]; | 67 | return [...commitStages, pushStage, "Creating PR..."]; |
| 68 | } | 68 | } |
| 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}`; |
apps/web/src/components/ui/toast.tsx+18-5| ... | @@ -2,6 +2,7 @@ | ... | @@ -2,6 +2,7 @@ |
| 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 { | ... | @@ -12,7 +13,6 @@ import { |
| 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( | ... | @@ -55,6 +55,21 @@ function shouldRenderForActiveThread( |
| 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 | ... | @@ -143,9 +158,8 @@ function ToastProvider({ children, position = "top-right", ...props }: ToastProv |
| 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) { | ... | @@ -292,9 +306,8 @@ function AnchoredToastProvider({ children, ...props }: Toast.Provider.Props) { |
| 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"> |
apps/web/src/hooks/useTheme.ts+1| ... | @@ -28,6 +28,7 @@ function applyTheme(theme: Theme, suppressTransitions = false) { | ... | @@ -28,6 +28,7 @@ function applyTheme(theme: Theme, suppressTransitions = false) { |
| 28 | document.documentElement.classList.toggle("dark", isDark); | 28 | document.documentElement.classList.toggle("dark", isDark); |
| 29 | if (suppressTransitions) { | 29 | if (suppressTransitions) { |
| 30 | // Force a reflow so the no-transitions class takes effect before 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"); |
apps/web/src/main.tsx+10-14| ... | @@ -2,7 +2,6 @@ import React from "react"; | ... | @@ -2,7 +2,6 @@ import React from "react"; |
| 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({ | ... | @@ -23,24 +22,21 @@ const router = createRouter({ |
| 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 | ); |
apps/web/src/persistenceSchema.test.ts-2| ... | @@ -106,7 +106,6 @@ describe("hydratePersistedState", () => { | ... | @@ -106,7 +106,6 @@ describe("hydratePersistedState", () => { |
| 106 | { id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, terminalIds: [DEFAULT_THREAD_TERMINAL_ID] }, | 106 | { id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, terminalIds: [DEFAULT_THREAD_TERMINAL_ID] }, |
| 107 | ]); | 107 | ]); |
| 108 | expect(hydrated?.threads[0]?.activeTerminalGroupId).toBe(`group-${DEFAULT_THREAD_TERMINAL_ID}`); | 108 | expect(hydrated?.threads[0]?.activeTerminalGroupId).toBe(`group-${DEFAULT_THREAD_TERMINAL_ID}`); |
| 109 | expect(hydrated?.activeThreadId).toBe("t-1"); | ||
| 110 | expect(hydrated?.runtimeMode).toBe("full-access"); | 109 | expect(hydrated?.runtimeMode).toBe("full-access"); |
| 111 | }); | 110 | }); |
| 112 | 111 | ||
| ... | @@ -442,7 +441,6 @@ describe("toPersistedState", () => { | ... | @@ -442,7 +441,6 @@ describe("toPersistedState", () => { |
| 442 | }, | 441 | }, |
| 443 | ], | 442 | ], |
| 444 | threads: [thread], | 443 | threads: [thread], |
| 445 | activeThreadId: "t-1", | ||
| 446 | runtimeMode: "full-access", | 444 | runtimeMode: "full-access", |
| 447 | }); | 445 | }); |
| 448 | 446 |
apps/web/src/persistenceSchema.ts+1-9| ... | @@ -90,7 +90,7 @@ const persistedThreadSchema = z.object({ | ... | @@ -90,7 +90,7 @@ const persistedThreadSchema = z.object({ |
| 90 | const persistedStateBodySchema = z.object({ | 90 | const persistedStateBodySchema = z.object({ |
| 91 | projects: z.array(persistedProjectSchema), | 91 | projects: z.array(persistedProjectSchema), |
| 92 | threads: z.array(persistedThreadSchema), | 92 | threads: z.array(persistedThreadSchema), |
| 93 | activeThreadId: z.string().min(1).nullable(), | 93 | activeThreadId: z.string().min(1).nullable().optional(), |
| 94 | }); | 94 | }); |
| 95 | 95 | ||
| 96 | const runtimeModeSchema = z.enum(["approval-required", "full-access"]); | 96 | const runtimeModeSchema = z.enum(["approval-required", "full-access"]); |
| ... | @@ -141,7 +141,6 @@ const persistedStateSchema = z.union([ | ... | @@ -141,7 +141,6 @@ const persistedStateSchema = z.union([ |
| 141 | export interface PersistedStoreSnapshot { | 141 | export interface PersistedStoreSnapshot { |
| 142 | projects: Project[]; | 142 | projects: Project[]; |
| 143 | threads: Thread[]; | 143 | threads: Thread[]; |
| 144 | activeThreadId: string | null; | ||
| 145 | runtimeMode: RuntimeMode; | 144 | runtimeMode: RuntimeMode; |
| 146 | } | 145 | } |
| 147 | 146 | ||
| ... | @@ -332,15 +331,9 @@ export function hydratePersistedState( | ... | @@ -332,15 +331,9 @@ export function hydratePersistedState( |
| 332 | const threads = parsedState.data.threads | 331 | const threads = parsedState.data.threads |
| 333 | .map((thread) => hydrateThread(thread, isLegacyPayload)) | 332 | .map((thread) => hydrateThread(thread, isLegacyPayload)) |
| 334 | .filter((thread) => projectIds.has(thread.projectId)); | 333 | .filter((thread) => projectIds.has(thread.projectId)); |
| 335 | const hasActiveThread = Boolean( | ||
| 336 | parsedState.data.activeThreadId && | ||
| 337 | threads.some((thread) => thread.id === parsedState.data.activeThreadId), | ||
| 338 | ); | ||
| 339 | |||
| 340 | return { | 334 | return { |
| 341 | projects, | 335 | projects, |
| 342 | threads, | 336 | threads, |
| 343 | activeThreadId: hasActiveThread ? parsedState.data.activeThreadId : (threads[0]?.id ?? null), | ||
| 344 | runtimeMode: | 337 | runtimeMode: |
| 345 | "runtimeMode" in parsedState.data ? parsedState.data.runtimeMode : DEFAULT_RUNTIME_MODE, | 338 | "runtimeMode" in parsedState.data ? parsedState.data.runtimeMode : DEFAULT_RUNTIME_MODE, |
| 346 | }; | 339 | }; |
| ... | @@ -402,7 +395,6 @@ export function toPersistedState( | ... | @@ -402,7 +395,6 @@ export function toPersistedState( |
| 402 | : {}), | 395 | : {}), |
| 403 | })), | 396 | })), |
| 404 | })), | 397 | })), |
| 405 | activeThreadId: state.activeThreadId, | ||
| 406 | runtimeMode: state.runtimeMode, | 398 | runtimeMode: state.runtimeMode, |
| 407 | }; | 399 | }; |
| 408 | } | 400 | } |
apps/web/src/routes/__root.tsx+18-22| ... | @@ -1,12 +1,12 @@ | ... | @@ -1,12 +1,12 @@ |
| 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() { | ... | @@ -30,12 +30,14 @@ function RootRouteView() { |
| 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() { | ... | @@ -44,6 +46,10 @@ function EventRouter() { |
| 44 | const { dispatch } = useStore(); | 46 | const { dispatch } = useStore(); |
| 45 | const queryClient = useQueryClient(); | 47 | const queryClient = useQueryClient(); |
| 46 | const activeAssistantItemRef = useRef<string | null>(null); | 48 | const activeAssistantItemRef = useRef<string | null>(null); |
| 49 | const activeThreadId = useParams({ | ||
| 50 | strict: false, | ||
| 51 | select: (params) => params.threadId, | ||
| 52 | }); | ||
| 47 | 53 | ||
| 48 | useEffect(() => { | 54 | useEffect(() => { |
| 49 | if (!api) return; | 55 | if (!api) return; |
| ... | @@ -51,13 +57,15 @@ function EventRouter() { | ... | @@ -51,13 +57,15 @@ function EventRouter() { |
| 51 | if (event.method === "turn/completed") { | 57 | if (event.method === "turn/completed") { |
| 52 | void invalidateGitQueries(queryClient); | 58 | void invalidateGitQueries(queryClient); |
| 53 | } | 59 | } |
| 60 | if (!activeThreadId) return; | ||
| 54 | dispatch({ | 61 | dispatch({ |
| 55 | type: "APPLY_EVENT", | 62 | type: "APPLY_EVENT", |
| 56 | event, | 63 | event, |
| 57 | activeAssistantItemRef, | 64 | activeAssistantItemRef, |
| 65 | activeThreadId, | ||
| 58 | }); | 66 | }); |
| 59 | }); | 67 | }); |
| 60 | }, [api, dispatch, queryClient]); | 68 | }, [activeThreadId, api, dispatch, queryClient]); |
| 61 | 69 | ||
| 62 | useEffect(() => { | 70 | useEffect(() => { |
| 63 | if (!api) return; | 71 | if (!api) return; |
| ... | @@ -88,14 +96,6 @@ function AutoProjectBootstrap() { | ... | @@ -88,14 +96,6 @@ function AutoProjectBootstrap() { |
| 88 | const existing = state.projects.find((project) => project.cwd === payload.cwd); | 96 | const existing = state.projects.find((project) => project.cwd === payload.cwd); |
| 89 | if (existing) { | 97 | if (existing) { |
| 90 | bootstrappedRef.current = true; | 98 | bootstrappedRef.current = true; |
| 91 | // Ensure a thread is active | ||
| 92 | const existingThread = state.threads.find((thread) => thread.projectId === existing.id); | ||
| 93 | if (existingThread && !state.activeThreadId) { | ||
| 94 | dispatch({ | ||
| 95 | type: "SET_ACTIVE_THREAD", | ||
| 96 | threadId: existingThread.id, | ||
| 97 | }); | ||
| 98 | } | ||
| 99 | dispatch({ type: "SET_THREADS_HYDRATED", hydrated: true }); | 99 | dispatch({ type: "SET_THREADS_HYDRATED", hydrated: true }); |
| 100 | return; | 100 | return; |
| 101 | } | 101 | } |
| ... | @@ -115,13 +115,9 @@ function AutoProjectBootstrap() { | ... | @@ -115,13 +115,9 @@ function AutoProjectBootstrap() { |
| 115 | scripts: [], | 115 | scripts: [], |
| 116 | }, | 116 | }, |
| 117 | }); | 117 | }); |
| 118 | dispatch({ | ||
| 119 | type: "ADD_THREAD", | ||
| 120 | thread: createThread(projectId), | ||
| 121 | }); | ||
| 122 | dispatch({ type: "SET_THREADS_HYDRATED", hydrated: true }); | 118 | dispatch({ type: "SET_THREADS_HYDRATED", hydrated: true }); |
| 123 | }); | 119 | }); |
| 124 | }, [state.projects, state.threads, state.activeThreadId, dispatch]); | 120 | }, [state.projects, dispatch]); |
| 125 | 121 | ||
| 126 | return null; | 122 | return null; |
| 127 | } | 123 | } |
apps/web/src/routes/_chat.$threadId.tsx+2-8| ... | @@ -5,7 +5,7 @@ import ChatView from "../components/ChatView"; | ... | @@ -5,7 +5,7 @@ import ChatView from "../components/ChatView"; |
| 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() { | ... | @@ -24,13 +24,7 @@ function ChatThreadRouteView() { |
| 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; |
apps/web/src/routes/_chat.index.tsx+12-72| ... | @@ -1,82 +1,22 @@ | ... | @@ -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 |
apps/web/src/routes/_chat.tsx+1-14| ... | @@ -1,5 +1,4 @@ | ... | @@ -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"; | ... | @@ -7,18 +6,6 @@ import { useStore } from "../store"; |
| 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"> |
apps/web/src/store.test.ts+1-23| ... | @@ -79,7 +79,6 @@ function makeState(thread: Thread): AppState { | ... | @@ -79,7 +79,6 @@ function makeState(thread: Thread): AppState { |
| 79 | }, | 79 | }, |
| 80 | ], | 80 | ], |
| 81 | threads: [thread], | 81 | threads: [thread], |
| 82 | activeThreadId: thread.id, | ||
| 83 | threadsHydrated: true, | 82 | threadsHydrated: true, |
| 84 | runtimeMode: "full-access", | 83 | runtimeMode: "full-access", |
| 85 | diffOpen: false, | 84 | diffOpen: false, |
| ... | @@ -617,7 +616,6 @@ describe("store reducer thread continuity", () => { | ... | @@ -617,7 +616,6 @@ describe("store reducer thread continuity", () => { |
| 617 | projectId: "project-old-b", | 616 | projectId: "project-old-b", |
| 618 | }), | 617 | }), |
| 619 | ], | 618 | ], |
| 620 | activeThreadId: "thread-b", | ||
| 621 | threadsHydrated: true, | 619 | threadsHydrated: true, |
| 622 | runtimeMode: "full-access", | 620 | runtimeMode: "full-access", |
| 623 | diffOpen: true, | 621 | diffOpen: true, |
| ... | @@ -647,7 +645,6 @@ describe("store reducer thread continuity", () => { | ... | @@ -647,7 +645,6 @@ describe("store reducer thread continuity", () => { |
| 647 | expect(next.threads).toHaveLength(1); | 645 | expect(next.threads).toHaveLength(1); |
| 648 | expect(next.threads[0]?.id).toBe("thread-a"); | 646 | expect(next.threads[0]?.id).toBe("thread-a"); |
| 649 | expect(next.threads[0]?.projectId).toBe("project-new-a"); | 647 | expect(next.threads[0]?.projectId).toBe("project-new-a"); |
| 650 | expect(next.activeThreadId).toBe("thread-a"); | ||
| 651 | expect(next.diffOpen).toBe(false); | 648 | expect(next.diffOpen).toBe(false); |
| 652 | expect(next.diffThreadId).toBeNull(); | 649 | expect(next.diffThreadId).toBeNull(); |
| 653 | expect(next.diffTurnId).toBeNull(); | 650 | expect(next.diffTurnId).toBeNull(); |
| ... | @@ -675,7 +672,6 @@ describe("store reducer thread continuity", () => { | ... | @@ -675,7 +672,6 @@ describe("store reducer thread continuity", () => { |
| 675 | }, | 672 | }, |
| 676 | ], | 673 | ], |
| 677 | threads: [makeThread({ id: "thread-a", projectId: "project-old-a" })], | 674 | threads: [makeThread({ id: "thread-a", projectId: "project-old-a" })], |
| 678 | activeThreadId: "thread-a", | ||
| 679 | threadsHydrated: true, | 675 | threadsHydrated: true, |
| 680 | runtimeMode: "full-access", | 676 | runtimeMode: "full-access", |
| 681 | diffOpen: false, | 677 | diffOpen: false, |
| ... | @@ -768,7 +764,6 @@ describe("store reducer thread continuity", () => { | ... | @@ -768,7 +764,6 @@ describe("store reducer thread continuity", () => { |
| 768 | projectId: "project-2", | 764 | projectId: "project-2", |
| 769 | }), | 765 | }), |
| 770 | ], | 766 | ], |
| 771 | activeThreadId: "thread-a", | ||
| 772 | threadsHydrated: true, | 767 | threadsHydrated: true, |
| 773 | runtimeMode: "full-access", | 768 | runtimeMode: "full-access", |
| 774 | diffOpen: true, | 769 | diffOpen: true, |
| ... | @@ -786,30 +781,12 @@ describe("store reducer thread continuity", () => { | ... | @@ -786,30 +781,12 @@ describe("store reducer thread continuity", () => { |
| 786 | expect(next.projects[0]?.id).toBe("project-2"); | 781 | expect(next.projects[0]?.id).toBe("project-2"); |
| 787 | expect(next.threads).toHaveLength(1); | 782 | expect(next.threads).toHaveLength(1); |
| 788 | expect(next.threads[0]?.id).toBe("thread-b"); | 783 | expect(next.threads[0]?.id).toBe("thread-b"); |
| 789 | expect(next.activeThreadId).toBe("thread-b"); | ||
| 790 | expect(next.diffOpen).toBe(false); | 784 | expect(next.diffOpen).toBe(false); |
| 791 | expect(next.diffThreadId).toBeNull(); | 785 | expect(next.diffThreadId).toBeNull(); |
| 792 | expect(next.diffTurnId).toBeNull(); | 786 | expect(next.diffTurnId).toBeNull(); |
| 793 | expect(next.diffFilePath).toBeNull(); | 787 | expect(next.diffFilePath).toBeNull(); |
| 794 | }); | 788 | }); |
| 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 | it("marks completion as seen immediately for the active thread", () => { | 790 | it("marks completion as seen immediately for the active thread", () => { |
| 814 | const state = makeState( | 791 | const state = makeState( |
| 815 | makeThread({ | 792 | makeThread({ |
| ... | @@ -831,6 +808,7 @@ describe("store reducer thread continuity", () => { | ... | @@ -831,6 +808,7 @@ describe("store reducer thread continuity", () => { |
| 831 | payload: { turn: { id: "turn-1", status: "completed" } }, | 808 | payload: { turn: { id: "turn-1", status: "completed" } }, |
| 832 | }), | 809 | }), |
| 833 | activeAssistantItemRef: { current: null }, | 810 | activeAssistantItemRef: { current: null }, |
| 811 | activeThreadId: "thread-local-1", | ||
| 834 | }); | 812 | }); |
| 835 | 813 | ||
| 836 | expect(next.threads[0]?.latestTurnCompletedAt).toBe(completedAt); | 814 | expect(next.threads[0]?.latestTurnCompletedAt).toBe(completedAt); |
apps/web/src/store.ts+5-42| ... | @@ -42,7 +42,6 @@ type Action = | ... | @@ -42,7 +42,6 @@ type Action = |
| 42 | | { type: "TOGGLE_PROJECT"; projectId: string } | 42 | | { type: "TOGGLE_PROJECT"; projectId: string } |
| 43 | | { type: "DELETE_PROJECT"; projectId: string } | 43 | | { type: "DELETE_PROJECT"; projectId: string } |
| 44 | | { type: "ADD_THREAD"; thread: Thread } | 44 | | { type: "ADD_THREAD"; thread: Thread } |
| 45 | | { type: "SET_ACTIVE_THREAD"; threadId: string } | ||
| 46 | | { type: "TOGGLE_THREAD_TERMINAL"; threadId: string } | 45 | | { type: "TOGGLE_THREAD_TERMINAL"; threadId: string } |
| 47 | | { type: "SET_THREAD_TERMINAL_OPEN"; threadId: string; open: boolean } | 46 | | { type: "SET_THREAD_TERMINAL_OPEN"; threadId: string; open: boolean } |
| 48 | | { type: "SET_THREAD_TERMINAL_HEIGHT"; threadId: string; height: number } | 47 | | { type: "SET_THREAD_TERMINAL_HEIGHT"; threadId: string; height: number } |
| ... | @@ -68,6 +67,7 @@ type Action = | ... | @@ -68,6 +67,7 @@ type Action = |
| 68 | type: "APPLY_EVENT"; | 67 | type: "APPLY_EVENT"; |
| 69 | event: ProviderEvent; | 68 | event: ProviderEvent; |
| 70 | activeAssistantItemRef: { current: string | null }; | 69 | activeAssistantItemRef: { current: string | null }; |
| 70 | activeThreadId?: string | null; | ||
| 71 | } | 71 | } |
| 72 | | { type: "APPLY_TERMINAL_EVENT"; event: TerminalEvent } | 72 | | { type: "APPLY_TERMINAL_EVENT"; event: TerminalEvent } |
| 73 | | { type: "UPDATE_SESSION"; threadId: string; session: ProviderSession } | 73 | | { type: "UPDATE_SESSION"; threadId: string; session: ProviderSession } |
| ... | @@ -113,7 +113,6 @@ type Action = | ... | @@ -113,7 +113,6 @@ type Action = |
| 113 | export interface AppState { | 113 | export interface AppState { |
| 114 | projects: Project[]; | 114 | projects: Project[]; |
| 115 | threads: Thread[]; | 115 | threads: Thread[]; |
| 116 | activeThreadId: string | null; | ||
| 117 | threadsHydrated: boolean; | 116 | threadsHydrated: boolean; |
| 118 | runtimeMode: RuntimeMode; | 117 | runtimeMode: RuntimeMode; |
| 119 | diffOpen: boolean; | 118 | diffOpen: boolean; |
| ... | @@ -137,7 +136,6 @@ const LEGACY_PERSISTED_STATE_KEYS = [ | ... | @@ -137,7 +136,6 @@ const LEGACY_PERSISTED_STATE_KEYS = [ |
| 137 | const initialState: AppState = { | 136 | const initialState: AppState = { |
| 138 | projects: [], | 137 | projects: [], |
| 139 | threads: [], | 138 | threads: [], |
| 140 | activeThreadId: null, | ||
| 141 | threadsHydrated: false, | 139 | threadsHydrated: false, |
| 142 | runtimeMode: DEFAULT_RUNTIME_MODE, | 140 | runtimeMode: DEFAULT_RUNTIME_MODE, |
| 143 | diffOpen: false, | 141 | diffOpen: false, |
| ... | @@ -162,14 +160,10 @@ function readPersistedState(): AppState { | ... | @@ -162,14 +160,10 @@ function readPersistedState(): AppState { |
| 162 | if (!hydrated) return initialState; | 160 | if (!hydrated) return initialState; |
| 163 | 161 | ||
| 164 | const threads = hydrated.threads.map((thread) => normalizeThreadTerminals(thread)); | 162 | const threads = hydrated.threads.map((thread) => normalizeThreadTerminals(thread)); |
| 165 | const activeThreadId = threads.some((thread) => thread.id === hydrated.activeThreadId) | ||
| 166 | ? hydrated.activeThreadId | ||
| 167 | : (threads[0]?.id ?? null); | ||
| 168 | 163 | ||
| 169 | return { | 164 | return { |
| 170 | ...hydrated, | 165 | ...hydrated, |
| 171 | threads, | 166 | threads, |
| 172 | activeThreadId, | ||
| 173 | threadsHydrated: threads.length > 0, | 167 | threadsHydrated: threads.length > 0, |
| 174 | diffOpen: false, | 168 | diffOpen: false, |
| 175 | diffThreadId: null, | 169 | diffThreadId: null, |
| ... | @@ -638,16 +632,12 @@ export function reducer(state: AppState, action: Action): AppState { | ... | @@ -638,16 +632,12 @@ export function reducer(state: AppState, action: Action): AppState { |
| 638 | }); | 632 | }); |
| 639 | }) | 633 | }) |
| 640 | .filter((thread): thread is Thread => thread !== null); | 634 | .filter((thread): thread is Thread => thread !== null); |
| 641 | const activeThreadId = nextThreads.some((thread) => thread.id === state.activeThreadId) | ||
| 642 | ? state.activeThreadId | ||
| 643 | : (nextThreads[0]?.id ?? null); | ||
| 644 | const diffState = resetDiffTargetIfMissing(state, nextThreads); | 635 | const diffState = resetDiffTargetIfMissing(state, nextThreads); |
| 645 | 636 | ||
| 646 | return { | 637 | return { |
| 647 | ...state, | 638 | ...state, |
| 648 | projects: nextProjects, | 639 | projects: nextProjects, |
| 649 | threads: nextThreads, | 640 | threads: nextThreads, |
| 650 | activeThreadId, | ||
| 651 | ...diffState, | 641 | ...diffState, |
| 652 | }; | 642 | }; |
| 653 | } | 643 | } |
| ... | @@ -667,16 +657,12 @@ export function reducer(state: AppState, action: Action): AppState { | ... | @@ -667,16 +657,12 @@ export function reducer(state: AppState, action: Action): AppState { |
| 667 | } | 657 | } |
| 668 | 658 | ||
| 669 | const threads = state.threads.filter((thread) => thread.projectId !== action.projectId); | 659 | const threads = state.threads.filter((thread) => thread.projectId !== action.projectId); |
| 670 | const activeThreadId = threads.some((thread) => thread.id === state.activeThreadId) | ||
| 671 | ? state.activeThreadId | ||
| 672 | : (threads[0]?.id ?? null); | ||
| 673 | const diffState = resetDiffTargetIfMissing(state, threads); | 660 | const diffState = resetDiffTargetIfMissing(state, threads); |
| 674 | 661 | ||
| 675 | return { | 662 | return { |
| 676 | ...state, | 663 | ...state, |
| 677 | projects, | 664 | projects, |
| 678 | threads, | 665 | threads, |
| 679 | activeThreadId, | ||
| 680 | ...diffState, | 666 | ...diffState, |
| 681 | }; | 667 | }; |
| 682 | } | 668 | } |
| ... | @@ -691,19 +677,6 @@ export function reducer(state: AppState, action: Action): AppState { | ... | @@ -691,19 +677,6 @@ export function reducer(state: AppState, action: Action): AppState { |
| 691 | return { | 677 | return { |
| 692 | ...state, | 678 | ...state, |
| 693 | threads: [...state.threads, nextThread], | 679 | threads: [...state.threads, nextThread], |
| 694 | activeThreadId: action.thread.id, | ||
| 695 | }; | ||
| 696 | } | ||
| 697 | |||
| 698 | case "SET_ACTIVE_THREAD": { | ||
| 699 | const visitedAt = new Date().toISOString(); | ||
| 700 | return { | ||
| 701 | ...state, | ||
| 702 | activeThreadId: action.threadId, | ||
| 703 | threads: updateThread(state.threads, action.threadId, (t) => ({ | ||
| 704 | ...t, | ||
| 705 | lastVisitedAt: visitedAt, | ||
| 706 | })), | ||
| 707 | }; | 680 | }; |
| 708 | } | 681 | } |
| 709 | 682 | ||
| ... | @@ -926,7 +899,7 @@ export function reducer(state: AppState, action: Action): AppState { | ... | @@ -926,7 +899,7 @@ export function reducer(state: AppState, action: Action): AppState { |
| 926 | }; | 899 | }; |
| 927 | 900 | ||
| 928 | case "APPLY_EVENT": { | 901 | case "APPLY_EVENT": { |
| 929 | const { event, activeAssistantItemRef } = action; | 902 | const { event, activeAssistantItemRef, activeThreadId } = action; |
| 930 | const target = findThreadBySessionId(state.threads, event.sessionId); | 903 | const target = findThreadBySessionId(state.threads, event.sessionId); |
| 931 | if (!target) return state; | 904 | if (!target) return state; |
| 932 | if (shouldIgnoreForeignThreadEvent(target, event)) return state; | 905 | if (shouldIgnoreForeignThreadEvent(target, event)) return state; |
| ... | @@ -959,7 +932,7 @@ export function reducer(state: AppState, action: Action): AppState { | ... | @@ -959,7 +932,7 @@ export function reducer(state: AppState, action: Action): AppState { |
| 959 | events: nextEvents, | 932 | events: nextEvents, |
| 960 | turnDiffSummaries, | 933 | turnDiffSummaries, |
| 961 | ...updateTurnFields(t, event), | 934 | ...updateTurnFields(t, event), |
| 962 | ...(event.method === "turn/completed" && t.id === state.activeThreadId | 935 | ...(event.method === "turn/completed" && t.id === activeThreadId |
| 963 | ? { lastVisitedAt: event.createdAt } | 936 | ? { lastVisitedAt: event.createdAt } |
| 964 | : {}), | 937 | : {}), |
| 965 | }; | 938 | }; |
| ... | @@ -1176,21 +1149,11 @@ export function reducer(state: AppState, action: Action): AppState { | ... | @@ -1176,21 +1149,11 @@ export function reducer(state: AppState, action: Action): AppState { |
| 1176 | 1149 | ||
| 1177 | case "DELETE_THREAD": { | 1150 | case "DELETE_THREAD": { |
| 1178 | const threads = state.threads.filter((t) => t.id !== action.threadId); | 1151 | const threads = state.threads.filter((t) => t.id !== action.threadId); |
| 1179 | const activeThreadId = | 1152 | const diffState = resetDiffTargetIfMissing(state, threads); |
| 1180 | state.activeThreadId === action.threadId ? (threads[0]?.id ?? null) : state.activeThreadId; | ||
| 1181 | const shouldClearDiffTarget = state.diffThreadId === action.threadId; | ||
| 1182 | return { | 1153 | return { |
| 1183 | ...state, | 1154 | ...state, |
| 1184 | threads, | 1155 | threads, |
| 1185 | activeThreadId, | 1156 | ...diffState, |
| 1186 | ...(shouldClearDiffTarget | ||
| 1187 | ? { | ||
| 1188 | diffOpen: false, | ||
| 1189 | diffThreadId: null, | ||
| 1190 | diffTurnId: null, | ||
| 1191 | diffFilePath: null, | ||
| 1192 | } | ||
| 1193 | : {}), | ||
| 1194 | }; | 1157 | }; |
| 1195 | } | 1158 | } |
| 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 | }); | ||