6c605849d8
- /api/workspaces and `agenthub workspace(s)` / `--workspace` as aliases of /api/lanes and `agenthub lanes` / `--lane`; old names print one stderr note. REPL `help lanes|workspace|workspaces` resolves through the aliases. - A workspace holds up to ten sessions (slots 0-9); every console tab is a group of 1-4 split panes (PaneGroups, agenthub.workspace.paneLayout); parseGroups keeps the stored active tab when an earlier one is dropped. - Browse button beside path fields: POST /api/lanes/pick-path opens the OS picker on the server's desktop — loopback only and no forwarding header (EREMOTE), a display failure is ENODIALOG not a Cancel, the dialog is killed when the request closes, a folder field falls back to the in-app browser on any failure. - Changes rail counts chip, image viewer (/git/raw), status bar session counts and per-day cost (`date=` on /api/pricing/cost). - /agenthub-open and /agenthub-doctor run from the INSTALLED build (supersededBy), doctor offers fixes behind two confirmations; plugin-open treats a locked bootstrap as non-fatal. - New-terminal shell waits for the first attach (issue #55). - repo-onboarding / debug-live-issue skills removed. - OpenAPI: pick-path, lane/date on /pricing/cost, tool on /run/history, /api/workspaces mount.
121 lines
3.8 KiB
TypeScript
121 lines
3.8 KiB
TypeScript
/**
|
|
* @file PageTabs.tsx
|
|
* @description Shared top-of-page tab bar used by the pages that merged several
|
|
* former routes into one (Settings hub, Sessions/Events history). The owning
|
|
* page keeps the active tab in its own query param. `actions` sits at the bar's
|
|
* right edge, outside the tablist.
|
|
*
|
|
* These are real in-page tabs (one view swapped in place), so they follow the
|
|
* WAI-ARIA tabs pattern: one tab stop (roving tabindex), Left/Right/Home/End
|
|
* move AND select. Pass the active view as `children` and it is wrapped in the
|
|
* `role="tabpanel"` the tabs point at with `aria-controls`; without children no
|
|
* `aria-controls` is emitted (it would name an element that does not exist).
|
|
* The selected tab is marked by weight and an underline, not colour alone.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
import { useId, type ComponentType, type KeyboardEvent, type ReactNode } from "react";
|
|
|
|
export interface PageTab {
|
|
id: string;
|
|
label: string;
|
|
Icon: ComponentType<{ className?: string }>;
|
|
}
|
|
|
|
interface PageTabsProps {
|
|
tabs: PageTab[];
|
|
activeId: string;
|
|
onSelect: (id: string) => void;
|
|
ariaLabel: string;
|
|
/** Buttons at the right edge of the bar (not tabs). */
|
|
actions?: ReactNode;
|
|
/** The active tab's view, rendered inside its `role="tabpanel"`. */
|
|
children?: ReactNode;
|
|
}
|
|
|
|
export function PageTabs({
|
|
tabs,
|
|
activeId,
|
|
onSelect,
|
|
ariaLabel,
|
|
actions,
|
|
children,
|
|
}: PageTabsProps) {
|
|
const base = useId();
|
|
const tabId = (id: string) => `${base}-tab-${id}`;
|
|
const panelId = `${base}-panel`;
|
|
const hasPanel = children !== undefined;
|
|
|
|
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
|
|
const i = tabs.findIndex((t) => t.id === activeId);
|
|
const last = tabs.length - 1;
|
|
const next =
|
|
e.key === "ArrowRight"
|
|
? (i + 1) % tabs.length
|
|
: e.key === "ArrowLeft"
|
|
? (i - 1 + tabs.length) % tabs.length
|
|
: e.key === "Home"
|
|
? 0
|
|
: e.key === "End"
|
|
? last
|
|
: -1;
|
|
const tab = tabs[next];
|
|
if (!tab) return;
|
|
e.preventDefault();
|
|
onSelect(tab.id);
|
|
document.getElementById(tabId(tab.id))?.focus();
|
|
};
|
|
|
|
const list = (
|
|
<div
|
|
role="tablist"
|
|
aria-label={ariaLabel}
|
|
onKeyDown={onKeyDown}
|
|
className={`flex items-center gap-1.5 overflow-x-auto no-scrollbar ${
|
|
actions ? "min-w-0 flex-1" : "border-b border-border/60 pb-2"
|
|
}`}
|
|
>
|
|
{tabs.map(({ id, label, Icon }) => {
|
|
const isActive = id === activeId;
|
|
return (
|
|
<button
|
|
key={id}
|
|
id={tabId(id)}
|
|
type="button"
|
|
role="tab"
|
|
aria-selected={isActive}
|
|
aria-controls={hasPanel && isActive ? panelId : undefined}
|
|
tabIndex={isActive ? 0 : -1}
|
|
onClick={() => onSelect(id)}
|
|
className={`inline-flex items-center gap-1.5 text-xs font-medium whitespace-nowrap px-3 py-1.5 rounded-lg border transition-colors flex-shrink-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 ${
|
|
isActive
|
|
? "bg-accent/15 border-accent/30 text-accent shadow-[inset_0_-2px_0_rgb(var(--accent))]"
|
|
: "border-border text-fg-secondary hover:text-fg-primary hover:bg-surface-3"
|
|
}`}
|
|
>
|
|
<Icon className="w-3.5 h-3.5" />
|
|
{label}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
const bar = actions ? (
|
|
<div className="flex items-center gap-3 border-b border-border/60 pb-2">
|
|
{list}
|
|
<div className="flex flex-shrink-0 items-center gap-1.5">{actions}</div>
|
|
</div>
|
|
) : (
|
|
list
|
|
);
|
|
if (!hasPanel) return bar;
|
|
return (
|
|
<>
|
|
{bar}
|
|
<div role="tabpanel" id={panelId} aria-labelledby={tabId(activeId)}>
|
|
{children}
|
|
</div>
|
|
</>
|
|
);
|
|
}
|