diff --git a/apps/desktop/src/contexts/SnapshotContext.tsx b/apps/desktop/src/contexts/SnapshotContext.tsx
index c73a75b..7a44935 100644
--- a/apps/desktop/src/contexts/SnapshotContext.tsx
+++ b/apps/desktop/src/contexts/SnapshotContext.tsx
@@ -1,14 +1,14 @@
import { createContext, useContext } from "react";
import type { Snapshot } from "../types";
-/** Workspace state that's continuously refreshed by the SSE stream — tasks,
- * roadmap, running agents, logs, config. Provided once at the RootLayout
- * level; every view consumes it via useSnapshot() rather than receiving
- * snapshot data as props.
+/** Workspace state kept live by the engine event stream — tasks, roadmap,
+ * running agents, logs, config. Provided once per workspace by
+ * WorkspaceLayout; every view consumes it via useSnapshot() rather than
+ * receiving snapshot data as props.
*
- * `snap` is null during the brief window between workspace-switch and the
- * first SSE message; consumers should handle that case (most views are only
- * rendered after RootLayout's "connecting…" guard, so this is rare). */
+ * `snap` is null until a workspace's first snapshot fetch lands; consumers
+ * should handle that case (most views are only rendered after
+ * WorkspaceLayout's "connecting…" guard, so this is rare). */
interface SnapshotState {
snap: Snapshot | null;
}
@@ -26,7 +26,7 @@ export function SnapshotProvider({
}
/** Returns the live snapshot. Components that can't tolerate `null` should
- * be rendered after RootLayout's connecting-state guard so this is non-null. */
+ * be rendered after WorkspaceLayout's connecting-state guard so this is non-null. */
export function useSnapshot(): SnapshotState {
return useContext(SnapshotContext);
}
diff --git a/apps/desktop/src/contexts/WorkspaceContext.tsx b/apps/desktop/src/contexts/WorkspaceContext.tsx
index 8e3be23..9e358f9 100644
--- a/apps/desktop/src/contexts/WorkspaceContext.tsx
+++ b/apps/desktop/src/contexts/WorkspaceContext.tsx
@@ -1,7 +1,21 @@
import { createContext } from "react";
+import type { WorkspaceInfo } from "../types";
-/** Current workspace id, available to descendants via useContext. Empty
- * string means "no workspace selected yet" — happens briefly on first load
- * before `WorkspaceService.List` returns. The `useWorkspaceState` hook auto-selects
- * the first available workspace once the list arrives. */
+/** The workspace a subtree belongs to. Each visited workspace mounts its own
+ * layout, so this is fixed for the life of that subtree. */
export const WorkspaceContext = createContext
("");
+
+/** The active workspace, the list, and the switcher, shared by every layout. */
+interface WorkspaceSwitcherValue {
+ active: string;
+ workspaces: WorkspaceInfo[];
+ setWorkspace: (ws: string) => void;
+ refreshWorkspaces: () => void;
+}
+
+export const WorkspaceSwitcherContext = createContext({
+ active: "",
+ workspaces: [],
+ setWorkspace: () => {},
+ refreshWorkspaces: () => {},
+});
diff --git a/apps/desktop/src/lib/useKeptElement.ts b/apps/desktop/src/lib/useKeptElement.ts
new file mode 100644
index 0000000..7752a80
--- /dev/null
+++ b/apps/desktop/src/lib/useKeptElement.ts
@@ -0,0 +1,13 @@
+import { useCallback, useState } from "react";
+
+/** A portal target held in state, as `[el, ref]`. Hiding an `` detaches
+ * refs but keeps the node, so the target is cleared only once the node is gone. */
+export function useKeptElement() {
+ const [el, setEl] = useState(null);
+ const ref = useCallback((node: T | null) => {
+ if (node) setEl(node);
+ // Deferred: React detaches before it removes the node from the DOM.
+ else queueMicrotask(() => setEl((prev) => (prev?.isConnected ? prev : null)));
+ }, []);
+ return [el, ref] as const;
+}
diff --git a/apps/desktop/src/lib/useLocalStorage.ts b/apps/desktop/src/lib/useLocalStorage.ts
index 060bc11..83c9d88 100644
--- a/apps/desktop/src/lib/useLocalStorage.ts
+++ b/apps/desktop/src/lib/useLocalStorage.ts
@@ -17,7 +17,11 @@ export function useLocalStorage(key: string, initial: T): [T, Dispatch(null);
useEffect(() => {
+ if (written.current?.key === key && Object.is(written.current.value, value)) return;
+ written.current = { key, value };
try {
localStorage.setItem(key, JSON.stringify(value));
} catch {}
diff --git a/apps/desktop/src/lib/useResetOnChange.ts b/apps/desktop/src/lib/useResetOnChange.ts
new file mode 100644
index 0000000..2d94694
--- /dev/null
+++ b/apps/desktop/src/lib/useResetOnChange.ts
@@ -0,0 +1,14 @@
+import { useState } from "react";
+
+const UNSET = Symbol("unset");
+
+/** Runs `reset` during render on mount and whenever `key` changes; `reset` may
+ * only set the caller's own state. An effect would also re-run when a hidden
+ * workspace is shown again and wipe what the user left there. */
+export function useResetOnChange(key: unknown, reset: () => void) {
+ const [prev, setPrev] = useState(UNSET);
+ if (!Object.is(prev, key)) {
+ setPrev(key);
+ reset();
+ }
+}
diff --git a/apps/desktop/src/queries/queryClient.ts b/apps/desktop/src/queries/queryClient.ts
index 7af0087..47eb601 100644
--- a/apps/desktop/src/queries/queryClient.ts
+++ b/apps/desktop/src/queries/queryClient.ts
@@ -2,11 +2,13 @@ import { QueryClient } from "@tanstack/react-query";
// Single module-scope client so the cache survives re-renders. Desktop app:
// focus refetches are noisy, so they're off; 30s staleTime kills re-open flashing.
+// Hidden workspaces drop their observers, so a finite gcTime would evict what
+// they show.
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
- gcTime: 5 * 60_000,
+ gcTime: Number.POSITIVE_INFINITY,
refetchOnWindowFocus: false,
retry: 1,
},
diff --git a/apps/desktop/src/router.tsx b/apps/desktop/src/router.tsx
index 770b472..14eda38 100644
--- a/apps/desktop/src/router.tsx
+++ b/apps/desktop/src/router.tsx
@@ -1,4 +1,4 @@
-import { createBrowserRouter, Navigate } from "react-router-dom";
+import { createMemoryRouter, Navigate, type RouteObject } from "react-router-dom";
import { AgentEditPage } from "./views/AgentEdit";
import { AgentsView } from "./views/Agents";
import { ConversationDetailPage, ConversationsView } from "./views/Conversations";
@@ -9,15 +9,15 @@ import { ProjectsView } from "./views/Projects";
import { ProviderEditPage } from "./views/ProviderEdit";
import { ProvidersView } from "./views/Providers";
import { RoadmapView } from "./views/Roadmap";
-import { RootLayout } from "./views/RootLayout";
import { SettingsView } from "./views/Settings";
import { TaskDetailPage, TasksView } from "./views/Tasks";
import { UsageView } from "./views/Usage";
import { WikiView } from "./views/Wiki";
+import { WorkspaceLayout } from "./views/WorkspaceLayout";
-/** All routes. Single source of truth. The layout route owns app-wide state
- * (workspace, snapshot, selection, pipeline) and renders the Header; child
- * routes render into its .
+/** One workspace's routes. Each visited workspace gets its own memory router,
+ * so it keeps its location and history while another is on screen.
+ * `WorkspaceLayout` renders the chrome and the matched child into its .
*
* URL conventions:
* / — redirects to /tasks
@@ -36,60 +36,70 @@ import { WikiView } from "./views/Wiki";
* /providers — providers CRUD
* /overview — three.js operations grid
*/
-export const router = createBrowserRouter([
+const routes: RouteObject[] = [
+ { index: true, element: },
{
- path: "/",
- element: ,
+ path: "tasks",
children: [
- { index: true, element: },
- {
- path: "tasks",
- children: [
- { index: true, element: },
- { path: ":id", element: },
- ],
- },
- { path: "roadmap", element: },
- {
- path: "agents",
- children: [
- { index: true, element: },
- { path: "new", element: },
- { path: "edit/:id", element: },
- { path: "logs", element: },
- { path: "logs/:log", element: },
- { path: "usage", element: },
- ],
- },
- {
- path: "projects",
- children: [
- { index: true, element: },
- { path: "new", element: },
- { path: "edit/:id", element: },
- ],
- },
- {
- path: "providers",
- children: [
- { index: true, element: },
- { path: "new", element: },
- { path: "edit/:id", element: },
- ],
- },
- { path: "wiki", element: },
- { path: "overview", element: },
- { path: "settings", element: },
- {
- path: "conversations",
- children: [
- { index: true, element: },
- { path: ":id", element: },
- ],
- },
-
- // Catch-all → tasks.
- { path: "*", element: },
+ { index: true, element: },
+ { path: ":id", element: },
+ ],
+ },
+ { path: "roadmap", element: },
+ {
+ path: "agents",
+ children: [
+ { index: true, element: },
+ { path: "new", element: },
+ { path: "edit/:id", element: },
+ { path: "logs", element: },
+ { path: "logs/:log", element: },
+ { path: "usage", element: },
],
},
-]);
+ {
+ path: "projects",
+ children: [
+ { index: true, element: },
+ { path: "new", element: },
+ { path: "edit/:id", element: },
+ ],
+ },
+ {
+ path: "providers",
+ children: [
+ { index: true, element: },
+ { path: "new", element: },
+ { path: "edit/:id", element: },
+ ],
+ },
+ { path: "wiki", element: },
+ { path: "overview", element: },
+ { path: "settings", element: },
+ {
+ path: "conversations",
+ children: [
+ { index: true, element: },
+ { path: ":id", element: },
+ ],
+ },
+
+ // Catch-all → tasks.
+ { path: "*", element: },
+];
+
+type WorkspaceRouter = ReturnType;
+
+const routers = new Map();
+
+export function workspaceRouter(ws: string): WorkspaceRouter {
+ let router = routers.get(ws);
+ if (!router) {
+ router = createMemoryRouter(
+ [{ path: "/", element: , children: routes }],
+ { initialEntries: ["/tasks"] },
+ );
+ routers.set(ws, router);
+ }
+ return router;
+}
diff --git a/apps/desktop/src/views/AgentEdit.tsx b/apps/desktop/src/views/AgentEdit.tsx
index 71fee5e..f9b59b0 100644
--- a/apps/desktop/src/views/AgentEdit.tsx
+++ b/apps/desktop/src/views/AgentEdit.tsx
@@ -1,4 +1,4 @@
-import { useContext, useEffect, useMemo, useState } from "react";
+import { useContext, useMemo, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { AgentIdenticon } from "../components/AgentIdenticon";
import { AiAssist, AiAssistTrigger } from "../components/AiAssist";
@@ -23,6 +23,7 @@ import { contextLabel } from "../lib/contextArgs";
import type { ProviderModel } from "../lib/providerCatalog";
import { ProviderTypeIcon } from "../lib/providerTypes";
import { linesToRules, parseRules, serializeRules } from "../lib/rules";
+import { useResetOnChange } from "../lib/useResetOnChange";
import { useAgent, useAgentSkills } from "../queries/agent";
import { useProviderCatalog } from "../queries/catalog";
import { useProviders } from "../queries/provider";
@@ -145,9 +146,7 @@ function AgentForm({ detail }: { detail: AgentDetail | null }) {
const optionalSkills = skillCatalog.filter((s) => !s.hidden);
// Re-seed when a different detail arrives (workspace switch, refetch).
- useEffect(() => {
- setState(detail ? formStateFrom(detail) : emptyFormState());
- }, [detail]);
+ useResetOnChange(detail, () => setState(detail ? formStateFrom(detail) : emptyFormState()));
function toggleSkill(slug: string) {
setState((s) => ({
diff --git a/apps/desktop/src/views/Conversations.tsx b/apps/desktop/src/views/Conversations.tsx
index 48dd750..efe6884 100644
--- a/apps/desktop/src/views/Conversations.tsx
+++ b/apps/desktop/src/views/Conversations.tsx
@@ -6,6 +6,7 @@ import { AiAssist, AiAssistTrigger } from "../components/AiAssist";
import { Button } from "../components/Button";
import { ChatInput } from "../components/chat/ChatInput";
import { ChatInputShell } from "../components/chat/ChatInputShell";
+import { ChatRightSidebar } from "../components/chat/ChatRightSidebar";
import { ContextPills } from "../components/chat/ContextPills";
import { MessageList } from "../components/chat/MessageList";
import { QueuedMessages } from "../components/chat/QueuedMessages";
@@ -13,12 +14,13 @@ import { SendButton } from "../components/chat/SendButton";
import { DropdownSelect } from "../components/DropdownMenu";
import { HeaderSlot } from "../components/HeaderSlot";
import { IconButton } from "../components/IconButton";
-import { useChatSidebar } from "../contexts/ChatSidebarContext";
+import { ChatSidebarPortal, useChatSidebar } from "../contexts/ChatSidebarContext";
import { useConversationStream } from "../contexts/ConversationStreamProvider";
import { useSnapshot } from "../contexts/SnapshotContext";
import { WorkspaceContext } from "../contexts/WorkspaceContext";
import { ProviderTypeIcon } from "../lib/providerTypes";
import { useLocalStorage } from "../lib/useLocalStorage";
+import { useResetOnChange } from "../lib/useResetOnChange";
import { useProviderCatalog } from "../queries/catalog";
import { conversationKeys, useConversation } from "../queries/conversation";
import { client, errorMessage } from "../services/client";
@@ -62,9 +64,7 @@ export function ConversationsView() {
}, [providers, providerId]);
// Reset model when provider changes — the previous pick may not exist for the new type.
- useEffect(() => {
- setModel(null);
- }, [providerId]);
+ useResetOnChange(providerId, () => setModel(null));
const providerType = providers.find((p) => p.id === providerId)?.type ?? null;
const availableModels = providerType
@@ -199,6 +199,8 @@ export function ConversationsView() {
// ─── Thread (/conversations/:id) ───────────────────────────────────────────
+const NO_CONTEXT: ConversationContext = {};
+
export function ConversationDetailPage() {
const ws = useContext(WorkspaceContext);
const { snap } = useSnapshot();
@@ -218,11 +220,7 @@ export function ConversationDetailPage() {
`modula.chat.model.${ws}.${id ?? ""}`,
null,
);
- const {
- open: sidebarOpen,
- toggle: toggleSidebar,
- setConfig: setChatSidebarConfig,
- } = useChatSidebar();
+ const { open: sidebarOpen, toggle: toggleSidebar } = useChatSidebar();
const bottomRef = useRef(null);
const scrollContainerRef = useRef(null);
const userScrolledRef = useRef(false);
@@ -242,10 +240,10 @@ export function ConversationDetailPage() {
// Sync server-loaded messages into local state (which also holds optimistic
// sends); on first load adopt the conversation's persisted model.
- useEffect(() => {
+ useResetOnChange(conv, () => {
setMessages(conv ? conv.messages : []);
if (conv) setSelectedModel((prev) => (prev === null ? conv.model : prev));
- }, [conv]);
+ });
const { inFlightText, inFlightTools, streaming, error, injected, send, attach, cancel } =
useConversationStream(ws, id ?? "");
@@ -274,23 +272,6 @@ export function ConversationDetailPage() {
}
}, [injected, ws, id, queryClient]);
- // Publish config for the layout-level right-sidebar; cleared on unmount.
- useEffect(() => {
- setChatSidebarConfig({
- workspace: ws,
- context: conv?.context ?? {},
- refreshNonce: sidebarNonce,
- });
- }, [
- ws,
- conv?.context?.project,
- conv?.context?.task,
- conv?.context?.variant,
- sidebarNonce,
- setChatSidebarConfig,
- ]);
- useEffect(() => () => setChatSidebarConfig(null), [setChatSidebarConfig]);
-
const provider = conv
? (snap?.config?.providers.find((p) => p.id === conv.provider_id) ?? null)
: null;
@@ -326,7 +307,7 @@ export function ConversationDetailPage() {
// or the message is gone with no signal.
const [queueError, setQueueError] = useState(null);
// Stream errors are held per conversation id; this one is not, so drop it on navigation.
- useEffect(() => setQueueError(null), [id]);
+ useResetOnChange(id, () => setQueueError(null));
const settleQueue = useCallback(
(p: Promise) => {
p.then(() => setQueueError(null))
@@ -359,11 +340,15 @@ export function ConversationDetailPage() {
}
}, [conv, initialMsg, handleSend]);
- useEffect(() => {
+ // Keyed so only a conversation change resets, before the positioning below.
+ const positionedKeyRef = useRef(scrollKey);
+ useLayoutEffect(() => {
+ if (positionedKeyRef.current === scrollKey) return;
+ positionedKeyRef.current = scrollKey;
initialPositionedRef.current = false;
userScrolledRef.current = false;
lastLength.current = 0;
- }, [ws, id]);
+ }, [scrollKey]);
// Position synchronously before paint to avoid a top-then-bottom scroll flash.
useLayoutEffect(() => {
@@ -456,6 +441,13 @@ export function ConversationDetailPage() {
)}
+
+
+
diff --git a/apps/desktop/src/views/LogViewer.tsx b/apps/desktop/src/views/LogViewer.tsx
index a5237be..4de7c56 100644
--- a/apps/desktop/src/views/LogViewer.tsx
+++ b/apps/desktop/src/views/LogViewer.tsx
@@ -9,23 +9,33 @@ export function LogViewer({ name }: { name: string }) {
const [entries, setEntries] = useState
([]);
const scrollRef = useRef(null);
const stickToBottom = useRef(true);
+ const tail = useRef<{ key: string; channel: Channel } | null>(null);
useEffect(() => {
if (!ws) return;
- setEntries([]);
- // One newline-terminated line per chunk from the engine's log-tail stream.
- const channel = new Channel();
- channel.onmessage = (chunk) => {
- const parsed = parseEvent(chunk.trimEnd());
- if (parsed.length === 0) return;
- setEntries((prev) => [...prev, ...parsed]);
- };
- invoke("log_stream", { workspaceId: ws, logName: name, onChunk: channel }).catch(() => {
- // Stream ended or the engine went away; nothing to surface here.
- });
- // Detaching the channel ends the tail on the engine without affecting the run.
+ const key = `${ws}/${name}`;
+ if (tail.current?.key !== key) {
+ // Detaching the channel ends the tail on the engine without affecting the run.
+ if (tail.current) tail.current.channel.onmessage = () => {};
+ setEntries([]);
+ // One newline-terminated line per chunk from the engine's log-tail stream.
+ const channel = new Channel();
+ channel.onmessage = (chunk) => {
+ const parsed = parseEvent(chunk.trimEnd());
+ if (parsed.length === 0) return;
+ setEntries((prev) => [...prev, ...parsed]);
+ };
+ invoke("log_stream", { workspaceId: ws, logName: name, onChunk: channel }).catch(() => {
+ // Stream ended or the engine went away; nothing to surface here.
+ });
+ tail.current = { key, channel };
+ }
+ const host = scrollRef.current;
return () => {
- channel.onmessage = () => {};
+ // Still in the document means hidden, not unmounted: keep tailing.
+ if (host?.isConnected || !tail.current) return;
+ tail.current.channel.onmessage = () => {};
+ tail.current = null;
};
}, [ws, name]);
diff --git a/apps/desktop/src/views/RootLayout.tsx b/apps/desktop/src/views/RootLayout.tsx
index 522a94a..6487e49 100644
--- a/apps/desktop/src/views/RootLayout.tsx
+++ b/apps/desktop/src/views/RootLayout.tsx
@@ -1,41 +1,21 @@
-import { useEffect, useMemo, useRef, useState } from "react";
-import { Outlet, useLocation } from "react-router-dom";
-import { ChatRightSidebar } from "../components/chat/ChatRightSidebar";
-import { RightPanel, RightPanelCard } from "../components/right-panel/RightPanel";
-import { SearchModal } from "../components/SearchModal";
-import { AsideCardProvider, AsideCardTarget } from "../contexts/AsideCardContext";
-import { ChatSidebarProvider, useChatSidebar } from "../contexts/ChatSidebarContext";
+import { Fragment, useEffect, useState } from "react";
+import { RouterProvider } from "react-router-dom";
+import { ChatSidebarProvider } from "../contexts/ChatSidebarContext";
import { ConversationStreamProvider } from "../contexts/ConversationStreamProvider";
-import { HeaderSlotProvider } from "../contexts/HeaderSlotContext";
-import { ModalPortalProvider } from "../contexts/ModalPortalContext";
-import { PipelineContext } from "../contexts/PipelineContext";
-import { RightPanelProvider } from "../contexts/RightPanelProvider";
-import { ShortcutsProvider, useShortcut } from "../contexts/ShortcutsContext";
-import { SidebarProvider, useSidebarContext } from "../contexts/SidebarContext";
-import { SnapshotProvider, useSnapshot } from "../contexts/SnapshotContext";
-import { ThemeProvider, useThemeContext } from "../contexts/ThemeContext";
+import { ShortcutsProvider } from "../contexts/ShortcutsContext";
+import { SidebarProvider } from "../contexts/SidebarContext";
+import { ThemeProvider } from "../contexts/ThemeContext";
import { ToastProvider } from "../contexts/ToastContext";
-import { WorkspaceContext } from "../contexts/WorkspaceContext";
+import { WorkspaceSwitcherContext } from "../contexts/WorkspaceContext";
import { useEngineEvents } from "../hooks/useEngineEvents";
import { useSuppressContextMenu } from "../hooks/useSuppressContextMenu";
import { useTitlebarDrag } from "../hooks/useTitlebarDrag";
import { useWorkspaceState } from "../hooks/useWorkspaceState";
-import { getPipeline } from "../lib/pipeline";
import { useLocalStorage } from "../lib/useLocalStorage";
-import { useSnapshotQuery } from "../queries/snapshot";
-import { windowButtons } from "../tauri/window";
-import { Header } from "./Header";
+import { workspaceRouter } from "../router";
import { Onboarding } from "./Onboarding";
-import { Sidebar } from "./Sidebar";
-import { Titlebar } from "./Titlebar";
-/** Top-level layout route. Owns the long-lived state (workspace + SSE stream)
- * and exposes it to descendants via context. Always renders the Header and
- * Sidebar; the outlet renders whichever child route matched.
- *
- * While the snapshot is loading we still render the Header and Sidebar so nav
- * is usable; the body shows a "connecting…" hint until the first SSE message
- * arrives. */
+/** App shell: the workspace selection plus the state every workspace shares. */
export function RootLayout() {
// Native window drag + double-click-to-maximize for the overlay titlebar.
// Mounted here, above both the onboarding and app trees, so every screen is
@@ -82,134 +62,36 @@ function EngineEvents({ workspace }: { workspace: string }) {
function AppRoot({ wsState }: { wsState: ReturnType }) {
const { workspace, workspaces, setWorkspace, refreshWorkspaces } = wsState;
- const snap = useSnapshotQuery(workspace).data ?? null;
- const pipeline = useMemo(() => getPipeline(snap), [snap]);
return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
);
}
-/** Body that runs INSIDE all the providers, so it can consume their context
- * (snapshot, location) without wrapping the whole tree. */
-function RootLayoutBody({
- workspace,
- workspaces,
- setWorkspace,
- refreshWorkspaces,
-}: {
- workspace: string;
- workspaces: ReturnType["workspaces"];
- setWorkspace: (ws: string) => void;
- refreshWorkspaces: () => void;
-}) {
- const { snap } = useSnapshot();
- const { glass } = useThemeContext();
- const location = useLocation();
- const { notifyRegionWidth } = useSidebarContext();
- const navRef = useRef(null);
- const contentRef = useRef(null);
- const [searchOpen, setSearchOpen] = useState(false);
+/** Every visited workspace stays mounted under its own router; `WorkspaceLayout`
+ * hides the inactive ones. Each keeps its event watch so its cache stays current. */
+function WorkspaceViews({ active }: { active: string }) {
+ const [visited, setVisited] = useState([]);
+ if (active && !visited.includes(active)) setVisited([...visited, active]);
- useShortcut("mod+k", () => setSearchOpen(true));
-
- // Windows acrylic reads more solid than macOS vibrancy, so it needs less tint.
- let plate = "bg-chrome";
- if (glass) plate = windowButtons() === "system" ? "bg-chrome/80" : "bg-chrome/70";
-
- // Scroll to top on route change so deep-scrolled pages don't carry over.
- useEffect(() => {
- window.scrollTo(0, 0);
- }, [location.pathname]);
-
- // Nav + content = window minus the drawers and aside card. Summing the two
- // keeps it constant as the nav animates, so collapsing never feeds back in.
- useEffect(() => {
- const nav = navRef.current;
- const content = contentRef.current;
- if (!nav || !content) return;
- const measure = () => notifyRegionWidth(nav.offsetWidth + content.offsetWidth);
- const ro = new ResizeObserver(measure);
- ro.observe(nav);
- ro.observe(content);
- measure();
- return () => ro.disconnect();
- }, [notifyRegionWidth]);
-
- return (
- // Base plate: title bar and sidebar sit flat on it, the content card above it.
-
-
-
-
-
setSearchOpen(true)}
- />
-
-
-
-
-
- {snap ? (
-
- ) : (
-
- connecting…
-
- )}
-
-
-
-
-
-
-
-
- {searchOpen && setSearchOpen(false)} />}
-
-
-
- );
-}
-
-/** Chat right-sidebar as a layout-level sibling of the content column, like RightPanel. */
-function ChatRightSidebarHost() {
- const { open, config } = useChatSidebar();
- if (!open || !config) return null;
- return (
-
- );
+ return visited.map((ws) => (
+
+
+
+
+ ));
}
diff --git a/apps/desktop/src/views/Sidebar.tsx b/apps/desktop/src/views/Sidebar.tsx
index 19f2597..0c16070 100644
--- a/apps/desktop/src/views/Sidebar.tsx
+++ b/apps/desktop/src/views/Sidebar.tsx
@@ -15,7 +15,7 @@ import {
Settings,
Trash2,
} from "lucide-react";
-import { useEffect, useState } from "react";
+import { useState } from "react";
import { NavLink, useLocation, useNavigate } from "react-router-dom";
import { ConfirmModal } from "../components/ConfirmModal";
import { ContextMenu } from "../components/ContextMenu";
@@ -28,6 +28,7 @@ import { useStreamingConvIds } from "../contexts/ConversationStreamProvider";
import { useSidebarContext } from "../contexts/SidebarContext";
import { useSnapshot } from "../contexts/SnapshotContext";
import { useAppUpdate } from "../hooks/useAppUpdate";
+import { useResetOnChange } from "../lib/useResetOnChange";
import { client } from "../services/client";
import type { ConversationSummary, WorkspaceInfo } from "../types";
@@ -92,9 +93,9 @@ function SidebarItem({
const [expanded, setExpanded] = useState(hasActiveChild || !!item.defaultOpen);
- useEffect(() => {
+ useResetOnChange(hasActiveChild, () => {
if (hasActiveChild) setExpanded(true);
- }, [hasActiveChild]);
+ });
const indent = depth > 0 ? "pl-8 pr-3" : "px-3";
const rowLayout = sidebarOpen
@@ -259,7 +260,7 @@ export function Sidebar({
onSwitchWorkspace: (ws: string) => void;
onRefreshWorkspaces: () => void;
onOpenSearch: () => void;
- /** RootLayout measures the nav's live width for the auto-collapse decision. */
+ /** WorkspaceLayout measures the nav's live width for the auto-collapse decision. */
ref?: React.Ref;
}) {
const { open } = useSidebarContext();
diff --git a/apps/desktop/src/views/TaskDetail.tsx b/apps/desktop/src/views/TaskDetail.tsx
index acb4480..3da4af5 100644
--- a/apps/desktop/src/views/TaskDetail.tsx
+++ b/apps/desktop/src/views/TaskDetail.tsx
@@ -41,6 +41,7 @@ import { WorkspaceContext } from "../contexts/WorkspaceContext";
import { useFeedback } from "../hooks/useFeedback";
import { toneDotClasses } from "../lib/pipeline";
import { externalStatusTextClass, sourceLabel, variantStatusTone } from "../lib/task";
+import { useResetOnChange } from "../lib/useResetOnChange";
import { useVariantPr } from "../queries/diff";
import { taskKeys, useTaskAgentSettings, useTaskBranches } from "../queries/task";
import { threadKeys, useThreads } from "../queries/thread";
@@ -180,9 +181,7 @@ export function TaskDetail({
const dirtyRef = useRef(dirty);
dirtyRef.current = dirty;
// Switching tasks always resets the form to the new task's values.
- useEffect(() => {
- setForm(taskToForm(task));
- }, [task.id]);
+ useResetOnChange(task.id, () => setForm(taskToForm(task)));
// For same-id updates (snapshot reflecting our own save, or someone else's
// edit), only sync if the form has no in-flight local edits.
useEffect(() => {
@@ -711,7 +710,7 @@ function AgentLoopSetting({
onSave: (amount: number) => void;
}) {
const [val, setVal] = useState(String(amount));
- useEffect(() => setVal(String(amount)), [amount]);
+ useResetOnChange(amount, () => setVal(String(amount)));
const commit = () => {
const n = Math.max(1, Math.min(100, parseInt(val, 10) || 1));
setVal(String(n));
@@ -1186,11 +1185,18 @@ function ThreadEntryView({ entry, taskId }: { entry: ThreadEntry; taskId: string
el.style.height = `${el.scrollHeight}px`;
}, [editing, draft]);
- // On entering edit mode, focus with the caret at the end.
+ // On entering edit mode, focus with the caret at the end. Once per entry:
+ // effects also re-run when a hidden workspace is shown again.
+ const focused = useRef(false);
useEffect(() => {
- if (!editing) return;
+ if (!editing) {
+ focused.current = false;
+ return;
+ }
+ if (focused.current) return;
const el = textareaRef.current;
if (!el) return;
+ focused.current = true;
el.focus();
el.setSelectionRange(el.value.length, el.value.length);
}, [editing]);
diff --git a/apps/desktop/src/views/Tasks.tsx b/apps/desktop/src/views/Tasks.tsx
index 2cbf92b..9d7ef87 100644
--- a/apps/desktop/src/views/Tasks.tsx
+++ b/apps/desktop/src/views/Tasks.tsx
@@ -1,5 +1,5 @@
import { ChevronDown, ChevronRight, Download, Plus } from "lucide-react";
-import { useContext, useEffect, useMemo, useState } from "react";
+import { useContext, useMemo, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { AiAssist, AiAssistTrigger } from "../components/AiAssist";
import { BaseModal } from "../components/BaseModal";
@@ -16,6 +16,7 @@ import { useSnapshot } from "../contexts/SnapshotContext";
import { WorkspaceContext } from "../contexts/WorkspaceContext";
import { pipelineLabel, pipelineStatusFor, pipelineTone, toneDotClasses } from "../lib/pipeline";
import { externalStatusTextClass, variantStatusTone } from "../lib/task";
+import { useResetOnChange } from "../lib/useResetOnChange";
import { client, errorMessage } from "../services/client";
import type { Agent, RoadmapItem, Task } from "../types";
import { logPath } from "./Logs";
@@ -323,13 +324,13 @@ function NewTaskForm({
const [busy, setBusy] = useState(false);
const [error, setError] = useState(null);
- useEffect(() => {
+ useResetOnChange(open, () => {
if (open) {
setTitle("");
setDescription("");
setError(null);
}
- }, [open]);
+ });
async function save() {
if (!title.trim()) return;
diff --git a/apps/desktop/src/views/Titlebar.tsx b/apps/desktop/src/views/Titlebar.tsx
index ae90caa..92e088d 100644
--- a/apps/desktop/src/views/Titlebar.tsx
+++ b/apps/desktop/src/views/Titlebar.tsx
@@ -1,5 +1,6 @@
import { ChevronLeft, ChevronRight, PanelLeftClose, PanelLeftOpen } from "lucide-react";
-import { useLocation, useNavigate } from "react-router-dom";
+import { useState } from "react";
+import { NavigationType, useLocation, useNavigate, useNavigationType } from "react-router-dom";
import { IconButton } from "../components/IconButton";
import { WindowControls } from "../components/WindowControls";
import { useSidebarContext } from "../contexts/SidebarContext";
@@ -10,25 +11,17 @@ import { windowButtons } from "../tauri/window";
export function Titlebar() {
const { open, toggle } = useSidebarContext();
const navigate = useNavigate();
- const location = useLocation();
-
- // `location` is read so back/forward recompute on every navigation.
- const historyIdx = (window.history.state?.idx as number | undefined) ?? 0;
- void location;
+ const { canBack, canForward } = useHistoryBounds();
return (
// z-60 keeps the window buttons live above modals; pl clears the macOS lights.
- navigate(-1)} disabled={historyIdx <= 0} title="Back">
+ navigate(-1)} disabled={!canBack} title="Back">
- navigate(1)}
- disabled={historyIdx >= window.history.length - 1}
- title="Forward"
- >
+ navigate(1)} disabled={!canForward} title="Forward">
);
}
+
+/** Back/forward availability. The workspace's memory router keeps its history
+ * index private, so mirror the entry stack from location keys. */
+function useHistoryBounds() {
+ const { key } = useLocation();
+ const action = useNavigationType();
+ const [h, setH] = useState({ keys: [key], idx: 0 });
+ if (h.keys[h.idx] !== key) {
+ if (action === NavigationType.Push) {
+ setH({ keys: [...h.keys.slice(0, h.idx + 1), key], idx: h.idx + 1 });
+ } else if (action === NavigationType.Replace) {
+ setH({ keys: h.keys.map((k, i) => (i === h.idx ? key : k)), idx: h.idx });
+ } else {
+ const idx = h.keys.indexOf(key);
+ setH(idx >= 0 ? { keys: h.keys, idx } : { keys: [key], idx: 0 });
+ }
+ }
+ return { canBack: h.idx > 0, canForward: h.idx < h.keys.length - 1 };
+}
diff --git a/apps/desktop/src/views/Wiki.tsx b/apps/desktop/src/views/Wiki.tsx
index 32c3bc0..ffb7796 100644
--- a/apps/desktop/src/views/Wiki.tsx
+++ b/apps/desktop/src/views/Wiki.tsx
@@ -10,6 +10,7 @@ import { PromptModal } from "../components/PromptModal";
import { Spinner } from "../components/Spinner";
import { useShortcut } from "../contexts/ShortcutsContext";
import { WorkspaceContext } from "../contexts/WorkspaceContext";
+import { useResetOnChange } from "../lib/useResetOnChange";
import { useWikiFile, useWikiTree, wikiKeys } from "../queries/wiki";
import type { WikiFile, WikiFileBody, WikiNode } from "../services/client";
import { client, errorMessage } from "../services/client";
@@ -107,9 +108,7 @@ export function WikiView() {
// Sync the editable draft from the server copy whenever it (re)loads, and
// clear it when no file is selected (the query is disabled, `file` undefined).
- useEffect(() => {
- setDraft(file ? file.content : null);
- }, [file]);
+ useResetOnChange(file, () => setDraft(file ? file.content : null));
// Reset selection on workspace switch. The ref guard keeps this off the mount
// pass, where it would clear a page just deep-linked to.
diff --git a/apps/desktop/src/views/WorkspaceLayout.tsx b/apps/desktop/src/views/WorkspaceLayout.tsx
new file mode 100644
index 0000000..8863644
--- /dev/null
+++ b/apps/desktop/src/views/WorkspaceLayout.tsx
@@ -0,0 +1,144 @@
+import { Activity, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
+import { Outlet, useLocation } from "react-router-dom";
+import { RightPanel, RightPanelCard } from "../components/right-panel/RightPanel";
+import { SearchModal } from "../components/SearchModal";
+import { AsideCardProvider, AsideCardTarget } from "../contexts/AsideCardContext";
+import { ChatSidebarTarget, ChatSidebarTargetProvider } from "../contexts/ChatSidebarContext";
+import { HeaderSlotProvider } from "../contexts/HeaderSlotContext";
+import { ModalPortalProvider } from "../contexts/ModalPortalContext";
+import { PipelineContext } from "../contexts/PipelineContext";
+import { RightPanelProvider } from "../contexts/RightPanelProvider";
+import { useShortcut } from "../contexts/ShortcutsContext";
+import { useSidebarContext } from "../contexts/SidebarContext";
+import { SnapshotProvider, useSnapshot } from "../contexts/SnapshotContext";
+import { useThemeContext } from "../contexts/ThemeContext";
+import { WorkspaceContext, WorkspaceSwitcherContext } from "../contexts/WorkspaceContext";
+import { getPipeline } from "../lib/pipeline";
+import { useSnapshotQuery } from "../queries/snapshot";
+import { windowButtons } from "../tauri/window";
+import { Header } from "./Header";
+import { Sidebar } from "./Sidebar";
+import { Titlebar } from "./Titlebar";
+
+/** Root route of a workspace's router. `` keeps a hidden workspace's
+ * DOM (scroll included) and state but tears down its effects, which re-run when
+ * it is shown again. It sits inside the router so a navigation fired while
+ * hidden still lands. */
+export function WorkspaceLayout({ workspace }: { workspace: string }) {
+ const { active } = useContext(WorkspaceSwitcherContext);
+ return (
+
+
+
+ );
+}
+
+/** While the snapshot is loading we still render the Header and Sidebar so nav
+ * is usable; the body shows a "connecting…" hint until the first fetch lands. */
+function WorkspaceProviders({ workspace }: { workspace: string }) {
+ const snap = useSnapshotQuery(workspace).data ?? null;
+ const pipeline = useMemo(() => getPipeline(snap), [snap]);
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+/** Body that runs INSIDE the workspace providers, so it can consume their
+ * context (snapshot, location) without wrapping the whole tree. */
+function WorkspaceLayoutBody({ workspace }: { workspace: string }) {
+ const { workspaces, setWorkspace, refreshWorkspaces } = useContext(WorkspaceSwitcherContext);
+ const { snap } = useSnapshot();
+ const { glass } = useThemeContext();
+ const location = useLocation();
+ const { notifyRegionWidth } = useSidebarContext();
+ const navRef = useRef(null);
+ const contentRef = useRef(null);
+ const [searchOpen, setSearchOpen] = useState(false);
+
+ useShortcut("mod+k", () => setSearchOpen(true));
+
+ // Windows acrylic reads more solid than macOS vibrancy, so it needs less tint.
+ let plate = "bg-chrome";
+ if (glass) plate = windowButtons() === "system" ? "bg-chrome/80" : "bg-chrome/70";
+
+ // Scroll to top on route change so deep-scrolled pages don't carry over.
+ useEffect(() => {
+ window.scrollTo(0, 0);
+ }, [location.pathname]);
+
+ // Hiding the workspace blurs whatever it had focused; put focus back on return.
+ const lastFocused = useRef(null);
+ useLayoutEffect(() => {
+ if (lastFocused.current?.isConnected) lastFocused.current.focus({ preventScroll: true });
+ }, []);
+
+ // Nav + content = window minus the drawers and aside card. Summing the two
+ // keeps it constant as the nav animates, so collapsing never feeds back in.
+ useEffect(() => {
+ const nav = navRef.current;
+ const content = contentRef.current;
+ if (!nav || !content) return;
+ const measure = () => notifyRegionWidth(nav.offsetWidth + content.offsetWidth);
+ const ro = new ResizeObserver(measure);
+ ro.observe(nav);
+ ro.observe(content);
+ measure();
+ return () => ro.disconnect();
+ }, [notifyRegionWidth]);
+
+ return (
+ // Base plate: title bar and sidebar sit flat on it, the content card above it.
+
+
+
+ {
+ if (!navRef.current?.contains(e.target)) lastFocused.current = e.target;
+ }}
+ >
+
setSearchOpen(true)}
+ />
+
+
+
+
+
+ {snap ? (
+
+ ) : (
+
+ connecting…
+
+ )}
+
+
+
+
+
+
+
+
+ {searchOpen && setSearchOpen(false)} />}
+
+
+
+ );
+}