feat: v2.6.0 — Chat UI composer (+ menu, project files, activity panel, permission mode, Stop), git on a workspace's extra folders (#70), long paste as a file (#68), S3 server mode (experimental)
Chat UI: - Each Claude/Codex session keeps its own chat/terminal view; Settings only sets how new sessions open. - Composer + menu: upload, project files and folders (#54, also dragged from the Files/Changes tree), context files, recent uploads, skills, plugins. - Activity side panel (progress, outputs, files read, uploads, memory, skills, connectors) with a file preview; edited-file counts per turn. - Permission-mode chip (Shift+Tab steps, read back from the pane: server/lib/pane-mode.js, GET|POST /api/run/:id/permission-mode). - Stop (Esc) while the main agent works; the status is checked per agent (with session_id /api/agents ignores its status filter) and re-read every 5 s while Stop shows. - Dropped images shown in the conversation (GET /api/run/drop/:name, the requester's own drop dir), full-size viewer. - A plain-text paste over 5,000 characters is attached as pasted-text.txt, so no prompt reaches tmux/argv limits (#68). Changes panel: every /api/lanes/:id/git* route takes ?source=<lane_sources id> (sourceFolder; ENOSOURCE otherwise) and a folder picker switches the whole panel to a workspace's extra folder (#70). Server mode S3 (one Linux user per account) — experimental, verified with fake sudo plus a P6 managed-settings spike on a test account: - member tmux listing runs in the background (cached, 15 s => unknown); - transcript snapshot copy is async, chunked, temp file + rename; - Express Layer patch only on Express 4; - tmux capturePane/sendKey go to the member's own server. Docs: the multi-user design moved in as docs/plans/server-mode/README.md (the AgentHub-Server repo is gone); S3-PLAN P6 result; LANES/API/openapi.
This commit is contained in:
+242
-106
@@ -7,13 +7,30 @@
|
||||
* an admin — the account changes: add a user, change a role, disable/enable,
|
||||
* reset a password, give a workspace. Every change asks for the admin's OWN
|
||||
* password again (the server refuses without it); an analyst only reads.
|
||||
* Laid out like the other list pages: icon header, a row of totals
|
||||
* (`StatCard`), the accounts in a `card` table, the audit log in a card.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState, type KeyboardEvent, type ReactNode } from "react";
|
||||
import { Navigate } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FolderInput, KeyRound, Lock, LockOpen, RefreshCw, Shield, UserPlus } from "lucide-react";
|
||||
import {
|
||||
AlertCircle,
|
||||
DollarSign,
|
||||
Eye,
|
||||
FolderInput,
|
||||
FolderOpen,
|
||||
KeyRound,
|
||||
Lock,
|
||||
LockOpen,
|
||||
MessagesSquare,
|
||||
RefreshCw,
|
||||
ScrollText,
|
||||
Shield,
|
||||
UserPlus,
|
||||
Users as UsersIcon,
|
||||
} from "lucide-react";
|
||||
import { api, type AuditEntry, type AuthUser, type ServerUserUsage } from "../lib/api";
|
||||
import type { Lane } from "../lib/types";
|
||||
import { fmt, fmtCost, formatDateTime } from "../lib/format";
|
||||
@@ -22,7 +39,8 @@ import { isSubmitEnter } from "../lib/keys";
|
||||
import { useSpin } from "../hooks/useSpin";
|
||||
import { useAuth } from "../components/AuthGate";
|
||||
import { ConfirmModal } from "../components/ConfirmModal";
|
||||
import { SettingsSection } from "../components/settings/SettingsRow";
|
||||
import { StatCard } from "../components/StatCard";
|
||||
import { TableRowSkeleton } from "../components/Skeleton";
|
||||
|
||||
type Role = AuthUser["role"];
|
||||
const ROLES: Role[] = ["admin", "analyst", "developer"];
|
||||
@@ -67,12 +85,22 @@ export function Users() {
|
||||
// Local mode, or a developer: there is no Users page for them.
|
||||
if (!me || me.role === "developer") return <Navigate to="/" replace />;
|
||||
|
||||
const sum = (pick: (u: ServerUserUsage) => number) =>
|
||||
(users ?? []).reduce((n, u) => n + pick(u), 0);
|
||||
const active = (users ?? []).filter((u) => !u.disabled).length;
|
||||
const th = "px-5 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider";
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in space-y-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold text-fg-primary">{t("title")}</h1>
|
||||
<p className="text-xs text-fg-muted">{t("description")}</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-xl bg-accent/15 flex items-center justify-center">
|
||||
<UsersIcon className="w-4.5 h-4.5 text-accent" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold text-fg-primary">{t("title")}</h1>
|
||||
<p className="text-xs text-fg-muted">{t("description")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
@@ -97,115 +125,206 @@ export function Users() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SettingsSection title={t("title")}>
|
||||
{error && (
|
||||
<p role="alert" className="py-2 text-xs text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{!isAdmin && <p className="pb-2 text-xs text-fg-muted">{t("readOnly")}</p>}
|
||||
{users && (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm" data-testid="users-table">
|
||||
<thead className="text-left text-[11px] uppercase text-fg-muted">
|
||||
<tr>
|
||||
<th className="py-2 pr-3 font-medium">{t("name")}</th>
|
||||
<th className="py-2 pr-3 font-medium">{t("role")}</th>
|
||||
<th className="py-2 pr-3 text-right font-medium">{t("workspaces")}</th>
|
||||
<th className="py-2 pr-3 text-right font-medium">{t("sessions")}</th>
|
||||
<th className="py-2 pr-3 text-right font-medium">{t("last7d")}</th>
|
||||
<th className="py-2 pr-3 text-right font-medium">{t("last30d")}</th>
|
||||
{isAdmin && <th className="py-2 text-right font-medium">{t("actions")}</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border/60">
|
||||
{users.map((u) => {
|
||||
const self = u.id === me.id;
|
||||
return (
|
||||
<tr key={u.id} className={u.disabled ? "text-fg-muted" : "text-fg-secondary"}>
|
||||
<td className="py-2 pr-3">
|
||||
{u.username}
|
||||
{self && <span className="ml-2 text-[11px] text-accent">({t("you")})</span>}
|
||||
{u.disabled && <span className="ml-2 text-[11px]">({t("disabled")})</span>}
|
||||
</td>
|
||||
<td className="py-2 pr-3" title={t(`roleHint.${u.role}`)}>
|
||||
{t(`roles.${u.role}`)}
|
||||
</td>
|
||||
<td className="py-2 pr-3 text-right tabular-nums">{u.workspaces}</td>
|
||||
<td className="py-2 pr-3 text-right tabular-nums">{u.sessions}</td>
|
||||
<td
|
||||
className="py-2 pr-3 text-right tabular-nums"
|
||||
title={t("tokens", { n: fmt(u.last7d.tokens) })}
|
||||
>
|
||||
{fmtCost(u.last7d.cost)}
|
||||
</td>
|
||||
<td
|
||||
className="py-2 pr-3 text-right tabular-nums"
|
||||
title={t("tokens", { n: fmt(u.last30d.tokens) })}
|
||||
>
|
||||
{fmtCost(u.last30d.cost)}
|
||||
</td>
|
||||
{isAdmin && (
|
||||
<td className="py-1 text-right whitespace-nowrap">
|
||||
<RowAction
|
||||
label={t("changeRole")}
|
||||
disabled={self}
|
||||
onClick={() => setDialog({ kind: "role", user: u })}
|
||||
>
|
||||
<Shield className="h-4 w-4" />
|
||||
</RowAction>
|
||||
<RowAction
|
||||
label={t("resetPassword")}
|
||||
onClick={() => setDialog({ kind: "password", user: u })}
|
||||
>
|
||||
<KeyRound className="h-4 w-4" />
|
||||
</RowAction>
|
||||
<RowAction
|
||||
label={t("assignWorkspace")}
|
||||
onClick={() => setDialog({ kind: "workspace", user: u })}
|
||||
>
|
||||
<FolderInput className="h-4 w-4" />
|
||||
</RowAction>
|
||||
<RowAction
|
||||
label={u.disabled ? t("enable") : t("disable")}
|
||||
disabled={self}
|
||||
onClick={() =>
|
||||
setDialog({ kind: u.disabled ? "enable" : "disable", user: u })
|
||||
}
|
||||
>
|
||||
{u.disabled ? (
|
||||
<LockOpen className="h-4 w-4" />
|
||||
) : (
|
||||
<Lock className="h-4 w-4" />
|
||||
)}
|
||||
</RowAction>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</SettingsSection>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<StatCard
|
||||
label={t("title")}
|
||||
value={users?.length ?? 0}
|
||||
icon={UsersIcon}
|
||||
trend={users ? t("activeCount", { n: active }) : undefined}
|
||||
loading={!users && !error}
|
||||
/>
|
||||
<StatCard
|
||||
label={t("workspaces")}
|
||||
value={sum((u) => u.workspaces)}
|
||||
icon={FolderOpen}
|
||||
loading={!users && !error}
|
||||
/>
|
||||
<StatCard
|
||||
label={t("sessions")}
|
||||
value={sum((u) => u.sessions)}
|
||||
icon={MessagesSquare}
|
||||
loading={!users && !error}
|
||||
/>
|
||||
<StatCard
|
||||
label={t("cost30d")}
|
||||
value={fmtCost(sum((u) => u.last30d.cost))}
|
||||
icon={DollarSign}
|
||||
trend={users ? `${t("last7d")}: ${fmtCost(sum((u) => u.last7d.cost))}` : undefined}
|
||||
raw={t("tokens", { n: fmt(sum((u) => u.last30d.tokens)) })}
|
||||
loading={!users && !error}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SettingsSection title={t("auditTitle")} description={t("auditDescription")}>
|
||||
{audit?.length === 0 && <p className="py-2 text-sm text-fg-muted">{t("auditEmpty")}</p>}
|
||||
{error && (
|
||||
<div
|
||||
role="alert"
|
||||
className="card flex flex-wrap items-center gap-3 p-3 border-status-danger/40 text-sm text-status-danger"
|
||||
>
|
||||
<AlertCircle className="w-4 h-4 flex-shrink-0" />
|
||||
<span className="flex-1 min-w-0">{error}</span>
|
||||
<button type="button" onClick={refresh} className="btn-ghost text-xs">
|
||||
{t("common:retry")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{!isAdmin && (
|
||||
<p className="flex items-center gap-1.5 text-xs text-fg-muted">
|
||||
<Eye className="w-3.5 h-3.5" /> {t("readOnly")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="card overflow-x-auto">
|
||||
<table className="w-full min-w-[760px]" data-testid="users-table">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-left">
|
||||
<th className={th}>{t("name")}</th>
|
||||
<th className={th}>{t("role")}</th>
|
||||
<th className={`${th} text-right`}>{t("workspaces")}</th>
|
||||
<th className={`${th} text-right`}>{t("sessions")}</th>
|
||||
<th className={`${th} text-right`}>{t("last7d")}</th>
|
||||
<th className={`${th} text-right`}>{t("last30d")}</th>
|
||||
{isAdmin && <th className={`${th} text-right`}>{t("actions")}</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{!users &&
|
||||
!error &&
|
||||
Array.from({ length: 4 }).map((_, i) => (
|
||||
<TableRowSkeleton
|
||||
key={i}
|
||||
columns={isAdmin ? 7 : 6}
|
||||
widths={["w-36", "w-20", "w-8", "w-8", "w-14", "w-14", "w-28"]}
|
||||
/>
|
||||
))}
|
||||
{users?.map((u) => {
|
||||
const self = u.id === me.id;
|
||||
return (
|
||||
<tr
|
||||
key={u.id}
|
||||
className={`hover:bg-surface-4/40 transition-colors ${u.disabled ? "opacity-60" : ""}`}
|
||||
>
|
||||
<td className="px-5 py-3">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<span
|
||||
aria-hidden
|
||||
className="w-8 h-8 flex-shrink-0 rounded-full bg-surface-4 flex items-center justify-center text-xs font-semibold uppercase text-fg-secondary"
|
||||
>
|
||||
{u.username.slice(0, 2)}
|
||||
</span>
|
||||
<span className="truncate text-sm font-medium text-fg-primary">
|
||||
{u.username}
|
||||
</span>
|
||||
{self && (
|
||||
<span className="badge py-0.5 text-[11px] bg-accent/15 text-accent border-accent/20">
|
||||
{t("you")}
|
||||
</span>
|
||||
)}
|
||||
{u.disabled && (
|
||||
<span className="badge py-0.5 text-[11px] bg-status-danger/10 text-status-danger border-status-danger/20">
|
||||
{t("disabled")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
<span
|
||||
className={`badge py-0.5 text-[11px] ${ROLE_BADGE[u.role]}`}
|
||||
title={t(`roleHint.${u.role}`)}
|
||||
>
|
||||
{t(`roles.${u.role}`)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-3 text-right text-sm tabular-nums text-fg-secondary">
|
||||
{u.workspaces}
|
||||
</td>
|
||||
<td className="px-5 py-3 text-right text-sm tabular-nums text-fg-secondary">
|
||||
{u.sessions}
|
||||
</td>
|
||||
<UsageCell usage={u.last7d} />
|
||||
<UsageCell usage={u.last30d} />
|
||||
{isAdmin && (
|
||||
<td className="px-5 py-2 text-right whitespace-nowrap">
|
||||
<RowAction
|
||||
label={t("changeRole")}
|
||||
disabled={self}
|
||||
onClick={() => setDialog({ kind: "role", user: u })}
|
||||
>
|
||||
<Shield className="h-4 w-4" />
|
||||
</RowAction>
|
||||
<RowAction
|
||||
label={t("resetPassword")}
|
||||
onClick={() => setDialog({ kind: "password", user: u })}
|
||||
>
|
||||
<KeyRound className="h-4 w-4" />
|
||||
</RowAction>
|
||||
<RowAction
|
||||
label={t("assignWorkspace")}
|
||||
onClick={() => setDialog({ kind: "workspace", user: u })}
|
||||
>
|
||||
<FolderInput className="h-4 w-4" />
|
||||
</RowAction>
|
||||
<RowAction
|
||||
label={u.disabled ? t("enable") : t("disable")}
|
||||
disabled={self}
|
||||
onClick={() =>
|
||||
setDialog({ kind: u.disabled ? "enable" : "disable", user: u })
|
||||
}
|
||||
>
|
||||
{u.disabled ? (
|
||||
<LockOpen className="h-4 w-4" />
|
||||
) : (
|
||||
<Lock className="h-4 w-4" />
|
||||
)}
|
||||
</RowAction>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<section className="card">
|
||||
<div className="flex items-center gap-2 px-5 py-4 border-b border-border">
|
||||
<ScrollText className="w-4 h-4 text-fg-muted" />
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-fg-primary">{t("auditTitle")}</h2>
|
||||
<p className="text-xs text-fg-muted">{t("auditDescription")}</p>
|
||||
</div>
|
||||
</div>
|
||||
{audit?.length === 0 && (
|
||||
<p className="px-5 py-8 text-center text-sm text-fg-muted">{t("auditEmpty")}</p>
|
||||
)}
|
||||
{audit && audit.length > 0 && (
|
||||
<ul className="divide-y divide-border/60 text-xs" data-testid="audit-list">
|
||||
<ul
|
||||
className="divide-y divide-border text-xs max-h-[28rem] overflow-y-auto"
|
||||
data-testid="audit-list"
|
||||
>
|
||||
{audit.map((a) => (
|
||||
<li key={a.id} className="flex gap-3 py-1.5">
|
||||
<span className="w-36 flex-shrink-0 text-fg-muted">{formatDateTime(a.at)}</span>
|
||||
<span className="w-40 flex-shrink-0 text-fg-secondary">
|
||||
{a.reader} → {a.owner}
|
||||
<li
|
||||
key={a.id}
|
||||
className="flex items-center gap-3 px-5 py-2 hover:bg-surface-4/40 transition-colors"
|
||||
>
|
||||
<span className="w-36 flex-shrink-0 tabular-nums text-fg-muted">
|
||||
{formatDateTime(a.at)}
|
||||
</span>
|
||||
<span className="w-44 flex-shrink-0 truncate text-fg-secondary">
|
||||
<span className="font-medium text-fg-primary">{a.reader}</span>
|
||||
<span className="mx-1.5 text-fg-muted">→</span>
|
||||
{a.owner}
|
||||
</span>
|
||||
<span className="badge py-0 px-1.5 text-[10px] font-mono bg-surface-4 text-fg-muted border-border">
|
||||
{a.method}
|
||||
</span>
|
||||
<span className="min-w-0 truncate font-mono text-fg-muted" title={a.path}>
|
||||
{a.path}
|
||||
</span>
|
||||
<span className="min-w-0 truncate font-mono text-fg-muted">{a.path}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</SettingsSection>
|
||||
</section>
|
||||
|
||||
{dialog && (
|
||||
<AccountDialog
|
||||
@@ -223,6 +342,23 @@ export function Users() {
|
||||
);
|
||||
}
|
||||
|
||||
const ROLE_BADGE: Record<Role, string> = {
|
||||
admin: "bg-accent/15 text-accent border-accent/20",
|
||||
analyst: "bg-status-warning/10 text-status-warning border-status-warning/20",
|
||||
developer: "bg-surface-4 text-fg-secondary border-border",
|
||||
};
|
||||
|
||||
/** A period's cost, with its tokens underneath. */
|
||||
function UsageCell({ usage }: { usage: { cost: number; tokens: number } }) {
|
||||
const { t } = useTranslation("users");
|
||||
return (
|
||||
<td className="px-5 py-3 text-right tabular-nums">
|
||||
<div className="text-sm text-fg-primary">{fmtCost(usage.cost)}</div>
|
||||
<div className="text-[11px] text-fg-muted">{t("tokens", { n: fmt(usage.tokens) })}</div>
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
function RowAction({
|
||||
label,
|
||||
disabled,
|
||||
|
||||
+166
-105
@@ -15,8 +15,9 @@
|
||||
* a tab click replaces the history entry (Back leaves the session view, not
|
||||
* the last tab). Keyboard, outside a terminal: Alt+1–4 focus a pane, Alt+\\
|
||||
* splits the focused one right with a new terminal, Alt+W hides it. The
|
||||
* Changes panel resizes by its button's presets or by dragging (or arrow keys
|
||||
* on) its splitter; an unsaved edit in it is asked about before the panel
|
||||
* Changes panel has Full screen (Restore at full screen) and Collapse
|
||||
* buttons, and resizes by dragging (or arrow keys on) its splitter; a file in
|
||||
* a chat's "Edited N files" card opens in it (`OPEN_IN_CHANGES_EVENT`); an unsaved edit in it is asked about before the panel
|
||||
* closes, switches tab, or moves to another workspace or session (it stays on
|
||||
* the old one until saved or discarded). Errors are toasts.
|
||||
*
|
||||
@@ -54,17 +55,15 @@ import { runWorktreeSession, type RunWorktree } from "../lib/runWorktree";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useLocation, useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Plus,
|
||||
Columns2,
|
||||
Layers,
|
||||
PanelLeftClose,
|
||||
PanelRightClose,
|
||||
Maximize2,
|
||||
ChartColumn,
|
||||
} from "lucide-react";
|
||||
import { PanelLeftClose, PanelRightClose, Maximize2, Minimize2, ChartColumn } from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
import type { CwdSuggestion, RunHandle, RunListResponse } from "../lib/api";
|
||||
import type {
|
||||
CwdSuggestion,
|
||||
LaneSource,
|
||||
LaneSourcesView,
|
||||
RunHandle,
|
||||
RunListResponse,
|
||||
} from "../lib/api";
|
||||
import type { Lane, LaneFeature, ProofFeature, Session } from "../lib/types";
|
||||
import { toast } from "../lib/toast";
|
||||
import { confirmDialog, confirmDiscard } from "../lib/confirm";
|
||||
@@ -109,6 +108,7 @@ import PipelineMap from "../components/lanes/PipelineMap";
|
||||
import LaneCard, { since } from "../components/lanes/LaneCard";
|
||||
import { AddLaneModal } from "../components/lanes/AddLaneModal";
|
||||
import { LaneChangesPanel } from "../components/lanes/LaneChangesPanel";
|
||||
import { OPEN_IN_CHANGES_EVENT, type OpenInChanges } from "../lib/openInChanges";
|
||||
import { LaneGitRailCounts } from "../components/lanes/LaneGitHeader";
|
||||
import { WorkspaceHome } from "../components/lanes/WorkspaceHome";
|
||||
import { SessionReplay } from "../components/lanes/SessionReplay";
|
||||
@@ -118,39 +118,37 @@ import { DestructiveLaneModal } from "../components/lanes/DestructiveLaneModal";
|
||||
const CHANGES_OPEN_KEY = "agenthub.workspace.changesOpen";
|
||||
/** localStorage key for how wide the Changes panel is. */
|
||||
const CHANGES_SIZE_KEY = "agenthub.workspace.changesSize";
|
||||
/** localStorage key for the last width the user dragged the panel to — what
|
||||
* it reopens at after the step control closes it. */
|
||||
const CHANGES_DRAGGED_KEY = "agenthub.workspace.changesDragged";
|
||||
/** localStorage key for the last workspace shown, restored on a bare /workspace. */
|
||||
const SELECTED_KEY = "agenthub.workspace.selected";
|
||||
|
||||
/** The three widths the Changes panel cycles through, then closed. Reading a
|
||||
* diff and watching a console want opposite amounts of room, and which one is
|
||||
* "work" changes several times an hour — hence ONE control that steps
|
||||
* 30/70 → 50/50 → full → closed, rather than a width button beside a
|
||||
* collapse button that both live in the panel's tightest corner. */
|
||||
const CHANGES_SIZES = ["compact", "half", "full"] as const;
|
||||
/** The Changes panel's two preset widths. Its header has two controls, like a
|
||||
* window's: Full screen (Restore once at full screen, back to the last
|
||||
* dragged width) and Collapse (to the rail). */
|
||||
const CHANGES_SIZES = ["compact", "full"] as const;
|
||||
type ChangesPreset = (typeof CHANGES_SIZES)[number];
|
||||
/** A preset, or a width (panel fraction 0.15–0.85) the splitter was dragged to. */
|
||||
type ChangesSize = ChangesPreset | number;
|
||||
const CHANGES_WIDTH: Record<ChangesPreset, string> = {
|
||||
compact: "w-[30%]",
|
||||
half: "w-1/2",
|
||||
compact: "w-[25%]",
|
||||
// The console beside it is HIDDEN, not unmounted: a live run's terminal
|
||||
// and a half-typed prompt must survive a round trip through full width.
|
||||
full: "w-full",
|
||||
};
|
||||
/** The icon the one resize control shows AT each size: what the next click
|
||||
* gives you — an even split, the full width, then closed. */
|
||||
const CHANGES_NEXT_ICON: Record<ChangesPreset, typeof Columns2> = {
|
||||
compact: Columns2,
|
||||
half: Maximize2,
|
||||
full: PanelRightClose,
|
||||
};
|
||||
/** The panel's share of the row, for the splitter. */
|
||||
const changesRatio = (s: ChangesSize) =>
|
||||
typeof s === "number" ? s : s === "compact" ? 0.3 : s === "half" ? 0.5 : 1;
|
||||
/** The preset a size counts as for the button: under ~45% is narrow. */
|
||||
const changesStep = (s: ChangesSize): ChangesPreset =>
|
||||
s === "full" ? "full" : changesRatio(s) < 0.45 ? "compact" : "half";
|
||||
const changesRatio = (s: ChangesSize) => (typeof s === "number" ? s : s === "compact" ? 0.25 : 1);
|
||||
const clampChanges = (r: number) => Math.min(0.85, Math.max(0.15, r));
|
||||
/** The width the user last dragged the panel to, else `compact`. */
|
||||
function lastDraggedChanges(): ChangesSize {
|
||||
try {
|
||||
const n = Number(window.localStorage.getItem(CHANGES_DRAGGED_KEY) || NaN);
|
||||
return Number.isFinite(n) ? clampChanges(n) : "compact";
|
||||
} catch {
|
||||
return "compact";
|
||||
}
|
||||
}
|
||||
/** The panel's own minimum (`min-w-[18rem]`), in px. */
|
||||
const CHANGES_MIN_PX = 288;
|
||||
|
||||
@@ -502,6 +500,7 @@ function ConsoleArea({
|
||||
onRunExit={() => ended(slot)}
|
||||
// Only the focused pane takes the keyboard when it appears.
|
||||
autoFocus={slot === focusSlot}
|
||||
solo={shown.length === 1}
|
||||
attachRequest={
|
||||
attachRequest && slotOf(attachRequest.runId) === slot ? attachRequest : null
|
||||
}
|
||||
@@ -646,17 +645,14 @@ export function Workspace() {
|
||||
useEffect(() => {
|
||||
try {
|
||||
window.localStorage.setItem(CHANGES_SIZE_KEY, String(changesSize));
|
||||
if (typeof changesSize === "number")
|
||||
window.localStorage.setItem(CHANGES_DRAGGED_KEY, String(changesSize));
|
||||
} catch {
|
||||
/* same as above: a forgotten width, not an error */
|
||||
}
|
||||
}, [changesSize]);
|
||||
const changesFull = changesSize === "full";
|
||||
const changesPreset = changesStep(changesSize);
|
||||
const nextChangesSize =
|
||||
CHANGES_SIZES[(CHANGES_SIZES.indexOf(changesPreset) + 1) % CHANGES_SIZES.length]!;
|
||||
const changesNextLabel = changesFull
|
||||
? tLanes("changes.collapse")
|
||||
: tLanes("changes.resize", { size: tLanes(`changes.size.${nextChangesSize}`) });
|
||||
const changesSizeLabel = changesFull ? tLanes("changes.restore") : tLanes("changes.size.full");
|
||||
const changesRowRef = useRef<HTMLDivElement>(null);
|
||||
// The row's width: the panel never gets narrower than its 18rem minimum,
|
||||
// so the splitter is drawn — and dragged — no further than that edge.
|
||||
@@ -713,16 +709,49 @@ export function Workspace() {
|
||||
? sessionParam
|
||||
: undefined;
|
||||
|
||||
// The workspace's extra folders: each is its own repo the Changes panel can
|
||||
// show instead (`?source=`), picked in its header. Read per workspace, and
|
||||
// again from the home's Folder list whenever it adds or removes one.
|
||||
const [gitFolders, setGitFolders] = useState<{ laneId: number; items: LaneSource[] } | null>(
|
||||
null
|
||||
);
|
||||
const takeGitFolders = useCallback(
|
||||
(laneId: number, view: LaneSourcesView) =>
|
||||
setGitFolders({
|
||||
laneId,
|
||||
items: view.sources.filter((s) => s.kind === "folder" && !s.missing),
|
||||
}),
|
||||
[]
|
||||
);
|
||||
useEffect(() => {
|
||||
if (selectedLaneId === null) return;
|
||||
api.lanes.sources(selectedLaneId).then(
|
||||
(v) => takeGitFolders(selectedLaneId, v),
|
||||
() => setGitFolders({ laneId: selectedLaneId, items: [] })
|
||||
);
|
||||
}, [selectedLaneId, takeGitFolders]);
|
||||
const folders = gitFolders?.laneId === selectedLaneId ? gitFolders.items : [];
|
||||
const [gitSourcePick, setGitSourcePick] = useState<{ laneId: number; id: number } | null>(null);
|
||||
// A pick holds for its own workspace, while that folder is still listed.
|
||||
const gitSource =
|
||||
gitSourcePick?.laneId === selectedLaneId && folders.some((f) => f.id === gitSourcePick.id)
|
||||
? gitSourcePick.id
|
||||
: undefined;
|
||||
|
||||
// An unsaved whole-file edit in the Changes panel. While it is open the
|
||||
// panel stays on the tree it was opened in — moving it (another workspace,
|
||||
// session or worktree) would remount it and drop the edit — until the user
|
||||
// says to discard it, or saves.
|
||||
// session, worktree or folder) would remount it and drop the edit — until
|
||||
// the user says to discard it, or saves.
|
||||
const [changesDirty, setChangesDirty] = useState(false);
|
||||
const wantPanel =
|
||||
selectedLaneId === null ? null : { laneId: selectedLaneId, session: gitSession };
|
||||
const wantPanelKey = wantPanel ? `${wantPanel.laneId}:${wantPanel.session ?? ""}` : "";
|
||||
selectedLaneId === null
|
||||
? null
|
||||
: { laneId: selectedLaneId, session: gitSession, source: gitSource };
|
||||
const keyOf = (p: typeof wantPanel) =>
|
||||
p ? `${p.laneId}:${p.session ?? ""}:${p.source ?? ""}` : "";
|
||||
const wantPanelKey = keyOf(wantPanel);
|
||||
const [panelAt, setPanelAt] = useState(wantPanel);
|
||||
const panelKey = panelAt ? `${panelAt.laneId}:${panelAt.session ?? ""}` : "";
|
||||
const panelKey = keyOf(panelAt);
|
||||
const asking = useRef(false);
|
||||
useEffect(() => {
|
||||
if (wantPanelKey === panelKey) return;
|
||||
@@ -740,6 +769,33 @@ export function Workspace() {
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- keyed on the keys
|
||||
}, [wantPanelKey, panelKey, changesDirty]);
|
||||
// A chat's "Edited N files" card asks for a file: open the panel on it, if
|
||||
// the panel shows that workspace.
|
||||
const [changesRequest, setChangesRequest] = useState<{ path: string; nonce: number } | null>(
|
||||
null
|
||||
);
|
||||
const panelLaneRef = useRef<number | null>(null);
|
||||
panelLaneRef.current = panelAt?.laneId ?? null;
|
||||
useEffect(() => {
|
||||
const onOpen = (e: Event) => {
|
||||
const { laneId, path } = (e as CustomEvent<OpenInChanges>).detail;
|
||||
if (laneId !== panelLaneRef.current) return;
|
||||
// Its path is the workspace folder's: back from an extra folder first.
|
||||
setGitSourcePick(null);
|
||||
setChangesOpen(true);
|
||||
setChangesRequest((r) => ({ path, nonce: (r?.nonce ?? 0) + 1 }));
|
||||
};
|
||||
window.addEventListener(OPEN_IN_CHANGES_EVENT, onOpen);
|
||||
return () => window.removeEventListener(OPEN_IN_CHANGES_EVENT, onOpen);
|
||||
}, []);
|
||||
// Asked once: a panel reopened or moved to another tree must not replay it.
|
||||
useEffect(() => {
|
||||
if (!changesOpen) setChangesRequest(null);
|
||||
}, [changesOpen]);
|
||||
// Not on a folder pick: the request above makes one, to land in the panel
|
||||
// it remounts; the picker drops the request itself.
|
||||
const panelTreeKey = panelAt ? `${panelAt.laneId}:${panelAt.session ?? ""}` : "";
|
||||
useEffect(() => setChangesRequest(null), [panelTreeKey]);
|
||||
/** Runs `fn` once an unsaved edit (if any) may be dropped. */
|
||||
const afterDrop = (fn: () => void) => {
|
||||
if (!changesDirty) return fn();
|
||||
@@ -958,41 +1014,9 @@ export function Workspace() {
|
||||
: "space-y-5"
|
||||
}
|
||||
>
|
||||
{/* Workspaces are picked in the sidebar's project tree; this page shows
|
||||
the selected one. With none at all, one banner offers to create it. */}
|
||||
{/* Workspaces are picked — and added, with the + — in the sidebar's
|
||||
project tree; this page shows the selected one. */}
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-4">
|
||||
{lanesLoaded && lanes.length === 0 && (
|
||||
/* With no lanes the strip was a dashed tile beside a stray sentence,
|
||||
which reads as a half-rendered row rather than a state. One banner
|
||||
instead — and a compact one on purpose: the run setup form below
|
||||
is what the user actually needs the height for. */
|
||||
<button
|
||||
type="button"
|
||||
data-testid="lane-empty"
|
||||
onClick={() => setAddLaneOpen(true)}
|
||||
className="card flex w-full shrink-0 flex-wrap items-center gap-4 border-dashed px-5 py-4 text-left transition-colors hover:border-accent/50 hover:bg-surface-2"
|
||||
>
|
||||
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-surface-4 text-fg-muted">
|
||||
<Layers className="h-5 w-5" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="block text-sm font-medium text-fg-primary">
|
||||
{tLanes("empty.title")}
|
||||
</span>
|
||||
<p className="mt-0.5 text-xs text-fg-muted">
|
||||
{tLanes("empty.body")}{" "}
|
||||
<code className="rounded bg-surface-2 px-1.5 py-0.5 text-[11px] text-fg-secondary">
|
||||
agenthub workspace add --cwd $(pwd)
|
||||
</code>
|
||||
</p>
|
||||
</div>
|
||||
<span className="flex shrink-0 items-center gap-1.5 text-xs font-medium text-accent">
|
||||
<Plus className="h-4 w-4" />
|
||||
{tLanes("add")}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* The selected lane's pipeline — the thing you actually come to this
|
||||
page to read, beside the list rather than stacked under it.
|
||||
The section itself is ALWAYS rendered, even with no lane selected:
|
||||
@@ -1011,6 +1035,7 @@ export function Workspace() {
|
||||
<WorkspaceHome
|
||||
lane={currentLane}
|
||||
onRenamed={() => void refreshLanes()}
|
||||
onSourcesChange={(v) => takeGitFolders(currentLane.id, v)}
|
||||
onMenuAction={(action) => {
|
||||
if (action === "new-here") {
|
||||
setAddLanePrefill({ sourceRepo: currentLane.source_repo || currentLane.cwd });
|
||||
@@ -1232,7 +1257,7 @@ export function Workspace() {
|
||||
typeof changesSize === "number" ? "" : CHANGES_WIDTH[changesSize]
|
||||
}${changesFull ? "" : " min-w-[18rem] border-l border-border"}`}
|
||||
>
|
||||
{/* Header: the title, Analyst, then the resize control — the
|
||||
{/* Header: the title, Analyst, then Full screen and Collapse — the
|
||||
same height as the console's tab bar, so the two read as
|
||||
one header strip. Files and Changes are ONE panel now; its
|
||||
own mode switcher moves between them. */}
|
||||
@@ -1243,6 +1268,34 @@ export function Workspace() {
|
||||
<span className="px-1 text-xs font-medium text-fg-secondary">
|
||||
{tLanes("panel.changes")}
|
||||
</span>
|
||||
{/* Which repo the panel shows: the workspace's folder or
|
||||
one of its extra folders. Only when it has any. */}
|
||||
{folders.length > 0 && (
|
||||
<select
|
||||
data-testid="panel-git-folder"
|
||||
value={gitSource ?? ""}
|
||||
onChange={(e) => {
|
||||
setChangesRequest(null);
|
||||
setGitSourcePick(
|
||||
e.target.value
|
||||
? { laneId: currentLane.id, id: Number(e.target.value) }
|
||||
: null
|
||||
);
|
||||
}}
|
||||
aria-label={tLanes("panel.folder")}
|
||||
title={tLanes("panel.folder")}
|
||||
className="min-w-0 max-w-[12rem] truncate rounded-md border border-border bg-surface-2 px-1.5 py-0.5 text-xs text-fg-primary"
|
||||
>
|
||||
<option value="">
|
||||
{currentLane.cwd.split(/[\\/]/).filter(Boolean).pop() || currentLane.cwd}
|
||||
</option>
|
||||
{folders.map((f) => (
|
||||
<option key={f.id} value={f.id}>
|
||||
{f.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
{/* The opened session's analysis page — a page of its own. */}
|
||||
<button
|
||||
type="button"
|
||||
@@ -1258,37 +1311,42 @@ export function Workspace() {
|
||||
>
|
||||
<ChartColumn className="h-4 w-4" />
|
||||
</button>
|
||||
{/* A window's two controls: Full screen (Restore at full
|
||||
screen, to the last dragged width) and Collapse. */}
|
||||
<button
|
||||
type="button"
|
||||
data-testid="lane-changes-resize"
|
||||
onClick={() => {
|
||||
// Past the widest step the next state is "gone", which
|
||||
// is what the rail button reopens at `compact` — and
|
||||
// what drops an unsaved edit, so that asks first.
|
||||
if (changesFull) {
|
||||
afterDrop(() => {
|
||||
setChangesOpen(false);
|
||||
setChangesSize("compact");
|
||||
});
|
||||
} else {
|
||||
setChangesSize(nextChangesSize);
|
||||
}
|
||||
}}
|
||||
// The control names the same region the rail button does,
|
||||
// so a screen reader reads one panel that is open or
|
||||
// closed rather than two unrelated buttons.
|
||||
aria-expanded
|
||||
aria-controls="lane-changes-panel"
|
||||
aria-label={changesNextLabel}
|
||||
title={changesNextLabel}
|
||||
onClick={() => setChangesSize(changesFull ? lastDraggedChanges() : "full")}
|
||||
aria-label={changesSizeLabel}
|
||||
title={changesSizeLabel}
|
||||
className="ml-auto flex h-6 w-6 items-center justify-center rounded-md text-fg-muted transition-colors hover:bg-surface-4 hover:text-fg-primary"
|
||||
>
|
||||
{/* Icon-only, and it draws the width the click LANDS on,
|
||||
not the current one — the label the tooltip carries. */}
|
||||
{(() => {
|
||||
const Next = CHANGES_NEXT_ICON[changesPreset];
|
||||
return <Next className="h-4 w-4" />;
|
||||
})()}
|
||||
{changesFull ? (
|
||||
<Minimize2 className="h-4 w-4" />
|
||||
) : (
|
||||
<Maximize2 className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="lane-changes-collapse"
|
||||
// Closing drops an unsaved edit, so that asks first; it
|
||||
// reopens from the rail at the last dragged width.
|
||||
onClick={() =>
|
||||
afterDrop(() => {
|
||||
setChangesOpen(false);
|
||||
setChangesSize(lastDraggedChanges());
|
||||
})
|
||||
}
|
||||
// The same region the rail button names, so a screen
|
||||
// reader reads one panel that is open or closed.
|
||||
aria-expanded
|
||||
aria-controls="lane-changes-panel"
|
||||
aria-label={tLanes("changes.collapse")}
|
||||
title={tLanes("changes.collapse")}
|
||||
className="flex h-6 w-6 items-center justify-center rounded-md text-fg-muted transition-colors hover:bg-surface-4 hover:text-fg-primary"
|
||||
>
|
||||
<PanelRightClose className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex min-h-0 flex-1 flex-col px-3 pt-2">
|
||||
@@ -1300,9 +1358,11 @@ export function Workspace() {
|
||||
key={panelKey}
|
||||
laneId={panelAt.laneId}
|
||||
session={panelAt.session}
|
||||
source={panelAt.source}
|
||||
full={changesFull}
|
||||
wide={changesPreset !== "compact"}
|
||||
wide={changesRatio(changesSize) >= 0.45}
|
||||
onDirtyChange={setChangesDirty}
|
||||
openRequest={panelAt.source ? null : changesRequest}
|
||||
// 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.
|
||||
@@ -1341,6 +1401,7 @@ export function Workspace() {
|
||||
<LaneGitRailCounts
|
||||
laneId={panelAt.laneId}
|
||||
session={panelAt.session}
|
||||
source={panelAt.source}
|
||||
onShowAll={() => setChangesOpen(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* @file Workspace.changesPanel.test.tsx
|
||||
* @description The Changes panel is ONE control: it steps 30/70 → 50/50 →
|
||||
* full → closed, and the rail reopens it at 30/70. The width survives a reload.
|
||||
* @description The Changes panel has two controls: Full screen (Restore at
|
||||
* full screen, back to the last dragged width) and Collapse; the rail reopens
|
||||
* it at the last dragged width, else 25/75. The width survives a reload.
|
||||
* All of it lives in localStorage and reaches no server — the same rule as the
|
||||
* lane strip's order.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
@@ -82,7 +83,22 @@ vi.mock("../../lib/api", async (importOriginal) => {
|
||||
}),
|
||||
pipelines: r({ pipelines: [] }),
|
||||
git: r({ available: false }),
|
||||
// Workspace 1 has one extra folder, its own repo (`?source=`).
|
||||
sources: vi.fn((id: number) =>
|
||||
Promise.resolve({
|
||||
sources:
|
||||
id === 1
|
||||
? [
|
||||
{ id: 7, kind: "folder", path: "/x/shop", name: "shop", missing: false },
|
||||
{ id: 8, kind: "file", path: "/d/notes.md", name: "notes.md", missing: false },
|
||||
]
|
||||
: [],
|
||||
})
|
||||
),
|
||||
gitTree: r({ entries: [] }),
|
||||
// A file opened in the panel on a chat's request.
|
||||
changes: r({ available: false }),
|
||||
gitFile: r({ available: false }),
|
||||
runtime: r({ up: false, ports: {}, lastError: null }),
|
||||
features: { list: r({ features: [] }), show: r({ feature: null }) },
|
||||
proof: { list: r({ features: [] }), imageUrl: () => "" },
|
||||
@@ -104,6 +120,8 @@ vi.mock("../../lib/eventBus", () => ({
|
||||
}));
|
||||
|
||||
import { Workspace } from "../Workspace";
|
||||
import { openInChanges } from "../../lib/openInChanges";
|
||||
import { api } from "../../lib/api";
|
||||
|
||||
class ObserverStub {
|
||||
observe() {}
|
||||
@@ -150,43 +168,115 @@ describe("Workspace — the Changes panel's width", () => {
|
||||
}
|
||||
}
|
||||
|
||||
it("steps 30/70 → 50/50 → full → closed on one control, with no 70/30 step", async () => {
|
||||
it("offers Full screen and Collapse, then Restore and Collapse at full screen", async () => {
|
||||
await openPanel();
|
||||
const card = () => screen.getByTestId("lane-changes-card");
|
||||
expect(card().className).toContain("w-[30%]");
|
||||
|
||||
fireEvent.click(screen.getByTestId("lane-changes-resize"));
|
||||
expect(card().className).toContain("w-1/2");
|
||||
const size = () => screen.getByTestId("lane-changes-resize");
|
||||
expect(card().className).toContain("w-[25%]");
|
||||
expect(size()).toHaveAttribute("aria-label", "Full screen");
|
||||
expect(screen.getByTestId("lane-changes-collapse")).toBeInTheDocument();
|
||||
|
||||
// Full width HIDES the console beside it — hidden, still mounted.
|
||||
fireEvent.click(screen.getByTestId("lane-changes-resize"));
|
||||
fireEvent.click(size());
|
||||
expect(card().className).toContain("w-full");
|
||||
expect(screen.getByTestId("lane-detail").className).toContain("hidden");
|
||||
expect(size()).toHaveAttribute("aria-label", "Restore size");
|
||||
expect(screen.getByTestId("lane-changes-collapse")).toBeInTheDocument();
|
||||
|
||||
// Past the widest step the panel closes — and reopens at 30/70, not back
|
||||
// at full width, which is why closing resets the width.
|
||||
fireEvent.click(screen.getByTestId("lane-changes-resize"));
|
||||
// Restore brings the console back beside it.
|
||||
fireEvent.click(size());
|
||||
expect(card().className).toContain("w-[25%]");
|
||||
expect(screen.getByTestId("lane-detail").className).not.toContain("hidden");
|
||||
|
||||
// Collapsing from full screen reopens at 25/75, not back at full width.
|
||||
fireEvent.click(size());
|
||||
fireEvent.click(screen.getByTestId("lane-changes-collapse"));
|
||||
await settle();
|
||||
expect(screen.queryByTestId("lane-changes-card")).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByTestId("lane-changes-expand"));
|
||||
await settle();
|
||||
expect(card().className).toContain("w-[30%]");
|
||||
expect(card().className).toContain("w-[25%]");
|
||||
});
|
||||
|
||||
it("remembers the width across a reload, and never asks the server", async () => {
|
||||
await openPanel();
|
||||
fireEvent.click(screen.getByTestId("lane-changes-resize"));
|
||||
expect(window.localStorage.getItem("agenthub.workspace.changesSize")).toBe("half");
|
||||
expect(window.localStorage.getItem("agenthub.workspace.changesSize")).toBe("full");
|
||||
|
||||
cleanup();
|
||||
await openPanel();
|
||||
expect(screen.getByTestId("lane-changes-card").className).toContain("w-1/2");
|
||||
expect(screen.getByTestId("lane-changes-card").className).toContain("w-full");
|
||||
});
|
||||
|
||||
it("keeps the rail as the only way back in", async () => {
|
||||
it("restores, and reopens, at the width last dragged to, not the preset", async () => {
|
||||
window.localStorage.setItem("agenthub.workspace.changesSize", "full");
|
||||
window.localStorage.setItem("agenthub.workspace.changesDragged", "0.4");
|
||||
await openPanel();
|
||||
expect(screen.queryByTestId("lane-changes-collapse")).toBeNull();
|
||||
fireEvent.click(screen.getByTestId("lane-changes-resize"));
|
||||
expect(screen.getByTestId("lane-changes-card").style.width).toBe("40%");
|
||||
fireEvent.click(screen.getByTestId("lane-changes-resize"));
|
||||
fireEvent.click(screen.getByTestId("lane-changes-collapse"));
|
||||
await settle();
|
||||
fireEvent.click(screen.getByTestId("lane-changes-expand"));
|
||||
await settle();
|
||||
expect(screen.getByTestId("lane-changes-card").style.width).toBe("40%");
|
||||
});
|
||||
|
||||
it("opens on a chat's request for a file of the workspace it shows, not another's", async () => {
|
||||
window.localStorage.setItem("agenthub.workspace.changesOpen", "0");
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/workspace?lane=1"]}>
|
||||
<Workspace />
|
||||
</MemoryRouter>
|
||||
);
|
||||
await settle();
|
||||
expect(screen.queryByTestId("lane-changes-card")).toBeNull();
|
||||
act(() => {
|
||||
openInChanges(2, "/workspace/beta", "/workspace/beta/a.ts");
|
||||
});
|
||||
await settle();
|
||||
expect(screen.queryByTestId("lane-changes-card")).toBeNull();
|
||||
act(() => {
|
||||
openInChanges(1, "/workspace/alpha", "/workspace/alpha/src/a.ts");
|
||||
});
|
||||
await settle();
|
||||
expect(screen.getByTestId("lane-changes-card")).toBeInTheDocument();
|
||||
expect(api.lanes.gitFile).toHaveBeenCalledWith(1, "src/a.ts", "work");
|
||||
});
|
||||
|
||||
it("shows an extra folder's repo when picked, and only a workspace's own folders", async () => {
|
||||
await openPanel();
|
||||
const pick = screen.getByTestId("panel-git-folder") as HTMLSelectElement;
|
||||
// The workspace folder, then its folders — never a context file.
|
||||
expect([...pick.options].map((o) => o.text)).toEqual(["alpha", "shop"]);
|
||||
vi.mocked(api.lanes.git).mockClear();
|
||||
fireEvent.change(pick, { target: { value: "7" } });
|
||||
await settle();
|
||||
expect(api.lanes.git).toHaveBeenCalledWith({ lane: 1, source: 7 });
|
||||
// A chat's file is the workspace folder's: the panel goes back there.
|
||||
act(() => {
|
||||
openInChanges(1, "/workspace/alpha", "/workspace/alpha/src/b.ts");
|
||||
});
|
||||
await settle();
|
||||
expect(pick.value).toBe("");
|
||||
expect(api.lanes.gitFile).toHaveBeenCalledWith(1, "src/b.ts", "work");
|
||||
});
|
||||
|
||||
it("has no folder picker for a workspace with no extra folders", async () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/workspace?lane=2"]}>
|
||||
<Workspace />
|
||||
</MemoryRouter>
|
||||
);
|
||||
await settle();
|
||||
expect(screen.queryByTestId("panel-git-folder")).toBeNull();
|
||||
});
|
||||
|
||||
it("collapses from its narrow width too, back to the rail", async () => {
|
||||
await openPanel();
|
||||
fireEvent.click(screen.getByTestId("lane-changes-collapse"));
|
||||
await settle();
|
||||
expect(screen.queryByTestId("lane-changes-card")).toBeNull();
|
||||
expect(screen.getByTestId("lane-changes-expand")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -103,6 +103,8 @@ vi.mock("../../lib/api", async (importOriginal) => {
|
||||
api: {
|
||||
aiTools: { iconUrl: (id: string) => `/api/ai-tools/${id}/icon` },
|
||||
lanes: {
|
||||
// The Changes panel's folder picker reads the workspace's extra folders.
|
||||
sources: vi.fn().mockResolvedValue({ sources: [] }),
|
||||
list: vi.fn().mockImplementation(async () => {
|
||||
recordCall("GET", "/api/lanes");
|
||||
return { lanes: lanesToReturn, counts: countsToReturn };
|
||||
@@ -434,15 +436,10 @@ describe("Workspace page — lane integration", () => {
|
||||
});
|
||||
|
||||
describe("Workspace layout", () => {
|
||||
it("opens the add-workspace modal from the empty banner when there is none", async () => {
|
||||
const user = userEvent.setup();
|
||||
it("shows no empty banner when there is no workspace (the sidebar's + adds one)", async () => {
|
||||
lanesToReturn = [];
|
||||
await renderWorkspace();
|
||||
|
||||
await user.click(screen.getByTestId("lane-empty"));
|
||||
expect(
|
||||
await screen.findByText("Create a workspace from a working directory")
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("lane-empty")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows the full card only for the selected lane, in the detail panel", async () => {
|
||||
|
||||
@@ -5563,82 +5563,6 @@ exports[`screen snapshots > Run 1`] = `
|
||||
<div
|
||||
class="flex min-h-0 flex-1 flex-col gap-4"
|
||||
>
|
||||
<button
|
||||
class="card flex w-full shrink-0 flex-wrap items-center gap-4 border-dashed px-5 py-4 text-left transition-colors hover:border-accent/50 hover:bg-surface-2"
|
||||
data-testid="lane-empty"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-surface-4 text-fg-muted"
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-layers h-5 w-5"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83z"
|
||||
/>
|
||||
<path
|
||||
d="M2 12a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 12"
|
||||
/>
|
||||
<path
|
||||
d="M2 17a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 17"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
<div
|
||||
class="min-w-0 flex-1"
|
||||
>
|
||||
<span
|
||||
class="block text-sm font-medium text-fg-primary"
|
||||
>
|
||||
No workspaces yet
|
||||
</span>
|
||||
<p
|
||||
class="mt-0.5 text-xs text-fg-muted"
|
||||
>
|
||||
A workspace is one working directory the dashboard follows — its pipeline, sessions and terminal. Click anywhere on this card to add one, or from a terminal:
|
||||
|
||||
<code
|
||||
class="rounded bg-surface-2 px-1.5 py-0.5 text-[11px] text-fg-secondary"
|
||||
>
|
||||
agenthub workspace add --cwd $(pwd)
|
||||
</code>
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
class="flex shrink-0 items-center gap-1.5 text-xs font-medium text-accent"
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-plus h-4 w-4"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M5 12h14"
|
||||
/>
|
||||
<path
|
||||
d="M12 5v14"
|
||||
/>
|
||||
</svg>
|
||||
Add workspace
|
||||
</span>
|
||||
</button>
|
||||
<div
|
||||
class="relative flex min-h-0 flex-1"
|
||||
>
|
||||
@@ -5662,6 +5586,7 @@ exports[`screen snapshots > Run 1`] = `
|
||||
<div
|
||||
class="absolute flex flex-col"
|
||||
data-focused="true"
|
||||
data-menu-bounds="true"
|
||||
data-testid="pane-0"
|
||||
style="left: 0%; top: 0%; width: 100%; height: 100%;"
|
||||
>
|
||||
@@ -7249,7 +7174,7 @@ exports[`screen snapshots > Settings — about 1`] = `
|
||||
<span
|
||||
class="text-status-warning/90"
|
||||
>
|
||||
UI build v2.5.13 (rebuild client to match)
|
||||
UI build v2.6.0 (rebuild client to match)
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user