diff --git a/packages/app/src/app/app.tsx b/packages/app/src/app/app.tsx index 5160d5df..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(); @@ -4110,6 +4143,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..f9766d85 100644 --- a/packages/app/src/app/components/session/sidebar.tsx +++ b/packages/app/src/app/components/session/sidebar.tsx @@ -1,7 +1,19 @@ import { For, Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js"; -import { Check, ChevronDown, Plus } from "lucide-solid"; +import { Check, ChevronDown, GripVertical, 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; @@ -17,10 +29,14 @@ export type SidebarProps = { todos: TodoItem[]; expandedSections: SidebarSectionState; onToggleSection: (section: keyof SidebarSectionState) => void; - workspaceName: string; - sessions: Array<{ id: string; title: string; slug?: string | null }>; + workspaceGroups: WorkspaceSessionGroup[]; + activeWorkspaceId: string; + 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; - onSelectSession: (id: string) => void; sessionStatusById: Record; onCreateSession: () => void; onDeleteSession: (id: string) => void; @@ -29,6 +45,109 @@ 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() || + 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 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(); @@ -79,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 (
@@ -94,56 +231,185 @@ export default function SessionSidebar(props: SidebarProps) {
-
{props.workspaceName}
-
+
+
Workspaces
+
+
0} + when={props.workspaceGroups.length > 0} fallback={
- No sessions yet. Start a task to see your work here. + No workspaces in this session yet. Add one to get started.
} > - - {(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 027d3707..9343b24c 100644 --- a/packages/app/src/app/pages/session.tsx +++ b/packages/app/src/app/pages/session.tsx @@ -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,35 @@ export type SessionViewProps = { deleteSession: (sessionId: string) => Promise; }; +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; @@ -139,6 +174,8 @@ 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); const [scrollOnNextUpdate, setScrollOnNextUpdate] = createSignal(false); @@ -612,10 +649,47 @@ 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"; + 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(() => { + 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 orderedWorkspaces().map((workspace) => ({ + workspace, + sessions: byWorkspace[workspace.id] ?? [], + })); }); const pickFallbackSessionId = (targetId: string) => { @@ -1152,6 +1226,42 @@ export default function SessionView(props: SessionViewProps) { props.setView("dashboard"); }; + const openWorkspacePicker = () => { + props.setWorkspaceSearch(""); + 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 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"; @@ -1181,13 +1291,11 @@ export default function SessionView(props: SessionViewProps) { - { - props.setWorkspaceSearch(""); - props.setWorkspacePickerOpen(true); - }} - /> + {props.headerStatus} @@ -1214,14 +1322,14 @@ export default function SessionView(props: SessionViewProps) { onToggleSection={(section) => { props.setExpandedSidebarSections((curr) => ({...curr, [section]: !curr[section]})); }} - workspaceName={workspaceLabel()} - sessions={props.sessions} + workspaceGroups={sessionWorkspaceGroups()} + activeWorkspaceId={props.activeWorkspaceId} + connectingWorkspaceId={props.connectingWorkspaceId} + onSelectWorkspace={props.activateWorkspace} + onAddWorkspace={openWorkspacePicker} + onReorderWorkspace={handleReorderWorkspace} + 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}