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
This commit is contained in:
Benjamin Shafii
2026-02-04 22:55:47 -08:00
parent 58ec9c03d8
commit 3f2ff5df05
12 changed files with 810 additions and 399 deletions

View File

@@ -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<Session[]>([]);
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<WorkspaceSessionGroup[]>(() => {
const workspaces = workspaceStore.workspaces();
const sessionList = sidebarSessions();
const workspaceByRoot = new Map<string, string>();
const sessionsByWorkspaceId = new Map<string, Session[]>();
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<string | null>(null);
const [editRemoteWorkspaceError, setEditRemoteWorkspaceError] = createSignal<string | null>(null);
const [renameWorkspaceOpen, setRenameWorkspaceOpen] = createSignal(false);
const [renameWorkspaceId, setRenameWorkspaceId] = createSignal<string | null>(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() {
}
/>
<RenameWorkspaceModal
open={renameWorkspaceOpen()}
title={renameWorkspaceName()}
busy={renameWorkspaceBusy()}
canSave={renameWorkspaceName().trim().length > 0 && !renameWorkspaceBusy()}
onClose={closeRenameWorkspace}
onSave={saveRenameWorkspace}
onTitleChange={setRenameWorkspaceName}
/>
<CreateRemoteWorkspaceModal
open={editRemoteWorkspaceOpen()}
onClose={() => {
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())}

View File

@@ -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: {
</div>
</div>
<div class="p-6 border-t border-gray-6 bg-gray-1 flex justify-end gap-3">
<Show when={showClose()}>
<Button variant="ghost" onClick={props.onClose} disabled={submitting()}>
{translate("common.cancel")}
</Button>
<div class="p-6 border-t border-gray-6 bg-gray-1 space-y-3">
<Show when={props.error}>
<div class="p-3 rounded-lg bg-red-3/50 border border-red-6 text-sm text-red-11">
{props.error}
</div>
</Show>
<Button
onClick={() =>
props.onConfirm({
openworkHostUrl: openworkHostUrl().trim(),
openworkToken: openworkToken().trim(),
directory: directory().trim() ? directory().trim() : null,
displayName: displayName().trim() ? displayName().trim() : null,
})
}
disabled={!canSubmit()}
title={!openworkHostUrl().trim() ? translate("dashboard.remote_base_url_required") : undefined}
>
{confirmLabel()}
</Button>
<div class="flex justify-end gap-3">
<Show when={showClose()}>
<Button variant="ghost" onClick={props.onClose} disabled={submitting()}>
{translate("common.cancel")}
</Button>
</Show>
<Button
onClick={() =>
props.onConfirm({
openworkHostUrl: openworkHostUrl().trim(),
openworkToken: openworkToken().trim(),
directory: directory().trim() ? directory().trim() : null,
displayName: displayName().trim() ? displayName().trim() : null,
})
}
disabled={!canSubmit()}
title={!openworkHostUrl().trim() ? translate("dashboard.remote_base_url_required") : undefined}
>
{confirmLabel()}
</Button>
</div>
</div>
</div>
);

View File

@@ -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 (
<Show when={props.open}>
<div class="fixed inset-0 z-50 bg-gray-1/60 backdrop-blur-sm flex items-center justify-center p-4">
<div class="bg-gray-2 border border-gray-6/70 w-full max-w-lg rounded-2xl shadow-2xl overflow-hidden">
<div class="p-6">
<div class="flex items-start justify-between gap-4">
<div>
<h3 class="text-lg font-semibold text-gray-12">{translate("workspace.rename_title")}</h3>
<p class="text-sm text-gray-11 mt-1">{translate("workspace.rename_description")}</p>
</div>
<Button variant="ghost" class="!p-2 rounded-full" onClick={props.onClose}>
<X size={16} />
</Button>
</div>
<div class="mt-6">
<TextInput
ref={inputRef}
label={translate("workspace.rename_label")}
value={props.title}
onInput={(e) => 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();
}}
/>
</div>
<div class="mt-6 flex justify-end gap-2">
<Button variant="outline" onClick={props.onClose} disabled={props.busy}>
{translate("common.cancel")}
</Button>
<Button onClick={props.onSave} disabled={!props.canSave}>
{translate("common.save")}
</Button>
</div>
</div>
</div>
</div>
</Show>
);
}

View File

@@ -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,

View File

@@ -204,6 +204,16 @@ export async function workspaceUpdateRemote(input: {
});
}
export async function workspaceUpdateDisplayName(input: {
workspaceId: string;
displayName?: string | null;
}): Promise<WorkspaceList> {
return invoke<WorkspaceList>("workspace_update_display_name", {
workspaceId: input.workspaceId,
displayName: input.displayName ?? null,
});
}
export async function workspaceForget(workspaceId: string): Promise<WorkspaceList> {
return invoke<WorkspaceList>("workspace_forget", { workspaceId });
}

