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 () => {
|
||||
|
||||
@@ -88,6 +88,11 @@
|
||||
"changes.expandFile": "Expand {{path}}",
|
||||
"changes.fileGone": "That file no longer has changes in this range.",
|
||||
"changes.fileTruncated": "Diff cut short — open the file in an editor for the rest.",
|
||||
"changes.commentLine": "Comment on line {{n}} — pasted into this workspace's pane",
|
||||
"changes.commentOn": "Comment on {{at}} — pasted into the pane you used last; nothing is sent until you press Enter there",
|
||||
"changes.commentSend": "Paste into pane",
|
||||
"changes.commentCancel": "Cancel",
|
||||
"changes.commentNoPane": "No pane of this workspace is open — open its session first",
|
||||
"changes.layout.split": "Side-by-side",
|
||||
"changes.layout.unified": "Unified",
|
||||
"changes.loading": "Reading the working copy…",
|
||||
@@ -325,16 +330,17 @@
|
||||
"pane.close": "Stop this pane's session",
|
||||
"panel.files": "Files",
|
||||
"panel.changes": "Changes",
|
||||
"panel.sessions": "Sessions",
|
||||
"sessions.empty": "No sessions in this workspace yet",
|
||||
"sessions.resume": "Resume",
|
||||
"sessions.open": "Open",
|
||||
"resume.openPane": "Open pane",
|
||||
"resume.hint": "Resume this session in a workspace pane — re-reads its whole transcript into context",
|
||||
"resume.title": "Resume this session?",
|
||||
"resume.body": "Resuming re-reads the session's whole transcript into the model's context, so its next turns pay for all of it again. It opens in the first free pane of {{workspace}}. To only read it, stay on this page.",
|
||||
"resume.confirm": "Resume session",
|
||||
"sessions.search": "Filter sessions…",
|
||||
"resume.fork": "Fork",
|
||||
"resume.forkHint": "Continue from here as a NEW session — the original stays as it is",
|
||||
"resume.forkTitle": "Fork this session?",
|
||||
"resume.forkBody": "A fork re-reads the session's whole transcript into a NEW session (its own id), so its turns pay for all of it again; the original is left untouched. It opens in the first free pane of {{workspace}}.",
|
||||
"resume.forkConfirm": "Fork session",
|
||||
"tree.title": "Workspaces",
|
||||
"tree.search": "Search workspaces and sessions",
|
||||
"tree.toolFilter": "Filter by tool",
|
||||
@@ -342,6 +348,12 @@
|
||||
"tree.newSession": "New session",
|
||||
"tree.noSessions": "No sessions yet",
|
||||
"tree.transcriptHint": "No live pane — opens the transcript",
|
||||
"tree.splitHint": "Live pane — Ctrl/Cmd+click opens it in a second pane",
|
||||
"tree.renameSession": "Rename session",
|
||||
"tree.archiveSession": "Archive — hide from this workspace (deletes nothing)",
|
||||
"tree.archived_one": "{{count}} archived session",
|
||||
"tree.archived_other": "{{count}} archived sessions",
|
||||
"tree.unarchive": "Unarchive",
|
||||
"home.breadcrumb": "Workspaces",
|
||||
"home.repoRoot": "Repository root",
|
||||
"home.mainBranch": "Main worktree's branch",
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
"checkingForUpdates": "Checking for updates...",
|
||||
"close": "Close",
|
||||
"collapse": "Collapse sidebar",
|
||||
"collapseShort": "Collapse",
|
||||
"connection": "Connection",
|
||||
"connectionDetails": "Connection details",
|
||||
"connectionUptime": "Connected",
|
||||
@@ -49,14 +48,6 @@
|
||||
"settings": "Settings",
|
||||
"skipToContent": "Skip to content",
|
||||
"statsPersisted": "Stats persist across reloads",
|
||||
"switchLanguage": "Switch to {{language}}",
|
||||
"switchTheme": "Switch to {{theme}}",
|
||||
"theme": "Theme",
|
||||
"themeNames": {
|
||||
"dark": "Dark",
|
||||
"light": "Light",
|
||||
"system": "System"
|
||||
},
|
||||
"throughput60s": "Throughput · last 60s",
|
||||
"topEventTypes": "Top event types",
|
||||
"unitEvents": "events",
|
||||
@@ -74,5 +65,6 @@
|
||||
"running": "{{count}} running",
|
||||
"needYou": "{{count}} need you",
|
||||
"today": "Today {{cost}}"
|
||||
}
|
||||
},
|
||||
"version": "Version"
|
||||
}
|
||||
|
||||
@@ -710,5 +710,14 @@
|
||||
"claudeDesc": "Worktrees under <repo>/.claude/worktrees/, made by Claude Code's own agents.",
|
||||
"agenthub": "AgentHub-managed worktrees",
|
||||
"agenthubDesc": "Worktrees AgentHub created under its workspaces root."
|
||||
},
|
||||
"language": {
|
||||
"title": "Language",
|
||||
"description": "The language of the whole dashboard, remembered in this browser.",
|
||||
"label": "Interface language",
|
||||
"names": {
|
||||
"en": "English",
|
||||
"vi": "Tiếng Việt"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,6 +88,11 @@
|
||||
"changes.expandFile": "Mở {{path}}",
|
||||
"changes.fileGone": "File này không còn thay đổi nào trong phạm vi đang xem.",
|
||||
"changes.fileTruncated": "Diff bị cắt bớt — mở file trong editor để xem phần còn lại.",
|
||||
"changes.commentLine": "Comment dòng {{n}} — dán vào pane của workspace này",
|
||||
"changes.commentOn": "Comment {{at}} — dán vào pane bạn dùng gần nhất; chưa gửi gì cho tới khi bạn nhấn Enter ở đó",
|
||||
"changes.commentSend": "Dán vào pane",
|
||||
"changes.commentCancel": "Huỷ",
|
||||
"changes.commentNoPane": "Workspace này chưa mở pane nào — hãy mở session trước",
|
||||
"changes.layout.split": "Hai cột",
|
||||
"changes.layout.unified": "Một cột",
|
||||
"changes.loading": "Đang đọc working copy…",
|
||||
@@ -325,16 +330,17 @@
|
||||
"pane.close": "Dừng phiên của ô này",
|
||||
"panel.files": "Tệp",
|
||||
"panel.changes": "Thay đổi",
|
||||
"panel.sessions": "Phiên",
|
||||
"sessions.empty": "Chưa có phiên nào trong workspace này",
|
||||
"sessions.resume": "Tiếp tục",
|
||||
"sessions.open": "Mở",
|
||||
"resume.openPane": "Mở pane",
|
||||
"resume.hint": "Tiếp tục session này trong một pane của workspace — nạp lại toàn bộ transcript vào context",
|
||||
"resume.title": "Tiếp tục session này?",
|
||||
"resume.body": "Tiếp tục sẽ nạp lại toàn bộ transcript của session vào context của model, nên các lượt sau phải trả lại chi phí cho tất cả phần đó. Session mở ở pane trống đầu tiên của {{workspace}}. Nếu chỉ cần đọc, hãy ở lại trang này.",
|
||||
"resume.confirm": "Tiếp tục session",
|
||||
"sessions.search": "Lọc phiên…",
|
||||
"resume.fork": "Fork",
|
||||
"resume.forkHint": "Tiếp tục từ đây thành một session MỚI — session gốc giữ nguyên",
|
||||
"resume.forkTitle": "Fork session này?",
|
||||
"resume.forkBody": "Fork nạp lại toàn bộ transcript vào một session MỚI (id riêng), nên các lượt sau phải trả lại chi phí cho tất cả phần đó; session gốc không bị đụng tới. Session mở ở pane trống đầu tiên của {{workspace}}.",
|
||||
"resume.forkConfirm": "Fork session",
|
||||
"tree.title": "Không gian làm việc",
|
||||
"tree.search": "Tìm workspace và session",
|
||||
"tree.toolFilter": "Lọc theo tool",
|
||||
@@ -342,6 +348,12 @@
|
||||
"tree.newSession": "Session mới",
|
||||
"tree.noSessions": "Chưa có session",
|
||||
"tree.transcriptHint": "Không còn pane — mở transcript",
|
||||
"tree.splitHint": "Pane đang chạy — Ctrl/Cmd+click để mở ở pane thứ hai",
|
||||
"tree.renameSession": "Đổi tên session",
|
||||
"tree.archiveSession": "Lưu trữ — ẩn khỏi workspace này (không xoá gì)",
|
||||
"tree.archived_one": "{{count}} session đã lưu trữ",
|
||||
"tree.archived_other": "{{count}} session đã lưu trữ",
|
||||
"tree.unarchive": "Bỏ lưu trữ",
|
||||
"home.breadcrumb": "Không gian làm việc",
|
||||
"home.repoRoot": "Thư mục gốc của repo",
|
||||
"home.mainBranch": "Branch của worktree chính",
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
"checkingForUpdates": "Đang kiểm tra cập nhật...",
|
||||
"close": "Đóng",
|
||||
"collapse": "Thu gọn thanh bên",
|
||||
"collapseShort": "Thu gọn",
|
||||
"connection": "Kết nối",
|
||||
"connectionDetails": "Chi tiết kết nối",
|
||||
"connectionUptime": "Đã kết nối",
|
||||
@@ -49,14 +48,6 @@
|
||||
"settings": "Cài đặt",
|
||||
"skipToContent": "Bỏ qua đến nội dung",
|
||||
"statsPersisted": "Số liệu được lưu sau khi tải lại",
|
||||
"switchLanguage": "Chuyển sang {{language}}",
|
||||
"switchTheme": "Chuyển sang {{theme}}",
|
||||
"theme": "Giao diện",
|
||||
"themeNames": {
|
||||
"dark": "Tối",
|
||||
"light": "Sáng",
|
||||
"system": "Theo hệ thống"
|
||||
},
|
||||
"throughput60s": "Lưu lượng · 60s qua",
|
||||
"topEventTypes": "Loại sự kiện phổ biến",
|
||||
"unitEvents": "sự kiện",
|
||||
@@ -74,5 +65,6 @@
|
||||
"running": "{{count}} đang chạy",
|
||||
"needYou": "{{count}} cần bạn",
|
||||
"today": "Hôm nay {{cost}}"
|
||||
}
|
||||
},
|
||||
"version": "Phiên bản"
|
||||
}
|
||||
|
||||
@@ -710,5 +710,14 @@
|
||||
"claudeDesc": "Worktree dưới <repo>/.claude/worktrees/, do agent của Claude Code tạo.",
|
||||
"agenthub": "Worktree do AgentHub quản lý",
|
||||
"agenthubDesc": "Worktree AgentHub tạo dưới thư mục gốc workspace của nó."
|
||||
},
|
||||
"language": {
|
||||
"title": "Ngôn ngữ",
|
||||
"description": "Ngôn ngữ của toàn bộ dashboard, được ghi nhớ trong trình duyệt này.",
|
||||
"label": "Ngôn ngữ giao diện",
|
||||
"names": {
|
||||
"en": "English",
|
||||
"vi": "Tiếng Việt"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* @file panePaste.test.ts
|
||||
* @description Pins where a pasted diff comment lands: the workspace's pane
|
||||
* focused last, else any of its panes, never another workspace's (lane 1 is
|
||||
* not lane 12), and nowhere once the pane unmounted.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { notePaneFocus, pasteToLane, registerPane } from "../panePaste";
|
||||
|
||||
describe("panePaste", () => {
|
||||
it("pastes into the workspace's last-focused pane, and only its own", () => {
|
||||
const got: Record<string, string[]> = {};
|
||||
const reg = (id: string) => registerPane(id, (t) => (got[id] ??= []).push(t));
|
||||
const off12 = reg("agenthub-lane-12");
|
||||
const off0 = reg("agenthub-lane-1");
|
||||
const off2 = reg("agenthub-lane-1-2");
|
||||
expect(pasteToLane(1, "a")).toBe(true);
|
||||
expect(got["agenthub-lane-1"]).toEqual(["a"]);
|
||||
notePaneFocus("agenthub-lane-1-2");
|
||||
pasteToLane(1, "b");
|
||||
expect(got["agenthub-lane-1-2"]).toEqual(["b"]);
|
||||
// Focus on another workspace's pane does not pull lane 1's text there.
|
||||
notePaneFocus("agenthub-lane-12");
|
||||
pasteToLane(1, "c");
|
||||
expect(got["agenthub-lane-12"]).toBeUndefined();
|
||||
off0();
|
||||
off2();
|
||||
expect(pasteToLane(1, "d")).toBe(false);
|
||||
off12();
|
||||
});
|
||||
});
|
||||
@@ -681,6 +681,10 @@ export const api = {
|
||||
cwd?: string;
|
||||
/** One CLI's sessions only (`claude`, `codex`, `antigravity`). */
|
||||
tool?: string;
|
||||
/** The sessions this workspace owns (`sessions.lane_id`). */
|
||||
lane?: number;
|
||||
/** `false` = not archived, `true` = archived only; omitted = both. */
|
||||
archived?: boolean;
|
||||
sort_by?: string;
|
||||
sort_desc?: boolean;
|
||||
limit?: number;
|
||||
@@ -692,6 +696,8 @@ export const api = {
|
||||
if (params?.q) qs.set("q", params.q);
|
||||
if (params?.cwd) qs.set("cwd", params.cwd);
|
||||
if (params?.tool) qs.set("tool", params.tool);
|
||||
if (params?.lane !== undefined) qs.set("lane", String(params.lane));
|
||||
if (params?.archived !== undefined) qs.set("archived", params.archived ? "1" : "0");
|
||||
if (params?.sort_by) qs.set("sort_by", params.sort_by);
|
||||
// `!== undefined` (not truthiness) so an explicit `sort_desc: false` is preserved.
|
||||
if (params?.sort_desc !== undefined) qs.set("sort_desc", String(params.sort_desc));
|
||||
@@ -733,6 +739,13 @@ export const api = {
|
||||
* @returns {@link SessionStats} for that one session.
|
||||
*/
|
||||
stats: (id: string) => request<SessionStats>(`/sessions/${encodeURIComponent(id)}/stats`),
|
||||
/** PATCH /api/sessions/:id — rename, or archive (hides it from its
|
||||
* workspace, deletes nothing). */
|
||||
patch: (id: string, body: { name?: string; archived?: boolean }) =>
|
||||
request<{ session: Session }>(`/sessions/${encodeURIComponent(id)}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
/**
|
||||
* GET /api/sessions/:id/transcripts - the picker list of available
|
||||
* transcripts (main agent, subagents, compaction markers) for this session.
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* @file panePaste.ts — lets another part of the Workspace page paste text into
|
||||
* a workspace's terminal (a diff-line comment from the Changes panel). Every
|
||||
* mounted TerminalView registers its run id; the text goes to the pane of that
|
||||
* workspace the user focused last, else any of its panes. It is a paste —
|
||||
* bracketed, so `claude` takes it as one block in its composer — and never an
|
||||
* Enter: the user reads it and sends it.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const panes = new Map<string, (text: string) => void>();
|
||||
let lastFocused: string | null = null;
|
||||
|
||||
/** Registers a mounted terminal; returns its unregister. */
|
||||
export function registerPane(runId: string, paste: (text: string) => void): () => void {
|
||||
panes.set(runId, paste);
|
||||
return () => {
|
||||
if (panes.get(runId) === paste) panes.delete(runId);
|
||||
if (lastFocused === runId) lastFocused = null;
|
||||
};
|
||||
}
|
||||
|
||||
export function notePaneFocus(runId: string): void {
|
||||
lastFocused = runId;
|
||||
}
|
||||
|
||||
/** The tmux names of workspace `laneId`'s panes: `agenthub-lane-<id>[-slot]`. */
|
||||
function ofLane(runId: string, laneId: number): boolean {
|
||||
const base = `agenthub-lane-${laneId}`;
|
||||
return runId === base || runId.startsWith(`${base}-`);
|
||||
}
|
||||
|
||||
/** Pastes `text` into one of the workspace's mounted panes; false when none is. */
|
||||
export function pasteToLane(laneId: number, text: string): boolean {
|
||||
const id =
|
||||
lastFocused && panes.has(lastFocused) && ofLane(lastFocused, laneId)
|
||||
? lastFocused
|
||||
: [...panes.keys()].find((k) => ofLane(k, laneId));
|
||||
if (!id) return false;
|
||||
panes.get(id)!(text);
|
||||
return true;
|
||||
}
|
||||
@@ -25,6 +25,11 @@ export const SETTINGS_INDEX: {
|
||||
descKey?: string;
|
||||
keywords?: string[];
|
||||
}[] = [
|
||||
{
|
||||
section: "general",
|
||||
titleKey: "language.title",
|
||||
keywords: ["english", "tiếng việt", "vietnamese", "ngôn ngữ"],
|
||||
},
|
||||
{ section: "general", titleKey: "lanesSection.title", descKey: "lanesSection.description" },
|
||||
{ section: "general", titleKey: "lanesSection.runHousekeeping" },
|
||||
{ section: "general", titleKey: "import.title", keywords: ["history", "transcripts"] },
|
||||
|
||||
@@ -734,6 +734,13 @@ export interface Session {
|
||||
* journals and hooks) or `"antigravity"` (agy's conversation files and
|
||||
* hooks). Attach/resume offer `claude` rows only. Maps to `sessions.tool`. */
|
||||
tool?: string;
|
||||
/** The workspace (lane id) that owns the session — the one whose pane held
|
||||
* it; null for a session no workspace started.
|
||||
* Maps to `sessions.lane_id`. */
|
||||
lane_id?: number | null;
|
||||
/** ISO time the user archived the session out of its workspace's lists;
|
||||
* null when not archived. Hides only. Maps to `sessions.archived_at`. */
|
||||
archived_at?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import { Checkbox } from "../components/Checkbox";
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
@@ -108,6 +109,7 @@ import { Skeleton } from "../components/Skeleton";
|
||||
import { AlertsNotifications } from "../components/AlertsNotifications";
|
||||
import {
|
||||
SettingsRow,
|
||||
SettingsSection,
|
||||
SettingsSegmented,
|
||||
SettingsSwitchRow,
|
||||
} from "../components/settings/SettingsRow";
|
||||
@@ -194,7 +196,7 @@ export function Settings({
|
||||
/** Which of the two Settings pages this renders (`/settings/general` or `/settings/notifications`). */
|
||||
section?: "general" | "notifications";
|
||||
} = {}) {
|
||||
const { t } = useTranslation("settings");
|
||||
const { t, i18n } = useTranslation("settings");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [totalCost, setTotalCost] = useState<number | null>(null);
|
||||
@@ -414,6 +416,23 @@ export function Settings({
|
||||
|
||||
{section === "general" && (
|
||||
<>
|
||||
<SettingsSection title={t("language.title")} description={t("language.description")}>
|
||||
<SettingsRow
|
||||
label={t("language.label")}
|
||||
control={
|
||||
<SettingsSegmented
|
||||
ariaLabel={t("language.label")}
|
||||
value={(i18n.resolvedLanguage ?? i18n.language).startsWith("vi") ? "vi" : "en"}
|
||||
onChange={(v) => void i18n.changeLanguage(v)}
|
||||
options={[
|
||||
{ value: "en" as const, label: t("language.names.en") },
|
||||
{ value: "vi" as const, label: t("language.names.vi") },
|
||||
]}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
{/* Cost summary card */}
|
||||
<div className="card p-6">
|
||||
<div className="flex items-center justify-between flex-wrap gap-4">
|
||||
@@ -453,14 +472,11 @@ export function Settings({
|
||||
|
||||
<div className="card p-5 space-y-4">
|
||||
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||
<label className="flex items-center gap-2 text-xs text-fg-secondary">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={gcDryRun}
|
||||
onChange={(e) => setGcDryRun(e.target.checked)}
|
||||
/>
|
||||
{t("lanesSection.dryRun")}
|
||||
</label>
|
||||
<Checkbox
|
||||
checked={gcDryRun}
|
||||
onChange={setGcDryRun}
|
||||
label={t("lanesSection.dryRun")}
|
||||
/>
|
||||
<button
|
||||
onClick={handleGc}
|
||||
disabled={actionLoading !== null}
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
||||
import { useLocation, useSearchParams } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
@@ -58,7 +58,6 @@ import {
|
||||
Maximize2,
|
||||
FolderTree,
|
||||
GitCompare,
|
||||
History,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
@@ -79,7 +78,6 @@ import LaneCard, {
|
||||
} from "../components/lanes/LaneCard";
|
||||
import { AddLaneModal } from "../components/lanes/AddLaneModal";
|
||||
import { LaneChangesPanel } from "../components/lanes/LaneChangesPanel";
|
||||
import { WorkspaceSessionsTab } from "../components/lanes/WorkspaceSessionsTab";
|
||||
import { WorkspaceHome } from "../components/lanes/WorkspaceHome";
|
||||
import { DestructiveLaneModal } from "../components/lanes/DestructiveLaneModal";
|
||||
import { readSplitViewState, writeSplitViewState } from "../lib/splitViewStorage";
|
||||
@@ -97,7 +95,6 @@ const SELECTED_KEY = "agenthub.workspace.selected";
|
||||
const PANEL_TABS = [
|
||||
{ id: "files", Icon: FolderTree },
|
||||
{ id: "changes", Icon: GitCompare },
|
||||
{ id: "sessions", Icon: History },
|
||||
] as const;
|
||||
type PanelTab = (typeof PANEL_TABS)[number]["id"];
|
||||
|
||||
@@ -233,7 +230,6 @@ function ConsoleArea({
|
||||
onLaneCreated,
|
||||
onLaneIdChange,
|
||||
showToggle = true,
|
||||
resumeRequest,
|
||||
attachRequest,
|
||||
}: {
|
||||
lanes: Lane[];
|
||||
@@ -250,10 +246,8 @@ function ConsoleArea({
|
||||
onLaneCreated: (lane: Lane) => void;
|
||||
onLaneIdChange: (id: number | null) => void;
|
||||
showToggle?: boolean;
|
||||
/** A session to resume in slot 0, from the Sessions tab. */
|
||||
resumeRequest: { sessionId: string; model: string | null; nonce: number } | null;
|
||||
/** A live run to attach slot 0 to, from the sidebar (`?run=`). */
|
||||
attachRequest: { runId: string; nonce: string } | null;
|
||||
/** A live run to attach a pane to, from the sidebar (`?run=`, `&pane=`). */
|
||||
attachRequest: { runId: string; nonce: string; pane: number } | null;
|
||||
}) {
|
||||
// Every split pane shows the SELECTED lane, each in its own tmux session
|
||||
// (slot = pane index). Tracking what each pane shows lets the others leave
|
||||
@@ -297,8 +291,7 @@ function ConsoleArea({
|
||||
wsConnected={wsConnected}
|
||||
defaultCwd={defaultCwd}
|
||||
onHasActiveRunChange={onHasActiveRunChange}
|
||||
resumeRequest={resumeRequest}
|
||||
attachRequest={attachRequest}
|
||||
attachRequest={attachRequest?.pane === 0 ? attachRequest : null}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
@@ -329,8 +322,7 @@ function ConsoleArea({
|
||||
externalSessions={externalSessions}
|
||||
wsConnected={wsConnected}
|
||||
defaultCwd={defaultCwd}
|
||||
resumeRequest={i === 0 ? resumeRequest : null}
|
||||
attachRequest={i === 0 ? attachRequest : null}
|
||||
attachRequest={attachRequest?.pane === i ? attachRequest : null}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
@@ -354,9 +346,12 @@ export function Workspace() {
|
||||
// second pick of the same session a new request.
|
||||
const location = useLocation();
|
||||
const runParam = searchParams.get("run");
|
||||
// `&pane=<n>` — which split pane attaches it (Ctrl/Cmd+click in the sidebar
|
||||
// asks for pane 1); pane 0 otherwise.
|
||||
const paneParam = Math.min(Math.max(Number(searchParams.get("pane")) || 0, 0), 3);
|
||||
const attachRequest = useMemo(
|
||||
() => (runParam ? { runId: runParam, nonce: location.key } : null),
|
||||
[runParam, location.key]
|
||||
() => (runParam ? { runId: runParam, nonce: location.key, pane: paneParam } : null),
|
||||
[runParam, location.key, paneParam]
|
||||
);
|
||||
const paramId = laneParam === null ? null : Number(laneParam);
|
||||
const fallbackId = (() => {
|
||||
@@ -417,11 +412,6 @@ export function Workspace() {
|
||||
>([]);
|
||||
const [viewedFeature, setViewedFeature] = useState<LaneFeature | null>(null);
|
||||
const [proofFeatures, setProofFeatures] = useState<ProofFeature[]>([]);
|
||||
const [resumeRequest, setResumeRequest] = useState<{
|
||||
sessionId: string;
|
||||
model: string | null;
|
||||
nonce: number;
|
||||
} | null>(null);
|
||||
|
||||
// Run state kept at page level: shared across every pane, or drives the
|
||||
// lane strip itself rather than any one pane's form.
|
||||
@@ -503,6 +493,16 @@ export function Workspace() {
|
||||
});
|
||||
}, []);
|
||||
|
||||
// A pane the layout does not show yet (`&pane=1` on one pane) widens the
|
||||
// split so the run lands where it was asked for — once per request, so the
|
||||
// user can still go back to one pane afterwards.
|
||||
const layoutRef = useRef(splitView.layout);
|
||||
layoutRef.current = splitView.layout;
|
||||
useEffect(() => {
|
||||
const pane = attachRequest?.pane ?? 0;
|
||||
if (pane >= layoutRef.current) setLayout(pane < 2 ? 2 : 4);
|
||||
}, [attachRequest, setLayout]);
|
||||
|
||||
// Probe the CLI of every tool a lane uses — fresh on the server, so a CLI
|
||||
// installed since the page loaded clears its missing-binary banner. Claude is
|
||||
// always probed: the console's no-lane state runs it.
|
||||
@@ -1002,7 +1002,6 @@ export function Workspace() {
|
||||
onLaneCreated={() => void refreshLanes()}
|
||||
onLaneIdChange={setSelectedLaneId}
|
||||
showToggle={!currentLane}
|
||||
resumeRequest={resumeRequest}
|
||||
attachRequest={attachRequest}
|
||||
/>
|
||||
</div>
|
||||
@@ -1065,34 +1064,21 @@ export function Workspace() {
|
||||
})()}
|
||||
</button>
|
||||
</div>
|
||||
{panelTab === "sessions" ? (
|
||||
<WorkspaceSessionsTab
|
||||
lane={currentLane}
|
||||
onResume={(session) =>
|
||||
setResumeRequest({
|
||||
sessionId: session.id,
|
||||
model: session.model,
|
||||
nonce: Date.now(),
|
||||
})
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<LaneChangesPanel
|
||||
laneId={currentLane.id}
|
||||
tab={panelTab}
|
||||
full={changesSize === "full"}
|
||||
// A sibling worktree forks from the lane's SOURCE repo when
|
||||
// it has one (this lane is itself a worktree of it); an
|
||||
// adopted lane is its own repo.
|
||||
onNewLane={(branch) => {
|
||||
setAddLanePrefill({
|
||||
sourceRepo: currentLane.source_repo || currentLane.cwd,
|
||||
branch,
|
||||
});
|
||||
setAddLaneOpen(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<LaneChangesPanel
|
||||
laneId={currentLane.id}
|
||||
tab={panelTab}
|
||||
full={changesSize === "full"}
|
||||
// A sibling worktree forks from the lane's SOURCE repo when
|
||||
// it has one (this lane is itself a worktree of it); an
|
||||
// adopted lane is its own repo.
|
||||
onNewLane={(branch) => {
|
||||
setAddLanePrefill({
|
||||
sourceRepo: currentLane.source_repo || currentLane.cwd,
|
||||
branch,
|
||||
});
|
||||
setAddLaneOpen(true);
|
||||
}}
|
||||
/>
|
||||
</aside>
|
||||
) : (
|
||||
<button
|
||||
|
||||
@@ -722,4 +722,16 @@ describe("Workspace — home and session view", () => {
|
||||
await userEvent.setup().click(screen.getByTestId("workspace-back-home"));
|
||||
expect(await screen.findByTestId("workspace-home")).toBeInTheDocument();
|
||||
});
|
||||
it("widens one pane to two for &pane=1, so the run opens beside the first", async () => {
|
||||
localStorage.removeItem("agenthub.workspace.splitView");
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/workspace?lane=1&run=agenthub-lane-1&pane=1"]}>
|
||||
<Workspace />
|
||||
</MemoryRouter>
|
||||
);
|
||||
await settle();
|
||||
await settle();
|
||||
expect(screen.getAllByTestId("console-body")).toHaveLength(2);
|
||||
expect(JSON.parse(localStorage.getItem("agenthub.workspace.splitView")!).layout).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7174,6 +7174,63 @@ exports[`screen snapshots > Settings 1`] = `
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
<section
|
||||
class="card p-5"
|
||||
>
|
||||
<h3
|
||||
class="text-sm font-semibold text-fg-primary"
|
||||
>
|
||||
Language
|
||||
</h3>
|
||||
<p
|
||||
class="mt-0.5 text-xs text-fg-muted"
|
||||
>
|
||||
The language of the whole dashboard, remembered in this browser.
|
||||
</p>
|
||||
<div
|
||||
class="mt-2"
|
||||
>
|
||||
<div
|
||||
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-2 items-center"
|
||||
>
|
||||
<div
|
||||
class="min-w-0"
|
||||
>
|
||||
<p
|
||||
class="text-sm text-fg-secondary"
|
||||
>
|
||||
Interface language
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="shrink-0"
|
||||
>
|
||||
<div
|
||||
aria-label="Interface language"
|
||||
class="inline-flex rounded-lg border border-border bg-surface-2 p-0.5"
|
||||
role="radiogroup"
|
||||
>
|
||||
<button
|
||||
aria-checked="true"
|
||||
class="flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-colors bg-accent/20 text-accent"
|
||||
role="radio"
|
||||
type="button"
|
||||
>
|
||||
English
|
||||
</button>
|
||||
<button
|
||||
aria-checked="false"
|
||||
class="flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-colors text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
|
||||
role="radio"
|
||||
type="button"
|
||||
>
|
||||
Tiếng Việt
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<div
|
||||
class="card p-6"
|
||||
>
|
||||
@@ -7290,14 +7347,21 @@ exports[`screen snapshots > Settings 1`] = `
|
||||
<div
|
||||
class="flex items-center justify-between flex-wrap gap-3"
|
||||
>
|
||||
<label
|
||||
class="flex items-center gap-2 text-xs text-fg-secondary"
|
||||
<button
|
||||
aria-checked="false"
|
||||
class="group inline-flex items-center gap-2 text-left disabled:cursor-not-allowed disabled:opacity-50 focus-visible:outline-none "
|
||||
role="checkbox"
|
||||
type="button"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
<span
|
||||
class="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 bg-surface-2 border-border group-hover:border-border-light"
|
||||
/>
|
||||
Dry run
|
||||
</label>
|
||||
<span
|
||||
class="text-xs text-fg-secondary group-hover:text-fg-secondary"
|
||||
>
|
||||
Dry run
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
class="btn-ghost text-xs disabled:opacity-50"
|
||||
>
|
||||
@@ -8274,12 +8338,13 @@ exports[`screen snapshots > Settings 1`] = `
|
||||
<button
|
||||
aria-checked="true"
|
||||
aria-label="Show Sagi"
|
||||
class="relative inline-flex h-5 w-9 rounded-full border-2 border-transparent transition-colors disabled:opacity-50 bg-accent"
|
||||
class="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 bg-accent"
|
||||
role="switch"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow transition-transform translate-x-4"
|
||||
aria-hidden="true"
|
||||
class="inline-block h-4 w-4 rounded-full bg-white shadow transition-transform translate-x-[18px]"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
@@ -9064,12 +9129,13 @@ exports[`screen snapshots > Settings — notifications 1`] = `
|
||||
<button
|
||||
aria-checked="false"
|
||||
aria-label="Enable Browser Notifications"
|
||||
class="relative inline-flex h-5 w-9 rounded-full border-2 border-transparent transition-colors disabled:opacity-50 bg-surface-4"
|
||||
class="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 bg-surface-4"
|
||||
role="switch"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow transition-transform translate-x-0"
|
||||
aria-hidden="true"
|
||||
class="inline-block h-4 w-4 rounded-full bg-white shadow transition-transform translate-x-0.5"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -18,7 +18,7 @@ if (nodeProcess) nodeProcess.env.TZ = "UTC";
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, beforeAll, afterAll } from "vitest";
|
||||
import type { ReactNode } from "react";
|
||||
import { render, act, screen, fireEvent } from "@testing-library/react";
|
||||
import { render, act, screen, fireEvent, within } from "@testing-library/react";
|
||||
import { MemoryRouter, Routes, Route } from "react-router-dom";
|
||||
import i18n from "i18next";
|
||||
|
||||
@@ -604,6 +604,29 @@ describe("screen snapshots", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// The language picker moved off the sidebar into Settings → General.
|
||||
describe("Settings — language", () => {
|
||||
it("switches the interface language from General", async () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/settings/general"]}>
|
||||
<Settings section="general" />
|
||||
</MemoryRouter>
|
||||
);
|
||||
await settle();
|
||||
const group = screen.getByRole("radiogroup", { name: "Interface language" });
|
||||
expect(within(group).getByRole("radio", { name: "English" })).toHaveAttribute(
|
||||
"aria-checked",
|
||||
"true"
|
||||
);
|
||||
fireEvent.click(within(group).getByRole("radio", { name: "Tiếng Việt" }));
|
||||
await settle();
|
||||
expect(i18n.language).toBe("vi");
|
||||
await act(async () => {
|
||||
await i18n.changeLanguage("en");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Pricing, hook status and CLAUDE_HOME belong to Claude Code (#40 part B):
|
||||
// they sit under the Claude Code tab's Overview, not on Dashboard or on the
|
||||
// other sub-tabs.
|
||||
|
||||
Reference in New Issue
Block a user