feat: AI Router per session; Settings at the sidebar's foot; shipped tool icons

AI Router is a session's choice, not a workspace's:
- an AI Router select beside the model picker in the workspace home and a
  pane's run form (RouterPicker.tsx; hidden off Claude/Codex or with only
  Native; last pick remembered in agenthub.session.router)
- `router` on POST /api/lanes/:id/start and POST /api/run; Claude gets
  --settings=<data dir>/router-settings/<name>.json (0600, outranks project
  and user settings, key never in argv), Codex -p <name> + key via tmux -e
- dashboard_runs.router; a resume naming none goes back to it
- profile PATCH rewrites the session files, DELETE removes them
- gone: POST /api/lanes/:id/router/apply, LaneRouterRow, the Add-workspace
  router radio, the claude-/codex-home `routers` answers; clearLaneRouters
  takes the old keys out of each workspace's settings.local.json at start

Sidebar: Settings pinned to the foot, the collapse toggle beside it (stacked
on the rail) instead of in the brand's corner.

Icons: shipped SVG marks for claude, codex, openclaw and hermes (a
black/white pair swapped by theme) in client/public/ai-tools/, used by
AiToolIcon before the server's favicon.
This commit is contained in:
2026-10-02 14:19:02 +07:00
parent 5084334d81
commit c507d37baf
55 changed files with 851 additions and 1992 deletions
+47 -32
View File
@@ -1,6 +1,6 @@
/**
* @file Sidebar.tsx
* @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.
* @description The app's left sidebar: the brand, the navigation links and the workspace list, with Settings and the collapse/expand button pinned to its foot. 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>
*/
/* =============================================================================
@@ -82,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: "/settings", icon: Settings, key: "nav:settings" },
] as const;
// SmartGift wordmark, replacing the old text subtitle. Remote URLs: the logo is
@@ -384,32 +383,23 @@ export function Sidebar({
onUpdateAvailableChange?.(updateAvailable);
}, [updateAvailable, onUpdateAvailableChange]);
const navClass = ({ isActive }: { isActive: boolean }) =>
`flex items-center gap-3 rounded-lg text-sm font-medium transition-colors duration-150 ${
collapsed ? "justify-center px-2 py-2.5" : "px-3 py-2.5"
} ${
isActive
? "bg-accent/10 text-accent border border-accent/20"
: "text-fg-secondary hover:text-fg-primary hover:bg-surface-3 border border-transparent"
}`;
return (
<aside
className={`fixed left-0 top-0 bottom-0 bg-surface-1 border-r border-border flex flex-col z-30 overflow-hidden transition-[width] duration-200 ${
collapsed ? "w-[4.25rem]" : "w-60"
}`}
>
{/* 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" : ""}`}>
<div className="px-3 py-4 border-b border-border flex-shrink-0">
<div className="flex flex-col items-center justify-center">
{collapsed ? (
<img src={BRAND_MARK} alt={t("nav:brandSub")} className="w-8 h-8 flex-shrink-0" />
) : (
@@ -431,7 +421,7 @@ export function Sidebar({
</div>
{/* Nav - only this section scrolls when its items overflow; the rest of
the sidebar (brand, language, collapse toggle, footer) stays pinned.
the sidebar (brand, and the Settings + collapse footer) stays pinned.
Chevron buttons appear at the edges when content is clipped, so the
user knows there's more to reach without inspecting the scrollbar. */}
<div className="flex-1 min-h-0 relative flex">
@@ -444,15 +434,7 @@ export function Sidebar({
to={to}
end={to === "/"}
title={collapsed ? label : undefined}
className={({ isActive }) =>
`flex items-center gap-3 rounded-lg text-sm font-medium transition-colors duration-150 ${
collapsed ? "justify-center px-2 py-2.5" : "px-3 py-2.5"
} ${
isActive
? "bg-accent/10 text-accent border border-accent/20"
: "text-fg-secondary hover:text-fg-primary hover:bg-surface-3 border border-transparent"
}`
}
className={navClass}
>
<Icon className="w-4 h-4 flex-shrink-0" />
{!collapsed && <span>{label}</span>}
@@ -499,6 +481,39 @@ export function Sidebar({
)}
</div>
{/* Settings and the collapse toggle stick to the foot, out of the
scrolling nav. On the rail they stack. */}
<div
className={`flex flex-shrink-0 gap-1 border-t border-border px-2 py-2 ${
collapsed ? "flex-col" : "items-center"
}`}
>
<NavLink
to="/settings"
title={collapsed ? t("nav:settings") : undefined}
className={(p) => `${navClass(p)} ${collapsed ? "" : "flex-1"}`}
>
<Settings className="w-4 h-4 flex-shrink-0" />
{!collapsed && <span>{t("nav:settings")}</span>}
</NavLink>
<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={`inline-flex items-center justify-center rounded-lg text-fg-muted transition-colors hover:bg-surface-3 hover:text-fg-primary ${
collapsed ? "py-2.5" : "h-9 w-9 flex-shrink-0"
}`}
>
{collapsed ? (
<PanelLeftOpen className="h-4 w-4" />
) : (
<PanelLeftClose className="h-4 w-4" />
)}
</button>
</div>
<ConnectionStatusModal
open={connectionOpen}
onClose={() => onConnectionOpenChange(false)}