View File

@@ -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<string, string>;
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<string | null>(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<string, number>
>({});
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<string | null>(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", <Box size={18} />)}
</div>
<div class="space-y-2 mb-6">
<div class="flex items-center justify-between text-[11px] font-bold text-dls-secondary uppercase px-3 tracking-tight">
<span>Workspaces</span>
<button
type="button"
aria-label="Workspace settings"
onClick={() => openSettings("general")}
class="text-dls-secondary hover:text-dls-text"
>
<Settings size={14} />
</button>
</div>
<div class="space-y-1">
<For each={props.workspaces}>
{(workspace) => {
const isActive = () => props.activeWorkspaceId === workspace.id;
const isConnecting = () => props.connectingWorkspaceId === workspace.id;
return (
<button
type="button"
class={`w-full flex items-center justify-between h-10 px-3 rounded-lg text-left transition-colors ${
isActive()
? "bg-dls-active text-dls-text"
: "text-dls-text hover:bg-dls-hover"
}`}
onClick={() => props.activateWorkspace(workspace.id)}
>
<div class="min-w-0">
<div class="text-sm font-medium truncate">{workspaceLabel(workspace)}</div>
<div class="text-[11px] text-dls-secondary">
{workspaceKindLabel(workspace)}
{isActive() ? ` · ${workspaceStatus().label}` : ""}
</div>
</div>
<Show when={isConnecting()}>
<Loader2 size={14} class="animate-spin text-dls-secondary" />
</Show>
</button>
);
}}
</For>
</div>
<button
type="button"
onClick={props.openCreateWorkspace}
class="w-full flex items-center gap-2 px-3 py-2 rounded-lg text-xs font-medium text-dls-secondary hover:text-dls-text hover:bg-dls-hover"
>
<Plus size={14} />
Add a workspace
</button>
</div>
<div class="flex-1 overflow-y-auto">
<div class="flex items-center justify-between text-[11px] font-bold text-dls-secondary uppercase px-3 mb-3 tracking-tight">
<span>Sessions</span>
<div class="flex gap-2 text-dls-secondary">
<button type="button" class="hover:text-dls-text" aria-label="Session layout">
<Layout size={14} />
</button>
<button
type="button"
class="hover:text-dls-text"
@@ -459,103 +427,173 @@ export default function DashboardView(props: DashboardViewProps) {
</div>
</div>
<div class="mb-2">
<div
role="button"
tabIndex={0}
onClick={() => 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"
>
<div class="flex items-center gap-2">
<ChevronRight
size={14}
class={`text-dls-secondary transition-transform ${
sessionsExpanded() ? "rotate-90" : ""
}`}
/>
<span class="text-sm font-medium">{props.activeWorkspaceDisplay.name}</span>
</div>
<div class="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
type="button"
class="p-1 hover:bg-dls-active rounded-md text-dls-secondary transition-colors"
onClick={(event) => event.stopPropagation()}
aria-label="Workspace options"
>
<MoreHorizontal size={14} />
</button>
</div>
</div>
<div class="space-y-3 mb-3">
<For each={props.workspaceSessionGroups}>
{(group) => {
const workspace = () => group.workspace;
const isConnecting = () => props.connectingWorkspaceId === workspace().id;
const isMenuOpen = () => workspaceMenuId() === workspace().id;
<Show when={sessionsExpanded()}>
<div class="mt-0.5 space-y-0.5 border-l border-dls-border ml-4">
<Show
when={props.sessions.length > 0}
fallback={
<div class="px-3 py-2 text-xs text-dls-secondary">
No sessions yet.
</div>
}
>
<For each={visibleSessions()}>
{(session) => (
return (
<div class="space-y-1">
<div class="relative group">
<div
role="button"
tabIndex={0}
class="group flex items-center justify-between h-8 px-3 hover:bg-dls-hover rounded-lg cursor-pointer relative overflow-hidden ml-5 w-[calc(100%-1.25rem)]"
onClick={() => 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);
}}
>
<span class="text-sm text-dls-text truncate mr-2 font-medium group-hover:pr-14 transition-all">
{session.title}
</span>
<span class="text-xs text-dls-secondary whitespace-nowrap group-hover:opacity-0 transition-opacity">
{formatRelativeTime(session.time.updated)}
</span>
<div class="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<div class="min-w-0">
<div class="text-sm font-medium truncate">{workspaceLabel(workspace())}</div>
<div class="text-[11px] text-dls-secondary">
{workspaceKindLabel(workspace())}
</div>
</div>
<Show when={isConnecting()}>
<Loader2 size={14} class="animate-spin text-dls-secondary" />
</Show>
</div>
<button
type="button"
class="absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded-md text-dls-secondary hover:text-dls-text hover:bg-dls-active opacity-0 group-hover:opacity-100 transition-opacity"
onClick={(event) => {
event.stopPropagation();
setWorkspaceMenuId((current) =>
current === workspace().id ? null : workspace().id
);
}}
aria-label="Workspace options"
>
<MoreHorizontal size={14} />
</button>
<Show when={isMenuOpen()}>
<div
ref={(el) => (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()}
>
<button
type="button"
class="p-1 hover:bg-dls-active rounded-md text-dls-text transition-colors"
onClick={(event) => event.stopPropagation()}
aria-label="Session options"
class="w-full text-left px-2 py-1.5 text-sm rounded-md hover:bg-dls-hover"
onClick={() => {
props.openRenameWorkspace(workspace().id);
setWorkspaceMenuId(null);
}}
>
<MoreHorizontal size={14} />
Edit name
</button>
<Show when={workspace().workspaceType === "remote"}>
<button
type="button"
class="w-full text-left px-2 py-1.5 text-sm rounded-md hover:bg-dls-hover"
onClick={() => {
props.editWorkspaceConnection(workspace().id);
setWorkspaceMenuId(null);
}}
>
Edit connection
</button>
</Show>
<button
type="button"
class="p-1 hover:bg-dls-active rounded-md text-dls-text transition-colors"
onClick={(event) => event.stopPropagation()}
aria-label="Rename session"
class="w-full text-left px-2 py-1.5 text-sm rounded-md hover:bg-dls-hover text-red-11"
onClick={() => {
props.forgetWorkspace(workspace().id);
setWorkspaceMenuId(null);
}}
>
<Edit2 size={14} />
Remove workspace
</button>
</div>
</div>
)}
</For>
</Show>
</div>
</Show>
</Show>
</div>
<div class="mt-0.5 space-y-0.5 border-l border-dls-border ml-2">
<Show
when={group.sessions.length > 0}
fallback={
<div class="px-3 py-2 text-xs text-dls-secondary ml-2">
No sessions yet.
</div>
}
>
<For each={previewSessions(workspace().id, group.sessions)}>
{(session) => {
const isSelected = () => props.selectedSessionId === session.id;
return (
<div
role="button"
tabIndex={0}
class={`group flex items-center justify-between h-8 px-3 rounded-lg cursor-pointer relative overflow-hidden ml-2 w-[calc(100%-0.5rem)] ${
isSelected()
? "bg-dls-active text-dls-text"
: "hover:bg-dls-hover"
}`}
onClick={() => openSessionFromList(workspace().id, session.id)}
onKeyDown={(event) => {
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
openSessionFromList(workspace().id, session.id);
}}
>
<span class="text-sm text-dls-text truncate mr-2 font-medium group-hover:pr-14 transition-all">
{session.title}
</span>
<span class="text-xs text-dls-secondary whitespace-nowrap group-hover:opacity-0 transition-opacity">
{formatRelativeTime(session.time?.updated ?? Date.now())}
</span>
<div class="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
type="button"
class="p-1 hover:bg-dls-active rounded-md text-dls-text transition-colors"
onClick={(event) => event.stopPropagation()}
aria-label="Session options"
>
<MoreHorizontal size={14} />
</button>
<button
type="button"
class="p-1 hover:bg-dls-active rounded-md text-dls-text transition-colors"
onClick={(event) => event.stopPropagation()}
aria-label="Rename session"
>
<Edit2 size={14} />
</button>
</div>
</div>
);
}}
</For>
<Show when={group.sessions.length > previewCount(workspace().id)}>
<button
type="button"
class="ml-2 w-[calc(100%-0.5rem)] px-3 py-2 text-xs text-dls-secondary hover:text-dls-text hover:bg-dls-hover rounded-lg transition-colors text-left"
onClick={() => showMoreSessions(workspace().id, group.sessions.length)}
>
{showMoreLabel(workspace().id, group.sessions.length)}
</button>
</Show>
</Show>
</div>
</div>
);
}}
</For>
</div>
<Show when={props.sessions.length > 5}>
<button
type="button"
onClick={() => setShowAllSessions((current) => !current)}
class="px-3 py-1.5 text-xs text-dls-secondary hover:text-dls-text font-medium"
>
{showAllSessions() ? "Show less" : "Show more"}
</button>
</Show>
<button
type="button"
onClick={props.openCreateWorkspace}
class="w-full flex items-center gap-2 px-3 py-2 rounded-lg text-xs font-medium text-dls-secondary hover:text-dls-text hover:bg-dls-hover"
>
<Plus size={14} />
Add a workspace
</button>
</div>
<div class="pt-4 border-t border-dls-border">

View File

@@ -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<void>;
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> | 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<string, number>
>({});
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<string | null>(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) {
</button>
</div>
<div class="space-y-2 mb-6">
<div class="flex items-center justify-between text-[11px] font-bold text-dls-secondary uppercase px-3 tracking-tight">
<span>Workspaces</span>
<button
type="button"
aria-label="Workspace settings"
onClick={() => openSettings("general")}
class="text-dls-secondary hover:text-dls-text"
>
<Settings size={14} />
</button>
</div>
<div class="space-y-1">
<For each={props.workspaces}>
{(workspace) => {
const isActive = () => props.activeWorkspaceId === workspace.id;
const isConnecting = () => props.connectingWorkspaceId === workspace.id;
return (
<button
type="button"
class={`w-full flex items-center justify-between h-10 px-3 rounded-lg text-left transition-colors ${
isActive()
? "bg-dls-active text-dls-text"
: "text-dls-text hover:bg-dls-hover"
}`}
onClick={() => props.activateWorkspace(workspace.id)}
>
<div class="min-w-0">
<div class="text-sm font-medium truncate">{workspaceLabel(workspace)}</div>
<div class="text-[11px] text-dls-secondary">
{workspaceKindLabel(workspace)}
{isActive() ? ` · ${workspaceStatus().label}` : ""}
</div>
</div>
<Show when={isConnecting()}>
<Loader2 size={14} class="animate-spin text-dls-secondary" />
</Show>
</button>
);
}}
</For>
</div>
<button
type="button"
onClick={props.openCreateWorkspace}
class="w-full flex items-center gap-2 px-3 py-2 rounded-lg text-xs font-medium text-dls-secondary hover:text-dls-text hover:bg-dls-hover"
>
<Plus size={14} />
Add a workspace
</button>
</div>
<div class="flex-1 overflow-y-auto">
<div class="flex items-center justify-between text-[11px] font-bold text-dls-secondary uppercase px-3 mb-3 tracking-tight">
<span>Sessions</span>
<div class="flex gap-2 text-dls-secondary">
<button type="button" class="hover:text-dls-text" aria-label="Session layout">
<Layout size={14} />
</button>
<button
type="button"
class="hover:text-dls-text"
@@ -881,105 +852,175 @@ export default function SessionView(props: SessionViewProps) {
</div>
</div>
<div class="mb-2">
<div
role="button"
tabIndex={0}
onClick={() => 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"
>
<div class="flex items-center gap-2">
<ChevronRight
size={14}
class={`text-dls-secondary transition-transform ${
sessionsExpanded() ? "rotate-90" : ""
}`}
/>
<span class="text-sm font-medium">{props.activeWorkspaceDisplay.name}</span>
</div>
<div class="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
type="button"
class="p-1 hover:bg-dls-active rounded-md text-dls-secondary transition-colors"
onClick={(event) => event.stopPropagation()}
aria-label="Workspace options"
>
<MoreHorizontal size={14} />
</button>
</div>
</div>
<div class="space-y-3 mb-3">
<For each={props.workspaceSessionGroups}>
{(group) => {
const workspace = () => group.workspace;
const isConnecting = () => props.connectingWorkspaceId === workspace().id;
const isMenuOpen = () => workspaceMenuId() === workspace().id;
<Show when={sessionsExpanded()}>
<div class="mt-0.5 space-y-0.5 border-l border-dls-border ml-4">
<Show
when={props.sessions.length > 0}
fallback={
<div class="px-3 py-2 text-xs text-dls-secondary">
No sessions yet.
</div>
}
>
<For each={visibleSessions()}>
{(session) => (
return (
<div class="space-y-1">
<div class="relative group">
<div
role="button"
tabIndex={0}
class="group flex items-center justify-between h-8 px-3 hover:bg-dls-hover rounded-lg cursor-pointer relative overflow-hidden ml-5 w-[calc(100%-1.25rem)]"
onClick={() => 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);
}}
>
<span class="text-sm text-dls-text truncate mr-2 font-medium group-hover:pr-14 transition-all">
{session.title}
</span>
<Show when={session.time?.updated}>
<span class="text-xs text-dls-secondary whitespace-nowrap group-hover:opacity-0 transition-opacity">
{formatRelativeTime(session.time?.updated ?? Date.now())}
</span>
<div class="min-w-0">
<div class="text-sm font-medium truncate">{workspaceLabel(workspace())}</div>
<div class="text-[11px] text-dls-secondary">
{workspaceKindLabel(workspace())}
</div>
</div>
<Show when={isConnecting()}>
<Loader2 size={14} class="animate-spin text-dls-secondary" />
</Show>
<div class="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
</div>
<button
type="button"
class="absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded-md text-dls-secondary hover:text-dls-text hover:bg-dls-active opacity-0 group-hover:opacity-100 transition-opacity"
onClick={(event) => {
event.stopPropagation();
setWorkspaceMenuId((current) =>
current === workspace().id ? null : workspace().id
);
}}
aria-label="Workspace options"
>
<MoreHorizontal size={14} />
</button>
<Show when={isMenuOpen()}>
<div
ref={(el) => (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()}
>
<button
type="button"
class="p-1 hover:bg-dls-active rounded-md text-dls-text transition-colors"
onClick={(event) => event.stopPropagation()}
aria-label="Session options"
class="w-full text-left px-2 py-1.5 text-sm rounded-md hover:bg-dls-hover"
onClick={() => {
props.openRenameWorkspace(workspace().id);
setWorkspaceMenuId(null);
}}
>
<MoreHorizontal size={14} />
Edit name
</button>
<Show when={workspace().workspaceType === "remote"}>
<button
type="button"
class="w-full text-left px-2 py-1.5 text-sm rounded-md hover:bg-dls-hover"
onClick={() => {
props.editWorkspaceConnection(workspace().id);
setWorkspaceMenuId(null);
}}
>
Edit connection
</button>
</Show>
<button
type="button"
class="p-1 hover:bg-dls-active rounded-md text-dls-text transition-colors"
onClick={(event) => event.stopPropagation()}
aria-label="Rename session"
class="w-full text-left px-2 py-1.5 text-sm rounded-md hover:bg-dls-hover text-red-11"
onClick={() => {
props.forgetWorkspace(workspace().id);
setWorkspaceMenuId(null);
}}
>
<Edit2 size={14} />
Remove workspace
</button>
</div>
</div>
)}
</For>
</Show>
</div>
</Show>
</Show>
</div>
<div class="mt-0.5 space-y-0.5 border-l border-dls-border ml-2">
<Show
when={group.sessions.length > 0}
fallback={
<div class="px-3 py-2 text-xs text-dls-secondary ml-2">
No sessions yet.
</div>
}
>
<For each={previewSessions(workspace().id, group.sessions)}>
{(session) => {
const isSelected = () => props.selectedSessionId === session.id;
return (
<div
role="button"
tabIndex={0}
class={`group flex items-center justify-between h-8 px-3 rounded-lg cursor-pointer relative overflow-hidden ml-2 w-[calc(100%-0.5rem)] ${
isSelected()
? "bg-dls-active text-dls-text"
: "hover:bg-dls-hover"
}`}
onClick={() => openSessionFromList(workspace().id, session.id)}
onKeyDown={(event) => {
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
openSessionFromList(workspace().id, session.id);
}}
>
<span class="text-sm text-dls-text truncate mr-2 font-medium group-hover:pr-14 transition-all">
{session.title}
</span>
<Show when={session.time?.updated}>
<span class="text-xs text-dls-secondary whitespace-nowrap group-hover:opacity-0 transition-opacity">
{formatRelativeTime(session.time?.updated ?? Date.now())}
</span>
</Show>
<div class="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
type="button"
class="p-1 hover:bg-dls-active rounded-md text-dls-text transition-colors"
onClick={(event) => event.stopPropagation()}
aria-label="Session options"
>
<MoreHorizontal size={14} />
</button>
<button
type="button"
class="p-1 hover:bg-dls-active rounded-md text-dls-text transition-colors"
onClick={(event) => event.stopPropagation()}
aria-label="Rename session"
>
<Edit2 size={14} />
</button>
</div>
</div>
);
}}
</For>
<Show when={group.sessions.length > previewCount(workspace().id)}>
<button
type="button"
class="ml-2 w-[calc(100%-0.5rem)] px-3 py-2 text-xs text-dls-secondary hover:text-dls-text hover:bg-dls-hover rounded-lg transition-colors text-left"
onClick={() => showMoreSessions(workspace().id, group.sessions.length)}
>
{showMoreLabel(workspace().id, group.sessions.length)}
</button>
</Show>
</Show>
</div>
</div>
);
}}
</For>
</div>
<Show when={props.sessions.length > 5}>
<button
type="button"
onClick={() => setShowAllSessions((current) => !current)}
class="px-3 py-1.5 text-xs text-dls-secondary hover:text-dls-text font-medium"
>
{showAllSessions() ? "Show less" : "Show more"}
</button>
</Show>
<button
type="button"
onClick={props.openCreateWorkspace}
class="w-full flex items-center gap-2 px-3 py-2 rounded-lg text-xs font-medium text-dls-secondary hover:text-dls-text hover:bg-dls-hover"
>
<Plus size={14} />
Add a workspace
</button>
</div>
<div class="pt-4 border-t border-dls-border">

View File

@@ -13,6 +13,22 @@ export type Client = ReturnType<typeof createClient>;
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;

View File

@@ -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",

View File

@@ -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": "返回仪表盘",

View File

@@ -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<String>,
) -> Result<WorkspaceList, String> {
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<String> = 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() {

View File

@@ -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,