From d698489b170a1dba4f164d0884a7aa3392107407 Mon Sep 17 00:00:00 2001 From: Benjamin Shafii Date: Tue, 3 Feb 2026 00:57:11 -0800 Subject: [PATCH 1/5] feat: persist session workspaces for switching --- packages/app/src/app/app.tsx | 4 + .../src/app/components/session/sidebar.tsx | 119 +++++++++++++- packages/app/src/app/pages/session.tsx | 147 ++++++++++++++++-- 3 files changed, 257 insertions(+), 13 deletions(-) diff --git a/packages/app/src/app/app.tsx b/packages/app/src/app/app.tsx index 5160d5df..bddf886e 100644 --- a/packages/app/src/app/app.tsx +++ b/packages/app/src/app/app.tsx @@ -4110,6 +4110,10 @@ export default function App() { setSettingsTab, activeWorkspaceDisplay: activeWorkspaceDisplay(), activeWorkspaceRoot: workspaceStore.activeWorkspaceRoot().trim(), + workspaces: workspaceStore.workspaces(), + activeWorkspaceId: workspaceStore.activeWorkspaceId(), + connectingWorkspaceId: workspaceStore.connectingWorkspaceId(), + activateWorkspace: workspaceStore.activateWorkspace, setWorkspaceSearch: workspaceStore.setWorkspaceSearch, setWorkspacePickerOpen: workspaceStore.setWorkspacePickerOpen, clientConnected: Boolean(client()), diff --git a/packages/app/src/app/components/session/sidebar.tsx b/packages/app/src/app/components/session/sidebar.tsx index b6a96e8e..fa04e4ed 100644 --- a/packages/app/src/app/components/session/sidebar.tsx +++ b/packages/app/src/app/components/session/sidebar.tsx @@ -1,7 +1,8 @@ import { For, Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js"; -import { Check, ChevronDown, Plus } from "lucide-solid"; +import { Check, ChevronDown, Loader2, Plus } from "lucide-solid"; import type { TodoItem } from "../../types"; +import type { WorkspaceInfo } from "../../lib/tauri"; export type SidebarSectionState = { progress: boolean; @@ -18,6 +19,11 @@ export type SidebarProps = { expandedSections: SidebarSectionState; onToggleSection: (section: keyof SidebarSectionState) => void; workspaceName: string; + sessionWorkspaces: WorkspaceInfo[]; + activeWorkspaceId: string; + connectingWorkspaceId?: string | null; + onSelectWorkspace: (workspaceId: string) => void; + onAddWorkspace: () => void; sessions: Array<{ id: string; title: string; slug?: string | null }>; selectedSessionId: string | null; onSelectSession: (id: string) => void; @@ -30,6 +36,33 @@ export type SidebarProps = { export default function SessionSidebar(props: SidebarProps) { const realTodos = createMemo(() => props.todos.filter((todo) => todo.content.trim())); + const workspaceLabel = (workspace: WorkspaceInfo) => + workspace.displayName?.trim() || + workspace.openworkWorkspaceName?.trim() || + workspace.name?.trim() || + workspace.path?.trim() || + "Workspace"; + + const workspacePathLabel = (workspace: WorkspaceInfo) => { + if (workspace.workspaceType === "remote") { + if (workspace.remoteType === "openwork") { + return ( + workspace.openworkHostUrl?.trim() || + workspace.baseUrl?.trim() || + workspace.path?.trim() || + "" + ); + } + return workspace.baseUrl?.trim() || workspace.path?.trim() || ""; + } + return workspace.path?.trim() || ""; + }; + + const workspaceDetailLabel = (workspace: WorkspaceInfo) => { + if (workspace.workspaceType !== "remote") return ""; + return workspace.openworkWorkspaceName?.trim() || workspace.directory?.trim() || ""; + }; + const progressDots = createMemo(() => { const activeTodos = realTodos(); const total = activeTodos.length; @@ -94,7 +127,89 @@ export default function SessionSidebar(props: SidebarProps) {
-
{props.workspaceName}
+
+
Session Workspaces
+ +
+
+ 0} + fallback={ +
+ No workspaces in this session yet. Add one to get started. +
+ } + > + + {(workspace) => { + const isActive = () => props.activeWorkspaceId === workspace.id; + const isConnecting = () => props.connectingWorkspaceId === workspace.id; + const pathLabel = () => workspacePathLabel(workspace); + const detailLabel = () => workspaceDetailLabel(workspace); + + return ( + + ); + }} + +
+
+
+ +
+
+ Sessions · {props.workspaceName} +
0} diff --git a/packages/app/src/app/pages/session.tsx b/packages/app/src/app/pages/session.tsx index 027d3707..4ae1900d 100644 --- a/packages/app/src/app/pages/session.tsx +++ b/packages/app/src/app/pages/session.tsx @@ -1,4 +1,4 @@ -import { For, Show, createEffect, createMemo, createSignal, on, onCleanup, onMount } from "solid-js"; +import { For, Show, createEffect, createMemo, createSignal, on, onCleanup, onMount, untrack } from "solid-js"; import type { Agent, Part } from "@opencode-ai/sdk/v2/client"; import type { ArtifactItem, @@ -20,6 +20,8 @@ import type { WorkspaceDisplay, } from "../types"; +import type { WorkspaceInfo } from "../lib/tauri"; + import { ArrowRight, ChevronDown, HardDrive, Shield, Zap } from "lucide-solid"; import Button from "../components/button"; @@ -46,6 +48,10 @@ export type SessionViewProps = { setSettingsTab: (tab: SettingsTab) => void; activeWorkspaceDisplay: WorkspaceDisplay; activeWorkspaceRoot: string; + workspaces: WorkspaceInfo[]; + activeWorkspaceId: string; + connectingWorkspaceId: string | null; + activateWorkspace: (workspaceId: string) => Promise | boolean | void; setWorkspaceSearch: (value: string) => void; setWorkspacePickerOpen: (open: boolean) => void; clientConnected: boolean; @@ -124,6 +130,38 @@ export type SessionViewProps = { deleteSession: (sessionId: string) => Promise; }; +type SessionWorkspaceMap = Record; + +const SESSION_WORKSPACE_STORE_KEY = "openwork.session-workspaces.v1"; + +const readSessionWorkspaceMap = (): SessionWorkspaceMap => { + if (typeof window === "undefined") return {}; + try { + const raw = window.localStorage.getItem(SESSION_WORKSPACE_STORE_KEY); + if (!raw) return {}; + const parsed = JSON.parse(raw); + if (!parsed || typeof parsed !== "object") return {}; + return parsed as SessionWorkspaceMap; + } catch { + return {}; + } +}; + +const writeSessionWorkspaceMap = (map: SessionWorkspaceMap) => { + if (typeof window === "undefined") return; + try { + window.localStorage.setItem(SESSION_WORKSPACE_STORE_KEY, JSON.stringify(map)); + } catch { + // ignore + } +}; + +const normalizeWorkspaceIds = (value: unknown): string[] => + Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === "string") : []; + +const arraysEqual = (a: string[], b: string[]) => + a.length === b.length && a.every((value, index) => value === b[index]); + export default function SessionView(props: SessionViewProps) { let messagesEndEl: HTMLDivElement | undefined; let chatContainerEl: HTMLDivElement | undefined; @@ -139,6 +177,7 @@ export default function SessionView(props: SessionViewProps) { const [agentPickerBusy, setAgentPickerBusy] = createSignal(false); const [agentPickerReady, setAgentPickerReady] = createSignal(false); const [agentPickerError, setAgentPickerError] = createSignal(null); + const [sessionWorkspaceIds, setSessionWorkspaceIds] = createSignal([]); const [agentOptions, setAgentOptions] = createSignal([]); const [autoScrollEnabled, setAutoScrollEnabled] = createSignal(false); const [scrollOnNextUpdate, setScrollOnNextUpdate] = createSignal(false); @@ -148,6 +187,40 @@ export default function SessionView(props: SessionViewProps) { const commandNeedsDetails = (command: { template: string }) => COMMAND_ARGS_RE.test(command.template); + const loadSessionWorkspaces = (sessionId: string) => { + const map = readSessionWorkspaceMap(); + const stored = normalizeWorkspaceIds(map[sessionId]); + const knownIds = new Set(props.workspaces.map((workspace) => workspace.id)); + return stored.filter((id) => knownIds.has(id)); + }; + + const persistSessionWorkspaces = (sessionId: string, ids: string[]) => { + const map = readSessionWorkspaceMap(); + if (ids.length) { + map[sessionId] = ids; + } else { + delete map[sessionId]; + } + writeSessionWorkspaceMap(map); + }; + + const updateSessionWorkspaces = (sessionId: string, updater: (current: string[]) => string[]) => { + const current = untrack(sessionWorkspaceIds); + const next = updater(current); + if (arraysEqual(current, next)) return; + setSessionWorkspaceIds(next); + persistSessionWorkspaces(sessionId, next); + }; + + const rememberWorkspaceForSession = (sessionId: string, workspaceId: string) => { + const knownIds = new Set(untrack(() => props.workspaces).map((workspace) => workspace.id)); + if (!knownIds.has(workspaceId)) return; + updateSessionWorkspaces(sessionId, (current) => { + const filtered = current.filter((id) => id !== workspaceId && knownIds.has(id)); + return [workspaceId, ...filtered]; + }); + }; + const agentLabel = createMemo(() => props.selectedSessionAgent ?? "Default agent"); const isNearBottom = (el: HTMLElement, threshold = 80) => { @@ -618,6 +691,50 @@ export default function SessionView(props: SessionViewProps) { return "Workspace"; }); + const sessionWorkspaces = createMemo(() => { + const byId = new Map(props.workspaces.map((workspace) => [workspace.id, workspace])); + const ids = sessionWorkspaceIds(); + const list = ids + .map((id) => byId.get(id)) + .filter((workspace): workspace is WorkspaceInfo => Boolean(workspace)); + const activeId = props.activeWorkspaceId; + if (!activeId) return list; + const active = byId.get(activeId); + if (!active) return list; + return [active, ...list.filter((workspace) => workspace.id !== activeId)]; + }); + + createEffect( + on( + () => props.selectedSessionId, + (sessionId) => { + if (!sessionId) { + setSessionWorkspaceIds([]); + return; + } + const next = loadSessionWorkspaces(sessionId); + setSessionWorkspaceIds(next); + }, + ), + ); + + createEffect(() => { + const sessionId = props.selectedSessionId; + if (!sessionId) return; + const knownIds = new Set(props.workspaces.map((workspace) => workspace.id)); + updateSessionWorkspaces(sessionId, (current) => current.filter((id) => knownIds.has(id))); + }); + + createEffect( + on( + [() => props.selectedSessionId, () => props.activeWorkspaceId], + ([sessionId, workspaceId]) => { + if (!sessionId || !workspaceId) return; + rememberWorkspaceForSession(sessionId, workspaceId); + }, + ), + ); + const pickFallbackSessionId = (targetId: string) => { const list = props.sessions.map((session) => session.id); if (list.length <= 1) return null; @@ -1152,6 +1269,11 @@ export default function SessionView(props: SessionViewProps) { props.setView("dashboard"); }; + const openWorkspacePicker = () => { + props.setWorkspaceSearch(""); + props.setWorkspacePickerOpen(true); + }; + const openProviderAuth = () => { void props.openProviderAuthModal().catch((error) => { const message = error instanceof Error ? error.message : "Connect failed"; @@ -1181,13 +1303,11 @@ export default function SessionView(props: SessionViewProps) { - { - props.setWorkspaceSearch(""); - props.setWorkspacePickerOpen(true); - }} - /> + {props.headerStatus} @@ -1215,12 +1335,17 @@ export default function SessionView(props: SessionViewProps) { props.setExpandedSidebarSections((curr) => ({...curr, [section]: !curr[section]})); }} workspaceName={workspaceLabel()} + sessionWorkspaces={sessionWorkspaces()} + activeWorkspaceId={props.activeWorkspaceId} + connectingWorkspaceId={props.connectingWorkspaceId} + onSelectWorkspace={props.activateWorkspace} + onAddWorkspace={openWorkspacePicker} sessions={props.sessions} selectedSessionId={props.selectedSessionId} onSelectSession={async (id) => { - await props.selectSession(id); - props.setView("session", id); - props.setTab("sessions"); + await props.selectSession(id); + props.setView("session", id); + props.setTab("sessions"); }} sessionStatusById={props.sessionStatusById} onCreateSession={props.createSessionAndOpen} From 8dd342e7f02d5c9e35e74e310e6e52b0c77fb2fb Mon Sep 17 00:00:00 2001 From: Benjamin Shafii Date: Tue, 3 Feb 2026 08:12:34 -0800 Subject: [PATCH 2/5] refine session sidebar workspace grouping --- .../src/app/components/session/sidebar.tsx | 243 ++++++++++-------- packages/app/src/app/pages/session.tsx | 62 +++-- 2 files changed, 173 insertions(+), 132 deletions(-) diff --git a/packages/app/src/app/components/session/sidebar.tsx b/packages/app/src/app/components/session/sidebar.tsx index fa04e4ed..39860ebe 100644 --- a/packages/app/src/app/components/session/sidebar.tsx +++ b/packages/app/src/app/components/session/sidebar.tsx @@ -4,6 +4,17 @@ import { Check, ChevronDown, Loader2, Plus } from "lucide-solid"; import type { TodoItem } from "../../types"; import type { WorkspaceInfo } from "../../lib/tauri"; +type SessionSummary = { + id: string; + title: string; + slug?: string | null; +}; + +type WorkspaceSessionGroup = { + workspace: WorkspaceInfo; + sessions: SessionSummary[]; +}; + export type SidebarSectionState = { progress: boolean; artifacts: boolean; @@ -18,15 +29,13 @@ export type SidebarProps = { todos: TodoItem[]; expandedSections: SidebarSectionState; onToggleSection: (section: keyof SidebarSectionState) => void; - workspaceName: string; - sessionWorkspaces: WorkspaceInfo[]; + workspaceGroups: WorkspaceSessionGroup[]; activeWorkspaceId: string; connectingWorkspaceId?: string | null; onSelectWorkspace: (workspaceId: string) => void; onAddWorkspace: () => void; - sessions: Array<{ id: string; title: string; slug?: string | null }>; + onSelectSession: (workspaceId: string, sessionId: string) => void; selectedSessionId: string | null; - onSelectSession: (id: string) => void; sessionStatusById: Record; onCreateSession: () => void; onDeleteSession: (id: string) => void; @@ -128,137 +137,143 @@ export default function SessionSidebar(props: SidebarProps) {
-
Session Workspaces
- +
Workspaces
-
+
0} + when={props.workspaceGroups.length > 0} fallback={
No workspaces in this session yet. Add one to get started.
} > - - {(workspace) => { - const isActive = () => props.activeWorkspaceId === workspace.id; - const isConnecting = () => props.connectingWorkspaceId === workspace.id; - const pathLabel = () => workspacePathLabel(workspace); - const detailLabel = () => workspaceDetailLabel(workspace); + + {(group) => { + const isActive = () => props.activeWorkspaceId === group.workspace.id; + const isConnecting = () => props.connectingWorkspaceId === group.workspace.id; + const pathLabel = () => workspacePathLabel(group.workspace); + const detailLabel = () => workspaceDetailLabel(group.workspace); + const sessions = () => group.sessions; + const allowActions = () => !props.connectingWorkspaceId || isConnecting(); return ( -
- - ); - }} - - -
-
- -
-
- Sessions · {props.workspaceName} -
-
- 0} - fallback={ -
- No sessions yet. Start a task to see your work here. -
- } - > - - {(session) => ( - +
0} + fallback={ +
+ No sessions yet. +
} > - -
- - + + {(session) => ( + + )} + + +
- - )} + ); + }}
+
diff --git a/packages/app/src/app/pages/session.tsx b/packages/app/src/app/pages/session.tsx index 4ae1900d..a6922b35 100644 --- a/packages/app/src/app/pages/session.tsx +++ b/packages/app/src/app/pages/session.tsx @@ -131,6 +131,7 @@ export type SessionViewProps = { }; type SessionWorkspaceMap = Record; +type SessionSummary = { id: string; title: string; slug?: string | null }; const SESSION_WORKSPACE_STORE_KEY = "openwork.session-workspaces.v1"; @@ -178,6 +179,7 @@ export default function SessionView(props: SessionViewProps) { const [agentPickerReady, setAgentPickerReady] = createSignal(false); const [agentPickerError, setAgentPickerError] = createSignal(null); const [sessionWorkspaceIds, setSessionWorkspaceIds] = createSignal([]); + const [sessionsByWorkspaceId, setSessionsByWorkspaceId] = createSignal>({}); const [agentOptions, setAgentOptions] = createSignal([]); const [autoScrollEnabled, setAutoScrollEnabled] = createSignal(false); const [scrollOnNextUpdate, setScrollOnNextUpdate] = createSignal(false); @@ -216,8 +218,9 @@ export default function SessionView(props: SessionViewProps) { const knownIds = new Set(untrack(() => props.workspaces).map((workspace) => workspace.id)); if (!knownIds.has(workspaceId)) return; updateSessionWorkspaces(sessionId, (current) => { - const filtered = current.filter((id) => id !== workspaceId && knownIds.has(id)); - return [workspaceId, ...filtered]; + const filtered = current.filter((id) => knownIds.has(id)); + if (filtered.includes(workspaceId)) return filtered; + return [...filtered, workspaceId]; }); }; @@ -685,23 +688,39 @@ export default function SessionView(props: SessionViewProps) { return props.sessions.find((session) => session.id === id)?.title ?? ""; }); - const workspaceLabel = createMemo(() => { - const name = props.activeWorkspaceDisplay.name.trim(); - if (name) return name; - return "Workspace"; - }); - const sessionWorkspaces = createMemo(() => { const byId = new Map(props.workspaces.map((workspace) => [workspace.id, workspace])); const ids = sessionWorkspaceIds(); const list = ids .map((id) => byId.get(id)) .filter((workspace): workspace is WorkspaceInfo => Boolean(workspace)); + if (list.length) return list; const activeId = props.activeWorkspaceId; if (!activeId) return list; const active = byId.get(activeId); - if (!active) return list; - return [active, ...list.filter((workspace) => workspace.id !== activeId)]; + return active ? [active] : list; + }); + + createEffect(() => { + const workspaceId = props.activeWorkspaceId; + if (!workspaceId) return; + const list = props.sessions.map((session) => ({ + id: session.id, + title: session.title, + slug: session.slug, + })); + setSessionsByWorkspaceId((prev) => ({ + ...prev, + [workspaceId]: list, + })); + }); + + const sessionWorkspaceGroups = createMemo(() => { + const byWorkspace = sessionsByWorkspaceId(); + return sessionWorkspaces().map((workspace) => ({ + workspace, + sessions: byWorkspace[workspace.id] ?? [], + })); }); createEffect( @@ -1274,6 +1293,19 @@ export default function SessionView(props: SessionViewProps) { props.setWorkspacePickerOpen(true); }; + const handleSelectSession = async (workspaceId: string, sessionId: string) => { + const targetWorkspaceId = workspaceId?.trim(); + if (!targetWorkspaceId || !sessionId) return; + if (props.connectingWorkspaceId && props.connectingWorkspaceId !== targetWorkspaceId) return; + if (targetWorkspaceId !== props.activeWorkspaceId) { + const result = await props.activateWorkspace(targetWorkspaceId); + if (result === false) return; + } + await props.selectSession(sessionId); + props.setView("session", sessionId); + props.setTab("sessions"); + }; + const openProviderAuth = () => { void props.openProviderAuthModal().catch((error) => { const message = error instanceof Error ? error.message : "Connect failed"; @@ -1334,19 +1366,13 @@ export default function SessionView(props: SessionViewProps) { onToggleSection={(section) => { props.setExpandedSidebarSections((curr) => ({...curr, [section]: !curr[section]})); }} - workspaceName={workspaceLabel()} - sessionWorkspaces={sessionWorkspaces()} + workspaceGroups={sessionWorkspaceGroups()} activeWorkspaceId={props.activeWorkspaceId} connectingWorkspaceId={props.connectingWorkspaceId} onSelectWorkspace={props.activateWorkspace} onAddWorkspace={openWorkspacePicker} - sessions={props.sessions} + onSelectSession={handleSelectSession} selectedSessionId={props.selectedSessionId} - onSelectSession={async (id) => { - await props.selectSession(id); - props.setView("session", id); - props.setTab("sessions"); - }} sessionStatusById={props.sessionStatusById} onCreateSession={props.createSessionAndOpen} onDeleteSession={handleDeleteSession} From bcc2b2e483dd1545a07e7963963418953bdfbd26 Mon Sep 17 00:00:00 2001 From: Benjamin Shafii Date: Tue, 3 Feb 2026 08:47:07 -0800 Subject: [PATCH 3/5] fix: preserve workspace session context --- packages/app/src/app/app.tsx | 67 ++++++++++---- packages/app/src/app/pages/session.tsx | 122 +++---------------------- 2 files changed, 62 insertions(+), 127 deletions(-) diff --git a/packages/app/src/app/app.tsx b/packages/app/src/app/app.tsx index bddf886e..8282c7f2 100644 --- a/packages/app/src/app/app.tsx +++ b/packages/app/src/app/app.tsx @@ -531,6 +531,27 @@ export default function App() { const [selectedSessionId, setSelectedSessionId] = createSignal( null ); + const SESSION_BY_WORKSPACE_KEY = "openwork.workspace-last-session.v1"; + const readSessionByWorkspace = () => { + if (typeof window === "undefined") return {} as Record; + try { + const raw = window.localStorage.getItem(SESSION_BY_WORKSPACE_KEY); + if (!raw) return {} as Record; + const parsed = JSON.parse(raw); + if (!parsed || typeof parsed !== "object") return {} as Record; + return parsed as Record; + } catch { + return {} as Record; + } + }; + const writeSessionByWorkspace = (map: Record) => { + if (typeof window === "undefined") return; + try { + window.localStorage.setItem(SESSION_BY_WORKSPACE_KEY, JSON.stringify(map)); + } catch { + // ignore + } + }; const [sessionModelOverrideById, setSessionModelOverrideById] = createSignal< Record >({}); @@ -616,23 +637,6 @@ export default function App() { const activeArtifacts = createMemo(() => artifacts()); const activeWorkingFiles = createMemo(() => workingFiles()); - createEffect(() => { - if (!client()) return; - if (!sessionsLoaded()) return; - if (creatingSession()) return; - if (selectedSessionId()) return; - - const list = sessions(); - if (list.length) { - const next = list[0]; - void selectSession(next.id); - setView("session", next.id); - return; - } - - return; - }); - const [prompt, setPrompt] = createSignal(""); const [lastPromptSent, setLastPromptSent] = createSignal(""); const [commandPaletteOpen, setCommandPaletteOpen] = createSignal(false); @@ -1289,6 +1293,35 @@ export default function App() { engineRuntime, }); + createEffect(() => { + if (typeof window === "undefined") return; + const workspaceId = workspaceStore.activeWorkspaceId(); + const sessionId = selectedSessionId(); + if (!workspaceId || !sessionId) return; + const map = readSessionByWorkspace(); + if (map[workspaceId] === sessionId) return; + map[workspaceId] = sessionId; + writeSessionByWorkspace(map); + }); + + createEffect(() => { + if (!client()) return; + if (!sessionsLoaded()) return; + if (creatingSession()) return; + if (selectedSessionId()) return; + + const list = sessions(); + if (!list.length) return; + + const workspaceId = workspaceStore.activeWorkspaceId(); + const map = workspaceId ? readSessionByWorkspace() : null; + const saved = workspaceId ? map?.[workspaceId] : null; + const match = saved ? list.find((session) => session.id === saved) : null; + const next = match ?? list[0]; + void selectSession(next.id); + setView("session", next.id); + }); + createEffect(() => { const active = workspaceStore.activeWorkspaceDisplay(); const client = openworkServerClient(); diff --git a/packages/app/src/app/pages/session.tsx b/packages/app/src/app/pages/session.tsx index a6922b35..cba7ea6f 100644 --- a/packages/app/src/app/pages/session.tsx +++ b/packages/app/src/app/pages/session.tsx @@ -1,4 +1,4 @@ -import { For, Show, createEffect, createMemo, createSignal, on, onCleanup, onMount, untrack } from "solid-js"; +import { For, Show, createEffect, createMemo, createSignal, on, onCleanup, onMount } from "solid-js"; import type { Agent, Part } from "@opencode-ai/sdk/v2/client"; import type { ArtifactItem, @@ -130,39 +130,8 @@ export type SessionViewProps = { deleteSession: (sessionId: string) => Promise; }; -type SessionWorkspaceMap = Record; type SessionSummary = { id: string; title: string; slug?: string | null }; -const SESSION_WORKSPACE_STORE_KEY = "openwork.session-workspaces.v1"; - -const readSessionWorkspaceMap = (): SessionWorkspaceMap => { - if (typeof window === "undefined") return {}; - try { - const raw = window.localStorage.getItem(SESSION_WORKSPACE_STORE_KEY); - if (!raw) return {}; - const parsed = JSON.parse(raw); - if (!parsed || typeof parsed !== "object") return {}; - return parsed as SessionWorkspaceMap; - } catch { - return {}; - } -}; - -const writeSessionWorkspaceMap = (map: SessionWorkspaceMap) => { - if (typeof window === "undefined") return; - try { - window.localStorage.setItem(SESSION_WORKSPACE_STORE_KEY, JSON.stringify(map)); - } catch { - // ignore - } -}; - -const normalizeWorkspaceIds = (value: unknown): string[] => - Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === "string") : []; - -const arraysEqual = (a: string[], b: string[]) => - a.length === b.length && a.every((value, index) => value === b[index]); - export default function SessionView(props: SessionViewProps) { let messagesEndEl: HTMLDivElement | undefined; let chatContainerEl: HTMLDivElement | undefined; @@ -178,7 +147,6 @@ export default function SessionView(props: SessionViewProps) { const [agentPickerBusy, setAgentPickerBusy] = createSignal(false); const [agentPickerReady, setAgentPickerReady] = createSignal(false); const [agentPickerError, setAgentPickerError] = createSignal(null); - const [sessionWorkspaceIds, setSessionWorkspaceIds] = createSignal([]); const [sessionsByWorkspaceId, setSessionsByWorkspaceId] = createSignal>({}); const [agentOptions, setAgentOptions] = createSignal([]); const [autoScrollEnabled, setAutoScrollEnabled] = createSignal(false); @@ -189,41 +157,6 @@ export default function SessionView(props: SessionViewProps) { const commandNeedsDetails = (command: { template: string }) => COMMAND_ARGS_RE.test(command.template); - const loadSessionWorkspaces = (sessionId: string) => { - const map = readSessionWorkspaceMap(); - const stored = normalizeWorkspaceIds(map[sessionId]); - const knownIds = new Set(props.workspaces.map((workspace) => workspace.id)); - return stored.filter((id) => knownIds.has(id)); - }; - - const persistSessionWorkspaces = (sessionId: string, ids: string[]) => { - const map = readSessionWorkspaceMap(); - if (ids.length) { - map[sessionId] = ids; - } else { - delete map[sessionId]; - } - writeSessionWorkspaceMap(map); - }; - - const updateSessionWorkspaces = (sessionId: string, updater: (current: string[]) => string[]) => { - const current = untrack(sessionWorkspaceIds); - const next = updater(current); - if (arraysEqual(current, next)) return; - setSessionWorkspaceIds(next); - persistSessionWorkspaces(sessionId, next); - }; - - const rememberWorkspaceForSession = (sessionId: string, workspaceId: string) => { - const knownIds = new Set(untrack(() => props.workspaces).map((workspace) => workspace.id)); - if (!knownIds.has(workspaceId)) return; - updateSessionWorkspaces(sessionId, (current) => { - const filtered = current.filter((id) => knownIds.has(id)); - if (filtered.includes(workspaceId)) return filtered; - return [...filtered, workspaceId]; - }); - }; - const agentLabel = createMemo(() => props.selectedSessionAgent ?? "Default agent"); const isNearBottom = (el: HTMLElement, threshold = 80) => { @@ -688,17 +621,17 @@ export default function SessionView(props: SessionViewProps) { return props.sessions.find((session) => session.id === id)?.title ?? ""; }); - const sessionWorkspaces = createMemo(() => { - const byId = new Map(props.workspaces.map((workspace) => [workspace.id, workspace])); - const ids = sessionWorkspaceIds(); - const list = ids - .map((id) => byId.get(id)) - .filter((workspace): workspace is WorkspaceInfo => Boolean(workspace)); - if (list.length) return list; + const sortedWorkspaces = createMemo(() => { const activeId = props.activeWorkspaceId; - if (!activeId) return list; - const active = byId.get(activeId); - return active ? [active] : list; + return props.workspaces + .slice() + .sort((a, b) => { + if (a.id === activeId) return -1; + if (b.id === activeId) return 1; + const aLabel = (a.displayName ?? a.openworkWorkspaceName ?? a.name ?? "").toLowerCase(); + const bLabel = (b.displayName ?? b.openworkWorkspaceName ?? b.name ?? "").toLowerCase(); + return aLabel.localeCompare(bLabel); + }); }); createEffect(() => { @@ -717,43 +650,12 @@ export default function SessionView(props: SessionViewProps) { const sessionWorkspaceGroups = createMemo(() => { const byWorkspace = sessionsByWorkspaceId(); - return sessionWorkspaces().map((workspace) => ({ + return sortedWorkspaces().map((workspace) => ({ workspace, sessions: byWorkspace[workspace.id] ?? [], })); }); - createEffect( - on( - () => props.selectedSessionId, - (sessionId) => { - if (!sessionId) { - setSessionWorkspaceIds([]); - return; - } - const next = loadSessionWorkspaces(sessionId); - setSessionWorkspaceIds(next); - }, - ), - ); - - createEffect(() => { - const sessionId = props.selectedSessionId; - if (!sessionId) return; - const knownIds = new Set(props.workspaces.map((workspace) => workspace.id)); - updateSessionWorkspaces(sessionId, (current) => current.filter((id) => knownIds.has(id))); - }); - - createEffect( - on( - [() => props.selectedSessionId, () => props.activeWorkspaceId], - ([sessionId, workspaceId]) => { - if (!sessionId || !workspaceId) return; - rememberWorkspaceForSession(sessionId, workspaceId); - }, - ), - ); - const pickFallbackSessionId = (targetId: string) => { const list = props.sessions.map((session) => session.id); if (list.length <= 1) return null; From 6301c1c053b5450538874e9e2f78a252f62ee56f Mon Sep 17 00:00:00 2001 From: Benjamin Shafii Date: Tue, 3 Feb 2026 09:07:27 -0800 Subject: [PATCH 4/5] feat: make workspace groups reorderable --- .../src/app/components/session/sidebar.tsx | 326 +++++++++++++----- packages/app/src/app/pages/session.tsx | 79 ++++- 2 files changed, 298 insertions(+), 107 deletions(-) diff --git a/packages/app/src/app/components/session/sidebar.tsx b/packages/app/src/app/components/session/sidebar.tsx index 39860ebe..7050b762 100644 --- a/packages/app/src/app/components/session/sidebar.tsx +++ b/packages/app/src/app/components/session/sidebar.tsx @@ -1,5 +1,5 @@ import { For, Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js"; -import { Check, ChevronDown, Loader2, Plus } from "lucide-solid"; +import { Check, ChevronDown, GripVertical, Loader2, Plus } from "lucide-solid"; import type { TodoItem } from "../../types"; import type { WorkspaceInfo } from "../../lib/tauri"; @@ -34,6 +34,7 @@ export type SidebarProps = { connectingWorkspaceId?: string | null; onSelectWorkspace: (workspaceId: string) => void; onAddWorkspace: () => void; + onReorderWorkspace: (fromId: string, toId: string | null) => void; onSelectSession: (workspaceId: string, sessionId: string) => void; selectedSessionId: string | null; sessionStatusById: Record; @@ -44,6 +45,30 @@ export type SidebarProps = { export default function SessionSidebar(props: SidebarProps) { const realTodos = createMemo(() => props.todos.filter((todo) => todo.content.trim())); + const WORKSPACE_COLLAPSE_KEY = "openwork.workspace-collapse.v1"; + const readWorkspaceCollapse = () => { + if (typeof window === "undefined") return {} as Record; + try { + const raw = window.localStorage.getItem(WORKSPACE_COLLAPSE_KEY); + if (!raw) return {} as Record; + const parsed = JSON.parse(raw); + if (!parsed || typeof parsed !== "object") return {} as Record; + return parsed as Record; + } catch { + return {} as Record; + } + }; + const writeWorkspaceCollapse = (next: Record) => { + if (typeof window === "undefined") return; + try { + window.localStorage.setItem(WORKSPACE_COLLAPSE_KEY, JSON.stringify(next)); + } catch { + // ignore + } + }; + const [collapsedById, setCollapsedById] = createSignal>(readWorkspaceCollapse()); + const [draggingWorkspaceId, setDraggingWorkspaceId] = createSignal(null); + const [dragOverWorkspaceId, setDragOverWorkspaceId] = createSignal(null); const workspaceLabel = (workspace: WorkspaceInfo) => workspace.displayName?.trim() || @@ -72,6 +97,58 @@ export default function SessionSidebar(props: SidebarProps) { return workspace.openworkWorkspaceName?.trim() || workspace.directory?.trim() || ""; }; + const toggleWorkspaceCollapse = (workspaceId: string) => { + setCollapsedById((prev) => { + const next = { ...prev, [workspaceId]: !prev[workspaceId] }; + writeWorkspaceCollapse(next); + return next; + }); + }; + + const isWorkspaceCollapsed = (workspaceId: string) => Boolean(collapsedById()[workspaceId]); + + const handleDragStart = (event: DragEvent, workspaceId: string) => { + event.dataTransfer?.setData("text/plain", workspaceId); + if (event.dataTransfer) { + event.dataTransfer.effectAllowed = "move"; + } + setDraggingWorkspaceId(workspaceId); + }; + + const handleDragOver = (event: DragEvent, workspaceId: string | null) => { + if (!draggingWorkspaceId()) return; + event.preventDefault(); + if (event.dataTransfer) { + event.dataTransfer.dropEffect = "move"; + } + setDragOverWorkspaceId(workspaceId); + }; + + const handleDragLeave = (workspaceId: string | null) => { + if (dragOverWorkspaceId() === workspaceId) { + setDragOverWorkspaceId(null); + } + }; + + const handleDrop = (event: DragEvent, workspaceId: string | null) => { + event.preventDefault(); + const dragId = draggingWorkspaceId() ?? event.dataTransfer?.getData("text/plain") ?? null; + if (!dragId) return; + if (workspaceId && dragId === workspaceId) { + setDraggingWorkspaceId(null); + setDragOverWorkspaceId(null); + return; + } + props.onReorderWorkspace(dragId, workspaceId); + setDraggingWorkspaceId(null); + setDragOverWorkspaceId(null); + }; + + const handleDragEnd = () => { + setDraggingWorkspaceId(null); + setDragOverWorkspaceId(null); + }; + const progressDots = createMemo(() => { const activeTodos = realTodos(); const total = activeTodos.length; @@ -121,6 +198,24 @@ export default function SessionSidebar(props: SidebarProps) { onCleanup(() => window.removeEventListener("keydown", onKeyDown)); }); + createEffect(() => { + const ids = new Set(props.workspaceGroups.map((group) => group.workspace.id)); + setCollapsedById((prev) => { + let changed = false; + const next: Record = {}; + for (const [id, value] of Object.entries(prev)) { + if (ids.has(id)) { + next[id] = value; + } else { + changed = true; + } + } + if (!changed) return prev; + writeWorkspaceCollapse(next); + return next; + }); + }); + return (
@@ -156,111 +251,149 @@ export default function SessionSidebar(props: SidebarProps) { const detailLabel = () => workspaceDetailLabel(group.workspace); const sessions = () => group.sessions; const allowActions = () => !props.connectingWorkspaceId || isConnecting(); + const collapsed = () => isWorkspaceCollapsed(group.workspace.id); + const dragOver = () => dragOverWorkspaceId() === group.workspace.id; return ( -
- +
+ +
- -
- 0} - fallback={ -
- No sessions yet. -
- } - > - - {(session) => ( - - )} - -
-
+ > +
+ + +
+ + )} + + +
+
); }} @@ -270,6 +403,9 @@ export default function SessionSidebar(props: SidebarProps) { type="button" class="w-full flex items-center justify-center gap-2 px-3 py-2 rounded-lg text-xs font-medium text-gray-11 border border-dashed border-gray-6 hover:border-gray-7 hover:text-gray-12 hover:bg-gray-2 transition-colors" onClick={props.onAddWorkspace} + onDragOver={(event) => handleDragOver(event, null)} + onDragLeave={() => handleDragLeave(null)} + onDrop={(event) => handleDrop(event, null)} > Add new workspace diff --git a/packages/app/src/app/pages/session.tsx b/packages/app/src/app/pages/session.tsx index cba7ea6f..9343b24c 100644 --- a/packages/app/src/app/pages/session.tsx +++ b/packages/app/src/app/pages/session.tsx @@ -132,6 +132,33 @@ export type SessionViewProps = { type SessionSummary = { id: string; title: string; slug?: string | null }; +const WORKSPACE_ORDER_KEY = "openwork.workspace-order.v1"; + +const readWorkspaceOrder = (): string[] => { + if (typeof window === "undefined") return []; + try { + const raw = window.localStorage.getItem(WORKSPACE_ORDER_KEY); + if (!raw) return []; + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) return []; + return parsed.filter((entry): entry is string => typeof entry === "string"); + } catch { + return []; + } +}; + +const writeWorkspaceOrder = (order: string[]) => { + if (typeof window === "undefined") return; + try { + window.localStorage.setItem(WORKSPACE_ORDER_KEY, JSON.stringify(order)); + } catch { + // ignore + } +}; + +const arraysEqual = (a: string[], b: string[]) => + a.length === b.length && a.every((value, index) => value === b[index]); + export default function SessionView(props: SessionViewProps) { let messagesEndEl: HTMLDivElement | undefined; let chatContainerEl: HTMLDivElement | undefined; @@ -147,6 +174,7 @@ export default function SessionView(props: SessionViewProps) { const [agentPickerBusy, setAgentPickerBusy] = createSignal(false); const [agentPickerReady, setAgentPickerReady] = createSignal(false); const [agentPickerError, setAgentPickerError] = createSignal(null); + const [workspaceOrder, setWorkspaceOrder] = createSignal(readWorkspaceOrder()); const [sessionsByWorkspaceId, setSessionsByWorkspaceId] = createSignal>({}); const [agentOptions, setAgentOptions] = createSignal([]); const [autoScrollEnabled, setAutoScrollEnabled] = createSignal(false); @@ -621,17 +649,25 @@ export default function SessionView(props: SessionViewProps) { return props.sessions.find((session) => session.id === id)?.title ?? ""; }); - const sortedWorkspaces = createMemo(() => { - const activeId = props.activeWorkspaceId; - return props.workspaces - .slice() - .sort((a, b) => { - if (a.id === activeId) return -1; - if (b.id === activeId) return 1; - const aLabel = (a.displayName ?? a.openworkWorkspaceName ?? a.name ?? "").toLowerCase(); - const bLabel = (b.displayName ?? b.openworkWorkspaceName ?? b.name ?? "").toLowerCase(); - return aLabel.localeCompare(bLabel); - }); + createEffect(() => { + const ids = props.workspaces.map((workspace) => workspace.id); + const base = workspaceOrder().length ? workspaceOrder() : readWorkspaceOrder(); + const filtered = base.filter((id) => ids.includes(id)); + const missing = ids.filter((id) => !filtered.includes(id)); + const next = [...filtered, ...missing]; + if (!arraysEqual(base, next)) { + writeWorkspaceOrder(next); + } + if (!arraysEqual(workspaceOrder(), next)) { + setWorkspaceOrder(next); + } + }); + + const orderedWorkspaces = createMemo(() => { + const byId = new Map(props.workspaces.map((workspace) => [workspace.id, workspace])); + const order = workspaceOrder(); + const list = order.map((id) => byId.get(id)).filter((workspace): workspace is WorkspaceInfo => Boolean(workspace)); + return list.length ? list : props.workspaces; }); createEffect(() => { @@ -650,7 +686,7 @@ export default function SessionView(props: SessionViewProps) { const sessionWorkspaceGroups = createMemo(() => { const byWorkspace = sessionsByWorkspaceId(); - return sortedWorkspaces().map((workspace) => ({ + return orderedWorkspaces().map((workspace) => ({ workspace, sessions: byWorkspace[workspace.id] ?? [], })); @@ -1208,6 +1244,24 @@ export default function SessionView(props: SessionViewProps) { props.setTab("sessions"); }; + const handleReorderWorkspace = (fromId: string, toId: string | null) => { + setWorkspaceOrder((current) => { + const base = current.length ? current : props.workspaces.map((workspace) => workspace.id); + if (!base.includes(fromId)) return current; + const next = base.filter((id) => id !== fromId); + if (toId) { + const index = next.indexOf(toId); + if (index === -1) return current; + next.splice(index, 0, fromId); + } else { + next.push(fromId); + } + if (arraysEqual(base, next)) return current; + writeWorkspaceOrder(next); + return next; + }); + }; + const openProviderAuth = () => { void props.openProviderAuthModal().catch((error) => { const message = error instanceof Error ? error.message : "Connect failed"; @@ -1273,6 +1327,7 @@ export default function SessionView(props: SessionViewProps) { connectingWorkspaceId={props.connectingWorkspaceId} onSelectWorkspace={props.activateWorkspace} onAddWorkspace={openWorkspacePicker} + onReorderWorkspace={handleReorderWorkspace} onSelectSession={handleSelectSession} selectedSessionId={props.selectedSessionId} sessionStatusById={props.sessionStatusById} From 9551e63093c58e86768cb9093d9f2b306bdb6282 Mon Sep 17 00:00:00 2001 From: Benjamin Shafii Date: Tue, 3 Feb 2026 09:15:20 -0800 Subject: [PATCH 5/5] chore: soften workspace path metadata --- packages/app/src/app/components/session/sidebar.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/app/src/app/components/session/sidebar.tsx b/packages/app/src/app/components/session/sidebar.tsx index 7050b762..f9766d85 100644 --- a/packages/app/src/app/components/session/sidebar.tsx +++ b/packages/app/src/app/components/session/sidebar.tsx @@ -293,10 +293,10 @@ export default function SessionSidebar(props: SidebarProps) {
-
{pathLabel()}
+
{pathLabel()}
-
{detailLabel()}
+
{detailLabel()}