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:
@@ -74,26 +74,44 @@ export interface CheckboxProps {
|
||||
className?: string;
|
||||
/** Classes applied to the label `<span>` when `label` is set. */
|
||||
labelClassName?: string;
|
||||
/** `danger` / `warning` for an opt-in that makes an action riskier
|
||||
* (bypass permissions, purge data, force). */
|
||||
tone?: "accent" | "danger" | "warning";
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const TONE = {
|
||||
accent: "bg-accent border-accent",
|
||||
danger: "bg-status-danger border-status-danger",
|
||||
warning: "bg-status-warning border-status-warning",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Themed checkbox control.
|
||||
* Themed checkbox control — the app's ONE checkbox; never a native
|
||||
* `<input type="checkbox">`, which renders as the browser's white box.
|
||||
* @param props See {@link CheckboxProps}.
|
||||
*/
|
||||
export function Checkbox({ checked, onChange, label, className, labelClassName }: CheckboxProps) {
|
||||
export function Checkbox({
|
||||
checked,
|
||||
onChange,
|
||||
label,
|
||||
className,
|
||||
labelClassName,
|
||||
tone = "accent",
|
||||
disabled = false,
|
||||
}: CheckboxProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="checkbox"
|
||||
aria-checked={checked}
|
||||
disabled={disabled}
|
||||
onClick={() => onChange(!checked)}
|
||||
className={`group inline-flex items-center gap-2 text-left ${className ?? ""}`}
|
||||
className={`group inline-flex items-center gap-2 text-left disabled:cursor-not-allowed disabled:opacity-50 focus-visible:outline-none ${className ?? ""}`}
|
||||
>
|
||||
<span
|
||||
className={`flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-[5px] border transition-colors ${
|
||||
checked
|
||||
? "bg-accent border-accent"
|
||||
: "bg-surface-2 border-border group-hover:border-border-light"
|
||||
className={`flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-[5px] border transition-colors group-focus-visible:ring-2 group-focus-visible:ring-accent/40 ${
|
||||
checked ? TONE[tone] : "bg-surface-2 border-border group-hover:border-border-light"
|
||||
}`}
|
||||
>
|
||||
{checked && <Check className="h-3 w-3 text-white" strokeWidth={3} />}
|
||||
|
||||
@@ -86,6 +86,7 @@
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import { Checkbox } from "./Checkbox";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Search, X, Filter } from "lucide-react";
|
||||
@@ -376,18 +377,14 @@ function ChipGroup({
|
||||
const checked = selected.includes(opt);
|
||||
const display = labels?.[opt] ?? opt;
|
||||
return (
|
||||
<label
|
||||
<Checkbox
|
||||
key={opt}
|
||||
className="flex items-center gap-2 px-2 py-1 text-[11px] text-fg-secondary rounded hover:bg-surface-3 cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => onToggle(opt)}
|
||||
className="accent-accent"
|
||||
/>
|
||||
<span className="font-mono break-all">{display}</span>
|
||||
</label>
|
||||
checked={checked}
|
||||
onChange={() => onToggle(opt)}
|
||||
label={display}
|
||||
className="flex w-full px-2 py-1 rounded hover:bg-surface-3"
|
||||
labelClassName="font-mono break-all text-[11px] text-fg-secondary"
|
||||
/>
|
||||
);
|
||||
})
|
||||
)}
|
||||
|
||||
@@ -86,6 +86,7 @@ export function Layout({ wsConnected }: LayoutProps) {
|
||||
const { t } = useTranslation("nav");
|
||||
const [collapsed, setCollapsed] = useState(loadCollapsed);
|
||||
const [connectionOpen, setConnectionOpen] = useState(false);
|
||||
const [updateAvailable, setUpdateAvailable] = useState(false);
|
||||
const sidebarWidth = collapsed ? "4.25rem" : "15rem";
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
@@ -111,6 +112,7 @@ export function Layout({ wsConnected }: LayoutProps) {
|
||||
onToggle={toggle}
|
||||
connectionOpen={connectionOpen}
|
||||
onConnectionOpenChange={setConnectionOpen}
|
||||
onUpdateAvailableChange={setUpdateAvailable}
|
||||
/>
|
||||
<main
|
||||
id="main-content"
|
||||
@@ -132,6 +134,7 @@ export function Layout({ wsConnected }: LayoutProps) {
|
||||
wsConnected={wsConnected}
|
||||
left={sidebarWidth}
|
||||
onConnectionClick={() => setConnectionOpen(true)}
|
||||
updateAvailable={updateAvailable}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import { Checkbox } from "./Checkbox";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
@@ -544,17 +545,12 @@ export function RemoteSources() {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="accent-accent"
|
||||
checked={!!form.enabled}
|
||||
onChange={(e) => setForm((f) => ({ ...f, enabled: e.target.checked }))}
|
||||
/>
|
||||
<span className="text-sm text-fg-secondary">
|
||||
{t("remoteSources.fieldEnabled", "Sync automatically in the background")}
|
||||
</span>
|
||||
</label>
|
||||
<Checkbox
|
||||
checked={!!form.enabled}
|
||||
onChange={(v) => setForm((f) => ({ ...f, enabled: v }))}
|
||||
label={t("remoteSources.fieldEnabled", "Sync automatically in the background")}
|
||||
labelClassName="text-sm text-fg-secondary"
|
||||
/>
|
||||
{formError && (
|
||||
<div className="text-xs text-status-danger bg-status-danger/10 border border-status-danger/25 rounded-lg px-3 py-2">
|
||||
{formError}
|
||||
@@ -723,22 +719,16 @@ export function RemoteSources() {
|
||||
<p className="text-xs text-fg-secondary mb-2">
|
||||
{t("remoteSources.confirmDelete", "Remove this source?")}
|
||||
</p>
|
||||
<label className="flex items-center gap-2 mb-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="accent-red-500"
|
||||
checked={confirmDelete.purge}
|
||||
onChange={(e) =>
|
||||
setConfirmDelete((c) => c && { ...c, purge: e.target.checked })
|
||||
}
|
||||
/>
|
||||
<span className="text-xs text-fg-secondary">
|
||||
{t(
|
||||
"remoteSources.purgeData",
|
||||
"Also delete the sessions imported from this source (cannot be undone)"
|
||||
)}
|
||||
</span>
|
||||
</label>
|
||||
<Checkbox
|
||||
className="mb-3"
|
||||
tone="danger"
|
||||
checked={confirmDelete.purge}
|
||||
onChange={(v) => setConfirmDelete((c) => c && { ...c, purge: v })}
|
||||
label={t(
|
||||
"remoteSources.purgeData",
|
||||
"Also delete the sessions imported from this source (cannot be undone)"
|
||||
)}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={doDelete}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* @file StatusBar — the one-line bar along the bottom of the app shell, on
|
||||
* every page: the live-connection state (click: the connection details modal,
|
||||
* which the sidebar owns), the version, how many workspaces are running and how
|
||||
* which the sidebar owns and which also carries the update check), the version
|
||||
* — with a green dot while a newer release is out —, how many workspaces are running and how
|
||||
* many need the user (from the shared lane list, no request of its own), and
|
||||
* today's spend — the `daily_costs` bucket for the local date from
|
||||
* `GET /api/pricing/cost`, the Dashboard's own cost call, re-read every 30 s
|
||||
@@ -27,11 +28,14 @@ export function StatusBar({
|
||||
wsConnected,
|
||||
left,
|
||||
onConnectionClick,
|
||||
updateAvailable = false,
|
||||
}: {
|
||||
wsConnected: boolean;
|
||||
/** Sidebar width, so the bar starts where the main column does. */
|
||||
left: string;
|
||||
onConnectionClick: () => void;
|
||||
/** A newer release is out: a dot beside the version. */
|
||||
updateAvailable?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation("nav");
|
||||
const { lanes } = useLanes();
|
||||
@@ -73,6 +77,13 @@ export function StatusBar({
|
||||
{wsConnected ? <Wifi className="h-3 w-3" /> : <WifiOff className="h-3 w-3" />}
|
||||
{wsConnected ? t("live") : t("disconnected")}
|
||||
<span className="text-fg-muted">{`v${__APP_VERSION__}`}</span>
|
||||
{updateAvailable && (
|
||||
<span
|
||||
data-testid="status-bar-update"
|
||||
title={t("updateAvailable")}
|
||||
className="h-1.5 w-1.5 rounded-full bg-status-success"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
<span data-testid="status-bar-counts">
|
||||
{t("statusBar.running", { count: running })}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* @file Switch.tsx
|
||||
* @description The app's ONE on/off switch (`role="switch"`): an accent track
|
||||
* with a white knob, the same size everywhere. Settings rows, webhook targets
|
||||
* and the AI Tools list all draw this — do not hand-roll another one; a
|
||||
* yes/no choice inside a form or list is a `Checkbox` instead.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
export function Switch({
|
||||
checked,
|
||||
onChange,
|
||||
label,
|
||||
title,
|
||||
disabled = false,
|
||||
}: {
|
||||
checked: boolean;
|
||||
onChange: (v: boolean) => void;
|
||||
/** Accessible name — a switch usually sits beside its visible label. */
|
||||
label?: string;
|
||||
title?: string;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
aria-label={label}
|
||||
title={title}
|
||||
disabled={disabled}
|
||||
onClick={() => onChange(!checked)}
|
||||
className={`relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-50 ${
|
||||
checked ? "bg-accent" : "bg-surface-4"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className={`inline-block h-4 w-4 rounded-full bg-white shadow transition-transform ${
|
||||
checked ? "translate-x-[18px]" : "translate-x-0.5"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -58,6 +58,7 @@
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import { Switch } from "./Switch";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
@@ -132,34 +133,6 @@ function defaultsFor(provider: WebhookProvider | undefined): Record<string, stri
|
||||
return out;
|
||||
}
|
||||
|
||||
function Toggle({
|
||||
checked,
|
||||
onChange,
|
||||
label,
|
||||
}: {
|
||||
checked: boolean;
|
||||
onChange: (v: boolean) => void;
|
||||
label?: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
aria-label={label}
|
||||
onClick={() => onChange(!checked)}
|
||||
className={`relative inline-flex h-5 w-9 flex-shrink-0 items-center rounded-full transition-colors ${
|
||||
checked ? "bg-blue-600" : "bg-surface-4"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className="inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform"
|
||||
style={{ transform: checked ? "translateX(18px)" : "translateX(3px)" }}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function WebhookSettings() {
|
||||
const { t } = useTranslation("settings");
|
||||
const [targets, setTargets] = useState<WebhookTarget[]>([]);
|
||||
@@ -452,7 +425,7 @@ export function WebhookSettings() {
|
||||
{timeAgo(target.last_delivery.created_at)}
|
||||
</span>
|
||||
)}
|
||||
<Toggle
|
||||
<Switch
|
||||
checked={target.enabled}
|
||||
onChange={() => onToggle(target)}
|
||||
label={t("webhooks.enabled")}
|
||||
@@ -772,7 +745,7 @@ export function WebhookSettings() {
|
||||
|
||||
<div className="flex items-center gap-3 pt-1">
|
||||
<label className="inline-flex items-center gap-2 text-[11px] text-fg-secondary cursor-pointer">
|
||||
<Toggle checked={form.enabled} onChange={(v) => set({ enabled: v })} />
|
||||
<Switch checked={form.enabled} onChange={(v) => set({ enabled: v })} />
|
||||
{t("webhooks.enabledOnSave")}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -1,24 +1,28 @@
|
||||
/**
|
||||
* @file Sidebar.test.tsx
|
||||
* @description Unit tests for the Sidebar component, which is responsible for rendering the application's sidebar navigation. The tests cover rendering of the brand name, subtitle, navigation links, WebSocket connection status, and version number. The tests use React Testing Library and Vitest for assertions and mocking.
|
||||
* @description Unit tests for the Sidebar: the brand, the navigation links, the small collapse button in the brand's corner, no footer / language / theme controls (those moved to the status bar and Settings), and the connection details modal carrying the version and the release check.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { act, render, screen, waitFor } from "@testing-library/react";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { act, render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { Sidebar } from "../Sidebar";
|
||||
import { eventBus } from "../../lib/eventBus";
|
||||
|
||||
function renderSidebar(wsConnected: boolean, collapsed = false) {
|
||||
function renderSidebar(
|
||||
wsConnected: boolean,
|
||||
collapsed = false,
|
||||
extra: { onToggle?: () => void; connectionOpen?: boolean } = {}
|
||||
) {
|
||||
return render(
|
||||
<MemoryRouter>
|
||||
<Sidebar
|
||||
wsConnected={wsConnected}
|
||||
collapsed={collapsed}
|
||||
onToggle={() => {}}
|
||||
connectionOpen={false}
|
||||
onToggle={extra.onToggle ?? (() => {})}
|
||||
connectionOpen={extra.connectionOpen ?? false}
|
||||
onConnectionOpenChange={() => {}}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
@@ -51,17 +55,29 @@ describe("Sidebar", () => {
|
||||
expect(screen.queryByText("Activity Feed")).toBeNull();
|
||||
});
|
||||
|
||||
// The live/version line moved to the status bar (StatusBar.test.tsx); the
|
||||
// rail keeps an icon-only button for the connection details.
|
||||
it("keeps the connection button in the rail only", () => {
|
||||
renderSidebar(true);
|
||||
expect(screen.queryByText("Live")).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "Connection details" })).toBeNull();
|
||||
// The live/version line lives in the status bar and the update check in the
|
||||
// connection details modal; language and theme live in Settings.
|
||||
it("has no footer, language or theme controls", () => {
|
||||
for (const collapsed of [false, true]) {
|
||||
const { unmount } = renderSidebar(true, collapsed);
|
||||
expect(screen.queryByText("Live")).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "Connection details" })).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "English" })).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "Dark" })).toBeNull();
|
||||
expect(screen.queryByText("Up to date")).toBeNull();
|
||||
unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it("offers the connection details from the collapsed rail", () => {
|
||||
renderSidebar(false, true);
|
||||
expect(screen.getByRole("button", { name: "Connection details" })).toBeInTheDocument();
|
||||
it("collapses and expands from a small button in the brand's corner", async () => {
|
||||
const onToggle = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
const { unmount } = renderSidebar(true, false, { onToggle });
|
||||
await user.click(screen.getByRole("button", { name: "Collapse sidebar" }));
|
||||
unmount();
|
||||
renderSidebar(true, true, { onToggle });
|
||||
await user.click(screen.getByRole("button", { name: "Expand sidebar" }));
|
||||
expect(onToggle).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("renders the workspace list when expanded and its icon in the rail", () => {
|
||||
@@ -81,39 +97,7 @@ describe("Sidebar", () => {
|
||||
expect(hrefs).not.toContain("/activity");
|
||||
});
|
||||
|
||||
it("should render both language options in expanded mode", () => {
|
||||
renderSidebar(true);
|
||||
expect(screen.getByRole("button", { name: "English" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Vietnamese" })).toBeInTheDocument();
|
||||
// Chinese and Korean were dropped; offering them would render raw keys.
|
||||
expect(screen.queryByRole("button", { name: "Chinese" })).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "Korean" })).toBeNull();
|
||||
});
|
||||
|
||||
it("should switch to Vietnamese when Vietnamese option is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderSidebar(true);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Vietnamese" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Tổng quan")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should cycle language in collapsed mode", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderSidebar(true, true);
|
||||
|
||||
expect(screen.getByText("EN")).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "Switch to Vietnamese" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("VI")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("update status line", () => {
|
||||
describe("update status line, in the connection details modal", () => {
|
||||
const plugin = {
|
||||
git_repo: false,
|
||||
install_kind: "plugin",
|
||||
@@ -124,32 +108,36 @@ describe("Sidebar", () => {
|
||||
act(() => eventBus.publish({ type: "update_status", data } as never));
|
||||
|
||||
it("does not claim up to date when a plugin install could not read the release", () => {
|
||||
renderSidebar(true);
|
||||
renderSidebar(true, false, { connectionOpen: true });
|
||||
publish({ ...plugin, update_available: false, release_check: "unreachable" });
|
||||
expect(screen.queryByText("Up to date")).toBeNull();
|
||||
expect(screen.getByText("Could not check for updates")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("update-status")).toHaveTextContent("Could not check for updates");
|
||||
});
|
||||
|
||||
it("names a build installed beside the running one", () => {
|
||||
renderSidebar(true);
|
||||
renderSidebar(true, false, { connectionOpen: true });
|
||||
publish({
|
||||
...plugin,
|
||||
update_available: true,
|
||||
release_check: "unreachable",
|
||||
installed_not_running: { root: "/x", version: "1.8.12" },
|
||||
});
|
||||
expect(screen.getByText("1.8.12 installed, not running")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("update-status")).toHaveTextContent(
|
||||
"1.8.12 installed, not running"
|
||||
);
|
||||
});
|
||||
|
||||
it("still says up to date when the release was read and matches", () => {
|
||||
renderSidebar(true);
|
||||
renderSidebar(true, false, { connectionOpen: true });
|
||||
publish({
|
||||
...plugin,
|
||||
update_available: false,
|
||||
release_check: "ok",
|
||||
latest_release: "v1.8.10",
|
||||
});
|
||||
expect(screen.getByText("Up to date")).toBeInTheDocument();
|
||||
const dialog = screen.getByRole("dialog");
|
||||
expect(within(dialog).getByTestId("update-status")).toHaveTextContent("Up to date");
|
||||
expect(within(dialog).getByRole("button", { name: "Check for updates" })).toBeEnabled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
/**
|
||||
* @file Sidebar.theme.test.tsx
|
||||
* @description Tests for the Sidebar's theme controls: the expanded panel's Dark / Light / System buttons and the collapsed one-click toggle, both writing through useTheme.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { Sidebar } from "../Sidebar";
|
||||
|
||||
function renderSidebar(collapsed = false) {
|
||||
return render(
|
||||
<MemoryRouter>
|
||||
<Sidebar
|
||||
wsConnected
|
||||
collapsed={collapsed}
|
||||
onToggle={() => {}}
|
||||
connectionOpen={false}
|
||||
onConnectionOpenChange={() => {}}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
);
|
||||
}
|
||||
|
||||
describe("Sidebar theme controls", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
document.documentElement.classList.remove("dark");
|
||||
});
|
||||
|
||||
it("expanded panel offers Dark, Light and System with Dark pressed by default", () => {
|
||||
renderSidebar();
|
||||
expect(screen.getByRole("button", { name: "Dark" })).toHaveAttribute("aria-pressed", "true");
|
||||
expect(screen.getByRole("button", { name: "Light" })).toHaveAttribute("aria-pressed", "false");
|
||||
expect(screen.getByRole("button", { name: "System" })).toHaveAttribute("aria-pressed", "false");
|
||||
});
|
||||
|
||||
it("clicking System sets the mode and presses only System", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderSidebar();
|
||||
await user.click(screen.getByRole("button", { name: "System" }));
|
||||
expect(localStorage.getItem("theme")).toBe("system");
|
||||
expect(screen.getByRole("button", { name: "System" })).toHaveAttribute("aria-pressed", "true");
|
||||
expect(screen.getByRole("button", { name: "Dark" })).toHaveAttribute("aria-pressed", "false");
|
||||
});
|
||||
|
||||
it("clicking Light applies light mode to <html>", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderSidebar();
|
||||
await user.click(screen.getByRole("button", { name: "Light" }));
|
||||
expect(localStorage.getItem("theme")).toBe("light");
|
||||
expect(document.documentElement.classList.contains("dark")).toBe(false);
|
||||
});
|
||||
|
||||
it("collapsed toggle flips to the opposite of the resolved mode", async () => {
|
||||
const user = userEvent.setup();
|
||||
localStorage.setItem("theme", "light");
|
||||
renderSidebar(true);
|
||||
await user.click(screen.getByRole("button", { name: "Switch to Dark" }));
|
||||
expect(localStorage.getItem("theme")).toBe("dark");
|
||||
expect(screen.getByRole("button", { name: "Switch to Light" })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,9 @@
|
||||
* @file WorkspaceTree.test.tsx
|
||||
* @description Tests for the sidebar's workspace list: one flat row per
|
||||
* workspace in the stored order, a row expanding to its sessions (remembered),
|
||||
* a live session opening its pane and an ended one its transcript, selection
|
||||
* a live session opening its pane and an ended one its transcript, the
|
||||
* workspace's OWN sessions only, a session's state dot, rename and archive,
|
||||
* selection
|
||||
* by URL, Alt+arrow reordering kept in localStorage, double-click rename, the
|
||||
* search, the row menu's items by kind and tool, and the status dot.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
@@ -57,6 +59,7 @@ vi.mock("../../lib/api", async (importOriginal) => {
|
||||
list: vi.fn(async () => ({
|
||||
sessions: [session("s-live", "Live one"), session("s-old", "Old one")],
|
||||
})),
|
||||
patch: vi.fn(async () => ({ session: {} })),
|
||||
},
|
||||
run: {
|
||||
list: vi.fn(async () => ({
|
||||
@@ -105,6 +108,12 @@ const rowIds = () => screen.getAllByTestId(/^workspace-row-\d+$/).map((el) => el
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(api.sessions.list).mockImplementation(async () => ({
|
||||
sessions: [session("s-live", "Live one"), session("s-old", "Old one")],
|
||||
total: 2,
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
}));
|
||||
lanes = [
|
||||
lane(1, "/r", { title: "Repo" }),
|
||||
lane(2, "/r/sub", { title: "Sub" }),
|
||||
@@ -128,12 +137,50 @@ describe("WorkspaceTree", () => {
|
||||
await user.click(await screen.findByTestId("workspace-toggle-1"));
|
||||
const list = await screen.findByTestId("workspace-sessions-1");
|
||||
expect(within(list).getByText("Live one")).toBeInTheDocument();
|
||||
expect(api.sessions.list).toHaveBeenCalledWith({ cwd: "/r", tool: "claude", limit: 20 });
|
||||
// Its OWN sessions only — never the rest of its folder.
|
||||
expect(api.sessions.list).toHaveBeenCalledTimes(1);
|
||||
expect(api.sessions.list).toHaveBeenCalledWith({ lane: 1, archived: false, limit: 20 });
|
||||
expect(JSON.parse(localStorage.getItem("agenthub.sidebar.projects")!).expanded).toEqual({
|
||||
"1": true,
|
||||
});
|
||||
});
|
||||
|
||||
it("renames a session on double-click + Enter, and archives one from its row", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderTree();
|
||||
await user.click(await screen.findByTestId("workspace-toggle-1"));
|
||||
await user.dblClick(await screen.findByTestId("workspace-session-s-old"));
|
||||
const input = screen.getByTestId("workspace-session-rename-s-old");
|
||||
await user.clear(input);
|
||||
await user.type(input, "Better name{Enter}");
|
||||
expect(api.sessions.patch).toHaveBeenCalledWith("s-old", { name: "Better name" });
|
||||
await user.click(screen.getByTestId("workspace-session-archive-s-live"));
|
||||
expect(api.sessions.patch).toHaveBeenCalledWith("s-live", { archived: true });
|
||||
});
|
||||
|
||||
it("dots a session waiting for the user amber and a failed one red", async () => {
|
||||
vi.mocked(api.sessions.list).mockImplementation(async () => ({
|
||||
sessions: [
|
||||
{
|
||||
...session("s-wait", "Waiting"),
|
||||
status: "active",
|
||||
awaiting_input_since: "2026-09-30T10:00:00Z",
|
||||
} as Session,
|
||||
{ ...session("s-bad", "Broke"), status: "error" } as Session,
|
||||
],
|
||||
total: 2,
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
}));
|
||||
const user = userEvent.setup();
|
||||
renderTree();
|
||||
await user.click(await screen.findByTestId("workspace-toggle-1"));
|
||||
expect(await screen.findByTestId("workspace-session-dot-s-wait")).toHaveClass(
|
||||
"bg-status-warning"
|
||||
);
|
||||
expect(screen.getByTestId("workspace-session-dot-s-bad")).toHaveClass("bg-status-danger");
|
||||
});
|
||||
|
||||
it("opens a live session's pane and an ended session's transcript, resuming nothing", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderTree();
|
||||
@@ -142,6 +189,11 @@ describe("WorkspaceTree", () => {
|
||||
await waitFor(() => expect(live).toHaveAttribute("data-live", "true"));
|
||||
await user.click(live);
|
||||
expect(screen.getByTestId("where").textContent).toBe("/workspace?lane=1&run=agenthub-lane-1-2");
|
||||
// Ctrl+click asks for a second pane.
|
||||
fireEvent.click(live, { ctrlKey: true });
|
||||
expect(screen.getByTestId("where").textContent).toBe(
|
||||
"/workspace?lane=1&run=agenthub-lane-1-2&pane=1"
|
||||
);
|
||||
await user.click(screen.getByTestId("workspace-session-s-old"));
|
||||
expect(screen.getByTestId("where").textContent).toBe("/sessions/s-old");
|
||||
expect(api.lanes.action).not.toHaveBeenCalled();
|
||||
@@ -225,7 +277,7 @@ describe("WorkspaceTree", () => {
|
||||
it("finds a workspace by one of its session names, showing only the matches", async () => {
|
||||
vi.mocked(api.sessions.list).mockImplementation(async (params) => ({
|
||||
sessions:
|
||||
(params as { cwd?: string }).cwd === "/r/sub"
|
||||
(params as { lane?: number }).lane === 2
|
||||
? [session("s-x", "Fix login bug"), session("s-y", "Docs")]
|
||||
: [session("s-live", "Live one"), session("s-old", "Old one")],
|
||||
total: 2,
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { Checkbox } from "../Checkbox";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ConfirmModal } from "../ConfirmModal";
|
||||
@@ -210,15 +211,14 @@ export function DestructiveLaneModal({
|
||||
</p>
|
||||
))}
|
||||
{requiresForce && (
|
||||
<label className="mt-3 flex items-start gap-2 text-xs text-status-warning">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={force}
|
||||
onChange={(event) => setForce(event.target.checked)}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
{t("destructive.force")}
|
||||
</label>
|
||||
<Checkbox
|
||||
className="mt-3 items-start"
|
||||
tone="warning"
|
||||
checked={force}
|
||||
onChange={setForce}
|
||||
label={t("destructive.force")}
|
||||
labelClassName="text-xs text-status-warning"
|
||||
/>
|
||||
)}
|
||||
{action === "reset" && (
|
||||
<p className="mt-3 text-xs text-fg-secondary">{t("destructive.reset.survives")}</p>
|
||||
|
||||
@@ -29,6 +29,10 @@
|
||||
* file is conflicted) and **Abort merge** (two clicks, `confirm: true`), and
|
||||
* conflicted files get their own group with Keep mine / Take theirs / Mark
|
||||
* resolved instead of stage — staging one blind would mark it resolved.
|
||||
*
|
||||
* A diff line's number opens a comment box (LaneDiffView `onComment`); the
|
||||
* comment is pasted, as `<path>:<line> — <text>`, into the workspace's pane
|
||||
* focused last (`lib/panePaste.ts`) and sent only when the user presses Enter.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -56,6 +60,7 @@ import {
|
||||
UnfoldVertical,
|
||||
} from "lucide-react";
|
||||
import { api } from "../../lib/api";
|
||||
import { pasteToLane } from "../../lib/panePaste";
|
||||
import { LaneDiffView, LaneFileView, type DiffLayout, type LaneDiffChange } from "./LaneDiffView";
|
||||
import { LaneConflictView } from "./LaneConflictView";
|
||||
import { MarkdownPreview, isMarkdown } from "../MarkdownPreview";
|
||||
@@ -436,6 +441,8 @@ export function LaneChangesPanel({
|
||||
// One read of the working copy, shared by the header and every write below:
|
||||
// a stage, a discard or a commit changes the counts the header shows.
|
||||
const [git, refreshGit] = useLaneGitFacts(laneId);
|
||||
// A diff line's comment goes into this workspace's pane, as a paste.
|
||||
const comment = useCallback((text: string) => pasteToLane(laneId, text), [laneId]);
|
||||
// Bumped by Refresh and by a git bar action: a pull or a switch moves
|
||||
// history and the tree too, not just the diff `load` re-reads.
|
||||
const [reread, setReread] = useState(0);
|
||||
@@ -1558,6 +1565,7 @@ export function LaneChangesPanel({
|
||||
layout={layout}
|
||||
fill
|
||||
hunkActions={whole ? undefined : hunkActions(current)}
|
||||
onComment={comment}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -1637,7 +1645,12 @@ export function LaneChangesPanel({
|
||||
)}
|
||||
</div>
|
||||
{!shut && (
|
||||
<LaneDiffView file={f} layout={layout} hunkActions={hunkActions(f)} />
|
||||
<LaneDiffView
|
||||
file={f}
|
||||
layout={layout}
|
||||
hunkActions={hunkActions(f)}
|
||||
onComment={comment}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -262,7 +262,28 @@ export function analyseHunk(hunk: LaneDiffHunk): Analysed {
|
||||
return out;
|
||||
}
|
||||
|
||||
function Gutter({ n }: { n: number | null }) {
|
||||
/** Set by a LaneDiffView given `onComment`: a click on a NEW line's number
|
||||
* opens the comment box for that line. */
|
||||
const CommentLine = createContext<{
|
||||
open: (line: number) => void;
|
||||
title: (line: number) => string;
|
||||
} | null>(null);
|
||||
|
||||
function Gutter({ n, commentable = false }: { n: number | null; commentable?: boolean }) {
|
||||
const comment = useContext(CommentLine);
|
||||
if (commentable && comment && n != null) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`diff-comment-line-${n}`}
|
||||
onClick={() => comment.open(n)}
|
||||
title={comment.title(n)}
|
||||
className="w-10 shrink-0 select-none px-1 text-right text-fg-muted/60 hover:bg-accent/20 hover:text-accent"
|
||||
>
|
||||
{n}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="w-10 shrink-0 select-none px-1 text-right text-fg-muted/60">{n ?? ""}</span>
|
||||
);
|
||||
@@ -462,7 +483,7 @@ function UnifiedLine({
|
||||
return (
|
||||
<Row tint={rowTint(line.type)} style={style} anchor={anchor}>
|
||||
<Gutter n={line.oldLine} />
|
||||
<Gutter n={line.newLine} />
|
||||
<Gutter n={line.newLine} commentable />
|
||||
<span className="whitespace-pre px-2">
|
||||
<span className={`select-none ${markerClass(line.type)}`}>
|
||||
{line.type === "add" ? "+" : line.type === "del" ? "-" : " "}
|
||||
@@ -534,7 +555,10 @@ function SplitLine({
|
||||
const anchor = useAnchor(start);
|
||||
return (
|
||||
<Row tint={rowTint(line?.type ?? null)} style={style} anchor={anchor}>
|
||||
<Gutter n={(side === "left" ? line?.oldLine : line?.newLine) ?? null} />
|
||||
<Gutter
|
||||
n={(side === "left" ? line?.oldLine : line?.newLine) ?? null}
|
||||
commentable={side === "right"}
|
||||
/>
|
||||
<span className="whitespace-pre px-1">
|
||||
{line ? (
|
||||
<Code
|
||||
@@ -700,6 +724,7 @@ export function LaneDiffView({
|
||||
layout,
|
||||
fill = false,
|
||||
hunkActions,
|
||||
onComment,
|
||||
}: {
|
||||
file: LaneChangeFile;
|
||||
layout: DiffLayout;
|
||||
@@ -707,9 +732,35 @@ export function LaneDiffView({
|
||||
fill?: boolean;
|
||||
/** Controls drawn in each change block's review bar. */
|
||||
hunkActions?: (change: LaneDiffChange) => ReactNode;
|
||||
/** Makes each new line's number a button that writes a comment on that line
|
||||
* and hands `<path>:<line> — <text>` here; false = it reached no pane. */
|
||||
onComment?: (text: string) => boolean;
|
||||
}) {
|
||||
const root = useRef<HTMLDivElement>(null);
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const [commentAt, setCommentAt] = useState<number | null>(null);
|
||||
const [draft, setDraft] = useState("");
|
||||
const [undelivered, setUndelivered] = useState(false);
|
||||
const openComment = useMemo(
|
||||
() =>
|
||||
onComment
|
||||
? {
|
||||
open: (line: number) => {
|
||||
setCommentAt(line);
|
||||
setUndelivered(false);
|
||||
},
|
||||
title: (line: number) => t("changes.commentLine", { n: line }),
|
||||
}
|
||||
: null,
|
||||
[onComment, t]
|
||||
);
|
||||
const sendComment = () => {
|
||||
const text = draft.trim();
|
||||
if (!text || commentAt == null || !onComment) return;
|
||||
if (!onComment(`${file.path}:${commentAt} — ${text}`)) return setUndelivered(true);
|
||||
setCommentAt(null);
|
||||
setDraft("");
|
||||
};
|
||||
const lang = langFor(file.path);
|
||||
const hunks = file.hunks;
|
||||
const items = useMemo(() => (hunks ? flatten(hunks, "unified") : []), [hunks]);
|
||||
@@ -755,50 +806,96 @@ export function LaneDiffView({
|
||||
return <p className="px-2 py-1 text-[10px] text-fg-muted">{t("changes.noHunks")}</p>;
|
||||
|
||||
return (
|
||||
<HunkActions.Provider value={review}>
|
||||
<div
|
||||
ref={root}
|
||||
data-testid="lane-diff-view"
|
||||
data-layout={layout}
|
||||
onScroll={(e) => win.onScroll(e.currentTarget)}
|
||||
className={`flex flex-col font-mono text-[11px] leading-[1.45] ${
|
||||
layout === "split"
|
||||
? boxHeight(fill, splitWindowed)
|
||||
: `overflow-auto ${boxHeight(fill, win.on)}`
|
||||
}`}
|
||||
>
|
||||
{layout === "split" ? (
|
||||
<SplitView hunks={file.hunks} lang={lang} fill={fill} />
|
||||
) : win.on ? (
|
||||
<Windowed count={items.length} range={win.range} minWidth={minWidth}>
|
||||
{items
|
||||
.slice(win.range.start, win.range.end)
|
||||
.map(({ hunk, i }, k) =>
|
||||
i < 0 ? (
|
||||
<HunkHeader key={win.range.start + k} hunk={hunk} fixed />
|
||||
) : (
|
||||
<UnifiedLine
|
||||
key={win.range.start + k}
|
||||
line={hunk.lines[i]!}
|
||||
lang={lang}
|
||||
emph={analyseHunk(hunk).emph.get(i)}
|
||||
style={FIXED}
|
||||
bar={barAtLine(hunk, i)}
|
||||
start={startAtLine(hunk, i)}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</Windowed>
|
||||
) : (
|
||||
file.hunks.map((hunk, i) => (
|
||||
<UnifiedHunk key={`${hunk.header}:${i}`} hunk={hunk} lang={lang} />
|
||||
))
|
||||
<CommentLine.Provider value={openComment}>
|
||||
<HunkActions.Provider value={review}>
|
||||
<div
|
||||
ref={root}
|
||||
data-testid="lane-diff-view"
|
||||
data-layout={layout}
|
||||
onScroll={(e) => win.onScroll(e.currentTarget)}
|
||||
className={`flex flex-col font-mono text-[11px] leading-[1.45] ${
|
||||
layout === "split"
|
||||
? boxHeight(fill, splitWindowed)
|
||||
: `overflow-auto ${boxHeight(fill, win.on)}`
|
||||
}`}
|
||||
>
|
||||
{layout === "split" ? (
|
||||
<SplitView hunks={file.hunks} lang={lang} fill={fill} />
|
||||
) : win.on ? (
|
||||
<Windowed count={items.length} range={win.range} minWidth={minWidth}>
|
||||
{items
|
||||
.slice(win.range.start, win.range.end)
|
||||
.map(({ hunk, i }, k) =>
|
||||
i < 0 ? (
|
||||
<HunkHeader key={win.range.start + k} hunk={hunk} fixed />
|
||||
) : (
|
||||
<UnifiedLine
|
||||
key={win.range.start + k}
|
||||
line={hunk.lines[i]!}
|
||||
lang={lang}
|
||||
emph={analyseHunk(hunk).emph.get(i)}
|
||||
style={FIXED}
|
||||
bar={barAtLine(hunk, i)}
|
||||
start={startAtLine(hunk, i)}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</Windowed>
|
||||
) : (
|
||||
file.hunks.map((hunk, i) => (
|
||||
<UnifiedHunk key={`${hunk.header}:${i}`} hunk={hunk} lang={lang} />
|
||||
))
|
||||
)}
|
||||
{file.truncated && (
|
||||
<p className="px-2 py-1 text-[10px] text-status-warning">
|
||||
{t("changes.fileTruncated")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{commentAt != null && (
|
||||
<form
|
||||
data-testid="diff-comment-box"
|
||||
className="flex flex-col gap-1 border-t border-border bg-surface-2 p-2"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
sendComment();
|
||||
}}
|
||||
>
|
||||
<label className="text-[11px] text-fg-muted">
|
||||
{t("changes.commentOn", { at: `${file.path}:${commentAt}` })}
|
||||
<textarea
|
||||
autoFocus
|
||||
rows={2}
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") setCommentAt(null);
|
||||
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) sendComment();
|
||||
}}
|
||||
className="mt-1 block w-full rounded border border-border bg-surface-1 px-2 py-1 font-sans text-xs text-fg-primary"
|
||||
/>
|
||||
</label>
|
||||
{undelivered && (
|
||||
<p role="alert" className="text-[11px] text-status-danger">
|
||||
{t("changes.commentNoPane")}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-ghost text-xs"
|
||||
onClick={() => setCommentAt(null)}
|
||||
>
|
||||
{t("changes.commentCancel")}
|
||||
</button>
|
||||
<button type="submit" className="btn-primary text-xs" disabled={!draft.trim()}>
|
||||
{t("changes.commentSend")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
{file.truncated && (
|
||||
<p className="px-2 py-1 text-[10px] text-status-warning">{t("changes.fileTruncated")}</p>
|
||||
)}
|
||||
</div>
|
||||
</HunkActions.Provider>
|
||||
</HunkActions.Provider>
|
||||
</CommentLine.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { Checkbox } from "../Checkbox";
|
||||
import { useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
@@ -516,14 +517,7 @@ export function LaneGitBar({
|
||||
className="min-w-0 flex-1 rounded border border-border-light bg-surface-0 px-2 py-0.5 text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-1.5 text-fg-secondary">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={remember}
|
||||
onChange={(e) => setRemember(e.target.checked)}
|
||||
/>
|
||||
{t("gitBar.loginRemember")}
|
||||
</label>
|
||||
<Checkbox checked={remember} onChange={setRemember} label={t("gitBar.loginRemember")} />
|
||||
<p className="text-[10px] text-fg-muted">{t("gitBar.loginTokenHint")}</p>
|
||||
{/* The token crosses the network in the clear on a plain-http LAN address. */}
|
||||
{location.protocol === "http:" && !LOCAL_HOST.test(location.hostname) && (
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
/**
|
||||
* @file SessionResumeButton.tsx
|
||||
* @description The transcript page's explicit Resume for a Claude session
|
||||
* inside a workspace. Opening a session never resumes it; this is one of the
|
||||
* two places that do (the session view's Sessions tab is the other), behind a
|
||||
* warning, because a resume re-reads the whole transcript into context. A
|
||||
* @description The transcript page's explicit Resume and Fork for a Claude
|
||||
* session inside a workspace. Opening a session never resumes it; this is the
|
||||
* one place that does, behind a warning, because a resume re-reads the whole
|
||||
* transcript into context. Fork resumes it into a NEW session id
|
||||
* (`forkSession: true` → `claude --resume <id> --fork-session`), leaving the
|
||||
* original untouched, and is offered even while the original has a live pane. A
|
||||
* session that still has a live pane offers that pane instead. A resume
|
||||
* starts in the workspace's first free pane slot (`POST /api/lanes/:id/start
|
||||
* {slot, resumeSessionId}`, panes re-read first) and opens it — offered only
|
||||
@@ -15,7 +17,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { History, SquareTerminal } from "lucide-react";
|
||||
import { GitFork, History, SquareTerminal } from "lucide-react";
|
||||
import { api, type RunHandle } from "../../lib/api";
|
||||
import type { Lane } from "../../lib/types";
|
||||
import { useLanes } from "../../hooks/useLanes";
|
||||
@@ -45,7 +47,7 @@ export function SessionResumeButton({
|
||||
const navigate = useNavigate();
|
||||
const { lanes } = useLanes();
|
||||
const [runs, setRuns] = useState<RunHandle[] | null>(null);
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const [confirming, setConfirming] = useState<"resume" | "fork" | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -60,7 +62,7 @@ export function SessionResumeButton({
|
||||
};
|
||||
}, [session.id]);
|
||||
|
||||
// Claude only, like the Sessions tab: a Codex session id is not what
|
||||
// Claude only: a Codex session id is not what
|
||||
// `codex resume` takes for every kind of thread.
|
||||
const tool = session.tool || "claude";
|
||||
const lane = tool === "claude" && session.cwd ? owningLane(session.cwd, tool, lanes) : null;
|
||||
@@ -72,7 +74,7 @@ export function SessionResumeButton({
|
||||
const open = (runId: string) =>
|
||||
navigate(`/workspace?lane=${lane.id}&run=${encodeURIComponent(runId)}`);
|
||||
|
||||
const resume = async () => {
|
||||
const resume = async (fork: boolean) => {
|
||||
// Busy first: the Confirm button is disabled only through it, and the
|
||||
// re-read below is a round trip a second click could land in.
|
||||
setBusy(true);
|
||||
@@ -86,19 +88,23 @@ export function SessionResumeButton({
|
||||
.catch(() => runs);
|
||||
setRuns(fresh);
|
||||
const live = liveRunFor(lane, fresh, session.id);
|
||||
if (live) return open(live.id);
|
||||
if (live && !fork) return open(live.id);
|
||||
const slot = freeSlot(lane.id, fresh);
|
||||
if (slot === null) {
|
||||
setError(t("home.allBusy"));
|
||||
return;
|
||||
}
|
||||
const r = await api.lanes.action(lane.id, "start", { slot, resumeSessionId: session.id });
|
||||
const r = await api.lanes.action(lane.id, "start", {
|
||||
slot,
|
||||
resumeSessionId: session.id,
|
||||
...(fork ? { forkSession: true } : {}),
|
||||
});
|
||||
open(r.run_id ?? paneRunId(lane.id, slot));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
setConfirming(false);
|
||||
setConfirming(null);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -112,7 +118,7 @@ export function SessionResumeButton({
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirming(true)}
|
||||
onClick={() => setConfirming("resume")}
|
||||
title={t("resume.hint")}
|
||||
className="btn-ghost text-xs"
|
||||
>
|
||||
@@ -120,21 +126,34 @@ export function SessionResumeButton({
|
||||
{t("sessions.resume")}
|
||||
</button>
|
||||
)}
|
||||
{resumable && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirming("fork")}
|
||||
title={t("resume.forkHint")}
|
||||
className="btn-ghost text-xs"
|
||||
>
|
||||
<GitFork className="h-4 w-4" />
|
||||
{t("resume.fork")}
|
||||
</button>
|
||||
)}
|
||||
{error && (
|
||||
<p role="alert" className="max-w-xs text-right text-[11px] text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<ConfirmModal
|
||||
open={confirming}
|
||||
title={t("resume.title")}
|
||||
message={t("resume.body", { workspace: lane.title || lane.cwd })}
|
||||
confirmLabel={t("resume.confirm")}
|
||||
open={confirming !== null}
|
||||
title={t(confirming === "fork" ? "resume.forkTitle" : "resume.title")}
|
||||
message={t(confirming === "fork" ? "resume.forkBody" : "resume.body", {
|
||||
workspace: lane.title || lane.cwd,
|
||||
})}
|
||||
confirmLabel={t(confirming === "fork" ? "resume.forkConfirm" : "resume.confirm")}
|
||||
cancelLabel={t("home.cancel")}
|
||||
destructive={false}
|
||||
busy={busy}
|
||||
onConfirm={() => void resume()}
|
||||
onCancel={() => setConfirming(false)}
|
||||
onConfirm={() => void resume(confirming === "fork")}
|
||||
onCancel={() => setConfirming(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
* @file WorkspaceHome — what `/workspace?lane=<id>` opens on, shaped like a
|
||||
* Claude Desktop project page: the name, a prompt box that starts a NEW
|
||||
* session in the first free pane slot (0–3, `POST /api/lanes/:id/start`) and
|
||||
* then opens it (`&run=<runId>`), the workspace's Recents (a live session
|
||||
* opens its pane, any other its transcript — nothing is resumed), and on the
|
||||
* right its context: Instructions, Memory (read-only), Context files and
|
||||
* then opens it (`&run=<runId>`), the workspace's Recents — the sessions it
|
||||
* owns and has not archived (`?lane=&archived=0`, with each one's state dot),
|
||||
* then the archived ones folded with Unarchive; a live session opens its pane,
|
||||
* any other its transcript, nothing is resumed — and on the right its context: Instructions, Memory (read-only), Context files and
|
||||
* Folders (`GET/PUT/POST/DELETE /api/lanes/:id/sources…`, see
|
||||
* server/lib/lane-sources.js). Folder suggestions are the repo's untracked
|
||||
* worktrees from `GET /api/projects`, filtered by Settings → Worktrees.
|
||||
@@ -14,7 +15,16 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { FileText, Folder, MoreHorizontal, Pencil, Play, Plus, X } from "lucide-react";
|
||||
import {
|
||||
ArchiveRestore,
|
||||
FileText,
|
||||
Folder,
|
||||
MoreHorizontal,
|
||||
Pencil,
|
||||
Play,
|
||||
Plus,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
api,
|
||||
type LaneMemoryFile,
|
||||
@@ -31,7 +41,7 @@ import { Select } from "../Select";
|
||||
import { MarkdownPreview } from "../MarkdownPreview";
|
||||
import { WorkspaceRowMenu, type WorkspaceMenuAction } from "./WorkspaceRowMenu";
|
||||
import { workspaceName } from "./WorkspaceRow";
|
||||
import { liveRunFor } from "./WorkspaceSessionList";
|
||||
import { liveRunFor, SessionFold, sessionDot, useSessionPage } from "./WorkspaceSessionList";
|
||||
|
||||
/** The tmux session a pane slot of a lane owns (server/lib/pty-run.js). */
|
||||
export function paneRunId(laneId: number, slot: number): string {
|
||||
@@ -103,6 +113,8 @@ function HomeBody({
|
||||
|
||||
// A new pane, a pane gone, a session id learned — all move these lane fields.
|
||||
const runKey = `${lane.status}:${lane.session_id}:${lane.run_id}`;
|
||||
// Bumped after a session is unarchived, so the lists re-read.
|
||||
const [version, setVersion] = useState(0);
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
api.run
|
||||
@@ -110,13 +122,17 @@ function HomeBody({
|
||||
.then((r) => live && setLiveRuns(r.items))
|
||||
.catch(() => undefined);
|
||||
api.sessions
|
||||
.list({ cwd: lane.cwd, tool: lane.tool, limit: 30 })
|
||||
.list({ lane: lane.id, archived: false, limit: 30 })
|
||||
.then((r) => live && setSessions(r.sessions))
|
||||
.catch(() => live && setSessions([]));
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [lane.id, lane.cwd, lane.tool, runKey]);
|
||||
}, [lane.id, runKey, version]);
|
||||
const archived = useSessionPage(
|
||||
{ lane: lane.id, archived: true, limit: 30 },
|
||||
`${runKey}:${version}`
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
@@ -362,11 +378,7 @@ function HomeBody({
|
||||
onClick={() => openSession(s)}
|
||||
className="flex w-full items-center gap-3 py-2.5 text-left hover:bg-surface-3"
|
||||
>
|
||||
<span
|
||||
className={`h-2 w-2 shrink-0 rounded-full ${
|
||||
live ? "bg-status-success" : "border border-fg-muted"
|
||||
}`}
|
||||
/>
|
||||
<span className={`h-2 w-2 shrink-0 rounded-full ${sessionDot(s, live)}`} />
|
||||
<span className="min-w-0 flex-1 truncate text-sm text-fg-primary">
|
||||
{s.name || s.id.slice(0, 8)}
|
||||
</span>
|
||||
@@ -383,6 +395,17 @@ function HomeBody({
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<div className="mt-2">
|
||||
<SessionFold
|
||||
testId={`workspace-archived-${lane.id}`}
|
||||
page={archived}
|
||||
label={(count) => t("tree.archived", { count })}
|
||||
actionLabel={t("tree.unarchive")}
|
||||
actionIcon={<ArchiveRestore className="h-3.5 w-3.5" />}
|
||||
onAction={(s) => api.sessions.patch(s.id, { archived: false })}
|
||||
onDone={() => setVersion((v) => v + 1)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside data-testid="workspace-context" className="card w-80 shrink-0 self-start p-0">
|
||||
|
||||
@@ -1,19 +1,26 @@
|
||||
/**
|
||||
* @file WorkspaceSessionList — the sessions under an expanded workspace in the
|
||||
* sidebar (`GET /api/sessions?cwd=&tool=`, newest first). A session with a LIVE
|
||||
* pane (a `GET /api/run` handle carrying its session id) opens that tmux pane
|
||||
* on the Workspace page (`?lane=<id>&run=<runId>`) — attaching, never starting
|
||||
* or resuming anything; any other session opens its read-only transcript
|
||||
* (`/sessions/:id`), where Resume is a separate, explicit action, because a
|
||||
* resume re-reads the whole transcript into context.
|
||||
* sidebar: the ones the workspace OWNS and has not archived
|
||||
* (`GET /api/sessions?lane=<id>&archived=0`, newest first) — never the other
|
||||
* sessions that ran in its folder. A row's dot
|
||||
* shows its state (`sessionDot`: live pane, needs you, error); double-click
|
||||
* renames it and its hover button archives it (`PATCH {name}` / `{archived}`,
|
||||
* which deletes nothing). A session with a LIVE pane (a `GET /api/run` handle
|
||||
* carrying its session id) opens that tmux pane on the Workspace page
|
||||
* (`?lane=<id>&run=<runId>`, Ctrl/Cmd+click `&pane=1` to open it in a second
|
||||
* split pane) — attaching, never starting or resuming anything;
|
||||
* any other session opens its read-only transcript (`/sessions/:id`), where
|
||||
* Resume is a separate, explicit action, because a resume re-reads the whole
|
||||
* transcript into context.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { Archive } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { api, type RunHandle } from "../../lib/api";
|
||||
import type { Lane, Session } from "../../lib/types";
|
||||
import { isSessionAwaitingInput, type Lane, type Session } from "../../lib/types";
|
||||
import { timeAgo } from "../../lib/format";
|
||||
|
||||
export const SESSION_LIMIT = 20;
|
||||
@@ -31,6 +38,118 @@ export function liveRunFor(lane: Lane, runs: RunHandle[], sessionId: string): Ru
|
||||
);
|
||||
}
|
||||
|
||||
/** A session row's dot: waiting for the user, failed, held by a live pane, or
|
||||
* at rest. */
|
||||
export function sessionDot(session: Session, live: boolean): string {
|
||||
if (isSessionAwaitingInput(session)) return "bg-status-warning";
|
||||
if (session.status === "error") return "bg-status-danger";
|
||||
if (live) return "bg-status-success";
|
||||
return "border border-fg-muted";
|
||||
}
|
||||
|
||||
export type SessionPage = { sessions: Session[]; total: number };
|
||||
|
||||
/** One page of `GET /api/sessions`, re-read whenever `refreshKey` changes. */
|
||||
export function useSessionPage(
|
||||
params: Parameters<typeof api.sessions.list>[0],
|
||||
refreshKey: string
|
||||
): SessionPage | null {
|
||||
const [page, setPage] = useState<SessionPage | null>(null);
|
||||
const key = JSON.stringify(params);
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
api.sessions
|
||||
.list(JSON.parse(key))
|
||||
.then((r) => live && setPage({ sessions: r.sessions, total: r.total ?? r.sessions.length }))
|
||||
.catch(() => live && setPage(null));
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [key, refreshKey]);
|
||||
return page;
|
||||
}
|
||||
|
||||
/** A folded list of sessions under a one-line toggle, each with one action
|
||||
* (Unarchive); `onDone` re-reads the lists after it. */
|
||||
export function SessionFold({
|
||||
testId,
|
||||
page,
|
||||
label,
|
||||
hint,
|
||||
actionLabel,
|
||||
actionIcon,
|
||||
onAction,
|
||||
onDone,
|
||||
indent = "",
|
||||
}: {
|
||||
testId: string;
|
||||
page: SessionPage | null;
|
||||
label: (count: number) => string;
|
||||
hint?: string;
|
||||
actionLabel: string;
|
||||
actionIcon: ReactNode;
|
||||
onAction: (s: Session) => Promise<unknown>;
|
||||
onDone: () => void;
|
||||
/** Left padding of the rows, to line up with the list above. */
|
||||
indent?: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
if (!page || page.total === 0) return null;
|
||||
const act = async (s: Session) => {
|
||||
setError(null);
|
||||
try {
|
||||
await onAction(s);
|
||||
onDone();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div data-testid={testId}>
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
title={hint}
|
||||
className={`w-full py-1 pr-2 text-left text-[11px] text-fg-muted hover:text-fg-primary ${indent}`}
|
||||
>
|
||||
{open ? "▾" : "▸"} {label(page.total)}
|
||||
</button>
|
||||
{open && (
|
||||
<ul className="space-y-px">
|
||||
{page.sessions.map((s) => (
|
||||
<li key={s.id} className={`flex items-center gap-1 pr-1 ${indent}`}>
|
||||
<Link
|
||||
to={`/sessions/${s.id}`}
|
||||
title={`${s.name || s.id}\n${timeAgo(s.last_activity ?? s.started_at)}`}
|
||||
className="min-w-0 flex-1 truncate rounded-md py-1 text-[12px] text-fg-muted hover:text-fg-primary"
|
||||
>
|
||||
{s.name || s.id.slice(0, 8)}
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`${testId}-action-${s.id}`}
|
||||
onClick={() => void act(s)}
|
||||
title={actionLabel}
|
||||
aria-label={actionLabel}
|
||||
className="shrink-0 rounded p-0.5 text-fg-muted hover:bg-surface-3 hover:text-fg-primary"
|
||||
>
|
||||
{actionIcon}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{error && (
|
||||
<p role="alert" className={`text-[11px] text-status-danger ${indent}`}>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function WorkspaceSessionList({
|
||||
lane,
|
||||
liveRuns,
|
||||
@@ -54,20 +173,17 @@ export function WorkspaceSessionList({
|
||||
}) {
|
||||
const { t } = useTranslation("lanes");
|
||||
const navigate = useNavigate();
|
||||
const [sessions, setSessions] = useState<Session[] | null>(null);
|
||||
// Bumped after a rename or an archive, so the list re-reads.
|
||||
const [version, setVersion] = useState(0);
|
||||
const [renaming, setRenaming] = useState<string | null>(null);
|
||||
const [draft, setDraft] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// The lane's own session/status move on every hook, which is when a new
|
||||
// session can appear — re-read then, not on a timer.
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
api.sessions
|
||||
.list({ cwd: lane.cwd, tool: lane.tool, limit: SESSION_LIMIT })
|
||||
.then((r) => live && setSessions(r.sessions))
|
||||
.catch(() => live && setSessions([]));
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [lane.cwd, lane.tool, lane.session_id, lane.status]);
|
||||
// The lane's own session/status/needs-you move on every hook, which is when
|
||||
// a session can appear or change state — re-read then, not on a timer.
|
||||
const refreshKey = `${lane.session_id}:${lane.status}:${lane.needs_action}:${version}`;
|
||||
const own = useSessionPage({ lane: lane.id, archived: false, limit: SESSION_LIMIT }, refreshKey);
|
||||
const sessions = own?.sessions ?? (own === null ? null : []);
|
||||
|
||||
const q = query.trim().toLowerCase();
|
||||
const shown = (sessions ?? []).filter(
|
||||
@@ -79,47 +195,110 @@ export function WorkspaceSessionList({
|
||||
if (count !== null) onMatches?.(count);
|
||||
}, [count, onMatches]);
|
||||
|
||||
const patch = async (s: Session, body: { name?: string; archived?: boolean }) => {
|
||||
setError(null);
|
||||
try {
|
||||
await api.sessions.patch(s.id, body);
|
||||
setVersion((v) => v + 1);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
};
|
||||
|
||||
if (hidden) return null;
|
||||
const failure = error && (
|
||||
<p role="alert" className="pl-7 text-[11px] text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
);
|
||||
if (sessions !== null && shown.length === 0) {
|
||||
return <p className="py-1 pl-8 text-[11px] text-fg-muted">{t("tree.noSessions")}</p>;
|
||||
return (
|
||||
<>
|
||||
<p className="py-1 pl-8 text-[11px] text-fg-muted">{t("tree.noSessions")}</p>
|
||||
{failure}
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<ul data-testid={`workspace-sessions-${lane.id}`} className="space-y-px">
|
||||
{shown.map((s) => {
|
||||
const run = runFor(s);
|
||||
const selected = !!run && run.id === selectedRunId;
|
||||
return (
|
||||
<li key={s.id}>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`workspace-session-${s.id}`}
|
||||
data-live={run ? "true" : undefined}
|
||||
onClick={() =>
|
||||
navigate(
|
||||
run
|
||||
? `/workspace?lane=${lane.id}&run=${encodeURIComponent(run.id)}`
|
||||
: `/sessions/${s.id}`
|
||||
)
|
||||
}
|
||||
title={`${s.name || s.id}\n${timeAgo(s.last_activity ?? s.started_at)}${
|
||||
run ? "" : `\n${t("tree.transcriptHint")}`
|
||||
}`}
|
||||
className={`flex w-full items-center gap-2 rounded-md py-1 pl-7 pr-2 text-left text-[12px] ${
|
||||
selected
|
||||
? "bg-accent/10 text-fg-primary"
|
||||
: "text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`h-1.5 w-1.5 shrink-0 rounded-full ${
|
||||
run ? "bg-status-success" : "border border-fg-muted"
|
||||
<>
|
||||
<ul data-testid={`workspace-sessions-${lane.id}`} className="space-y-px">
|
||||
{shown.map((s) => {
|
||||
const run = runFor(s);
|
||||
const selected = !!run && run.id === selectedRunId;
|
||||
if (renaming === s.id) {
|
||||
return (
|
||||
<li key={s.id} className="py-0.5 pl-7 pr-2">
|
||||
<input
|
||||
data-testid={`workspace-session-rename-${s.id}`}
|
||||
aria-label={t("tree.renameSession")}
|
||||
autoFocus
|
||||
value={draft}
|
||||
maxLength={200}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
// Blur CANCELS — Enter is the only save, like a workspace row.
|
||||
onBlur={() => setRenaming(null)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") setRenaming(null);
|
||||
if (e.key !== "Enter") return;
|
||||
setRenaming(null);
|
||||
const name = draft.trim();
|
||||
if (name && name !== s.name) void patch(s, { name });
|
||||
}}
|
||||
className="w-full rounded border border-accent bg-surface-1 px-1.5 py-0.5 text-[12px] text-fg-primary"
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<li key={s.id} className="group relative">
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`workspace-session-${s.id}`}
|
||||
data-live={run ? "true" : undefined}
|
||||
onClick={(e) =>
|
||||
navigate(
|
||||
run
|
||||
? // Ctrl/Cmd+click opens it beside the current pane.
|
||||
`/workspace?lane=${lane.id}&run=${encodeURIComponent(run.id)}${
|
||||
e.ctrlKey || e.metaKey ? "&pane=1" : ""
|
||||
}`
|
||||
: `/sessions/${s.id}`
|
||||
)
|
||||
}
|
||||
onDoubleClick={() => {
|
||||
setDraft(s.name || "");
|
||||
setRenaming(s.id);
|
||||
}}
|
||||
title={`${s.name || s.id}\n${timeAgo(s.last_activity ?? s.started_at)}\n${
|
||||
run ? t("tree.splitHint") : t("tree.transcriptHint")
|
||||
}`}
|
||||
/>
|
||||
<span className="truncate">{s.name || s.id.slice(0, 8)}</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
className={`flex w-full items-center gap-2 rounded-md py-1 pl-7 pr-7 text-left text-[12px] ${
|
||||
selected
|
||||
? "bg-accent/10 text-fg-primary"
|
||||
: "text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
data-testid={`workspace-session-dot-${s.id}`}
|
||||
className={`h-1.5 w-1.5 shrink-0 rounded-full ${sessionDot(s, !!run)}`}
|
||||
/>
|
||||
<span className="truncate">{s.name || s.id.slice(0, 8)}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`workspace-session-archive-${s.id}`}
|
||||
onClick={() => void patch(s, { archived: true })}
|
||||
title={t("tree.archiveSession")}
|
||||
aria-label={t("tree.archiveSession")}
|
||||
className="absolute right-1 top-1/2 hidden -translate-y-1/2 rounded p-0.5 text-fg-muted hover:bg-surface-4 hover:text-fg-primary focus:block group-hover:block"
|
||||
>
|
||||
<Archive className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
{failure}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
/**
|
||||
* @file WorkspaceSessionsTab — the right panel's Sessions tab: the selected
|
||||
* workspace's sessions (`GET /api/sessions?cwd=<lane cwd>&tool=<lane tool>`,
|
||||
* newest first, 50), each with its tool badge, title, model, relative time and
|
||||
* cost, filtered client-side by a search box. **Resume** (claude only — attach
|
||||
* and resume go through `claude`) hands the session to the console's slot-0
|
||||
* pane; **Open** goes to its detail page. The cwd match is exact, so a
|
||||
* session started in a subdirectory of the workspace is not listed here.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import { api } from "../../lib/api";
|
||||
import type { Lane, Session } from "../../lib/types";
|
||||
import { fmtCost, formatModelName, timeAgo } from "../../lib/format";
|
||||
import { SessionToolBadge } from "../SessionToolBadge";
|
||||
|
||||
export function WorkspaceSessionsTab({
|
||||
lane,
|
||||
onResume,
|
||||
}: {
|
||||
lane: Lane;
|
||||
onResume: (session: Session) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("lanes");
|
||||
const [sessions, setSessions] = useState<Session[] | null>(null);
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
setSessions(null);
|
||||
api.sessions
|
||||
.list({ cwd: lane.cwd, tool: lane.tool, limit: 50 })
|
||||
.then((r) => live && setSessions(r.sessions))
|
||||
.catch(() => live && setSessions([]));
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [lane.cwd, lane.tool]);
|
||||
|
||||
const q = query.trim().toLowerCase();
|
||||
const shown = (sessions ?? []).filter(
|
||||
(s) => !q || (s.name ?? "").toLowerCase().includes(q) || s.id.includes(q)
|
||||
);
|
||||
|
||||
return (
|
||||
<div data-testid="workspace-sessions" className="flex min-h-0 flex-1 flex-col gap-2">
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={t("sessions.search")}
|
||||
aria-label={t("sessions.search")}
|
||||
className="rounded border border-border bg-surface-1 px-2 py-1 text-xs text-fg-primary"
|
||||
/>
|
||||
{sessions !== null && shown.length === 0 && (
|
||||
<p className="py-6 text-center text-xs text-fg-muted">{t("sessions.empty")}</p>
|
||||
)}
|
||||
<ul className="min-h-0 flex-1 space-y-1 overflow-y-auto">
|
||||
{shown.map((s) => (
|
||||
<li
|
||||
key={s.id}
|
||||
data-testid={`workspace-session-${s.id}`}
|
||||
className="rounded-md border border-border bg-surface-2 px-2 py-1.5"
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<SessionToolBadge tool={s.tool} />
|
||||
<span className="min-w-0 flex-1 truncate text-xs font-medium text-fg-primary">
|
||||
{s.name || s.id.slice(0, 8)}
|
||||
</span>
|
||||
{lane.tool === "claude" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onResume(s)}
|
||||
className="shrink-0 rounded border border-border px-1.5 text-[11px] text-fg-secondary hover:bg-surface-4 hover:text-fg-primary"
|
||||
>
|
||||
{t("sessions.resume")}
|
||||
</button>
|
||||
)}
|
||||
<Link
|
||||
to={`/sessions/${s.id}`}
|
||||
className="shrink-0 rounded border border-border px-1.5 text-[11px] text-fg-secondary hover:bg-surface-4 hover:text-fg-primary"
|
||||
>
|
||||
{t("sessions.open")}
|
||||
</Link>
|
||||
</div>
|
||||
<div className="mt-0.5 flex gap-2 text-[11px] text-fg-muted">
|
||||
<span>{timeAgo(s.last_activity || s.started_at)}</span>
|
||||
{s.model && <span className="truncate">{formatModelName(s.model)}</span>}
|
||||
{s.cost !== undefined && (
|
||||
<span className="ml-auto tabular-nums">{fmtCost(s.cost)}</span>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -502,3 +502,34 @@ describe("LaneDiffView — hunk review bar navigation", () => {
|
||||
expect(screen.getByTestId("lane-diff-view").className).not.toContain("max-h-[60vh]");
|
||||
});
|
||||
});
|
||||
|
||||
describe("LaneDiffView — line comments", () => {
|
||||
it("writes a comment on a new line and hands over `<path>:<line> — <text>`", () => {
|
||||
const sent: string[] = [];
|
||||
let delivered = false;
|
||||
render(
|
||||
<LaneDiffView
|
||||
file={FILE}
|
||||
layout="unified"
|
||||
onComment={(text) => {
|
||||
sent.push(text);
|
||||
return delivered;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByTestId("diff-comment-line-1"));
|
||||
fireEvent.change(screen.getByRole("textbox"), { target: { value: "rename this" } });
|
||||
fireEvent.submit(screen.getByTestId("diff-comment-box"));
|
||||
expect(sent).toEqual(["server/lib/git-ops.js:1 — rename this"]);
|
||||
// No pane took it: the box stays, with the reason.
|
||||
expect(screen.getByRole("alert")).toBeInTheDocument();
|
||||
delivered = true;
|
||||
fireEvent.submit(screen.getByTestId("diff-comment-box"));
|
||||
expect(screen.queryByTestId("diff-comment-box")).toBeNull();
|
||||
});
|
||||
|
||||
it("offers no comment button without onComment", () => {
|
||||
render(<LaneDiffView file={FILE} layout="unified" />);
|
||||
expect(screen.queryByTestId("diff-comment-line-1")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -334,7 +334,7 @@ describe("git login (issue #45)", () => {
|
||||
const form = await screen.findByTestId("lane-git-login");
|
||||
expect(form).toHaveTextContent("git.smartgift.io.vn");
|
||||
expect(screen.getByLabelText("Username")).toHaveValue("vi");
|
||||
expect(screen.getByLabelText(/Remember for everyone/)).toBeChecked();
|
||||
expect(screen.getByRole("checkbox", { name: /Remember for everyone/ })).toBeChecked();
|
||||
await user.type(screen.getByLabelText("Password or access token"), "s3cret");
|
||||
await user.click(screen.getByRole("button", { name: "Log in and retry" }));
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
/**
|
||||
* @file SessionResumeButton.test.tsx
|
||||
* @description The transcript page's explicit Resume: it shows only for a
|
||||
* @description The transcript page's explicit Resume and Fork: it shows only for a
|
||||
* Claude session inside a workspace, a live session offers its pane instead,
|
||||
* and a resume asks first (it re-reads the whole transcript), then starts in
|
||||
* the first free pane slot and opens that pane — or refuses when all four are
|
||||
* busy.
|
||||
* busy; a Fork asks too and starts a new session id beside a live original.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -114,6 +114,28 @@ describe("SessionResumeButton", () => {
|
||||
expect(screen.getByTestId("where").textContent).toBe("/workspace?lane=3&run=agenthub-lane-3");
|
||||
});
|
||||
|
||||
it("forks into a free pane after asking, even while the original has a live pane", async () => {
|
||||
runs = [
|
||||
{
|
||||
id: "agenthub-lane-3",
|
||||
laneId: 3,
|
||||
slot: 0,
|
||||
status: "running",
|
||||
sessionId: "s-1",
|
||||
} as RunHandle,
|
||||
];
|
||||
const user = userEvent.setup();
|
||||
renderButton({ id: "s-1", cwd: "/r", tool: "claude" });
|
||||
await user.click(await screen.findByRole("button", { name: "Fork" }));
|
||||
expect(screen.getByRole("dialog")).toHaveTextContent("NEW session");
|
||||
await user.click(screen.getByRole("button", { name: "Fork session" }));
|
||||
expect(api.lanes.action).toHaveBeenCalledWith(3, "start", {
|
||||
slot: 1,
|
||||
resumeSessionId: "s-1",
|
||||
forkSession: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("offers no Resume for a session started in a subfolder, but opens its live pane", async () => {
|
||||
const { unmount } = renderButton({ id: "s-9", cwd: "/r/lib", tool: "claude" });
|
||||
await waitFor(() => expect(api.run.list).toHaveBeenCalled());
|
||||
|
||||
@@ -62,7 +62,10 @@ vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
routerList: vi.fn(async () => ({ routers: [] })),
|
||||
},
|
||||
run: { list: vi.fn(async () => ({ items: runs })) },
|
||||
sessions: { list: vi.fn(async () => ({ sessions })) },
|
||||
sessions: {
|
||||
list: vi.fn(async () => ({ sessions })),
|
||||
patch: vi.fn(async () => ({ session: {} })),
|
||||
},
|
||||
projects: {
|
||||
list: vi.fn(async () => ({
|
||||
projects: [
|
||||
@@ -134,6 +137,12 @@ function renderHome(l = lane()) {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(api.sessions.list).mockImplementation(async () => ({
|
||||
sessions,
|
||||
total: sessions.length,
|
||||
limit: 30,
|
||||
offset: 0,
|
||||
}));
|
||||
view = {
|
||||
instructions: "",
|
||||
supported: true,
|
||||
@@ -196,11 +205,28 @@ describe("WorkspaceHome", () => {
|
||||
expect(api.lanes.action).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("folds the archived sessions with Unarchive", async () => {
|
||||
vi.mocked(api.sessions.list).mockImplementation(async (params) => ({
|
||||
sessions: (params as { archived?: boolean }).archived ? [sessions[1]!] : [],
|
||||
total: (params as { archived?: boolean }).archived ? 1 : 0,
|
||||
limit: 30,
|
||||
offset: 0,
|
||||
}));
|
||||
const user = userEvent.setup();
|
||||
renderHome();
|
||||
const fold = await screen.findByTestId("workspace-archived-1");
|
||||
await user.click(within(fold).getByRole("button", { name: /1 archived session/ }));
|
||||
await user.click(within(fold).getByTestId("workspace-archived-1-action-s-old"));
|
||||
expect(api.sessions.patch).toHaveBeenCalledWith("s-old", { archived: false });
|
||||
});
|
||||
|
||||
it("opens a live recent's pane and an ended one's transcript, resuming nothing", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderHome();
|
||||
const live = await screen.findByTestId("workspace-recent-s-live");
|
||||
await waitFor(() => expect(live).toHaveAttribute("data-live", "true"));
|
||||
// Recents are the workspace's OWN sessions, not the folder's.
|
||||
expect(api.sessions.list).toHaveBeenCalledWith({ lane: 1, archived: false, limit: 30 });
|
||||
await user.click(live);
|
||||
expect(screen.getByTestId("where").textContent).toBe("/workspace?lane=1&run=agenthub-lane-1");
|
||||
await user.click(screen.getByTestId("workspace-recent-s-old"));
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
/**
|
||||
* @file WorkspaceSessionsTab.test.tsx
|
||||
* @description Tests for the Workspace right panel's Sessions tab: it lists
|
||||
* the workspace's own sessions (cwd + tool), filters them client-side, hands a
|
||||
* claude session to Resume, and offers no Resume on another tool's workspace.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import type { Lane, Session } from "../../../lib/types";
|
||||
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
api: {
|
||||
sessions: { list: vi.fn() },
|
||||
aiTools: { list: vi.fn().mockResolvedValue({ tools: [], default: "claude" }) },
|
||||
},
|
||||
}));
|
||||
|
||||
import { api } from "../../../lib/api";
|
||||
import { WorkspaceSessionsTab } from "../WorkspaceSessionsTab";
|
||||
|
||||
const session = (id: string, name: string): Session =>
|
||||
({
|
||||
id,
|
||||
name,
|
||||
status: "completed",
|
||||
cwd: "/w",
|
||||
model: "claude-opus-5-5",
|
||||
started_at: "2026-10-01T00:00:00Z",
|
||||
ended_at: null,
|
||||
metadata: null,
|
||||
cost: 1.5,
|
||||
tool: "claude",
|
||||
}) as Session;
|
||||
|
||||
const lane = (tool: string) => ({ id: 1, cwd: "/w", tool }) as unknown as Lane;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(api.sessions.list).mockResolvedValue({
|
||||
sessions: [session("s-1", "Fix login"), session("s-2", "Add tests")],
|
||||
} as never);
|
||||
});
|
||||
|
||||
function renderTab(tool: string, onResume = vi.fn()) {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<WorkspaceSessionsTab lane={lane(tool)} onResume={onResume} />
|
||||
</MemoryRouter>
|
||||
);
|
||||
return onResume;
|
||||
}
|
||||
|
||||
describe("WorkspaceSessionsTab", () => {
|
||||
it("lists the workspace's sessions by cwd and tool, and filters them", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderTab("claude");
|
||||
expect(await screen.findByText("Fix login")).toBeInTheDocument();
|
||||
expect(api.sessions.list).toHaveBeenCalledWith({ cwd: "/w", tool: "claude", limit: 50 });
|
||||
await user.type(screen.getByRole("searchbox"), "tests");
|
||||
expect(screen.queryByText("Fix login")).toBeNull();
|
||||
expect(screen.getByText("Add tests")).toBeInTheDocument();
|
||||
expect(screen.getAllByRole("link", { name: "Open" })[0]).toHaveAttribute(
|
||||
"href",
|
||||
"/sessions/s-2"
|
||||
);
|
||||
});
|
||||
|
||||
it("hands the session to Resume", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onResume = renderTab("claude");
|
||||
await screen.findByText("Fix login");
|
||||
await user.click(screen.getAllByRole("button", { name: "Resume" })[0]!);
|
||||
expect(onResume).toHaveBeenCalledWith(expect.objectContaining({ id: "s-1" }));
|
||||
});
|
||||
|
||||
it("offers no Resume on a non-claude workspace", async () => {
|
||||
renderTab("codex");
|
||||
await screen.findByText("Fix login");
|
||||
expect(screen.queryByRole("button", { name: "Resume" })).toBeNull();
|
||||
});
|
||||
|
||||
it("says so when the workspace has no session yet", async () => {
|
||||
vi.mocked(api.sessions.list).mockResolvedValue({ sessions: [] } as never);
|
||||
renderTab("claude");
|
||||
expect(await screen.findByText("No sessions in this workspace yet")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -86,10 +86,6 @@ export interface LaneConsolePaneProps {
|
||||
hiddenRunIds?: string[];
|
||||
/** Reports the run this pane shows (null on the setup form). */
|
||||
onHandleChange?: (runId: string | null) => void;
|
||||
/** A session to resume in this pane, from the Workspace's Sessions tab. A new
|
||||
* `nonce` is a new request; it goes through the same path as a resume from
|
||||
* the run history, live-run fallback included. */
|
||||
resumeRequest?: { sessionId: string; model: string | null; nonce: number } | null;
|
||||
/** A live run to show in this pane (a session picked in the sidebar). It
|
||||
* attaches to that tmux session only — nothing is started or resumed. */
|
||||
attachRequest?: { runId: string; nonce: string } | null;
|
||||
@@ -116,7 +112,6 @@ export function LaneConsolePane({
|
||||
slot = 0,
|
||||
hiddenRunIds,
|
||||
onHandleChange,
|
||||
resumeRequest,
|
||||
attachRequest,
|
||||
}: LaneConsolePaneProps) {
|
||||
const { t } = useTranslation("run");
|
||||
@@ -492,23 +487,6 @@ export function LaneConsolePane({
|
||||
[busy, refreshList, t, lanes, slot, onLaneCreated, onLaneIdChange, attachToRun]
|
||||
);
|
||||
|
||||
const resumeRef = useRef(onResumeFromHistory);
|
||||
resumeRef.current = onResumeFromHistory;
|
||||
useEffect(() => {
|
||||
const lane = latest.current.lanes.find((l) => l.id === laneId);
|
||||
if (!resumeRequest || !lane) return;
|
||||
void resumeRef.current({
|
||||
session_id: resumeRequest.sessionId,
|
||||
cwd: lane.cwd,
|
||||
tool: lane.tool,
|
||||
model: resumeRequest.model,
|
||||
permission_mode: null,
|
||||
effort: null,
|
||||
} as DashboardRunHistoryItem);
|
||||
// Only a new request (nonce) resumes; a lane switch never replays one.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [resumeRequest?.nonce]);
|
||||
|
||||
const onViewFromHistory = useCallback(
|
||||
(item: DashboardRunHistoryItem) => {
|
||||
if (item.session_id) void onResumeFromHistory(item);
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { Checkbox } from "../Checkbox";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
@@ -577,15 +578,13 @@ export function RunSetup(props: RunSetupProps) {
|
||||
|
||||
{canBypass && (
|
||||
<div className="px-4 pb-3">
|
||||
<label className="inline-flex items-center gap-2 text-[11.5px] text-status-danger cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={bypass}
|
||||
onChange={(e) => setBypass(e.target.checked)}
|
||||
className="accent-[rgb(var(--status-danger))]"
|
||||
/>
|
||||
{t("fields.bypass")}
|
||||
</label>
|
||||
<Checkbox
|
||||
tone="danger"
|
||||
checked={bypass}
|
||||
onChange={setBypass}
|
||||
label={t("fields.bypass")}
|
||||
labelClassName="text-[11.5px] text-status-danger"
|
||||
/>
|
||||
{bypass && (
|
||||
<div className="mt-2 rounded-md border border-status-danger/40 bg-status-danger/10 px-3 py-2 text-[11px] text-status-danger flex items-start gap-2">
|
||||
<ShieldAlert className="w-3.5 h-3.5 flex-shrink-0 mt-0.5" />
|
||||
|
||||
@@ -13,7 +13,9 @@
|
||||
* (`lib/terminalTheme.ts`), recoloured live without re-attaching.
|
||||
* An image dropped on it is saved on the server (`POST /api/run/drop-image`)
|
||||
* and its PATH pasted into the pane — what a local terminal does on a drop,
|
||||
* and how `claude` attaches an image.
|
||||
* and how `claude` attaches an image. The pane also registers with
|
||||
* `lib/panePaste.ts`, so a diff-line comment from the Changes panel can be
|
||||
* pasted into whichever of the workspace's panes was focused last.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { useEffect, useMemo, useRef, useState, type DragEvent } from "react";
|
||||
@@ -25,6 +27,7 @@ import "@xterm/xterm/css/xterm.css";
|
||||
import { useTheme } from "../../hooks/useTheme";
|
||||
import { terminalThemeFor } from "../../lib/terminalTheme";
|
||||
import { api } from "../../lib/api";
|
||||
import { notePaneFocus, registerPane } from "../../lib/panePaste";
|
||||
|
||||
type DropState = "hint" | "uploading" | "failed" | "imagesOnly";
|
||||
const hasFiles = (e: DragEvent) => Array.from(e.dataTransfer.types).includes("Files");
|
||||
@@ -291,6 +294,16 @@ export function TerminalView({ runId, wsBaseUrl, onExit }: TerminalViewProps) {
|
||||
if (termRef.current) termRef.current.options.theme = palette;
|
||||
}, [palette]);
|
||||
|
||||
// Other parts of the page (a diff-line comment) paste into this pane.
|
||||
useEffect(
|
||||
() =>
|
||||
registerPane(runId, (text) => {
|
||||
termRef.current?.paste(text);
|
||||
termRef.current?.focus();
|
||||
}),
|
||||
[runId]
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative h-full w-full"
|
||||
@@ -311,6 +324,7 @@ export function TerminalView({ runId, wsBaseUrl, onExit }: TerminalViewProps) {
|
||||
if (dragDepth.current === 0) setDrop((d) => (d === "hint" ? null : d));
|
||||
}}
|
||||
onDrop={(e) => void onDrop(e)}
|
||||
onFocus={() => notePaneFocus(runId)}
|
||||
>
|
||||
<div
|
||||
ref={containerRef}
|
||||
|
||||
@@ -484,49 +484,6 @@ describe("LaneConsolePane", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("resumes a session handed over by the Sessions tab, once per request", async () => {
|
||||
(api.lanes.action as ReturnType<typeof vi.fn>).mockResolvedValue({ run_id: "agenthub-lane-1" });
|
||||
(api.run.get as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
id: "agenthub-lane-1",
|
||||
laneId: 1,
|
||||
status: "running",
|
||||
cwd: "/workspace/a",
|
||||
model: null,
|
||||
permissionMode: null,
|
||||
effort: null,
|
||||
resumeSessionId: "s-1",
|
||||
sessionId: null,
|
||||
startedAt: null,
|
||||
promptPreview: null,
|
||||
});
|
||||
const request = { sessionId: "s-1", model: "claude-opus-5-5", nonce: 1 };
|
||||
const { rerender } = render(
|
||||
<MemoryRouter>
|
||||
<LaneConsolePane {...baseProps()} resumeRequest={request} />
|
||||
</MemoryRouter>
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.action).toHaveBeenCalledWith(
|
||||
1,
|
||||
"start",
|
||||
expect.objectContaining({
|
||||
slot: 0,
|
||||
prompt: "",
|
||||
resumeSessionId: "s-1",
|
||||
model: "claude-opus-5-5",
|
||||
})
|
||||
)
|
||||
);
|
||||
// A re-render with the same request is not a second resume.
|
||||
rerender(
|
||||
<MemoryRouter>
|
||||
<LaneConsolePane {...baseProps()} resumeRequest={{ ...request }} />
|
||||
</MemoryRouter>
|
||||
);
|
||||
await screen.findByTestId("terminal-view");
|
||||
expect(api.lanes.action).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("attaches to a run picked in the sidebar without starting or resuming anything", async () => {
|
||||
vi.mocked(api.run.get).mockResolvedValue({
|
||||
id: "agenthub-lane-1-2",
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { Switch } from "../Switch";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChevronDown, Download, ExternalLink, RefreshCw } from "lucide-react";
|
||||
@@ -44,7 +45,7 @@ function Homepage({ tool }: { tool: AiTool }) {
|
||||
);
|
||||
}
|
||||
|
||||
function Switch({
|
||||
function ToolSwitch({
|
||||
tool,
|
||||
disabled,
|
||||
onToggle,
|
||||
@@ -55,26 +56,13 @@ function Switch({
|
||||
}) {
|
||||
const { t } = useTranslation("settings");
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={tool.enabled}
|
||||
aria-label={t("aiTools.enabledOf", { tool: tool.label })}
|
||||
<Switch
|
||||
checked={tool.enabled}
|
||||
onChange={onToggle}
|
||||
label={t("aiTools.enabledOf", { tool: tool.label })}
|
||||
title={tool.enabled ? t("aiTools.enabled") : t("aiTools.disabled")}
|
||||
disabled={disabled}
|
||||
onClick={onToggle}
|
||||
className={`relative h-5 w-9 shrink-0 rounded-full transition-colors disabled:opacity-50 ${
|
||||
tool.enabled ? "bg-accent" : "bg-surface-4"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className={`absolute top-0.5 h-4 w-4 rounded-full bg-white shadow transition-all ${
|
||||
tool.enabled ? "left-[18px]" : "left-0.5"
|
||||
}`}
|
||||
/>
|
||||
<span className="sr-only">{tool.enabled ? t("aiTools.enabled") : t("aiTools.disabled")}</span>
|
||||
</button>
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -297,7 +285,7 @@ export function AiToolsSettings() {
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<Switch
|
||||
<ToolSwitch
|
||||
tool={tool}
|
||||
disabled={busy !== null}
|
||||
onToggle={() =>
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { Checkbox } from "../Checkbox";
|
||||
import { useEffect, useState, useCallback, useRef, Fragment } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { DollarSign, Plus, Pencil, Trash2, Check, X, RotateCcw, Info } from "lucide-react";
|
||||
@@ -571,15 +572,11 @@ export function PricingSettings({ vendor, allowReset = false, id = "pricing" }:
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{hidden > 0 && (
|
||||
<label className="inline-flex items-center gap-1.5 text-xs text-fg-secondary cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showAll}
|
||||
onChange={(e) => setShowAll(e.target.checked)}
|
||||
className="accent-current"
|
||||
/>
|
||||
{t("pricing.showAll", { count: hidden })}
|
||||
</label>
|
||||
<Checkbox
|
||||
checked={showAll}
|
||||
onChange={setShowAll}
|
||||
label={t("pricing.showAll", { count: hidden })}
|
||||
/>
|
||||
)}
|
||||
{allowReset && (
|
||||
<button
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { Switch } from "../Switch";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export function SettingsSection({
|
||||
@@ -70,25 +71,7 @@ export function SettingsSwitchRow({
|
||||
<SettingsRow
|
||||
label={label}
|
||||
description={description}
|
||||
control={
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
aria-label={label}
|
||||
disabled={disabled}
|
||||
onClick={() => onChange(!checked)}
|
||||
className={`relative inline-flex h-5 w-9 rounded-full border-2 border-transparent transition-colors disabled:opacity-50 ${
|
||||
checked ? "bg-accent" : "bg-surface-4"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow transition-transform ${
|
||||
checked ? "translate-x-4" : "translate-x-0"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
}
|
||||
control={<Switch checked={checked} onChange={onChange} label={label} disabled={disabled} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -115,7 +115,7 @@ describe("AiToolsSettings", () => {
|
||||
// A disabled tool cannot become the default.
|
||||
const grok = screen.getByTestId("ai-tool-grok");
|
||||
expect(within(grok).getByText("Set default")).toBeDisabled();
|
||||
expect(within(grok).getByRole("switch")).toHaveTextContent("Disabled");
|
||||
expect(within(grok).getByRole("switch")).toHaveAttribute("aria-checked", "false");
|
||||
});
|
||||
|
||||
it("refreshes detection", async () => {
|
||||
|
||||
Reference in New Issue
Block a user