feat(ui): sidebar project tree, status bar and a tabbed workbench for workspaces

The Workspace page's lane strip moves into the sidebar as a project tree:
workspaces grouped by git repository (new GET /api/projects, cached in
memory, POST /api/projects/refresh), untracked worktrees with Adopt, inline
rename, drag/Alt+arrow ordering and a right-click row menu. The selected
workspace is the URL's ?lane=, one shared lane list feeds the tree, the new
bottom status bar and the page. The Changes panel gains an activity bar
(Files / Changes / Sessions, with resume), and split panes get a header.
This commit is contained in:
2026-10-01 16:33:25 +07:00
parent 5b3884b309
commit ba2c4e1914
39 changed files with 2431 additions and 1439 deletions
+62 -35
View File
@@ -73,11 +73,13 @@ import {
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 { ProjectTree } from "./ProjectTree";
import type { UpdateStatusPayload, WSMessage } from "../lib/types";
function isUpdatePayload(x: unknown): x is UpdateStatusPayload {
@@ -174,9 +176,18 @@ interface SidebarProps {
wsConnected: boolean;
collapsed: boolean;
onToggle: () => void;
/** The connection details modal, opened from here or from the status bar. */
connectionOpen: boolean;
onConnectionOpenChange: (open: boolean) => void;
}
export function Sidebar({ wsConnected, collapsed, onToggle }: SidebarProps) {
export function Sidebar({
wsConnected,
collapsed,
onToggle,
connectionOpen,
onConnectionOpenChange,
}: SidebarProps) {
const { t, i18n } = useTranslation();
const { theme, mode, setMode, toggleTheme } = useTheme();
// Track whether nav items are clipped by overflow so we can render
@@ -187,7 +198,6 @@ export function Sidebar({ wsConnected, collapsed, onToggle }: SidebarProps) {
const [updateStatus, setUpdateStatus] = useState<UpdateStatusPayload | null>(null);
const [checking, setChecking] = useState(false);
const [checkError, setCheckError] = useState(false);
const [statusModalOpen, setStatusModalOpen] = useState(false);
const [connectedSince, setConnectedSince] = useState<number | null>(
wsConnected ? Date.now() : null
);
@@ -464,6 +474,21 @@ export function Sidebar({ wsConnected, collapsed, onToggle }: SidebarProps) {
</NavLink>
);
})}
{/* The project → workspace tree scrolls with the nav. In the rail it
is one icon that opens the sidebar instead. */}
{collapsed ? (
<button
type="button"
onClick={onToggle}
title={t("lanes:projects.title")}
aria-label={t("lanes:projects.title")}
className="flex w-full items-center justify-center rounded-lg border border-transparent px-2 py-2.5 text-fg-secondary transition-colors hover:bg-surface-3 hover:text-fg-primary"
>
<FolderTree className="w-4 h-4" />
</button>
) : (
<ProjectTree />
)}
</nav>
{!collapsed && navOverflow.up && (
<button
@@ -620,41 +645,43 @@ export function Sidebar({ wsConnected, collapsed, onToggle }: SidebarProps) {
<div
className={`px-3 pt-3 pb-4 border-t border-border space-y-2.5 flex-shrink-0 ${collapsed ? "px-2" : ""}`}
>
<button
type="button"
onClick={() => setStatusModalOpen(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"}`}
{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"
}`}
>
<span
className={`inline-flex items-center gap-2 ${
wsConnected ? "text-status-success" : "text-fg-muted"
}`}
<div
className={`flex items-center text-xs ${collapsed ? "justify-center" : "justify-between gap-2"}`}
>
{wsConnected ? (
<Wifi className="w-3.5 h-3.5 flex-shrink-0" />
) : (
<WifiOff className="w-3.5 h-3.5 flex-shrink-0" />
)}
<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="font-medium">
{wsConnected ? t("nav:live") : t("nav:disconnected")}
</span>
<span className="text-[11px] font-medium text-fg-muted">{`v${__APP_VERSION__}`}</span>
)}
</span>
{!collapsed && (
<span className="text-[11px] font-medium text-fg-muted">{`v${__APP_VERSION__}`}</span>
)}
</div>
</button>
</div>
</button>
)}
{collapsed ? (
<button
type="button"
@@ -704,8 +731,8 @@ export function Sidebar({ wsConnected, collapsed, onToggle }: SidebarProps) {
</div>
<ConnectionStatusModal
open={statusModalOpen}
onClose={() => setStatusModalOpen(false)}
open={connectionOpen}
onClose={() => onConnectionOpenChange(false)}
wsConnected={wsConnected}
connectedSince={connectedSince}
eventCountRef={eventCountRef}