feat: workspaces own their sessions; desktop parity; sidebar and settings cleanup

Workspace sessions (docs/plans/desktop-parity, phases 1-5 and 7):
- sessions.lane_id: a workspace lists only the sessions its panes held,
  stamped by bindRunSession (first writer wins, nested `claude -p`
  excluded); GET /api/sessions?lane=&archived=
- session row state dot, double-click rename, archive (sessions.archived_at,
  hides only); the activity bar's duplicate Sessions tab removed
- Fork beside Resume (--fork-session, Claude only), Ctrl/Cmd+click a live
  session opens it in a second pane, a diff line's comment is pasted into
  the workspace's last-focused pane (lib/panePaste.ts)

dashboard_runs stability:
- a pane's previous run is archived as <pane>@<started_at> instead of being
  overwritten by INSERT OR REPLACE
- a run is flagged abandoned only once its tmux session is gone, at boot and
  on the run-heal beat (panes outlive a server restart)

UI:
- sidebar: no footer, language or theme block; small collapse button in the
  brand corner; version + update check in the connection details modal, a
  status-bar dot while a release is out; language in Settings -> General
- one Switch component for every switch, the themed Checkbox for every
  checkbox (no native checkboxes)

Also: AI Tools catalog trimmed to 10 built-ins (custom-file prompt modes
tested through fixtures), and the workspace plans under docs/plans/ (the
multi-user server plan lives in the AgentHub-Server repo).
This commit is contained in:
2026-10-02 12:05:00 +07:00
parent 5b269698f6
commit a6bd33bcff
121 changed files with 2699 additions and 1552 deletions
+81 -257
View File
@@ -1,6 +1,6 @@
/**
* @file Sidebar.tsx
* @description Defines the Sidebar component that provides navigation links to different sections of the application, displays the connection status, and includes a toggle button for collapsing or expanding the sidebar. The component uses React Router's NavLink for navigation and Lucide icons for visual representation. The collapsed state of the sidebar is stored in localStorage to persist user preferences across sessions.
* @description The app's left sidebar: the brand with a small collapse/expand button in its corner, the navigation links and the workspace list. It also owns the connection details modal (opened from the status bar), which carries the version and the update check — the sidebar itself has no footer, language or theme controls (those live in Settings → General / Appearance). The collapsed state is kept in localStorage.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/* =============================================================================
@@ -55,13 +55,11 @@ import {
LayoutDashboard,
FolderOpen,
BarChart3,
Play,
Settings,
Wifi,
WifiOff,
PanelLeftClose,
PanelLeftOpen,
Languages,
RefreshCw,
X,
Plug,
@@ -69,15 +67,11 @@ import {
Gauge,
ChevronUp,
ChevronDown,
Sun,
Monitor,
Moon,
FolderTree,
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { api } from "../lib/api";
import { eventBus } from "../lib/eventBus";
import { useTheme } from "../hooks/useTheme";
import { WorkspaceTree } from "./WorkspaceTree";
import type { UpdateStatusPayload, WSMessage } from "../lib/types";
@@ -88,7 +82,6 @@ function isUpdatePayload(x: unknown): x is UpdateStatusPayload {
const NAV_KEYS = [
{ to: "/", icon: LayoutDashboard, key: "nav:dashboard" },
{ to: "/sessions", icon: FolderOpen, key: "nav:sessions" },
{ to: "/workspace", icon: Play, key: "nav:workspace" },
{ to: "/settings", icon: Settings, key: "nav:settings" },
] as const;
@@ -102,8 +95,6 @@ const BRAND_MARK = "https://smartgift.vn/favicon.ico";
const STORAGE_KEY = "sidebar-collapsed";
const STATS_STORAGE_KEY = "sidebar-connection-stats";
const RECENT_EVENTS_CAP = 8;
const SUPPORTED_LANGUAGES = ["en", "vi"] as const;
type SupportedLanguage = (typeof SUPPORTED_LANGUAGES)[number];
interface PersistedStats {
eventCount: number;
@@ -162,14 +153,6 @@ function loadStats(): PersistedStats {
}
}
function normalizeLanguage(language: string): SupportedLanguage {
const base = language.toLowerCase().split("-")[0];
if (base === "vi" || base === "en") {
return base;
}
return "en";
}
interface SidebarProps {
wsConnected: boolean;
collapsed: boolean;
@@ -177,6 +160,8 @@ interface SidebarProps {
/** The connection details modal, opened from here or from the status bar. */
connectionOpen: boolean;
onConnectionOpenChange: (open: boolean) => void;
/** Told whether a newer release is out, for the status bar's dot. */
onUpdateAvailableChange?: (available: boolean) => void;
}
export function Sidebar({
@@ -185,9 +170,9 @@ export function Sidebar({
onToggle,
connectionOpen,
onConnectionOpenChange,
onUpdateAvailableChange,
}: SidebarProps) {
const { t, i18n } = useTranslation();
const { theme, mode, setMode, toggleTheme } = useTheme();
const { t } = useTranslation();
// Track whether nav items are clipped by overflow so we can render
// chevron affordances pointing toward the hidden items. Recomputed on
// scroll, resize, and any structural change (e.g. collapse toggle).
@@ -395,24 +380,9 @@ export function Sidebar({
: updateStatus
? t("nav:upToDate")
: t("nav:checkForUpdates");
const currentLanguage = normalizeLanguage(i18n.resolvedLanguage ?? i18n.language);
const currentIndex = SUPPORTED_LANGUAGES.indexOf(currentLanguage);
const nextLanguage = SUPPORTED_LANGUAGES[(currentIndex + 1) % SUPPORTED_LANGUAGES.length];
const switchLanguageTitle = t("nav:switchLanguage", {
language: t(`nav:languageNames.${nextLanguage}`),
});
const nextTheme = theme === "dark" ? "light" : "dark";
const switchThemeTitle = t("nav:switchTheme", { theme: t(`nav:themeNames.${nextTheme}`) });
const toggleLang = () => {
i18n.changeLanguage(nextLanguage);
};
const changeLanguage = (language: SupportedLanguage) => {
if (language !== currentLanguage) {
i18n.changeLanguage(language);
}
};
useEffect(() => {
onUpdateAvailableChange?.(updateAvailable);
}, [updateAvailable, onUpdateAvailableChange]);
return (
<aside
@@ -420,9 +390,26 @@ export function Sidebar({
collapsed ? "w-[4.25rem]" : "w-60"
}`}
>
{/* Brand */}
<div className="px-3 py-4 border-b border-border flex-shrink-0">
<div className="flex flex-col items-center justify-center">
{/* Brand, with the collapse toggle as a small button in its corner (or
under the mark on the rail) rather than a full-width row. */}
<div className="relative px-3 py-4 border-b border-border flex-shrink-0">
<button
type="button"
onClick={onToggle}
data-testid="sidebar-toggle"
title={collapsed ? t("nav:expand") : t("nav:collapse")}
aria-label={collapsed ? t("nav:expand") : t("nav:collapse")}
className={`z-10 inline-flex h-6 w-6 items-center justify-center rounded-md text-fg-muted transition-colors hover:bg-surface-3 hover:text-fg-primary ${
collapsed ? "absolute bottom-1 left-1/2 -translate-x-1/2" : "absolute right-1.5 top-1.5"
}`}
>
{collapsed ? (
<PanelLeftOpen className="h-3.5 w-3.5" />
) : (
<PanelLeftClose className="h-3.5 w-3.5" />
)}
</button>
<div className={`flex flex-col items-center justify-center ${collapsed ? "pb-4" : ""}`}>
{collapsed ? (
<img src={BRAND_MARK} alt={t("nav:brandSub")} className="w-8 h-8 flex-shrink-0" />
) : (
@@ -512,222 +499,6 @@ export function Sidebar({
)}
</div>
{/* Language + theme controls. Theme sits next to language: same icon
row when collapsed, same row as the EN/VI buttons when expanded -
both are 2-state click-to-flip toggles, so they read as one family
of controls rather than two unrelated settings. */}
<div className="px-2 pb-2 flex-shrink-0">
{collapsed ? (
<div className="flex flex-col gap-1">
<button
onClick={toggleLang}
className="w-full h-9 rounded-lg border border-border bg-surface-2 text-fg-secondary hover:bg-surface-3 hover:text-fg-primary transition-colors flex flex-col items-center justify-center gap-0.5"
title={switchLanguageTitle}
aria-label={switchLanguageTitle}
>
<Languages className="w-3.5 h-3.5" />
<span className="text-[10px] font-semibold leading-none">
{t(`nav:languageShort.${currentLanguage}`)}
</span>
</button>
<button
onClick={toggleTheme}
className="w-full h-9 rounded-lg border border-border bg-surface-2 text-fg-secondary hover:bg-surface-3 hover:text-fg-primary transition-colors flex flex-col items-center justify-center gap-0.5"
title={switchThemeTitle}
aria-label={switchThemeTitle}
>
{theme === "dark" ? (
<Moon className="w-3.5 h-3.5" />
) : (
<Sun className="w-3.5 h-3.5" />
)}
<span className="text-[10px] font-semibold leading-none">
{t(`nav:themeNames.${theme}`).slice(0, 1)}
</span>
</button>
</div>
) : (
<div className="rounded-lg border border-border bg-surface-2 p-2 space-y-2">
<div className="flex items-center justify-between gap-2">
<div className="flex-1">
<p className="px-1 text-[10px] font-semibold uppercase tracking-wider text-fg-muted">
{t("nav:language")}
</p>
{/* One column per language: a wider grid squeezed each button
below the width of its own label once Theme grew a third. */}
<div className="mt-2 grid grid-cols-2 gap-1">
{SUPPORTED_LANGUAGES.map((language) => {
const active = language === currentLanguage;
return (
<button
key={language}
onClick={() => changeLanguage(language)}
aria-pressed={active}
aria-label={t(`nav:languageNames.${language}`)}
title={t(`nav:languageNames.${language}`)}
className={`rounded-md px-1 py-1.5 text-center text-[11px] font-semibold transition-colors ${
active
? "bg-accent/20 text-accent border border-accent/30"
: "bg-surface-1 text-fg-secondary border border-border hover:bg-surface-3 hover:text-fg-primary"
}`}
>
{t(`nav:languageShort.${language}`)}
</button>
);
})}
</div>
</div>
<div>
<p className="px-1 text-[10px] font-semibold uppercase tracking-wider text-fg-muted">
{t("nav:theme")}
</p>
<div className="mt-2 grid grid-cols-3 gap-1">
{(["dark", "light", "system"] as const).map((option) => {
const active = option === mode;
return (
<button
key={option}
onClick={() => setMode(option)}
aria-pressed={active}
aria-label={t(`nav:themeNames.${option}`)}
title={t(`nav:themeNames.${option}`)}
className={`rounded-md px-2 py-1.5 text-[11px] font-semibold transition-colors flex items-center justify-center ${
active
? "bg-accent/20 text-accent border border-accent/30"
: "bg-surface-1 text-fg-secondary border border-border hover:bg-surface-3 hover:text-fg-primary"
}`}
>
{option === "dark" ? (
<Moon className="w-3.5 h-3.5" />
) : option === "light" ? (
<Sun className="w-3.5 h-3.5" />
) : (
<Monitor className="w-3.5 h-3.5" />
)}
</button>
);
})}
</div>
</div>
</div>
</div>
)}
</div>
{/* Collapse toggle */}
<div className="px-2 py-2 flex-shrink-0">
<button
onClick={onToggle}
className={`w-full h-10 rounded-lg border border-border bg-surface-2 transition-colors ${
collapsed
? "flex items-center justify-center text-fg-secondary hover:text-fg-primary hover:bg-surface-3"
: "flex items-center gap-2.5 px-3 text-fg-secondary hover:text-fg-primary hover:bg-surface-3"
}`}
title={collapsed ? t("nav:expand") : t("nav:collapse")}
aria-label={collapsed ? t("nav:expand") : t("nav:collapse")}
>
{collapsed ? (
<PanelLeftOpen className="w-4 h-4 flex-shrink-0" />
) : (
<>
<PanelLeftClose className="w-4 h-4 flex-shrink-0" />
<span className="text-[11px] font-semibold uppercase tracking-wide">
{t("nav:collapseShort")}
</span>
</>
)}
</button>
</div>
{/* Footer */}
<div
className={`px-3 pt-3 pb-4 border-t border-border space-y-2.5 flex-shrink-0 ${collapsed ? "px-2" : ""}`}
>
{collapsed && (
<button
type="button"
onClick={() => onConnectionOpenChange(true)}
aria-label={t("nav:connectionDetails")}
title={t("nav:connectionDetails")}
className={`rounded-lg border border-border bg-surface-2 hover:bg-surface-3 transition-colors text-left cursor-pointer ${
collapsed
? "w-8 h-8 mx-auto flex items-center justify-center p-0"
: "block w-full px-2.5 py-2"
}`}
>
<div
className={`flex items-center text-xs ${collapsed ? "justify-center" : "justify-between gap-2"}`}
>
<span
className={`inline-flex items-center gap-2 ${
wsConnected ? "text-status-success" : "text-fg-muted"
}`}
>
{wsConnected ? (
<Wifi className="w-3.5 h-3.5 flex-shrink-0" />
) : (
<WifiOff className="w-3.5 h-3.5 flex-shrink-0" />
)}
{!collapsed && (
<span className="font-medium">
{wsConnected ? t("nav:live") : t("nav:disconnected")}
</span>
)}
</span>
{!collapsed && (
<span className="text-[11px] font-medium text-fg-muted">{`v${__APP_VERSION__}`}</span>
)}
</div>
</button>
)}
{collapsed ? (
<button
type="button"
onClick={onCheckUpdates}
disabled={checking}
title={checkTitle}
aria-label={checkTitle}
className={`relative w-8 h-8 mx-auto flex items-center justify-center rounded-lg border bg-surface-2 transition-colors disabled:opacity-60 ${
updateAvailable
? "border-status-success/40 text-status-success hover:bg-status-success/10"
: checkError
? "border-status-warning/40 text-status-warning hover:bg-status-warning/10"
: "border-border text-fg-secondary hover:text-fg-primary hover:bg-surface-3"
}`}
>
<RefreshCw className={`w-3.5 h-3.5 ${checking ? "animate-spin" : ""}`} aria-hidden />
{updateAvailable && !checking && (
<span className="absolute top-1 right-1 w-1.5 h-1.5 rounded-full bg-status-success" />
)}
</button>
) : (
<button
type="button"
onClick={onCheckUpdates}
disabled={checking}
title={checkTitle}
className={`w-full rounded-lg border bg-surface-2 px-2.5 py-2 text-xs transition-colors disabled:opacity-60 flex items-center justify-between gap-2 ${
updateAvailable
? "border-status-success/40 text-status-success hover:bg-status-success/10"
: checkError
? "border-status-warning/40 text-status-warning hover:bg-status-warning/10"
: "border-border text-fg-secondary hover:text-fg-primary hover:bg-surface-3"
}`}
>
<span className="inline-flex items-center gap-2 truncate">
<RefreshCw
className={`w-3.5 h-3.5 flex-shrink-0 ${checking ? "animate-spin" : ""}`}
aria-hidden
/>
<span className="font-medium truncate">{checkTitle}</span>
</span>
{updateAvailable && !checking && (
<span className="w-1.5 h-1.5 rounded-full bg-status-success flex-shrink-0" />
)}
</button>
)}
</div>
<ConnectionStatusModal
open={connectionOpen}
onClose={() => onConnectionOpenChange(false)}
@@ -739,6 +510,13 @@ export function Sidebar({
eventTimestampsRef={eventTimestampsRef}
typeCountRef={typeCountRef}
recentEventsRef={recentEventsRef}
update={{
title: checkTitle,
checking,
available: updateAvailable,
failed: checkError,
onCheck: onCheckUpdates,
}}
onResetStats={() => {
eventCountRef.current = 0;
peakPerSecRef.current = 0;
@@ -772,6 +550,15 @@ interface ConnectionStatusModalProps {
eventTimestampsRef: React.MutableRefObject<number[]>;
typeCountRef: React.MutableRefObject<Map<string, number>>;
recentEventsRef: React.MutableRefObject<Array<{ type: string; at: number }>>;
/** The release check: its one-line state ("Up to date", "Dev build · N
* ahead", a release out…) and the button that re-runs it. */
update: {
title: string;
checking: boolean;
available: boolean;
failed: boolean;
onCheck: () => void;
};
onResetStats: () => void;
}
@@ -786,6 +573,7 @@ function ConnectionStatusModal({
eventTimestampsRef,
typeCountRef,
recentEventsRef,
update,
onResetStats,
}: ConnectionStatusModalProps) {
const { t } = useTranslation();
@@ -894,6 +682,42 @@ function ConnectionStatusModal({
</div>
<div className="px-5 py-4 space-y-5 overflow-y-auto">
{/* Version + release check */}
<Section title={t("nav:version")} icon={RefreshCw}>
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<p className="font-mono text-sm text-fg-primary">{`v${__APP_VERSION__}`}</p>
<p
data-testid="update-status"
className={`mt-0.5 inline-flex items-center gap-1.5 text-xs ${
update.available
? "text-status-success"
: update.failed
? "text-status-warning"
: "text-fg-muted"
}`}
>
{update.available && (
<span className="h-1.5 w-1.5 rounded-full bg-status-success" />
)}
{update.title}
</p>
</div>
<button
type="button"
onClick={update.onCheck}
disabled={update.checking}
className="btn-ghost shrink-0 text-xs disabled:opacity-60"
>
<RefreshCw
className={`h-3.5 w-3.5 ${update.checking ? "animate-spin" : ""}`}
aria-hidden
/>
{t("nav:checkForUpdates")}
</button>
</div>
</Section>
{/* KPI row */}
<div className="grid grid-cols-3 gap-2">
<KpiTile