From 3f2ff5df054a4df8d4abd98e67f8d30ada7ca81b Mon Sep 17 00:00:00 2001 From: Benjamin Shafii Date: Wed, 4 Feb 2026 22:55:47 -0800 Subject: [PATCH] feat(app): refactor sidebar to group sessions under workspaces - Restructure sidebar with SESSIONS header and workspace groups - Add 3-dot menu for workspaces (edit name, edit connection, remove) - Add rename workspace modal with Tauri command support - Fix session indentation and add selected session highlighting - Implement progressive 'Show more' loading for sessions - Prevent workspace switch overlay for same-workspace clicks - Display remote connection errors within modal - Remove unused workspaceStatus memo from dashboard/session views --- packages/app/src/app/app.tsx | 216 ++++++++--- .../create-remote-workspace-modal.tsx | 46 ++- .../app/components/rename-workspace-modal.tsx | 77 ++++ packages/app/src/app/context/workspace.ts | 40 ++ packages/app/src/app/lib/tauri.ts | 10 + packages/app/src/app/pages/dashboard.tsx | 360 +++++++++-------- packages/app/src/app/pages/session.tsx | 365 ++++++++++-------- packages/app/src/app/types.ts | 16 + packages/app/src/i18n/locales/en.ts | 6 + packages/app/src/i18n/locales/zh.ts | 6 + .../src-tauri/src/commands/workspace.rs | 64 ++- packages/desktop/src-tauri/src/lib.rs | 3 +- 12 files changed, 810 insertions(+), 399 deletions(-) create mode 100644 packages/app/src/app/components/rename-workspace-modal.tsx diff --git a/packages/app/src/app/app.tsx b/packages/app/src/app/app.tsx index ee75aa4e9..63a0332e9 100644 --- a/packages/app/src/app/app.tsx +++ b/packages/app/src/app/app.tsx @@ -14,6 +14,7 @@ import { useLocation, useNavigate } from "@solidjs/router"; import type { Agent, Part, + Session, TextPartInput, FilePartInput, AgentPartInput, @@ -29,6 +30,7 @@ import ResetModal from "./components/reset-modal"; import WorkspaceSwitchOverlay from "./components/workspace-switch-overlay"; import CreateRemoteWorkspaceModal from "./components/create-remote-workspace-modal"; import CreateWorkspaceModal from "./components/create-workspace-modal"; +import RenameWorkspaceModal from "./components/rename-workspace-modal"; import McpAuthModal from "./components/mcp-auth-modal"; import ReloadWorkspaceToast from "./components/reload-workspace-toast"; import OnboardingView from "./pages/onboarding"; @@ -64,6 +66,7 @@ import type { SkillCard, TodoItem, View, + WorkspaceSessionGroup, WorkspaceDisplay, McpServerEntry, McpStatusMap, @@ -643,18 +646,6 @@ export default function App() { setPendingPermissions, } = sessionStore; - const [sessionsLoaded, setSessionsLoaded] = createSignal(false); - const loadSessionsWithReady = async (scopeRoot?: string) => { - await loadSessions(scopeRoot); - setSessionsLoaded(true); - }; - - createEffect(() => { - if (!client()) { - setSessionsLoaded(false); - } - }); - const artifacts = createMemo(() => deriveArtifacts(messages())); const workingFiles = createMemo(() => deriveWorkingFiles(artifacts())); const activeSessionId = createMemo(() => selectedSessionId()); @@ -665,6 +656,49 @@ export default function App() { const activeArtifacts = createMemo(() => artifacts()); const activeWorkingFiles = createMemo(() => workingFiles()); + const sessionActivity = (session: Session) => + session.time?.updated ?? session.time?.created ?? 0; + const sortSessionsByActivity = (list: Session[]) => + list + .slice() + .sort((a, b) => { + const delta = sessionActivity(b) - sessionActivity(a); + if (delta !== 0) return delta; + return a.id.localeCompare(b.id); + }); + + const [sidebarSessions, setSidebarSessions] = createSignal([]); + const refreshSidebarSessions = async () => { + const c = client(); + if (!c) { + setSidebarSessions([]); + return; + } + const list = unwrap(await c.session.list()); + setSidebarSessions(sortSessionsByActivity(list)); + }; + + createEffect(() => { + if (!client()) { + setSidebarSessions([]); + return; + } + refreshSidebarSessions().catch(() => undefined); + }); + + const [sessionsLoaded, setSessionsLoaded] = createSignal(false); + const loadSessionsWithReady = async (scopeRoot?: string) => { + await loadSessions(scopeRoot); + setSessionsLoaded(true); + await refreshSidebarSessions().catch(() => undefined); + }; + + createEffect(() => { + if (!client()) { + setSessionsLoaded(false); + } + }); + const [prompt, setPrompt] = createSignal(""); const [lastPromptSent, setLastPromptSent] = createSignal(""); @@ -799,6 +833,7 @@ export default function App() { } await renameSession(sessionID, trimmed); + await refreshSidebarSessions().catch(() => undefined); } async function deleteSessionById(sessionID: string) { @@ -813,6 +848,7 @@ export default function App() { const params = root ? { sessionID: trimmed, directory: root } : { sessionID: trimmed }; unwrap(await c.session.delete(params)); await loadSessions(root || undefined).catch(() => undefined); + await refreshSidebarSessions().catch(() => undefined); const nextStatus = { ...sessionStatusById() }; if (nextStatus[trimmed]) { @@ -1289,6 +1325,46 @@ export default function App() { engineRuntime, }); + const sidebarWorkspaceGroups = createMemo(() => { + const workspaces = workspaceStore.workspaces(); + const sessionList = sidebarSessions(); + const workspaceByRoot = new Map(); + const sessionsByWorkspaceId = new Map(); + + for (const workspace of workspaces) { + const root = normalizeDirectoryPath( + workspace.workspaceType === "remote" ? workspace.directory ?? "" : workspace.path + ); + if (root) { + workspaceByRoot.set(root, workspace.id); + } + sessionsByWorkspaceId.set(workspace.id, []); + } + + for (const session of sessionList) { + const root = normalizeDirectoryPath(session.directory ?? ""); + if (!root) continue; + const workspaceId = workspaceByRoot.get(root); + if (!workspaceId) continue; + const bucket = sessionsByWorkspaceId.get(workspaceId); + if (bucket) bucket.push(session); + } + + return workspaces.map((workspace) => { + const groupSessions = sortSessionsByActivity(sessionsByWorkspaceId.get(workspace.id) ?? []); + return { + workspace, + sessions: groupSessions.map((session) => ({ + id: session.id, + title: session.title, + slug: session.slug, + time: session.time, + directory: session.directory, + })), + }; + }); + }); + createEffect(() => { if (typeof window === "undefined") return; const workspaceId = workspaceStore.activeWorkspaceId(); @@ -1514,6 +1590,11 @@ export default function App() { const [editRemoteWorkspaceOpen, setEditRemoteWorkspaceOpen] = createSignal(false); const [editRemoteWorkspaceId, setEditRemoteWorkspaceId] = createSignal(null); + const [editRemoteWorkspaceError, setEditRemoteWorkspaceError] = createSignal(null); + const [renameWorkspaceOpen, setRenameWorkspaceOpen] = createSignal(false); + const [renameWorkspaceId, setRenameWorkspaceId] = createSignal(null); + const [renameWorkspaceName, setRenameWorkspaceName] = createSignal(""); + const [renameWorkspaceBusy, setRenameWorkspaceBusy] = createSignal(false); const editRemoteWorkspaceDefaults = createMemo(() => { const workspaceId = editRemoteWorkspaceId(); @@ -1528,6 +1609,49 @@ export default function App() { }; }); + const openRenameWorkspace = (workspaceId: string) => { + const workspace = workspaceStore.workspaces().find((item) => item.id === workspaceId) ?? null; + if (!workspace) return; + setRenameWorkspaceId(workspaceId); + setRenameWorkspaceName( + workspace.displayName?.trim() || + workspace.openworkWorkspaceName?.trim() || + workspace.name?.trim() || + "" + ); + setRenameWorkspaceOpen(true); + }; + + const closeRenameWorkspace = () => { + if (renameWorkspaceBusy()) return; + setRenameWorkspaceOpen(false); + setRenameWorkspaceId(null); + setRenameWorkspaceName(""); + }; + + const saveRenameWorkspace = async () => { + const workspaceId = renameWorkspaceId(); + if (!workspaceId) return; + const nextName = renameWorkspaceName().trim(); + if (!nextName) return; + if (renameWorkspaceBusy()) return; + + setRenameWorkspaceBusy(true); + setError(null); + try { + const ok = await workspaceStore.updateWorkspaceDisplayName(workspaceId, nextName); + if (!ok) return; + setRenameWorkspaceOpen(false); + setRenameWorkspaceId(null); + setRenameWorkspaceName(""); + } catch (e) { + const message = e instanceof Error ? e.message : safeStringify(e); + setError(addOpencodeCacheHint(message)); + } finally { + setRenameWorkspaceBusy(false); + } + }; + const testOpenworkServerConnection = async (next: OpenworkServerSettings) => { const derived = normalizeOpenworkServerUrl(next.urlOverride ?? ""); if (!derived) { @@ -1560,11 +1684,13 @@ export default function App() { const workspace = workspaceStore.workspaces().find((item) => item.id === workspaceId) ?? null; if (workspace?.workspaceType === "remote" && workspace.remoteType === "openwork") { setEditRemoteWorkspaceId(workspace.id); + setEditRemoteWorkspaceError(null); setEditRemoteWorkspaceOpen(true); return; } if (workspace?.workspaceType === "remote") { setEditRemoteWorkspaceId(workspace.id); + setEditRemoteWorkspaceError(null); setEditRemoteWorkspaceOpen(true); return; } @@ -3534,14 +3660,11 @@ export default function App() { setCreateWorkspaceOpen: workspaceStore.setCreateWorkspaceOpen, createWorkspaceFlow: workspaceStore.createWorkspaceFlow, pickWorkspaceFolder: workspaceStore.pickWorkspaceFolder, - sessions: activeSessions().map((s) => ({ - id: s.id, - slug: s.slug, - title: s.title, - time: s.time, - directory: s.directory, - })), - sessionStatusById: activeSessionStatusById(), + workspaceSessionGroups: sidebarWorkspaceGroups(), + selectedSessionId: activeSessionId(), + openRenameWorkspace, + editWorkspaceConnection: openWorkspaceConnectionSettings, + forgetWorkspace: workspaceStore.forgetWorkspace, scheduledJobs: scheduledJobs(), scheduledJobsSource: scheduledJobsSource(), scheduledJobsSourceReady: scheduledJobsSourceReady(), @@ -3665,19 +3788,6 @@ export default function App() { } }; - const workspaceLabelForDirectory = (directory?: string | null) => { - const normalized = normalizeDirectoryPath(directory); - if (!normalized) return null; - const match = workspaceStore.workspaces().find((workspace) => { - const workspacePath = normalizeDirectoryPath( - workspace.workspaceType === "remote" ? workspace.directory ?? "" : workspace.path - ); - return workspacePath === normalized; - }); - return match?.displayName ?? match?.name ?? null; - }; - - const sessionProps = () => ({ selectedSessionId: activeSessionId(), setView, @@ -3717,13 +3827,8 @@ export default function App() { createSessionAndOpen: createSessionAndOpen, sendPromptAsync: sendPrompt, newTaskDisabled: newTaskDisabled(), - sessions: activeSessions().map((session) => ({ - id: session.id, - title: session.title, - slug: session.slug, - time: { updated: session.time.updated }, - workspaceLabel: workspaceLabelForDirectory(session.directory), - })), + workspaceSessionGroups: sidebarWorkspaceGroups(), + openRenameWorkspace, selectSession: selectSession, messages: activeMessages(), todos: activeTodos(), @@ -3994,25 +4099,48 @@ export default function App() { } /> + 0 && !renameWorkspaceBusy()} + onClose={closeRenameWorkspace} + onSave={saveRenameWorkspace} + onTitleChange={setRenameWorkspaceName} + /> + { setEditRemoteWorkspaceOpen(false); setEditRemoteWorkspaceId(null); + setEditRemoteWorkspaceError(null); }} onConfirm={(input) => { const workspaceId = editRemoteWorkspaceId(); if (!workspaceId) return; + setEditRemoteWorkspaceError(null); void (async () => { - const ok = await workspaceStore.updateRemoteWorkspaceFlow(workspaceId, input); - if (ok) { - setEditRemoteWorkspaceOpen(false); - setEditRemoteWorkspaceId(null); + try { + const ok = await workspaceStore.updateRemoteWorkspaceFlow(workspaceId, input); + if (ok) { + setEditRemoteWorkspaceOpen(false); + setEditRemoteWorkspaceId(null); + setEditRemoteWorkspaceError(null); + } else { + setEditRemoteWorkspaceError(error() || "Connection failed. Check the URL and token."); + setError(null); + } + } catch (e) { + const message = e instanceof Error ? e.message : "Connection failed"; + setEditRemoteWorkspaceError(message); + setError(null); } })(); }} initialValues={editRemoteWorkspaceDefaults() ?? undefined} submitting={busy() && busyLabel() === "status.connecting"} + error={editRemoteWorkspaceError()} title={t("dashboard.edit_remote_workspace_title", currentLocale())} subtitle={t("dashboard.edit_remote_workspace_subtitle", currentLocale())} confirmLabel={t("dashboard.edit_remote_workspace_confirm", currentLocale())} diff --git a/packages/app/src/app/components/create-remote-workspace-modal.tsx b/packages/app/src/app/components/create-remote-workspace-modal.tsx index ca2c326c1..b6ae43200 100644 --- a/packages/app/src/app/components/create-remote-workspace-modal.tsx +++ b/packages/app/src/app/components/create-remote-workspace-modal.tsx @@ -22,6 +22,7 @@ export default function CreateRemoteWorkspaceModal(props: { displayName?: string | null; }; submitting?: boolean; + error?: string | null; inline?: boolean; showClose?: boolean; title?: string; @@ -146,26 +147,33 @@ export default function CreateRemoteWorkspaceModal(props: { -
- - +
+ +
+ {props.error} +
- +
+ + + + +
); diff --git a/packages/app/src/app/components/rename-workspace-modal.tsx b/packages/app/src/app/components/rename-workspace-modal.tsx new file mode 100644 index 000000000..58148cf1e --- /dev/null +++ b/packages/app/src/app/components/rename-workspace-modal.tsx @@ -0,0 +1,77 @@ +import { Show, createEffect } from "solid-js"; +import { X } from "lucide-solid"; +import { t, currentLocale } from "../../i18n"; + +import Button from "./button"; +import TextInput from "./text-input"; + +export type RenameWorkspaceModalProps = { + open: boolean; + title: string; + busy: boolean; + canSave: boolean; + onClose: () => void; + onSave: () => void; + onTitleChange: (value: string) => void; +}; + +export default function RenameWorkspaceModal(props: RenameWorkspaceModalProps) { + let inputRef: HTMLInputElement | undefined; + const translate = (key: string) => t(key, currentLocale()); + + createEffect(() => { + if (props.open) { + requestAnimationFrame(() => { + inputRef?.focus(); + if (inputRef) { + inputRef.select(); + } + }); + } + }); + + return ( + +
+
+
+
+
+

{translate("workspace.rename_title")}

+

{translate("workspace.rename_description")}

+
+ +
+ +
+ props.onTitleChange(e.currentTarget.value)} + placeholder={translate("workspace.rename_placeholder")} + class="bg-gray-3" + onKeyDown={(event) => { + if (event.key !== "Enter") return; + event.preventDefault(); + if (props.canSave) props.onSave(); + }} + /> +
+ +
+ + +
+
+
+
+
+ ); +} diff --git a/packages/app/src/app/context/workspace.ts b/packages/app/src/app/context/workspace.ts index 92d5a0500..4a575abd1 100644 --- a/packages/app/src/app/context/workspace.ts +++ b/packages/app/src/app/context/workspace.ts @@ -49,6 +49,7 @@ import { workspaceOpenworkRead, workspaceOpenworkWrite, workspaceSetActive, + workspaceUpdateDisplayName, workspaceUpdateRemote, type EngineDoctorResult, type EngineInfo, @@ -1492,6 +1493,44 @@ export function createWorkspaceStore(options: { } } + async function updateWorkspaceDisplayName(workspaceId: string, displayName: string | null) { + const id = workspaceId.trim(); + if (!id) return false; + const workspace = workspaces().find((item) => item.id === id) ?? null; + if (!workspace) return false; + + const nextDisplayName = displayName?.trim() || null; + options.setError(null); + + if (isTauriRuntime()) { + try { + const ws = await workspaceUpdateDisplayName({ workspaceId: id, displayName: nextDisplayName }); + setWorkspaces(ws.workspaces); + if (ws.activeId) { + updateWorkspaceConnectionState(ws.activeId, { status: "connected", message: null }); + } + return true; + } catch (e) { + const message = e instanceof Error ? e.message : safeStringify(e); + options.setError(addOpencodeCacheHint(message)); + return false; + } + } + + setWorkspaces((prev) => + prev.map((entry) => + entry.id === id + ? { + ...entry, + displayName: nextDisplayName, + name: nextDisplayName ?? entry.name, + } + : entry + ) + ); + return true; + } + async function stopHost() { options.setError(null); options.setBusy(true); @@ -1995,6 +2034,7 @@ export function createWorkspaceStore(options: { createWorkspaceFlow, createRemoteWorkspaceFlow, updateRemoteWorkspaceFlow, + updateWorkspaceDisplayName, forgetWorkspace, pickWorkspaceFolder, exportWorkspaceConfig, diff --git a/packages/app/src/app/lib/tauri.ts b/packages/app/src/app/lib/tauri.ts index 0f3c3b0ba..388141770 100644 --- a/packages/app/src/app/lib/tauri.ts +++ b/packages/app/src/app/lib/tauri.ts @@ -204,6 +204,16 @@ export async function workspaceUpdateRemote(input: { }); } +export async function workspaceUpdateDisplayName(input: { + workspaceId: string; + displayName?: string | null; +}): Promise { + return invoke("workspace_update_display_name", { + workspaceId: input.workspaceId, + displayName: input.displayName ?? null, + }); +} + export async function workspaceForget(workspaceId: string): Promise { return invoke("workspace_forget", { workspaceId }); } diff --git a/packages/app/src/app/pages/dashboard.tsx b/packages/app/src/app/pages/dashboard.tsx index ba22dbaf9..b643bba7d 100644 --- a/packages/app/src/app/pages/dashboard.tsx +++ b/packages/app/src/app/pages/dashboard.tsx @@ -10,6 +10,7 @@ import type { ScheduledJob, SkillCard, StartupPreference, + WorkspaceSessionGroup, View, } from "../types"; import type { McpDirectoryInfo } from "../constants"; @@ -33,10 +34,8 @@ import StatusBar from "../components/status-bar"; import ProviderAuthModal from "../components/provider-auth-modal"; import { Box, - ChevronRight, Edit2, History, - Layout, Loader2, MoreHorizontal, Plus, @@ -100,14 +99,11 @@ export type DashboardViewProps = { openCreateWorkspace: () => void; exportWorkspaceConfig: () => void; exportWorkspaceBusy: boolean; - sessions: Array<{ - id: string; - slug?: string | null; - title: string; - time: { updated: number }; - directory?: string | null; - }>; - sessionStatusById: Record; + workspaceSessionGroups: WorkspaceSessionGroup[]; + selectedSessionId: string | null; + openRenameWorkspace: (workspaceId: string) => void; + editWorkspaceConnection: (workspaceId: string) => void; + forgetWorkspace: (workspaceId: string) => void; scheduledJobs: ScheduledJob[]; scheduledJobsSource: "local" | "remote"; scheduledJobsSourceReady: boolean; @@ -243,17 +239,6 @@ export default function DashboardView(props: DashboardViewProps) { } }); - const workspaceStatus = createMemo(() => { - switch (props.openworkServerStatus) { - case "connected": - return { label: "Live", className: "bg-emerald-3 text-emerald-11" }; - case "limited": - return { label: "Limited", className: "bg-amber-3 text-amber-11" }; - case "disconnected": - default: - return { label: "Offline", className: "bg-red-3 text-red-11" }; - } - }); const workspaceLabel = (workspace: WorkspaceInfo) => workspace.displayName?.trim() || workspace.openworkWorkspaceName?.trim() || @@ -263,11 +248,20 @@ export default function DashboardView(props: DashboardViewProps) { const workspaceKindLabel = (workspace: WorkspaceInfo) => workspace.workspaceType === "remote" ? "Remote" : "Local"; - const openSessionFromList = (sessionId: string) => { - // Defer view switch to avoid click-through on the same event frame. - window.setTimeout(() => { + const openSessionFromList = (workspaceId: string, sessionId: string) => { + // For same-workspace clicks, just select the session without workspace activation + if (workspaceId === props.activeWorkspaceId) { void props.selectSession(sessionId); props.setView("session", sessionId); + return; + } + // For different workspace, activate workspace first + window.setTimeout(() => { + void (async () => { + await Promise.resolve(props.activateWorkspace(workspaceId)); + void props.selectSession(sessionId); + props.setView("session", sessionId); + })(); }, 0); }; @@ -275,11 +269,40 @@ export default function DashboardView(props: DashboardViewProps) { const [lastRefreshedTab, setLastRefreshedTab] = createSignal(null); const [refreshInProgress, setRefreshInProgress] = createSignal(false); const [providerAuthActionBusy, setProviderAuthActionBusy] = createSignal(false); - const [sessionsExpanded, setSessionsExpanded] = createSignal(true); - const [showAllSessions, setShowAllSessions] = createSignal(false); - const visibleSessions = createMemo(() => - showAllSessions() ? props.sessions : props.sessions.slice(0, 5) - ); + const MAX_SESSIONS_PREVIEW = 3; + const [previewCountByWorkspaceId, setPreviewCountByWorkspaceId] = createSignal< + Record + >({}); + const previewCount = (workspaceId: string) => + previewCountByWorkspaceId()[workspaceId] ?? MAX_SESSIONS_PREVIEW; + const previewSessions = (workspaceId: string, sessions: WorkspaceSessionGroup["sessions"]) => + sessions.slice(0, previewCount(workspaceId)); + const showMoreSessions = (workspaceId: string, total: number) => { + setPreviewCountByWorkspaceId((current) => { + const next = { ...current }; + const existing = next[workspaceId] ?? MAX_SESSIONS_PREVIEW; + next[workspaceId] = Math.min(existing + MAX_SESSIONS_PREVIEW, total); + return next; + }); + }; + const showMoreLabel = (workspaceId: string, total: number) => { + const remaining = Math.max(0, total - previewCount(workspaceId)); + const nextCount = Math.min(MAX_SESSIONS_PREVIEW, remaining); + return nextCount > 0 ? `Show ${nextCount} more` : "Show more"; + }; + const [workspaceMenuId, setWorkspaceMenuId] = createSignal(null); + let workspaceMenuRef: HTMLDivElement | undefined; + + createEffect(() => { + if (!workspaceMenuId()) return; + const closeMenu = (event: MouseEvent) => { + const target = event.target as Node | null; + if (workspaceMenuRef && target && workspaceMenuRef.contains(target)) return; + setWorkspaceMenuId(null); + }; + window.addEventListener("click", closeMenu); + onCleanup(() => window.removeEventListener("click", closeMenu)); + }); const handleProviderAuthSelect = async (providerId: string) => { if (providerAuthActionBusy()) return; @@ -388,65 +411,10 @@ export default function DashboardView(props: DashboardViewProps) { {navItem("mcp", "Apps", )} -
-
- Workspaces - -
-
- - {(workspace) => { - const isActive = () => props.activeWorkspaceId === workspace.id; - const isConnecting = () => props.connectingWorkspaceId === workspace.id; - return ( - - ); - }} - -
- -
-
Sessions
-
-
-
setSessionsExpanded((current) => !current)} - onKeyDown={(event) => { - if (event.key !== "Enter" && event.key !== " ") return; - event.preventDefault(); - setSessionsExpanded((current) => !current); - }} - class="group flex items-center justify-between h-8 px-3 rounded-lg cursor-pointer text-dls-text transition-colors hover:bg-dls-hover" - > -
- - {props.activeWorkspaceDisplay.name} -
-
- -
-
+
+ + {(group) => { + const workspace = () => group.workspace; + const isConnecting = () => props.connectingWorkspaceId === workspace().id; + const isMenuOpen = () => workspaceMenuId() === workspace().id; - -
- 0} - fallback={ -
- No sessions yet. -
- } - > - - {(session) => ( + return ( +
+
openSessionFromList(session.id)} + class="w-full flex items-center justify-between h-10 px-3 rounded-lg text-left transition-colors text-dls-text hover:bg-dls-hover" + onClick={() => props.activateWorkspace(workspace().id)} onKeyDown={(event) => { if (event.key !== "Enter" && event.key !== " ") return; event.preventDefault(); - openSessionFromList(session.id); + props.activateWorkspace(workspace().id); }} > - - {session.title} - - - {formatRelativeTime(session.time.updated)} - -
+
+
{workspaceLabel(workspace())}
+
+ {workspaceKindLabel(workspace())} +
+
+ + + +
+ + +
(workspaceMenuRef = el)} + class="absolute right-2 top-[calc(100%+4px)] z-20 w-44 rounded-lg border border-dls-border bg-dls-surface shadow-lg p-1" + onClick={(event) => event.stopPropagation()} + > + + +
-
- )} - - -
- + +
+ +
+ 0} + fallback={ +
+ No sessions yet. +
+ } + > + + {(session) => { + const isSelected = () => props.selectedSessionId === session.id; + return ( +
openSessionFromList(workspace().id, session.id)} + onKeyDown={(event) => { + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + openSessionFromList(workspace().id, session.id); + }} + > + + {session.title} + + + {formatRelativeTime(session.time?.updated ?? Date.now())} + +
+ + +
+
+ ); + }} +
+ previewCount(workspace().id)}> + + +
+
+
+ ); + }} +
- 5}> - - +
diff --git a/packages/app/src/app/pages/session.tsx b/packages/app/src/app/pages/session.tsx index 66d982825..3a64e159d 100644 --- a/packages/app/src/app/pages/session.tsx +++ b/packages/app/src/app/pages/session.tsx @@ -17,6 +17,7 @@ import type { View, WorkspaceConnectionState, WorkspaceDisplay, + WorkspaceSessionGroup, } from "../types"; import type { WorkspaceInfo } from "../lib/tauri"; @@ -24,11 +25,9 @@ import type { WorkspaceInfo } from "../lib/tauri"; import { Box, ChevronDown, - ChevronRight, Edit2, HardDrive, History, - Layout, Loader2, MoreHorizontal, Plus, @@ -78,13 +77,8 @@ export type SessionViewProps = { createSessionAndOpen: () => void; sendPromptAsync: (draft: ComposerDraft) => Promise; newTaskDisabled: boolean; - sessions: Array<{ - id: string; - title: string; - slug?: string | null; - workspaceLabel?: string | null; - time?: { updated: number }; - }>; + workspaceSessionGroups: WorkspaceSessionGroup[]; + openRenameWorkspace: (workspaceId: string) => void; selectSession: (sessionId: string) => Promise | void; messages: MessageWithParts[]; todos: TodoItem[]; @@ -169,17 +163,6 @@ export default function SessionView(props: SessionViewProps) { const [unreadCount, setUnreadCount] = createSignal(0); const agentLabel = createMemo(() => props.selectedSessionAgent ?? "Default agent"); - const workspaceStatus = createMemo(() => { - switch (props.openworkServerStatus) { - case "connected": - return { label: "Live", className: "bg-emerald-3 text-emerald-11" }; - case "limited": - return { label: "Limited", className: "bg-amber-3 text-amber-11" }; - case "disconnected": - default: - return { label: "Offline", className: "bg-red-3 text-red-11" }; - } - }); const workspaceLabel = (workspace: WorkspaceInfo) => workspace.displayName?.trim() || workspace.openworkWorkspaceName?.trim() || @@ -188,11 +171,40 @@ export default function SessionView(props: SessionViewProps) { "Workspace"; const workspaceKindLabel = (workspace: WorkspaceInfo) => workspace.workspaceType === "remote" ? "Remote" : "Local"; - const [sessionsExpanded, setSessionsExpanded] = createSignal(true); - const [showAllSessions, setShowAllSessions] = createSignal(false); - const visibleSessions = createMemo(() => - showAllSessions() ? props.sessions : props.sessions.slice(0, 5) - ); + const MAX_SESSIONS_PREVIEW = 3; + const [previewCountByWorkspaceId, setPreviewCountByWorkspaceId] = createSignal< + Record + >({}); + const previewCount = (workspaceId: string) => + previewCountByWorkspaceId()[workspaceId] ?? MAX_SESSIONS_PREVIEW; + const previewSessions = (workspaceId: string, sessions: WorkspaceSessionGroup["sessions"]) => + sessions.slice(0, previewCount(workspaceId)); + const showMoreSessions = (workspaceId: string, total: number) => { + setPreviewCountByWorkspaceId((current) => { + const next = { ...current }; + const existing = next[workspaceId] ?? MAX_SESSIONS_PREVIEW; + next[workspaceId] = Math.min(existing + MAX_SESSIONS_PREVIEW, total); + return next; + }); + }; + const showMoreLabel = (workspaceId: string, total: number) => { + const remaining = Math.max(0, total - previewCount(workspaceId)); + const nextCount = Math.min(MAX_SESSIONS_PREVIEW, remaining); + return nextCount > 0 ? `Show ${nextCount} more` : "Show more"; + }; + const [workspaceMenuId, setWorkspaceMenuId] = createSignal(null); + let workspaceMenuRef: HTMLDivElement | undefined; + + createEffect(() => { + if (!workspaceMenuId()) return; + const closeMenu = (event: MouseEvent) => { + const target = event.target as Node | null; + if (workspaceMenuRef && target && workspaceMenuRef.contains(target)) return; + setWorkspaceMenuId(null); + }; + window.addEventListener("click", closeMenu); + onCleanup(() => window.removeEventListener("click", closeMenu)); + }); const attachmentsEnabled = createMemo(() => { if (props.activeWorkspaceDisplay.workspaceType !== "remote") return true; return props.openworkServerStatus === "connected"; @@ -614,7 +626,11 @@ export default function SessionView(props: SessionViewProps) { const selectedSessionTitle = createMemo(() => { const id = props.selectedSessionId; if (!id) return ""; - return props.sessions.find((session) => session.id === id)?.title ?? ""; + for (const group of props.workspaceSessionGroups) { + const match = group.sessions.find((session) => session.id === id); + if (match) return match.title ?? ""; + } + return ""; }); const renameCanSave = createMemo(() => { @@ -729,10 +745,20 @@ export default function SessionView(props: SessionViewProps) { props.setPrompt(draft.text); }; - const openSessionFromList = (sessionId: string) => { + const openSessionFromList = (workspaceId: string, sessionId: string) => { if (!sessionId) return; - void props.selectSession(sessionId); - props.setView("session", sessionId); + // For same-workspace clicks, just select the session without workspace activation + if (workspaceId === props.activeWorkspaceId) { + void props.selectSession(sessionId); + props.setView("session", sessionId); + return; + } + // For different workspace, activate workspace first + void (async () => { + await Promise.resolve(props.activateWorkspace(workspaceId)); + void props.selectSession(sessionId); + props.setView("session", sessionId); + })(); }; const openSettings = (tab: SettingsTab = "general") => { @@ -810,65 +836,10 @@ export default function SessionView(props: SessionViewProps) {
-
-
- Workspaces - -
-
- - {(workspace) => { - const isActive = () => props.activeWorkspaceId === workspace.id; - const isConnecting = () => props.connectingWorkspaceId === workspace.id; - return ( - - ); - }} - -
- -
-
Sessions
-
-
-
setSessionsExpanded((current) => !current)} - onKeyDown={(event) => { - if (event.key !== "Enter" && event.key !== " ") return; - event.preventDefault(); - setSessionsExpanded((current) => !current); - }} - class="group flex items-center justify-between h-8 px-3 rounded-lg cursor-pointer text-dls-text transition-colors hover:bg-dls-hover" - > -
- - {props.activeWorkspaceDisplay.name} -
-
- -
-
+
+ + {(group) => { + const workspace = () => group.workspace; + const isConnecting = () => props.connectingWorkspaceId === workspace().id; + const isMenuOpen = () => workspaceMenuId() === workspace().id; - -
- 0} - fallback={ -
- No sessions yet. -
- } - > - - {(session) => ( + return ( +
+
openSessionFromList(session.id)} + class="w-full flex items-center justify-between h-10 px-3 rounded-lg text-left transition-colors text-dls-text hover:bg-dls-hover" + onClick={() => props.activateWorkspace(workspace().id)} onKeyDown={(event) => { if (event.key !== "Enter" && event.key !== " ") return; event.preventDefault(); - openSessionFromList(session.id); + props.activateWorkspace(workspace().id); }} > - - {session.title} - - - - {formatRelativeTime(session.time?.updated ?? Date.now())} - +
+
{workspaceLabel(workspace())}
+
+ {workspaceKindLabel(workspace())} +
+
+ + -
+
+ + +
(workspaceMenuRef = el)} + class="absolute right-2 top-[calc(100%+4px)] z-20 w-44 rounded-lg border border-dls-border bg-dls-surface shadow-lg p-1" + onClick={(event) => event.stopPropagation()} + > + + +
-
- )} - - -
- + +
+ +
+ 0} + fallback={ +
+ No sessions yet. +
+ } + > + + {(session) => { + const isSelected = () => props.selectedSessionId === session.id; + return ( +
openSessionFromList(workspace().id, session.id)} + onKeyDown={(event) => { + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + openSessionFromList(workspace().id, session.id); + }} + > + + {session.title} + + + + {formatRelativeTime(session.time?.updated ?? Date.now())} + + +
+ + +
+
+ ); + }} +
+ previewCount(workspace().id)}> + + +
+
+
+ ); + }} +
- 5}> - - +
diff --git a/packages/app/src/app/types.ts b/packages/app/src/app/types.ts index be75691d4..c04a5c8b4 100644 --- a/packages/app/src/app/types.ts +++ b/packages/app/src/app/types.ts @@ -13,6 +13,22 @@ export type Client = ReturnType; export type ProviderListItem = ProviderListResponse["all"][number]; +export type SidebarSessionItem = { + id: string; + title: string; + slug?: string | null; + time?: { + updated?: number | null; + created?: number | null; + }; + directory?: string | null; +}; + +export type WorkspaceSessionGroup = { + workspace: WorkspaceInfo; + sessions: SidebarSessionItem[]; +}; + export type PlaceholderAssistantMessage = { id: string; sessionID: string; diff --git a/packages/app/src/i18n/locales/en.ts b/packages/app/src/i18n/locales/en.ts index 75cf1aabc..e6e291e27 100644 --- a/packages/app/src/i18n/locales/en.ts +++ b/packages/app/src/i18n/locales/en.ts @@ -89,6 +89,12 @@ export default { "dashboard.empty_workspace": "Empty workspace", "dashboard.empty_workspace_desc": "Start with a blank folder and add what you need.", + // ==================== Workspace ==================== + "workspace.rename_title": "Edit workspace name", + "workspace.rename_description": "Update the name shown in the sidebar.", + "workspace.rename_label": "Workspace name", + "workspace.rename_placeholder": "Design team workspace", + // ==================== Session ==================== "session.no_selected": "No session selected", "session.back_to_dashboard": "Back to dashboard", diff --git a/packages/app/src/i18n/locales/zh.ts b/packages/app/src/i18n/locales/zh.ts index 26629f7f4..e55a76e91 100644 --- a/packages/app/src/i18n/locales/zh.ts +++ b/packages/app/src/i18n/locales/zh.ts @@ -89,6 +89,12 @@ export default { "dashboard.empty_workspace": "空白工作区", "dashboard.empty_workspace_desc": "从空白文件夹开始,添加您需要的内容。", + // ==================== Workspace ==================== + "workspace.rename_title": "编辑工作区名称", + "workspace.rename_description": "更新侧边栏中显示的名称。", + "workspace.rename_label": "工作区名称", + "workspace.rename_placeholder": "设计团队工作区", + // ==================== Session ==================== "session.no_selected": "未选择会话", "session.back_to_dashboard": "返回仪表盘", diff --git a/packages/desktop/src-tauri/src/commands/workspace.rs b/packages/desktop/src-tauri/src/commands/workspace.rs index 120788d21..585a51baa 100644 --- a/packages/desktop/src-tauri/src/commands/workspace.rs +++ b/packages/desktop/src-tauri/src/commands/workspace.rs @@ -127,6 +127,41 @@ pub fn workspace_set_active( }) } +#[tauri::command] +pub fn workspace_update_display_name( + app: tauri::AppHandle, + workspace_id: String, + display_name: Option, +) -> Result { + println!("[workspace] update display name request: {workspace_id}"); + let mut state = load_workspace_state(&app)?; + let id = workspace_id.trim(); + + if id.is_empty() { + return Err("workspaceId is required".to_string()); + } + + let next_name = display_name + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()); + + let workspace = state.workspaces.iter_mut().find(|w| w.id == id); + match workspace { + Some(entry) => { + entry.display_name = next_name; + } + None => return Err("Unknown workspaceId".to_string()), + } + + save_workspace_state(&app, &state)?; + println!("[workspace] update display name complete: {id}"); + + Ok(WorkspaceList { + active_id: state.active_id, + workspaces: state.workspaces, + }) +} + #[tauri::command] pub fn workspace_create( app: tauri::AppHandle, @@ -246,7 +281,9 @@ pub fn workspace_create_remote( .or_else(|| display_name.clone()) .unwrap_or_else(|| { if remote_type == RemoteType::Openwork { - openwork_host_url.clone().unwrap_or_else(|| base_url.clone()) + openwork_host_url + .clone() + .unwrap_or_else(|| base_url.clone()) } else { base_url.clone() } @@ -515,10 +552,7 @@ fn is_secret_name(name: &str) -> bool { if lower == ".env" || lower.starts_with(".env.") { return true; } - if lower == "credentials.json" - || lower == "credentials.yml" - || lower == "credentials.yaml" - { + if lower == "credentials.json" || lower == "credentials.yml" || lower == "credentials.yaml" { return true; } if lower.ends_with(".key") @@ -607,7 +641,10 @@ pub fn workspace_export_config( let workspace_root = PathBuf::from(&workspace.path); if !workspace_root.exists() { - return Err(format!("Workspace path not found: {}", workspace_root.display())); + return Err(format!( + "Workspace path not found: {}", + workspace_root.display() + )); } let output_path = PathBuf::from(&output_path); @@ -628,8 +665,8 @@ pub fn workspace_export_config( let mut included_paths: Vec = Vec::new(); for (src, rel) in entries { - let mut input = fs::File::open(&src) - .map_err(|e| format!("Failed to read {}: {e}", src.display()))?; + let mut input = + fs::File::open(&src).map_err(|e| format!("Failed to read {}: {e}", src.display()))?; zip.start_file(rel.clone(), options) .map_err(|e| format!("Failed to add {}: {e}", rel))?; let mut buffer = Vec::new(); @@ -701,8 +738,7 @@ pub fn workspace_import_config( let file = fs::File::open(&archive_path) .map_err(|e| format!("Failed to open {}: {e}", archive_path))?; - let mut archive = ZipArchive::new(file) - .map_err(|e| format!("Failed to read archive: {e}"))?; + let mut archive = ZipArchive::new(file).map_err(|e| format!("Failed to read archive: {e}"))?; for i in 0..archive.len() { let mut entry = archive.by_index(i).map_err(|e| e.to_string())?; @@ -738,7 +774,8 @@ pub fn workspace_import_config( continue; } let mut buffer = Vec::new(); - entry.read_to_end(&mut buffer) + entry + .read_to_end(&mut buffer) .map_err(|e| format!("Failed to read archive entry: {e}"))?; fs::write(&out_path, buffer) .map_err(|e| format!("Failed to write {}: {e}", out_path.display()))?; @@ -760,7 +797,10 @@ pub fn workspace_import_config( config.authorized_roots = vec![target_dir.clone()]; if let Some(workspace) = &config.workspace { if workspace_name.is_none() { - workspace_name = workspace.name.clone().filter(|value| !value.trim().is_empty()); + workspace_name = workspace + .name + .clone() + .filter(|value| !value.trim().is_empty()); } if let Some(next_preset) = &workspace.preset { if !next_preset.trim().is_empty() { diff --git a/packages/desktop/src-tauri/src/lib.rs b/packages/desktop/src-tauri/src/lib.rs index 6690144cf..563e1ab57 100644 --- a/packages/desktop/src-tauri/src/lib.rs +++ b/packages/desktop/src-tauri/src/lib.rs @@ -34,7 +34,7 @@ use commands::updater::updater_environment; use commands::workspace::{ workspace_add_authorized_root, workspace_bootstrap, workspace_create, workspace_create_remote, workspace_export_config, workspace_forget, workspace_import_config, workspace_openwork_read, - workspace_openwork_write, workspace_set_active, workspace_update_remote, + workspace_openwork_write, workspace_set_active, workspace_update_display_name, workspace_update_remote, }; use engine::manager::EngineManager; use openwrk::manager::OpenwrkManager; @@ -83,6 +83,7 @@ pub fn run() { workspace_set_active, workspace_create, workspace_create_remote, + workspace_update_display_name, workspace_update_remote, workspace_forget, workspace_add_authorized_root,