diff --git a/packages/app/pr/screenshots/workspace-session-sidebar-ux/dashboard-right-sidebar-collapsed.png b/packages/app/pr/screenshots/workspace-session-sidebar-ux/dashboard-right-sidebar-collapsed.png new file mode 100644 index 000000000..dcdd39259 Binary files /dev/null and b/packages/app/pr/screenshots/workspace-session-sidebar-ux/dashboard-right-sidebar-collapsed.png differ diff --git a/packages/app/pr/screenshots/workspace-session-sidebar-ux/session-left-sidebar-resized.png b/packages/app/pr/screenshots/workspace-session-sidebar-ux/session-left-sidebar-resized.png new file mode 100644 index 000000000..a1cb8099c Binary files /dev/null and b/packages/app/pr/screenshots/workspace-session-sidebar-ux/session-left-sidebar-resized.png differ diff --git a/packages/app/pr/screenshots/workspace-session-sidebar-ux/session-right-sidebar-collapsed.png b/packages/app/pr/screenshots/workspace-session-sidebar-ux/session-right-sidebar-collapsed.png new file mode 100644 index 000000000..95cd74cdd Binary files /dev/null and b/packages/app/pr/screenshots/workspace-session-sidebar-ux/session-right-sidebar-collapsed.png differ diff --git a/packages/app/pr/screenshots/workspace-session-sidebar-ux/session-right-sidebar-expanded.png b/packages/app/pr/screenshots/workspace-session-sidebar-ux/session-right-sidebar-expanded.png new file mode 100644 index 000000000..13c92228b Binary files /dev/null and b/packages/app/pr/screenshots/workspace-session-sidebar-ux/session-right-sidebar-expanded.png differ diff --git a/packages/app/pr/screenshots/workspace-session-sidebar-ux/settings-open-from-session.png b/packages/app/pr/screenshots/workspace-session-sidebar-ux/settings-open-from-session.png new file mode 100644 index 000000000..6496bbed7 Binary files /dev/null and b/packages/app/pr/screenshots/workspace-session-sidebar-ux/settings-open-from-session.png differ diff --git a/packages/app/pr/screenshots/workspace-session-sidebar-ux/settings-toggle-returned-session.png b/packages/app/pr/screenshots/workspace-session-sidebar-ux/settings-toggle-returned-session.png new file mode 100644 index 000000000..003eaf567 Binary files /dev/null and b/packages/app/pr/screenshots/workspace-session-sidebar-ux/settings-toggle-returned-session.png differ diff --git a/packages/app/pr/workspace-session-sidebar-ux.md b/packages/app/pr/workspace-session-sidebar-ux.md new file mode 100644 index 000000000..8e6068628 --- /dev/null +++ b/packages/app/pr/workspace-session-sidebar-ux.md @@ -0,0 +1,26 @@ +# Workspace shell sidebar UX + +## Summary + +- keeps the workspace right rail visible on both session and dashboard surfaces, defaulting to a compact icon-only state +- adds a draggable resize handle for the left workspace column on desktop breakpoints +- makes the status bar settings cog act like a toggle so a second click returns to the previous screen + +## Evidence + +- `packages/app/pr/screenshots/workspace-session-sidebar-ux/session-right-sidebar-collapsed.png` +- `packages/app/pr/screenshots/workspace-session-sidebar-ux/dashboard-right-sidebar-collapsed.png` +- `packages/app/pr/screenshots/workspace-session-sidebar-ux/session-right-sidebar-expanded.png` +- `packages/app/pr/screenshots/workspace-session-sidebar-ux/session-left-sidebar-resized.png` +- `packages/app/pr/screenshots/workspace-session-sidebar-ux/settings-open-from-session.png` +- `packages/app/pr/screenshots/workspace-session-sidebar-ux/settings-toggle-returned-session.png` + +## Verification + +- `pnpm typecheck` +- `pnpm build:ui` +- local Playwright smoke against `http://localhost:4173` + +## Docker blocker + +- `packaging/docker/dev-up.sh` failed before the stack came up because Docker could not read the `node:22-bookworm-slim` image blob from containerd (`input/output error`) diff --git a/packages/app/src/app/app.tsx b/packages/app/src/app/app.tsx index 5dd758922..4cc1cca35 100644 --- a/packages/app/src/app/app.tsx +++ b/packages/app/src/app/app.tsx @@ -240,6 +240,12 @@ type SharedBundleImportTarget = { directoryHint?: string | null; }; +type SettingsReturnTarget = { + view: View; + tab: DashboardTab; + sessionId: string | null; +}; + function normalizeSharedBundleImportIntent(value: string | null | undefined): SharedBundleImportIntent { const normalized = (value ?? "").trim().toLowerCase(); if (normalized === "new_worker" || normalized === "new-worker" || normalized === "newworker") { @@ -1098,6 +1104,11 @@ export default function App() { const [selectedSessionId, setSelectedSessionId] = createSignal( null ); + const [settingsReturnTarget, setSettingsReturnTarget] = createSignal({ + view: "dashboard", + tab: "scheduled", + sessionId: null, + }); const SESSION_BY_WORKSPACE_KEY = "openwork.workspace-last-session.v1"; const readSessionByWorkspace = () => { if (typeof window === "undefined") return {} as Record; @@ -1136,6 +1147,48 @@ export default function App() { const [providerAuthError, setProviderAuthError] = createSignal(null); const [providerAuthMethods, setProviderAuthMethods] = createSignal>({}); + createEffect(() => { + const view = currentView(); + const currentTab = tab(); + if (view === "dashboard" && currentTab === "settings") return; + setSettingsReturnTarget({ + view, + tab: currentTab, + sessionId: selectedSessionId(), + }); + }); + + const restoreSettingsReturnTarget = () => { + const target = settingsReturnTarget(); + if (target.view === "session") { + if (target.sessionId) { + goToSession(target.sessionId); + return; + } + navigate("/session"); + return; + } + if (target.view === "onboarding") { + navigate("/onboarding"); + return; + } + if (target.view === "proto") { + navigate("/proto/workspaces"); + return; + } + goToDashboard(target.tab); + }; + + const toggleSettingsView = (nextTab: SettingsTab = "general") => { + const settingsOpen = currentView() === "dashboard" && tab() === "settings"; + if (settingsOpen) { + restoreSettingsReturnTarget(); + return; + } + setSettingsTab(nextTab); + goToDashboard("settings"); + }; + const sessionStore = createSessionStore({ client, activeWorkspaceRoot: () => workspaceStore.activeWorkspaceRoot().trim(), @@ -5862,6 +5915,7 @@ export default function App() { submitProviderApiKey, view: currentView(), setView, + toggleSettings: () => toggleSettingsView("general"), startupPreference: startupPreference(), baseUrl: baseUrl(), clientConnected: Boolean(client()), @@ -6095,6 +6149,7 @@ export default function App() { tab: tab(), setTab, setSettingsTab, + toggleSettings: () => toggleSettingsView("general"), activeWorkspaceDisplay: activeWorkspaceDisplay(), activeWorkspaceRoot: workspaceStore.activeWorkspaceRoot().trim(), workspaces: workspaceStore.workspaces(), diff --git a/packages/app/src/app/components/status-bar.tsx b/packages/app/src/app/components/status-bar.tsx index a89389170..bdc6b0e3f 100644 --- a/packages/app/src/app/components/status-bar.tsx +++ b/packages/app/src/app/components/status-bar.tsx @@ -12,6 +12,7 @@ type StatusBarProps = { clientConnected: boolean; openworkServerStatus: OpenworkServerStatus; developerMode: boolean; + settingsOpen: boolean; onOpenSettings: () => void; onOpenMessaging: () => void; onOpenProviders: () => Promise | void; @@ -228,13 +229,17 @@ export default function StatusBar(props: StatusBarProps) { diff --git a/packages/app/src/app/lib/workspace-shell-layout.ts b/packages/app/src/app/lib/workspace-shell-layout.ts new file mode 100644 index 000000000..1adb37a86 --- /dev/null +++ b/packages/app/src/app/lib/workspace-shell-layout.ts @@ -0,0 +1,138 @@ +import { createEffect, createMemo, createSignal, onCleanup } from "solid-js"; + +const LEFT_SIDEBAR_WIDTH_KEY = "openwork.workspace-shell.left-width.v1"; +const RIGHT_SIDEBAR_EXPANDED_KEY = "openwork.workspace-shell.right-expanded.v1"; + +export const DEFAULT_WORKSPACE_LEFT_SIDEBAR_WIDTH = 260; +export const MIN_WORKSPACE_LEFT_SIDEBAR_WIDTH = 220; +export const MAX_WORKSPACE_LEFT_SIDEBAR_WIDTH = 420; +export const DEFAULT_WORKSPACE_RIGHT_SIDEBAR_COLLAPSED_WIDTH = 72; + +type WorkspaceShellLayoutOptions = { + defaultLeftWidth?: number; + minLeftWidth?: number; + maxLeftWidth?: number; + collapsedRightWidth?: number; + expandedRightWidth: number; +}; + +function readStorage(key: string): string | null { + if (typeof window === "undefined") return null; + try { + return window.localStorage.getItem(key); + } catch { + return null; + } +} + +function writeStorage(key: string, value: string) { + if (typeof window === "undefined") return; + try { + window.localStorage.setItem(key, value); + } catch { + // ignore persistence failures + } +} + +function clampNumber(value: number, min: number, max: number) { + return Math.min(max, Math.max(min, value)); +} + +export function createWorkspaceShellLayout(options: WorkspaceShellLayoutOptions) { + const minLeftWidth = Math.max(180, options.minLeftWidth ?? MIN_WORKSPACE_LEFT_SIDEBAR_WIDTH); + const maxLeftWidth = Math.max(minLeftWidth, options.maxLeftWidth ?? MAX_WORKSPACE_LEFT_SIDEBAR_WIDTH); + const defaultLeftWidth = clampNumber( + options.defaultLeftWidth ?? DEFAULT_WORKSPACE_LEFT_SIDEBAR_WIDTH, + minLeftWidth, + maxLeftWidth, + ); + const collapsedRightWidth = Math.max( + 56, + options.collapsedRightWidth ?? DEFAULT_WORKSPACE_RIGHT_SIDEBAR_COLLAPSED_WIDTH, + ); + const expandedRightWidth = Math.max(collapsedRightWidth, options.expandedRightWidth); + + const readLeftSidebarWidth = () => { + const raw = readStorage(LEFT_SIDEBAR_WIDTH_KEY); + const parsed = Number(raw); + if (!Number.isFinite(parsed)) return defaultLeftWidth; + return clampNumber(parsed, minLeftWidth, maxLeftWidth); + }; + + const readRightSidebarExpanded = () => readStorage(RIGHT_SIDEBAR_EXPANDED_KEY) === "1"; + + const [leftSidebarWidth, setLeftSidebarWidth] = createSignal(readLeftSidebarWidth()); + const [rightSidebarExpanded, setRightSidebarExpanded] = createSignal(readRightSidebarExpanded()); + + createEffect(() => { + writeStorage(LEFT_SIDEBAR_WIDTH_KEY, String(clampNumber(leftSidebarWidth(), minLeftWidth, maxLeftWidth))); + }); + + createEffect(() => { + writeStorage(RIGHT_SIDEBAR_EXPANDED_KEY, rightSidebarExpanded() ? "1" : "0"); + }); + + const rightSidebarWidth = createMemo(() => + rightSidebarExpanded() ? expandedRightWidth : collapsedRightWidth, + ); + + let dragCleanup: (() => void) | null = null; + + const stopLeftSidebarResize = () => { + dragCleanup?.(); + dragCleanup = null; + if (typeof document === "undefined") return; + document.body.style.removeProperty("cursor"); + document.body.style.removeProperty("user-select"); + }; + + const startLeftSidebarResize = (event: PointerEvent) => { + if (event.button !== 0 || typeof window === "undefined") return; + + stopLeftSidebarResize(); + const initialX = event.clientX; + const initialWidth = leftSidebarWidth(); + + const handleMove = (moveEvent: PointerEvent) => { + const delta = moveEvent.clientX - initialX; + setLeftSidebarWidth(clampNumber(initialWidth + delta, minLeftWidth, maxLeftWidth)); + }; + + const handleStop = () => { + stopLeftSidebarResize(); + }; + + window.addEventListener("pointermove", handleMove); + window.addEventListener("pointerup", handleStop); + window.addEventListener("pointercancel", handleStop); + dragCleanup = () => { + window.removeEventListener("pointermove", handleMove); + window.removeEventListener("pointerup", handleStop); + window.removeEventListener("pointercancel", handleStop); + }; + + if (typeof document !== "undefined") { + document.body.style.cursor = "col-resize"; + document.body.style.userSelect = "none"; + } + + event.preventDefault(); + }; + + const toggleRightSidebar = () => { + setRightSidebarExpanded((current) => !current); + }; + + onCleanup(() => { + stopLeftSidebarResize(); + }); + + return { + leftSidebarWidth, + rightSidebarExpanded, + rightSidebarWidth, + setRightSidebarExpanded, + startLeftSidebarResize, + toggleRightSidebar, + }; +} diff --git a/packages/app/src/app/pages/dashboard.tsx b/packages/app/src/app/pages/dashboard.tsx index 3bc229c82..dbfc6dcb1 100644 --- a/packages/app/src/app/pages/dashboard.tsx +++ b/packages/app/src/app/pages/dashboard.tsx @@ -23,6 +23,7 @@ import { isWindowsPlatform, normalizeDirectoryPath, } from "../utils"; +import { createWorkspaceShellLayout } from "../lib/workspace-shell-layout"; import { buildOpenworkConnectInviteUrl, buildOpenworkWorkspaceBaseUrl, @@ -54,7 +55,7 @@ import ShareWorkspaceModal from "../components/share-workspace-modal"; import WorkspaceSessionList from "../components/session/workspace-session-list"; import { Box, - ChevronDown, + ChevronLeft, ChevronRight, Circle, History, @@ -62,7 +63,6 @@ import { MessageCircle, MoreHorizontal, Plus, - Settings, SlidersHorizontal, Zap, } from "lucide-solid"; @@ -92,6 +92,7 @@ export type DashboardViewProps = { refreshProviders: () => Promise; view: View; setView: (view: View, sessionId?: string) => void; + toggleSettings: () => void; startupPreference: StartupPreference | null; baseUrl: string; clientConnected: boolean; @@ -399,6 +400,13 @@ export default function DashboardView(props: DashboardViewProps) { const [refreshInProgress, setRefreshInProgress] = createSignal(false); const [providerAuthActionBusy, setProviderAuthActionBusy] = createSignal(false); const [shareWorkspaceId, setShareWorkspaceId] = createSignal(null); + const { + leftSidebarWidth, + rightSidebarExpanded, + rightSidebarWidth, + startLeftSidebarResize, + toggleRightSidebar, + } = createWorkspaceShellLayout({ expandedRightWidth: 224 }); const handleProviderAuthSelect = async (providerId: string): Promise => { if (providerAuthActionBusy()) { @@ -494,15 +502,17 @@ export default function DashboardView(props: DashboardViewProps) { return ( ); }; @@ -1016,7 +1026,13 @@ export default function DashboardView(props: DashboardViewProps) { return (
-