feat(ui): sidebar lists workspaces with their sessions, Claude Desktop style
The sidebar's project tree becomes a flat workspace list (WorkspaceTree): one row per workspace in the stored order, each expanding to its sessions. A session that still has a live pane opens /workspace?lane=<id>&run=<runId>, which only attaches pane 0 to that tmux session; any other session opens its read-only transcript. Nothing in the sidebar starts or resumes a run.
This commit is contained in:
+1
-1
@@ -183,7 +183,7 @@ client/
|
||||
│ │ ├── EventDetail.tsx # Inline hook payload viewer (used by ActivityFeed + SessionDetail)
|
||||
│ │ ├── EmptyState.tsx
|
||||
│ │ ├── Sidebar.tsx
|
||||
│ │ ├── ProjectTree.tsx # Sidebar Projects section: workspaces grouped by repo (`GET /api/projects`), untracked worktrees + Adopt, row menu
|
||||
│ │ ├── WorkspaceTree.tsx # Sidebar Workspaces list: one row per workspace, expanding to its sessions (live → its pane, else the transcript), row menu
|
||||
│ │ ├── StatusBar.tsx # Fixed bottom bar: Live/Disconnected, version, running / needs-you counts, today's cost
|
||||
│ │ ├── Layout.tsx
|
||||
│ │ ├── RemoteSources.tsx # Remote Data Sources settings panel (SSH multi-machine collection)
|
||||
|
||||
@@ -1,345 +0,0 @@
|
||||
/**
|
||||
* @file ProjectTree — the sidebar's Projects section: every workspace (lane)
|
||||
* grouped under the git repo it belongs to (`GET /api/projects`), lanes outside
|
||||
* any repo under "Folders", plus each repo's worktrees no lane covers, dimmed,
|
||||
* with an **Adopt** button (`POST /api/lanes/ensure {exact: true}`). Rendered on
|
||||
* every page; picking a row opens `/workspace?lane=<id>`, the one place a
|
||||
* workspace is selected. Live status comes from the shared lane list
|
||||
* (`useLanes`); the project grouping is re-read only when the SET of lanes
|
||||
* changes, or on the header's Refresh (`POST /api/projects/refresh`), never on
|
||||
* every status frame. Owns its own Add-workspace and destructive modals so the
|
||||
* row menu works from any page. Collapse/visibility state lives in
|
||||
* `projectTreePrefs.ts`; row order in `laneOrderStorage.ts`.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { ChevronDown, ChevronRight, Folder, FolderGit2, Plus, RefreshCw } from "lucide-react";
|
||||
import { api, type CwdSuggestion } from "../lib/api";
|
||||
import type { Lane, Project } from "../lib/types";
|
||||
import { useLanes } from "../hooks/useLanes";
|
||||
import { useAiTools } from "../hooks/useAiTools";
|
||||
import {
|
||||
moveLane,
|
||||
readLaneOrder,
|
||||
sortByStoredOrder,
|
||||
writeLaneOrder,
|
||||
} from "../lib/laneOrderStorage";
|
||||
import {
|
||||
hiddenSourcesFor,
|
||||
useProjectTreePrefs,
|
||||
writeProjectTreePrefs,
|
||||
} from "../lib/projectTreePrefs";
|
||||
import { WorkspaceRow } from "./lanes/WorkspaceRow";
|
||||
import { WorkspaceRowMenu, type WorkspaceMenuAction } from "./lanes/WorkspaceRowMenu";
|
||||
import { AddLaneModal } from "./lanes/AddLaneModal";
|
||||
import { DestructiveLaneModal } from "./lanes/DestructiveLaneModal";
|
||||
|
||||
/** The workspace the Workspace page shows, read from its URL. */
|
||||
function selectedFrom(pathname: string, search: string): number | null {
|
||||
if (!pathname.startsWith("/workspace")) return null;
|
||||
const v = new URLSearchParams(search).get("lane");
|
||||
return v === null ? null : Number(v);
|
||||
}
|
||||
|
||||
export function ProjectTree() {
|
||||
const { t } = useTranslation("lanes");
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const selected = selectedFrom(location.pathname, location.search);
|
||||
const { lanes, refresh: refreshLanes } = useLanes();
|
||||
const aiTools = useAiTools();
|
||||
const prefs = useProjectTreePrefs();
|
||||
const [projects, setProjects] = useState<Project[] | null>(null);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [menu, setMenu] = useState<{ lane: Lane; x: number; y: number } | null>(null);
|
||||
const [renamingId, setRenamingId] = useState<number | null>(null);
|
||||
const [destructive, setDestructive] = useState<{ lane: Lane; action: "reset" | "remove" } | null>(
|
||||
null
|
||||
);
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
const [addPrefill, setAddPrefill] = useState<{ sourceRepo?: string } | null>(null);
|
||||
const [cwdSuggestions, setCwdSuggestions] = useState<CwdSuggestion[]>([]);
|
||||
const [orderTick, setOrderTick] = useState(0);
|
||||
// Untracked rows the user asked to see for this visit despite the setting.
|
||||
const [revealed, setRevealed] = useState<Set<string>>(new Set());
|
||||
|
||||
const laneKey = lanes.map((l) => l.id).join(",");
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
api.projects
|
||||
.list()
|
||||
.then((r) => live && setProjects(r.projects))
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [laneKey]);
|
||||
|
||||
const refresh = async () => {
|
||||
setRefreshing(true);
|
||||
try {
|
||||
setProjects((await api.projects.refresh()).projects);
|
||||
await refreshLanes();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openAdd = (sourceRepo?: string) => {
|
||||
setAddPrefill(sourceRepo ? { sourceRepo } : null);
|
||||
setAddOpen(true);
|
||||
api.run
|
||||
.cwds()
|
||||
.then((r) => setCwdSuggestions(r.items))
|
||||
.catch(() => undefined);
|
||||
};
|
||||
|
||||
const select = useCallback((id: number) => navigate(`/workspace?lane=${id}`), [navigate]);
|
||||
|
||||
const byId = useMemo(() => new Map(lanes.map((l) => [l.id, l])), [lanes]);
|
||||
const order = useMemo(() => readLaneOrder(), [orderTick]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const reorder = (ids: number[], from: number, to: number) => {
|
||||
const moved = moveLane(ids, from, to);
|
||||
if (moved === ids) return;
|
||||
const rest = sortByStoredOrder(
|
||||
lanes.filter((l) => !moved.includes(l.id)),
|
||||
order
|
||||
).map((l) => l.id);
|
||||
writeLaneOrder([...moved, ...rest]);
|
||||
setOrderTick((n) => n + 1);
|
||||
};
|
||||
|
||||
const rename = async (lane: Lane, title: string | null) => {
|
||||
setRenamingId(null);
|
||||
if (title === null || title === (lane.title || "")) return;
|
||||
try {
|
||||
await api.lanes.update(lane.id, { title });
|
||||
await refreshLanes();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
};
|
||||
|
||||
const adopt = async (path: string) => {
|
||||
setError(null);
|
||||
try {
|
||||
const { lane } = await api.lanes.ensure({
|
||||
cwd: path,
|
||||
tool: aiTools.defaultId ?? "claude",
|
||||
exact: true,
|
||||
});
|
||||
await refreshLanes();
|
||||
select(lane.id);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
};
|
||||
|
||||
const onMenuAction = (lane: Lane, action: WorkspaceMenuAction) => {
|
||||
if (action === "rename") setRenamingId(lane.id);
|
||||
else if (action === "new-here") openAdd(lane.source_repo || lane.cwd);
|
||||
else if (action === "reset" || action === "remove") setDestructive({ lane, action });
|
||||
else if (action === "started") select(lane.id);
|
||||
};
|
||||
|
||||
const confirmDestructive = async (body?: { expect?: unknown; force?: boolean }) => {
|
||||
if (!destructive) return;
|
||||
const { lane, action } = destructive;
|
||||
setDestructive(null);
|
||||
try {
|
||||
await api.lanes.action(lane.id, action, {
|
||||
confirm: true,
|
||||
expect: body?.expect,
|
||||
...(body?.force === true ? { force: true } : {}),
|
||||
});
|
||||
await refreshLanes();
|
||||
if (action === "remove" && selected === lane.id) navigate("/workspace");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section data-testid="project-tree" className="mt-3 border-t border-border pt-3">
|
||||
<div className="mb-1 flex items-center gap-1 px-2">
|
||||
<span className="flex-1 text-[11px] font-semibold uppercase tracking-wide text-fg-muted">
|
||||
{t("projects.title")}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void refresh()}
|
||||
aria-label={t("projects.refresh")}
|
||||
title={t("projects.refresh")}
|
||||
className="rounded p-1 text-fg-muted hover:bg-surface-3 hover:text-fg-primary"
|
||||
>
|
||||
<RefreshCw className={`h-3.5 w-3.5 ${refreshing ? "animate-spin" : ""}`} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="project-tree-add"
|
||||
onClick={() => openAdd()}
|
||||
aria-label={t("addLane")}
|
||||
title={t("addLane")}
|
||||
className="rounded p-1 text-fg-muted hover:bg-surface-3 hover:text-fg-primary"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{(projects ?? []).map((p) => {
|
||||
const key = p.root ?? "";
|
||||
const own = sortByStoredOrder(
|
||||
p.workspaces.map((id) => byId.get(id)).filter((l): l is Lane => !!l),
|
||||
order
|
||||
);
|
||||
const untracked = p.worktrees.filter((w) => w.workspace_id === null);
|
||||
const visible = untracked.filter(
|
||||
(w) =>
|
||||
revealed.has(key) ||
|
||||
(prefs.showUntracked &&
|
||||
!(w.source && hiddenSourcesFor(prefs, p.root).includes(w.source)))
|
||||
);
|
||||
const hidden = untracked.length - visible.length;
|
||||
const collapsed = !!prefs.collapsed[key];
|
||||
const name = p.root === null ? t("projects.folders") : p.name;
|
||||
const ids = own.map((l) => l.id);
|
||||
return (
|
||||
<div key={key} data-testid={`project-${p.root === null ? "folders" : p.name}`}>
|
||||
<div className="group flex items-center gap-1 rounded-md px-1 py-1 hover:bg-surface-3">
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={!collapsed}
|
||||
onClick={() =>
|
||||
writeProjectTreePrefs({ collapsed: { ...prefs.collapsed, [key]: !collapsed } })
|
||||
}
|
||||
title={p.root ?? undefined}
|
||||
className="flex min-w-0 flex-1 items-center gap-1 text-left text-[13px] font-medium text-fg-secondary"
|
||||
>
|
||||
{collapsed ? (
|
||||
<ChevronRight className="h-3.5 w-3.5 shrink-0" />
|
||||
) : (
|
||||
<ChevronDown className="h-3.5 w-3.5 shrink-0" />
|
||||
)}
|
||||
{p.root === null ? (
|
||||
<Folder className="h-3.5 w-3.5 shrink-0 text-fg-muted" />
|
||||
) : (
|
||||
<FolderGit2 className="h-3.5 w-3.5 shrink-0 text-fg-muted" />
|
||||
)}
|
||||
<span className="truncate">{name}</span>
|
||||
</button>
|
||||
{hidden > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRevealed((s) => new Set(s).add(key))}
|
||||
className="shrink-0 text-[10px] text-fg-muted hover:text-fg-primary"
|
||||
>
|
||||
{t("projects.hidden", { count: hidden })}
|
||||
</button>
|
||||
)}
|
||||
{p.root !== null && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openAdd(p.root ?? undefined)}
|
||||
aria-label={t("projects.newWorkspace", { name })}
|
||||
title={t("projects.newWorkspace", { name })}
|
||||
className="shrink-0 rounded p-0.5 text-fg-muted opacity-0 hover:bg-surface-4 hover:text-fg-primary focus:opacity-100 group-hover:opacity-100"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<div className="ml-3 space-y-0.5 border-l border-border pl-1">
|
||||
{own.map((lane, i) => (
|
||||
<WorkspaceRow
|
||||
key={lane.id}
|
||||
lane={lane}
|
||||
projectRoot={p.root}
|
||||
branch={
|
||||
lane.branch ||
|
||||
p.worktrees.find((w) => w.workspace_id === lane.id)?.branch ||
|
||||
null
|
||||
}
|
||||
index={i}
|
||||
selected={selected === lane.id}
|
||||
renaming={renamingId === lane.id}
|
||||
onSelect={() => select(lane.id)}
|
||||
onReorder={(from, to) => reorder(ids, from, to)}
|
||||
onMenu={(x, y) => setMenu({ lane, x, y })}
|
||||
onRenameStart={() => setRenamingId(lane.id)}
|
||||
onRenameEnd={(title) => void rename(lane, title)}
|
||||
/>
|
||||
))}
|
||||
{visible.map((w) => (
|
||||
<div
|
||||
key={w.path}
|
||||
data-testid="untracked-worktree"
|
||||
title={w.path}
|
||||
className="group flex items-center gap-2 rounded-md py-1 pl-2 pr-1 text-fg-muted"
|
||||
>
|
||||
<span className="h-2 w-2 shrink-0 rounded-full border border-fg-muted" />
|
||||
<span className="min-w-0 flex-1 truncate text-[12px]">
|
||||
{w.branch || w.path.split("/").pop()}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void adopt(w.path)}
|
||||
className="shrink-0 rounded border border-border px-1.5 text-[10px] hover:bg-surface-4 hover:text-fg-primary"
|
||||
>
|
||||
{t("row.adopt")}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{error && (
|
||||
<p role="alert" className="mx-2 mt-2 text-[11px] text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{menu && (
|
||||
<WorkspaceRowMenu
|
||||
lane={menu.lane}
|
||||
x={menu.x}
|
||||
y={menu.y}
|
||||
onClose={() => setMenu(null)}
|
||||
onAction={(a) => onMenuAction(menu.lane, a)}
|
||||
/>
|
||||
)}
|
||||
{destructive && (
|
||||
<DestructiveLaneModal
|
||||
lane={destructive.lane}
|
||||
action={destructive.action}
|
||||
open
|
||||
onClose={() => setDestructive(null)}
|
||||
onConfirm={(body) => void confirmDestructive(body)}
|
||||
/>
|
||||
)}
|
||||
<AddLaneModal
|
||||
open={addOpen}
|
||||
cwdSuggestions={cwdSuggestions}
|
||||
prefill={addPrefill}
|
||||
onClose={() => {
|
||||
setAddOpen(false);
|
||||
setAddPrefill(null);
|
||||
}}
|
||||
onAdded={(lane) => {
|
||||
void refreshLanes();
|
||||
select(lane.id);
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -78,7 +78,7 @@ import type { LucideIcon } from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
import { eventBus } from "../lib/eventBus";
|
||||
import { useTheme } from "../hooks/useTheme";
|
||||
import { ProjectTree } from "./ProjectTree";
|
||||
import { WorkspaceTree } from "./WorkspaceTree";
|
||||
import type { UpdateStatusPayload, WSMessage } from "../lib/types";
|
||||
|
||||
function isUpdatePayload(x: unknown): x is UpdateStatusPayload {
|
||||
@@ -472,20 +472,20 @@ export function Sidebar({
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
{/* The project → workspace tree scrolls with the nav. In the rail it
|
||||
{/* The workspace → sessions list scrolls with the nav. In the rail it
|
||||
is one icon that opens the sidebar instead. */}
|
||||
{collapsed ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
title={t("lanes:projects.title")}
|
||||
aria-label={t("lanes:projects.title")}
|
||||
title={t("lanes:tree.title")}
|
||||
aria-label={t("lanes:tree.title")}
|
||||
className="flex w-full items-center justify-center rounded-lg border border-transparent px-2 py-2.5 text-fg-secondary transition-colors hover:bg-surface-3 hover:text-fg-primary"
|
||||
>
|
||||
<FolderTree className="w-4 h-4" />
|
||||
</button>
|
||||
) : (
|
||||
<ProjectTree />
|
||||
<WorkspaceTree />
|
||||
)}
|
||||
</nav>
|
||||
{!collapsed && navOverflow.up && (
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
/**
|
||||
* @file WorkspaceTree — the sidebar's Workspaces section, shaped like Claude
|
||||
* Desktop's project list: every workspace (lane) on one line in the stored drag
|
||||
* order (`laneOrderStorage.ts`), each expanding to ITS sessions
|
||||
* (`WorkspaceSessionList.tsx`) — no repo grouping, no worktree rows. Picking a
|
||||
* workspace opens `/workspace?lane=<id>`, the one place a workspace is
|
||||
* selected; `+` on a row opens it for a new session. Live status comes from
|
||||
* the shared lane list (`useLanes`), live panes from `GET /api/run`, re-read
|
||||
* whenever a lane's status or session moves. Owns its own Add-workspace and
|
||||
* destructive modals so the row menu works from any page. Which rows are
|
||||
* expanded lives in `projectTreePrefs.ts` (`expanded`).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { Plus, Search } from "lucide-react";
|
||||
import { api, type CwdSuggestion, type RunHandle } from "../lib/api";
|
||||
import type { Lane } from "../lib/types";
|
||||
import { useLanes } from "../hooks/useLanes";
|
||||
import {
|
||||
moveLane,
|
||||
readLaneOrder,
|
||||
sortByStoredOrder,
|
||||
writeLaneOrder,
|
||||
} from "../lib/laneOrderStorage";
|
||||
import { useProjectTreePrefs, writeProjectTreePrefs } from "../lib/projectTreePrefs";
|
||||
import { WorkspaceRow, workspaceName } from "./lanes/WorkspaceRow";
|
||||
import { WorkspaceSessionList } from "./lanes/WorkspaceSessionList";
|
||||
import { WorkspaceRowMenu, type WorkspaceMenuAction } from "./lanes/WorkspaceRowMenu";
|
||||
import { AddLaneModal } from "./lanes/AddLaneModal";
|
||||
import { DestructiveLaneModal } from "./lanes/DestructiveLaneModal";
|
||||
|
||||
/** The workspace (and attached run) the Workspace page shows, from its URL. */
|
||||
function selectedFrom(pathname: string, search: string) {
|
||||
if (!pathname.startsWith("/workspace")) return { lane: null, run: null };
|
||||
const p = new URLSearchParams(search);
|
||||
const v = p.get("lane");
|
||||
return { lane: v === null ? null : Number(v), run: p.get("run") };
|
||||
}
|
||||
|
||||
export function WorkspaceTree() {
|
||||
const { t } = useTranslation("lanes");
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const selected = selectedFrom(location.pathname, location.search);
|
||||
const { lanes, refresh: refreshLanes } = useLanes();
|
||||
const prefs = useProjectTreePrefs();
|
||||
const [liveRuns, setLiveRuns] = useState<RunHandle[]>([]);
|
||||
const [query, setQuery] = useState("");
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [menu, setMenu] = useState<{ lane: Lane; x: number; y: number } | null>(null);
|
||||
const [renamingId, setRenamingId] = useState<number | null>(null);
|
||||
const [destructive, setDestructive] = useState<{ lane: Lane; action: "reset" | "remove" } | null>(
|
||||
null
|
||||
);
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
const [addPrefill, setAddPrefill] = useState<{ sourceRepo?: string } | null>(null);
|
||||
const [cwdSuggestions, setCwdSuggestions] = useState<CwdSuggestion[]>([]);
|
||||
const [orderTick, setOrderTick] = useState(0);
|
||||
|
||||
// A pane starts, ends or learns its session id exactly when a lane's status
|
||||
// or session moves, so that is when the live-pane list is re-read.
|
||||
const runKey = lanes.map((l) => `${l.id}:${l.status}:${l.session_id}:${l.run_id}`).join(",");
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
api.run
|
||||
.list()
|
||||
.then((r) => live && setLiveRuns(r.items))
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [runKey]);
|
||||
|
||||
const openAdd = (sourceRepo?: string) => {
|
||||
setAddPrefill(sourceRepo ? { sourceRepo } : null);
|
||||
setAddOpen(true);
|
||||
api.run
|
||||
.cwds()
|
||||
.then((r) => setCwdSuggestions(r.items))
|
||||
.catch(() => undefined);
|
||||
};
|
||||
|
||||
const select = useCallback((id: number) => navigate(`/workspace?lane=${id}`), [navigate]);
|
||||
const expanded = (id: number) => !!prefs.expanded[id] || !!query.trim();
|
||||
const setExpanded = (id: number, open: boolean) =>
|
||||
writeProjectTreePrefs({ expanded: { ...prefs.expanded, [id]: open } });
|
||||
|
||||
const order = useMemo(() => readLaneOrder(), [orderTick]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
const sorted = useMemo(() => sortByStoredOrder(lanes, order), [lanes, order]);
|
||||
const q = query.trim().toLowerCase();
|
||||
const shown = q
|
||||
? sorted.filter(
|
||||
(l) => workspaceName(l).toLowerCase().includes(q) || l.cwd.toLowerCase().includes(q)
|
||||
)
|
||||
: sorted;
|
||||
|
||||
const reorder = (from: number, to: number) => {
|
||||
const ids = sorted.map((l) => l.id);
|
||||
const moved = moveLane(ids, from, to);
|
||||
if (moved === ids) return;
|
||||
writeLaneOrder(moved);
|
||||
setOrderTick((n) => n + 1);
|
||||
};
|
||||
|
||||
const rename = async (lane: Lane, title: string | null) => {
|
||||
setRenamingId(null);
|
||||
if (title === null || title === (lane.title || "")) return;
|
||||
try {
|
||||
await api.lanes.update(lane.id, { title });
|
||||
await refreshLanes();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
};
|
||||
|
||||
const onMenuAction = (lane: Lane, action: WorkspaceMenuAction) => {
|
||||
if (action === "rename") setRenamingId(lane.id);
|
||||
else if (action === "new-here") openAdd(lane.source_repo || lane.cwd);
|
||||
else if (action === "reset" || action === "remove") setDestructive({ lane, action });
|
||||
else if (action === "started") select(lane.id);
|
||||
};
|
||||
|
||||
const confirmDestructive = async (body?: { expect?: unknown; force?: boolean }) => {
|
||||
if (!destructive) return;
|
||||
const { lane, action } = destructive;
|
||||
setDestructive(null);
|
||||
try {
|
||||
await api.lanes.action(lane.id, action, {
|
||||
confirm: true,
|
||||
expect: body?.expect,
|
||||
...(body?.force === true ? { force: true } : {}),
|
||||
});
|
||||
await refreshLanes();
|
||||
if (action === "remove" && selected.lane === lane.id) navigate("/workspace");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
};
|
||||
|
||||
const headBtn = "rounded p-1 text-fg-muted hover:bg-surface-3 hover:text-fg-primary";
|
||||
return (
|
||||
<section data-testid="workspace-tree" className="mt-3 border-t border-border pt-3">
|
||||
<div className="mb-1 flex items-center gap-1 px-2">
|
||||
<span className="flex-1 text-[11px] font-semibold uppercase tracking-wide text-fg-muted">
|
||||
{t("tree.title")}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSearching((v) => !v);
|
||||
setQuery("");
|
||||
}}
|
||||
aria-label={t("tree.search")}
|
||||
aria-pressed={searching}
|
||||
title={t("tree.search")}
|
||||
className={headBtn}
|
||||
>
|
||||
<Search className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="workspace-tree-add"
|
||||
onClick={() => openAdd()}
|
||||
aria-label={t("addLane")}
|
||||
title={t("addLane")}
|
||||
className={headBtn}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
{searching && (
|
||||
<input
|
||||
type="search"
|
||||
autoFocus
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") {
|
||||
setQuery("");
|
||||
setSearching(false);
|
||||
}
|
||||
}}
|
||||
placeholder={t("tree.search")}
|
||||
aria-label={t("tree.search")}
|
||||
className="mx-2 mb-1 w-[calc(100%-1rem)] rounded border border-border bg-surface-1 px-2 py-1 text-xs text-fg-primary"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="space-y-px px-1">
|
||||
{shown.map((lane, i) => (
|
||||
<div key={lane.id}>
|
||||
<WorkspaceRow
|
||||
lane={lane}
|
||||
index={i}
|
||||
selected={selected.lane === lane.id && !selected.run}
|
||||
expanded={expanded(lane.id)}
|
||||
renaming={renamingId === lane.id}
|
||||
onSelect={() => {
|
||||
select(lane.id);
|
||||
if (!expanded(lane.id)) setExpanded(lane.id, true);
|
||||
}}
|
||||
onToggle={() => setExpanded(lane.id, !expanded(lane.id))}
|
||||
onNewSession={() => select(lane.id)}
|
||||
onReorder={reorder}
|
||||
onMenu={(x, y) => setMenu({ lane, x, y })}
|
||||
onRenameStart={() => setRenamingId(lane.id)}
|
||||
onRenameEnd={(title) => void rename(lane, title)}
|
||||
/>
|
||||
{expanded(lane.id) && (
|
||||
<WorkspaceSessionList
|
||||
lane={lane}
|
||||
liveRuns={liveRuns}
|
||||
query={query}
|
||||
selectedRunId={selected.lane === lane.id ? selected.run : null}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p role="alert" className="mx-2 mt-2 text-[11px] text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{menu && (
|
||||
<WorkspaceRowMenu
|
||||
lane={menu.lane}
|
||||
x={menu.x}
|
||||
y={menu.y}
|
||||
onClose={() => setMenu(null)}
|
||||
onAction={(a) => onMenuAction(menu.lane, a)}
|
||||
/>
|
||||
)}
|
||||
{destructive && (
|
||||
<DestructiveLaneModal
|
||||
lane={destructive.lane}
|
||||
action={destructive.action}
|
||||
open
|
||||
onClose={() => setDestructive(null)}
|
||||
onConfirm={(body) => void confirmDestructive(body)}
|
||||
/>
|
||||
)}
|
||||
<AddLaneModal
|
||||
open={addOpen}
|
||||
cwdSuggestions={cwdSuggestions}
|
||||
prefill={addPrefill}
|
||||
onClose={() => {
|
||||
setAddOpen(false);
|
||||
setAddPrefill(null);
|
||||
}}
|
||||
onAdded={(lane) => {
|
||||
void refreshLanes();
|
||||
select(lane.id);
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -64,11 +64,11 @@ describe("Sidebar", () => {
|
||||
expect(screen.getByRole("button", { name: "Connection details" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the project tree when expanded and its icon in the rail", () => {
|
||||
it("renders the workspace list when expanded and its icon in the rail", () => {
|
||||
renderSidebar(true);
|
||||
expect(screen.getByTestId("project-tree")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("workspace-tree")).toBeInTheDocument();
|
||||
renderSidebar(true, true);
|
||||
expect(screen.getAllByRole("button", { name: "Projects" }).length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByRole("button", { name: "Workspaces" }).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should have correct navigation hrefs", () => {
|
||||
|
||||
+74
-93
@@ -1,10 +1,10 @@
|
||||
/**
|
||||
* @file ProjectTree.test.tsx
|
||||
* @description Tests for the sidebar's project → workspace tree: grouping by
|
||||
* repo with Folders last, the primary badge, untracked worktrees with Adopt and
|
||||
* the hidden count, collapse persistence, Alt+arrow reordering kept in
|
||||
* localStorage, double-click rename, the row menu's items by kind and tool, and
|
||||
* the status dot (`workspaceDot`).
|
||||
* @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
|
||||
* 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>
|
||||
*/
|
||||
|
||||
@@ -12,7 +12,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor, within, fireEvent } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
|
||||
import type { Lane, Project } from "../../lib/types";
|
||||
import type { Lane, Session } from "../../lib/types";
|
||||
|
||||
function lane(id: number, cwd: string, extra: Partial<Lane> = {}): Lane {
|
||||
return {
|
||||
@@ -37,8 +37,10 @@ function lane(id: number, cwd: string, extra: Partial<Lane> = {}): Lane {
|
||||
} as unknown as Lane;
|
||||
}
|
||||
|
||||
const session = (id: string, name: string) =>
|
||||
({ id, name, cwd: "/r", status: "completed", started_at: "2026-09-30T10:00:00Z" }) as Session;
|
||||
|
||||
let lanes: Lane[] = [];
|
||||
let projects: Project[] = [];
|
||||
|
||||
vi.mock("../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
@@ -48,15 +50,20 @@ vi.mock("../../lib/api", async (importOriginal) => {
|
||||
lanes: {
|
||||
list: vi.fn(async () => ({ lanes, counts: {} })),
|
||||
update: vi.fn(async () => ({ lane: lanes[0] })),
|
||||
ensure: vi.fn(async () => ({ lane: lane(9, "/r/wt"), created: true })),
|
||||
action: vi.fn(async () => ({ ok: true })),
|
||||
routerList: vi.fn(async () => ({ routers: [] })),
|
||||
},
|
||||
projects: {
|
||||
list: vi.fn(async () => ({ projects })),
|
||||
refresh: vi.fn(async () => ({ projects })),
|
||||
sessions: {
|
||||
list: vi.fn(async () => ({
|
||||
sessions: [session("s-live", "Live one"), session("s-old", "Old one")],
|
||||
})),
|
||||
},
|
||||
run: {
|
||||
list: vi.fn(async () => ({
|
||||
items: [{ id: "agenthub-lane-1-2", laneId: 1, status: "running", sessionId: "s-live" }],
|
||||
})),
|
||||
cwds: vi.fn(async () => ({ items: [] })),
|
||||
},
|
||||
run: { cwds: vi.fn(async () => ({ items: [] })) },
|
||||
aiTools: {
|
||||
list: vi.fn(async () => ({ tools: [], default: "claude" })),
|
||||
iconUrl: () => "",
|
||||
@@ -67,7 +74,7 @@ vi.mock("../../lib/api", async (importOriginal) => {
|
||||
});
|
||||
|
||||
import { api } from "../../lib/api";
|
||||
import { ProjectTree } from "../ProjectTree";
|
||||
import { WorkspaceTree } from "../WorkspaceTree";
|
||||
import { workspaceDot } from "../lanes/WorkspaceRow";
|
||||
|
||||
function Where() {
|
||||
@@ -83,7 +90,7 @@ function renderTree(at = "/") {
|
||||
path="*"
|
||||
element={
|
||||
<>
|
||||
<ProjectTree />
|
||||
<WorkspaceTree />
|
||||
<Where />
|
||||
</>
|
||||
}
|
||||
@@ -93,122 +100,96 @@ function renderTree(at = "/") {
|
||||
);
|
||||
}
|
||||
|
||||
const rowIds = () => screen.getAllByTestId(/^workspace-row-\d+$/).map((el) => el.dataset.testid);
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
vi.clearAllMocks();
|
||||
lanes = [
|
||||
lane(1, "/r", { branch: "main" }),
|
||||
lane(1, "/r", { title: "Repo" }),
|
||||
lane(2, "/r/sub", { title: "Sub" }),
|
||||
lane(3, "/tmp/plain", { kind: "managed", tool: "codex" }),
|
||||
];
|
||||
projects = [
|
||||
{
|
||||
root: "/r",
|
||||
name: "r",
|
||||
workspaces: [1, 2],
|
||||
worktrees: [
|
||||
{ path: "/r", branch: "main", main: true, workspace_id: 1 },
|
||||
{
|
||||
path: "/r/.claude/worktrees/x",
|
||||
branch: "wf-x",
|
||||
main: false,
|
||||
workspace_id: null,
|
||||
source: "claude",
|
||||
},
|
||||
{ path: "/elsewhere", branch: "feat", main: false, workspace_id: null },
|
||||
],
|
||||
},
|
||||
{ root: null, name: "Folders", workspaces: [3], worktrees: [] },
|
||||
];
|
||||
});
|
||||
|
||||
describe("ProjectTree", () => {
|
||||
it("groups workspaces under their repo, Folders last, with the primary badge", async () => {
|
||||
describe("WorkspaceTree", () => {
|
||||
it("lists every workspace on one flat level, collapsed", async () => {
|
||||
renderTree();
|
||||
const repo = await screen.findByTestId("project-r");
|
||||
expect(within(repo).getByTestId("workspace-row-1")).toBeInTheDocument();
|
||||
expect(within(repo).getByTestId("workspace-row-2")).toBeInTheDocument();
|
||||
expect(within(repo).getByText("primary")).toBeInTheDocument();
|
||||
const folders = screen.getByTestId("project-folders");
|
||||
expect(within(folders).getByTestId("workspace-row-3")).toBeInTheDocument();
|
||||
expect(repo.compareDocumentPosition(folders) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
await screen.findByTestId("workspace-row-3");
|
||||
expect(rowIds()).toEqual(["workspace-row-1", "workspace-row-2", "workspace-row-3"]);
|
||||
expect(screen.getByTestId("workspace-row-3")).toHaveTextContent("plain");
|
||||
expect(screen.queryByTestId("workspace-sessions-1")).toBeNull();
|
||||
expect(api.sessions.list).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("lists untracked worktrees with Adopt, and adopting selects the new workspace", async () => {
|
||||
it("expands a workspace to its sessions and remembers it", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderTree();
|
||||
await screen.findByTestId("project-r");
|
||||
expect(screen.getAllByTestId("untracked-worktree")).toHaveLength(2);
|
||||
await user.click(screen.getAllByRole("button", { name: "Adopt" })[1]!);
|
||||
expect(api.lanes.ensure).toHaveBeenCalledWith({
|
||||
cwd: "/elsewhere",
|
||||
tool: "claude",
|
||||
exact: true,
|
||||
});
|
||||
await waitFor(() => expect(screen.getByTestId("where").textContent).toBe("/workspace?lane=9"));
|
||||
});
|
||||
|
||||
it("hides a hidden source and says how many, and reveals them on click", async () => {
|
||||
const user = userEvent.setup();
|
||||
localStorage.setItem(
|
||||
"agenthub.sidebar.projects",
|
||||
JSON.stringify({ hideSources: ["claude"], showUntracked: true, collapsed: {} })
|
||||
);
|
||||
renderTree();
|
||||
await screen.findByTestId("project-r");
|
||||
expect(screen.getAllByTestId("untracked-worktree")).toHaveLength(1);
|
||||
await user.click(screen.getByText("1 hidden"));
|
||||
expect(screen.getAllByTestId("untracked-worktree")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("remembers a collapsed project", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderTree();
|
||||
const repo = await screen.findByTestId("project-r");
|
||||
await user.click(within(repo).getByRole("button", { name: "r" }));
|
||||
expect(screen.queryByTestId("workspace-row-1")).toBeNull();
|
||||
expect(JSON.parse(localStorage.getItem("agenthub.sidebar.projects")!).collapsed).toEqual({
|
||||
"/r": true,
|
||||
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 });
|
||||
expect(JSON.parse(localStorage.getItem("agenthub.sidebar.projects")!).expanded).toEqual({
|
||||
"1": true,
|
||||
});
|
||||
});
|
||||
|
||||
it("selects a workspace by URL and marks the current one", async () => {
|
||||
it("opens a live session's pane and an ended session's transcript, resuming nothing", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderTree();
|
||||
await user.click(await screen.findByTestId("workspace-toggle-1"));
|
||||
const live = await screen.findByTestId("workspace-session-s-live");
|
||||
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");
|
||||
await user.click(screen.getByTestId("workspace-session-s-old"));
|
||||
expect(screen.getByTestId("where").textContent).toBe("/sessions/s-old");
|
||||
expect(api.lanes.action).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("selects by URL, and a click selects and expands", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderTree("/workspace?lane=2");
|
||||
const row = await screen.findByTestId("workspace-row-2");
|
||||
expect(within(row).getAllByRole("button")[0]).toHaveAttribute("aria-current", "true");
|
||||
await user.click(within(screen.getByTestId("workspace-row-1")).getAllByRole("button")[0]!);
|
||||
expect(await screen.findByTestId("workspace-open-2")).toHaveAttribute("aria-current", "true");
|
||||
await user.click(screen.getByTestId("workspace-open-1"));
|
||||
expect(screen.getByTestId("where").textContent).toBe("/workspace?lane=1");
|
||||
expect(await screen.findByTestId("workspace-sessions-1")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("reorders within a project with Alt+ArrowUp and keeps the order in localStorage", async () => {
|
||||
it("reorders with Alt+ArrowUp and keeps the order in localStorage", async () => {
|
||||
renderTree();
|
||||
const row2 = await screen.findByTestId("workspace-row-2");
|
||||
fireEvent.keyDown(within(row2).getAllByRole("button")[0]!, { key: "ArrowUp", altKey: true });
|
||||
const order = JSON.parse(localStorage.getItem("agenthub.workspace.laneOrder")!);
|
||||
expect(order.slice(0, 2)).toEqual([2, 1]);
|
||||
const ids = within(screen.getByTestId("project-r"))
|
||||
.getAllByTestId(/^workspace-row-/)
|
||||
.map((el) => el.dataset.testid);
|
||||
expect(ids).toEqual(["workspace-row-2", "workspace-row-1"]);
|
||||
fireEvent.keyDown(await screen.findByTestId("workspace-open-2"), {
|
||||
key: "ArrowUp",
|
||||
altKey: true,
|
||||
});
|
||||
expect(JSON.parse(localStorage.getItem("agenthub.workspace.laneOrder")!)).toEqual([2, 1, 3]);
|
||||
expect(rowIds()).toEqual(["workspace-row-2", "workspace-row-1", "workspace-row-3"]);
|
||||
});
|
||||
|
||||
it("renames on double-click + Enter, and Escape cancels without a call", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderTree();
|
||||
const row = await screen.findByTestId("workspace-row-2");
|
||||
await user.dblClick(within(row).getAllByRole("button")[0]!);
|
||||
await user.dblClick(await screen.findByTestId("workspace-open-2"));
|
||||
const input = screen.getByTestId("workspace-rename-2");
|
||||
await user.clear(input);
|
||||
await user.type(input, " Renamed {Enter}");
|
||||
expect(api.lanes.update).toHaveBeenCalledWith(2, { title: "Renamed" });
|
||||
|
||||
vi.mocked(api.lanes.update).mockClear();
|
||||
await user.dblClick(within(screen.getByTestId("workspace-row-2")).getAllByRole("button")[0]!);
|
||||
await user.dblClick(screen.getByTestId("workspace-open-2"));
|
||||
await user.type(screen.getByTestId("workspace-rename-2"), "x{Escape}");
|
||||
expect(api.lanes.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("filters workspaces by search", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderTree();
|
||||
await screen.findByTestId("workspace-row-1");
|
||||
await user.click(screen.getByRole("button", { name: "Search workspaces and sessions" }));
|
||||
await user.type(screen.getByRole("searchbox"), "plain");
|
||||
expect(rowIds()).toEqual(["workspace-row-3"]);
|
||||
});
|
||||
|
||||
it("offers Reset only on a managed workspace and the readme run only on claude", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderTree();
|
||||
@@ -1,17 +1,18 @@
|
||||
/**
|
||||
* @file WorkspaceRow — one workspace (lane) in the sidebar's project tree:
|
||||
* status dot, title (double-click renames, `PATCH /api/lanes/:id {title}`),
|
||||
* branch or relative path, the tool's icon and the id the CLI addresses it by.
|
||||
* Selecting it opens the Workspace page on that lane. Dragging it, or Alt+↑/↓,
|
||||
* reorders it within its project — a viewer preference kept in localStorage
|
||||
* (`laneOrderStorage.ts`), never written to the lane row. Right-click or the
|
||||
* `⋯` button opens the row menu (`WorkspaceRowMenu.tsx`).
|
||||
* @file WorkspaceRow — one workspace (lane) in the sidebar's workspace list, one
|
||||
* line like a Claude Desktop project: a chevron that expands its sessions, the
|
||||
* status dot, the name (click opens the workspace, double-click renames,
|
||||
* `PATCH /api/lanes/:id {title}`), the tool's icon, and on hover `+` (a new
|
||||
* session there) and `⋯` (the row menu, `WorkspaceRowMenu.tsx`, also on
|
||||
* right-click). Dragging it, or Alt+↑/↓, reorders the list — a viewer
|
||||
* preference kept in localStorage (`laneOrderStorage.ts`), never written to
|
||||
* the lane row.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useState, type MouseEvent } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MoreHorizontal } from "lucide-react";
|
||||
import { ChevronDown, ChevronRight, MoreHorizontal, Plus } from "lucide-react";
|
||||
import type { Lane } from "../../lib/types";
|
||||
import { useAiTools } from "../../hooks/useAiTools";
|
||||
import { AiToolIcon } from "../AiToolIcon";
|
||||
@@ -25,34 +26,33 @@ export function workspaceDot(lane: Lane): string {
|
||||
return "bg-surface-4";
|
||||
}
|
||||
|
||||
function relativeTo(root: string | null, cwd: string): string {
|
||||
if (root && cwd.startsWith(root + "/")) return cwd.slice(root.length + 1);
|
||||
return cwd;
|
||||
export function workspaceName(lane: Lane): string {
|
||||
return lane.title || lane.cwd.split("/").filter(Boolean).pop() || lane.cwd;
|
||||
}
|
||||
|
||||
export function WorkspaceRow({
|
||||
lane,
|
||||
projectRoot,
|
||||
branch,
|
||||
index,
|
||||
selected,
|
||||
expanded,
|
||||
renaming,
|
||||
onSelect,
|
||||
onToggle,
|
||||
onNewSession,
|
||||
onReorder,
|
||||
onMenu,
|
||||
onRenameStart,
|
||||
onRenameEnd,
|
||||
}: {
|
||||
lane: Lane;
|
||||
projectRoot: string | null;
|
||||
/** The checked-out branch: the lane's own, else its worktree's (an adopted
|
||||
* lane records none). */
|
||||
branch: string | null;
|
||||
/** Position within its project — the unit both reorder paths speak in. */
|
||||
/** Position in the list — the unit both reorder paths speak in. */
|
||||
index: number;
|
||||
selected: boolean;
|
||||
expanded: boolean;
|
||||
renaming: boolean;
|
||||
onSelect: () => void;
|
||||
onToggle: () => void;
|
||||
onNewSession: () => void;
|
||||
onReorder: (from: number, to: number) => void;
|
||||
/** Open the row menu at a viewport point. */
|
||||
onMenu: (x: number, y: number) => void;
|
||||
@@ -64,12 +64,15 @@ export function WorkspaceRow({
|
||||
const tools = useAiTools();
|
||||
const [draft, setDraft] = useState("");
|
||||
const [dropTarget, setDropTarget] = useState(false);
|
||||
const name = lane.title || lane.cwd.split("/").filter(Boolean).pop() || lane.cwd;
|
||||
const name = workspaceName(lane);
|
||||
const openMenu = (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onMenu(e.clientX, e.clientY);
|
||||
};
|
||||
const Chevron = expanded ? ChevronDown : ChevronRight;
|
||||
const hoverBtn =
|
||||
"shrink-0 rounded p-0.5 text-fg-muted opacity-0 transition-opacity hover:bg-surface-4 hover:text-fg-primary focus:opacity-100 group-hover:opacity-100";
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -94,16 +97,26 @@ export function WorkspaceRow({
|
||||
if (Number.isInteger(from)) onReorder(from, index);
|
||||
}}
|
||||
onContextMenu={openMenu}
|
||||
className={`group relative flex items-center rounded-md border-l-2 transition-colors ${
|
||||
className={`group flex items-center gap-1 rounded-md pl-1 pr-1 transition-colors ${
|
||||
dropTarget
|
||||
? "border-accent bg-accent/20"
|
||||
? "bg-accent/20"
|
||||
: selected
|
||||
? "border-accent bg-accent/10"
|
||||
? "bg-accent/10"
|
||||
: lane.needs_action
|
||||
? "border-transparent bg-status-warning/10 hover:bg-status-warning/20"
|
||||
: "border-transparent hover:bg-surface-3"
|
||||
? "bg-status-warning/10 hover:bg-status-warning/20"
|
||||
: "hover:bg-surface-3"
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`workspace-toggle-${lane.id}`}
|
||||
aria-expanded={expanded}
|
||||
aria-label={name}
|
||||
onClick={onToggle}
|
||||
className="shrink-0 rounded p-0.5 text-fg-muted hover:text-fg-primary"
|
||||
>
|
||||
<Chevron className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
{renaming ? (
|
||||
<input
|
||||
data-testid={`workspace-rename-${lane.id}`}
|
||||
@@ -118,11 +131,12 @@ export function WorkspaceRow({
|
||||
if (e.key === "Enter") onRenameEnd(draft.trim());
|
||||
if (e.key === "Escape") onRenameEnd(null);
|
||||
}}
|
||||
className="m-1 min-w-0 flex-1 rounded border border-accent bg-surface-1 px-1.5 py-0.5 text-[13px] text-fg-primary"
|
||||
className="my-1 min-w-0 flex-1 rounded border border-accent bg-surface-1 px-1.5 py-0.5 text-[13px] text-fg-primary"
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`workspace-open-${lane.id}`}
|
||||
aria-current={selected ? "true" : undefined}
|
||||
onClick={onSelect}
|
||||
onDoubleClick={() => {
|
||||
@@ -135,42 +149,39 @@ export function WorkspaceRow({
|
||||
onReorder(index, index + (e.key === "ArrowUp" ? -1 : 1));
|
||||
}}
|
||||
title={`${lane.cwd}\n${t("row.hint")}`}
|
||||
className="flex min-w-0 flex-1 items-start gap-2 py-1 pl-2 pr-7 text-left"
|
||||
className="flex min-w-0 flex-1 items-center gap-2 py-1.5 text-left"
|
||||
>
|
||||
<span className={`mt-1.5 h-2 w-2 shrink-0 rounded-full ${workspaceDot(lane)}`} />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="truncate text-[13px] font-medium text-fg-primary">{name}</span>
|
||||
{projectRoot !== null && lane.cwd === projectRoot && (
|
||||
<span className="shrink-0 rounded border border-border px-1 text-[10px] text-fg-muted">
|
||||
{t("row.primary")}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="flex items-center gap-1 text-[11px] text-fg-muted">
|
||||
<span className="min-w-0 truncate">
|
||||
{branch || relativeTo(projectRoot, lane.cwd)}
|
||||
</span>
|
||||
<AiToolIcon
|
||||
tool={{ id: lane.tool, label: tools.label(lane.tool) }}
|
||||
className="ml-auto h-3.5 w-3.5 !p-0"
|
||||
/>
|
||||
<span className="shrink-0 tabular-nums">#{lane.id}</span>
|
||||
</span>
|
||||
</span>
|
||||
<span className={`h-2 w-2 shrink-0 rounded-full ${workspaceDot(lane)}`} />
|
||||
<span className="truncate text-[13px] text-fg-primary">{name}</span>
|
||||
<AiToolIcon
|
||||
tool={{ id: lane.tool, label: tools.label(lane.tool) }}
|
||||
className="h-3.5 w-3.5 shrink-0 !p-0 opacity-70"
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
{!renaming && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`workspace-menu-${lane.id}`}
|
||||
aria-label={t("row.actions")}
|
||||
title={t("row.actions")}
|
||||
onClick={openMenu}
|
||||
className="absolute right-1 top-1 rounded p-0.5 text-fg-muted opacity-0 transition-opacity hover:bg-surface-4 hover:text-fg-primary focus:opacity-100 group-hover:opacity-100"
|
||||
>
|
||||
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`workspace-new-${lane.id}`}
|
||||
aria-label={t("tree.newSession")}
|
||||
title={t("tree.newSession")}
|
||||
onClick={onNewSession}
|
||||
className={hoverBtn}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`workspace-menu-${lane.id}`}
|
||||
aria-label={t("row.actions")}
|
||||
title={t("row.actions")}
|
||||
onClick={openMenu}
|
||||
className={hoverBtn}
|
||||
>
|
||||
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* @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.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { api, type RunHandle } from "../../lib/api";
|
||||
import type { Lane, Session } from "../../lib/types";
|
||||
import { timeAgo } from "../../lib/format";
|
||||
|
||||
export const SESSION_LIMIT = 20;
|
||||
|
||||
export function WorkspaceSessionList({
|
||||
lane,
|
||||
liveRuns,
|
||||
query,
|
||||
selectedRunId,
|
||||
}: {
|
||||
lane: Lane;
|
||||
/** Every live dashboard pane, from `GET /api/run`. */
|
||||
liveRuns: RunHandle[];
|
||||
/** The sidebar search; filters by session name. */
|
||||
query: string;
|
||||
/** The `run` the Workspace page is attached to, to mark its row. */
|
||||
selectedRunId: string | null;
|
||||
}) {
|
||||
const { t } = useTranslation("lanes");
|
||||
const navigate = useNavigate();
|
||||
const [sessions, setSessions] = useState<Session[] | 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]);
|
||||
|
||||
const q = query.trim().toLowerCase();
|
||||
const shown = (sessions ?? []).filter(
|
||||
(s) => !q || (s.name ?? "").toLowerCase().includes(q) || s.id.startsWith(q)
|
||||
);
|
||||
const runFor = (s: Session) =>
|
||||
liveRuns.find((r) => r.sessionId === s.id && r.status === "running") ?? null;
|
||||
|
||||
if (sessions !== null && shown.length === 0) {
|
||||
return <p className="py-1 pl-8 text-[11px] text-fg-muted">{t("tree.noSessions")}</p>;
|
||||
}
|
||||
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"
|
||||
}`}
|
||||
/>
|
||||
<span className="truncate">{s.name || s.id.slice(0, 8)}</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -90,6 +90,9 @@ export interface LaneConsolePaneProps {
|
||||
* `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;
|
||||
}
|
||||
|
||||
/** The tmux session a lane's split pane owns (see server/lib/pty-run.js). */
|
||||
@@ -114,6 +117,7 @@ export function LaneConsolePane({
|
||||
hiddenRunIds,
|
||||
onHandleChange,
|
||||
resumeRequest,
|
||||
attachRequest,
|
||||
}: LaneConsolePaneProps) {
|
||||
const { t } = useTranslation("run");
|
||||
const { t: tLanes } = useTranslation("lanes");
|
||||
@@ -216,6 +220,29 @@ export function LaneConsolePane({
|
||||
refreshList();
|
||||
}, [laneId, slot, refreshList]);
|
||||
|
||||
// Runs after the lane-switch effect above, so a sidebar pick that changes
|
||||
// lane AND run at once lands on the picked run, not the lane's own.
|
||||
useEffect(() => {
|
||||
if (!attachRequest) return;
|
||||
let live = true;
|
||||
autoAttachedForLane.current = laneId;
|
||||
api.run
|
||||
.get(attachRequest.runId)
|
||||
.then((h) => live && setHandle(h))
|
||||
.catch(
|
||||
(err: unknown) =>
|
||||
live &&
|
||||
setError(
|
||||
t("errors.attachFailed", { message: err instanceof Error ? err.message : "unknown" })
|
||||
)
|
||||
);
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
// Only a new request (nonce) attaches; a lane switch never replays one.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [attachRequest?.nonce]);
|
||||
|
||||
// Picking a pipeline template writes NOTHING into the prompt box: a prefilled
|
||||
// slash command (or the codex stage-checklist prose) is sent as soon as the
|
||||
// run starts, which is an extra prompt nobody asked for. The prompt is the
|
||||
|
||||
@@ -526,4 +526,34 @@ describe("LaneConsolePane", () => {
|
||||
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",
|
||||
laneId: 1,
|
||||
slot: 2,
|
||||
status: "running",
|
||||
cwd: "/workspace/a",
|
||||
model: null,
|
||||
permissionMode: null,
|
||||
effort: null,
|
||||
resumeSessionId: null,
|
||||
sessionId: "s-9",
|
||||
startedAt: null,
|
||||
promptPreview: null,
|
||||
tool: "claude",
|
||||
});
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<LaneConsolePane
|
||||
{...baseProps()}
|
||||
attachRequest={{ runId: "agenthub-lane-1-2", nonce: "k1" }}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
);
|
||||
await screen.findByTestId("terminal-view");
|
||||
expect(api.run.get).toHaveBeenCalledWith("agenthub-lane-1-2");
|
||||
expect(api.lanes.action).not.toHaveBeenCalled();
|
||||
expect(api.run.start).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -312,15 +312,6 @@
|
||||
"conflict.save": "Save and mark resolved",
|
||||
"changes.takeOursHint": "Keep your version of the whole file",
|
||||
"changes.takeTheirsHint": "Take the incoming version of the whole file",
|
||||
"projects.title": "Projects",
|
||||
"projects.folders": "Folders",
|
||||
"projects.newWorkspace": "New workspace in {{name}}",
|
||||
"projects.refresh": "Refresh projects and worktrees",
|
||||
"projects.hidden_one": "{{count}} hidden",
|
||||
"projects.hidden_other": "{{count}} hidden",
|
||||
"projects.pick": "Pick a workspace in the sidebar",
|
||||
"row.primary": "primary",
|
||||
"row.adopt": "Adopt",
|
||||
"row.actions": "Workspace actions",
|
||||
"row.hint": "Double-click to rename · drag or Alt+↑/↓ to reorder · right-click for actions",
|
||||
"menu.start": "Start",
|
||||
@@ -338,5 +329,10 @@
|
||||
"sessions.empty": "No sessions in this workspace yet",
|
||||
"sessions.resume": "Resume",
|
||||
"sessions.open": "Open",
|
||||
"sessions.search": "Filter sessions…"
|
||||
"sessions.search": "Filter sessions…",
|
||||
"tree.title": "Workspaces",
|
||||
"tree.search": "Search workspaces and sessions",
|
||||
"tree.newSession": "New session",
|
||||
"tree.noSessions": "No sessions yet",
|
||||
"tree.transcriptHint": "No live pane — opens the transcript"
|
||||
}
|
||||
|
||||
@@ -312,15 +312,6 @@
|
||||
"conflict.save": "Lưu và đánh dấu đã xong",
|
||||
"changes.takeOursHint": "Giữ bản của mình cho cả file",
|
||||
"changes.takeTheirsHint": "Lấy bản kia cho cả file",
|
||||
"projects.title": "Dự án",
|
||||
"projects.folders": "Thư mục",
|
||||
"projects.newWorkspace": "Workspace mới trong {{name}}",
|
||||
"projects.refresh": "Làm mới dự án và worktree",
|
||||
"projects.hidden_one": "{{count}} đang ẩn",
|
||||
"projects.hidden_other": "{{count}} đang ẩn",
|
||||
"projects.pick": "Chọn một workspace ở thanh bên",
|
||||
"row.primary": "chính",
|
||||
"row.adopt": "Nhận",
|
||||
"row.actions": "Thao tác workspace",
|
||||
"row.hint": "Nhấp đúp để đổi tên · kéo hoặc Alt+↑/↓ để sắp xếp · chuột phải để xem thao tác",
|
||||
"menu.start": "Bắt đầu",
|
||||
@@ -338,5 +329,10 @@
|
||||
"sessions.empty": "Chưa có phiên nào trong workspace này",
|
||||
"sessions.resume": "Tiếp tục",
|
||||
"sessions.open": "Mở",
|
||||
"sessions.search": "Lọc phiên…"
|
||||
"sessions.search": "Lọc phiên…",
|
||||
"tree.title": "Không gian làm việc",
|
||||
"tree.search": "Tìm workspace và session",
|
||||
"tree.newSession": "Session mới",
|
||||
"tree.noSessions": "Chưa có session",
|
||||
"tree.transcriptHint": "Không còn pane — mở transcript"
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* @file projectTreePrefs — the sidebar project tree's viewer preferences, in
|
||||
* ONE localStorage key (`agenthub.sidebar.projects`): which projects are
|
||||
* collapsed, whether a repo's untracked worktrees are listed, and which
|
||||
* @file projectTreePrefs — the sidebar workspace list's viewer preferences, in
|
||||
* ONE localStorage key (`agenthub.sidebar.projects`): which workspaces are
|
||||
* expanded to their sessions (by lane id), whether a repo's untracked
|
||||
* worktrees are offered, and which
|
||||
* worktree sources (`claude` = `<repo>/.claude/worktrees/*`, `agenthub` =
|
||||
* LANES_ROOT) are hidden — globally, or per project root, where a project's own
|
||||
* list (Settings → Projects) replaces the global one. A `useSyncExternalStore` reader that caches nothing
|
||||
@@ -15,7 +16,8 @@ import { useSyncExternalStore } from "react";
|
||||
export const PROJECT_TREE_KEY = "agenthub.sidebar.projects";
|
||||
|
||||
export interface ProjectTreePrefs {
|
||||
collapsed: Record<string, boolean>;
|
||||
/** Lane id → its sessions are shown under it in the sidebar. */
|
||||
expanded: Record<string, boolean>;
|
||||
showUntracked: boolean;
|
||||
hideSources: string[];
|
||||
/** Per project root: replaces `hideSources` for that project. */
|
||||
@@ -23,7 +25,7 @@ export interface ProjectTreePrefs {
|
||||
}
|
||||
|
||||
const DEFAULTS: ProjectTreePrefs = {
|
||||
collapsed: {},
|
||||
expanded: {},
|
||||
showUntracked: true,
|
||||
hideSources: [],
|
||||
projectHideSources: {},
|
||||
@@ -46,7 +48,7 @@ export function readProjectTreePrefs(): ProjectTreePrefs {
|
||||
try {
|
||||
const parsed = raw ? (JSON.parse(raw) as Partial<ProjectTreePrefs>) : {};
|
||||
value = {
|
||||
collapsed: parsed.collapsed && typeof parsed.collapsed === "object" ? parsed.collapsed : {},
|
||||
expanded: parsed.expanded && typeof parsed.expanded === "object" ? parsed.expanded : {},
|
||||
showUntracked: parsed.showUntracked !== false,
|
||||
hideSources: Array.isArray(parsed.hideSources) ? parsed.hideSources : [],
|
||||
projectHideSources:
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { useLocation, useSearchParams } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Plus,
|
||||
@@ -231,6 +231,7 @@ function ConsoleArea({
|
||||
onLaneIdChange,
|
||||
showToggle = true,
|
||||
resumeRequest,
|
||||
attachRequest,
|
||||
}: {
|
||||
lanes: Lane[];
|
||||
selectedLaneId: number | null;
|
||||
@@ -248,6 +249,8 @@ function ConsoleArea({
|
||||
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;
|
||||
}) {
|
||||
// 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
|
||||
@@ -292,6 +295,7 @@ function ConsoleArea({
|
||||
defaultCwd={defaultCwd}
|
||||
onHasActiveRunChange={onHasActiveRunChange}
|
||||
resumeRequest={resumeRequest}
|
||||
attachRequest={attachRequest}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
@@ -323,6 +327,7 @@ function ConsoleArea({
|
||||
wsConnected={wsConnected}
|
||||
defaultCwd={defaultCwd}
|
||||
resumeRequest={i === 0 ? resumeRequest : null}
|
||||
attachRequest={i === 0 ? attachRequest : null}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
@@ -342,6 +347,14 @@ export function Workspace() {
|
||||
// notification click), falling back to the last one shown, then the first.
|
||||
const { lanes, loaded: lanesLoaded, refresh: refreshLanes } = useLanes();
|
||||
const laneParam = searchParams.get("lane");
|
||||
// `?run=` — a live session picked in the sidebar; the location key makes a
|
||||
// second pick of the same session a new request.
|
||||
const location = useLocation();
|
||||
const runParam = searchParams.get("run");
|
||||
const attachRequest = useMemo(
|
||||
() => (runParam ? { runId: runParam, nonce: location.key } : null),
|
||||
[runParam, location.key]
|
||||
);
|
||||
const paramId = laneParam === null ? null : Number(laneParam);
|
||||
const fallbackId = (() => {
|
||||
if (!lanesLoaded) return null;
|
||||
@@ -948,6 +961,7 @@ export function Workspace() {
|
||||
onLaneIdChange={setSelectedLaneId}
|
||||
showToggle={!currentLane}
|
||||
resumeRequest={resumeRequest}
|
||||
attachRequest={attachRequest}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
+5
-4
@@ -389,13 +389,14 @@ A missing source `.env` falls back to `.env.example` with a loud warning, never
|
||||
|
||||
The dashboard web UI merges workspaces and runs into a single **Workspace** page accessible at `/run` (the old `/lanes` route now redirects here). Top to bottom:
|
||||
|
||||
- **No page header.** The page title and the four counters (`lanes`, `running`, `needs you`, `dead`) are gone: the sidebar's project tree shows every workspace with its own status dot, and the status bar at the bottom of every page counts running and need-you workspaces. `GET /api/lanes` still returns `counts` for other callers.
|
||||
- **The sidebar's project tree picks the workspace** — on every page, between the nav and the footer (`client/src/components/ProjectTree.tsx`). Workspaces are grouped under **Projects**: one per git repository, keyed by the repo's MAIN worktree root, with every workspace outside a repo under **Folders** (last). The grouping comes from `GET /api/projects` (see `docs/API.md`), read when the set of workspaces changes or on the header's Refresh — never on every status frame, so it shells out to git only then, and caches what it read. A row (`WorkspaceRow.tsx`) shows a status dot (green = active, pulsing while running; amber row = needs you; red = dead or error; grey = idle), the title or folder name, a `primary` badge on the repo's own root, the branch (or the path relative to the repo), the tool's icon and the id as `#23` — the number the CLI addresses it by. Clicking a row opens `/workspace?lane=<id>`: the URL is the selection, so a notification link, F5 and the back button all land on the same workspace, and a bare `/workspace` reopens the last one shown (`agenthub.workspace.selected`) or the first. The project's `+` opens the Add-workspace modal prefilled with that repo; the PROJECTS header has Refresh and a plain `+`. In the collapsed rail the tree is a single icon that opens the sidebar.
|
||||
- **No page header.** The page title and the four counters (`lanes`, `running`, `needs you`, `dead`) are gone: the sidebar's workspace list shows every workspace with its own status dot, and the status bar at the bottom of every page counts running and need-you workspaces. `GET /api/lanes` still returns `counts` for other callers.
|
||||
- **The sidebar's workspace list picks the workspace** — on every page, between the nav and the footer (`client/src/components/WorkspaceTree.tsx`), shaped like Claude Desktop's project list: every workspace on ONE level (no repo grouping, no worktree rows), in your stored order. A row (`WorkspaceRow.tsx`) is a chevron, a status dot (green = active, pulsing while running; amber row = needs you; red = dead or error; grey = idle), the title or folder name and the tool's icon, with `+` (open it for a new session) and `⋯` on hover. Clicking the name opens `/workspace?lane=<id>` and expands the row: the URL is the selection, so a notification link, F5 and the back button all land on the same workspace, and a bare `/workspace` reopens the last one shown (`agenthub.workspace.selected`) or the first. The header has search (workspace names and paths, plus the names of the sessions listed) and `+` (Add workspace). In the collapsed rail the list is a single icon that opens the sidebar.
|
||||
- **A workspace's sessions** — the chevron expands the row to its sessions (`WorkspaceSessionList.tsx`: `GET /api/sessions?cwd=&tool=`, the newest 20, re-read when the workspace's status or session moves; which rows are expanded is kept in `agenthub.sidebar.projects` → `expanded`). A filled dot marks a session that still has a live pane (a `GET /api/run` handle carrying its session id): clicking it opens `/workspace?lane=<id>&run=<runId>`, which ATTACHES pane 0 to that tmux session (`attachRequest` on `LaneConsolePane`) — it starts nothing and resumes nothing. Any other session opens its read-only transcript at `/sessions/<id>`; resuming it is a separate, explicit action there, because a resume re-reads the whole transcript into context.
|
||||
- **Row menu** — right-click a row, or its `⋯` button: Start/Stop (slot 0), Rename, the Model (AI Router) select, Create/update readme file (Claude only), New workspace here…, Copy path, Reset worktree… (managed only) and Delete workspace…, each the call the rest of the UI already makes. Delete is record-only exactly as before (see "Removing a workspace").
|
||||
- **Untracked worktrees** — a repo's worktrees that no workspace sits on (from `git worktree list`) are listed dimmed under the project, with **Adopt** (`POST /api/lanes/ensure {exact: true}` with the default AI tool, then selected). Shown by default; the tree's preferences (`agenthub.sidebar.projects` in `localStorage`: collapsed projects, `showUntracked`, hidden sources `claude` = `<repo>/.claude/worktrees/*` and `agenthub` = `LANES_ROOT`) can hide them — Settings → Git → **Worktrees** (`/settings/worktrees`) for every project, and a project's own page (`/settings/projects/<encoded root>`) to override the sources for that project only (`projectHideSources`) — and the project row then says "N hidden" — a click shows them for this visit.
|
||||
- **Untracked worktrees** are no longer listed in the sidebar; the settings that hid them (`showUntracked`, hidden sources `claude` = `<repo>/.claude/worktrees/*` and `agenthub` = `LANES_ROOT`, Settings → Git → **Worktrees** and a project's own page) are kept for the workspace page's folder suggestions.
|
||||
- **A project's settings page** — Settings → Projects → <repo> shows the repository's root, main branch and worktree count, links each of its workspaces, holds the per-project worktree override above, and offers **Forget all workspaces…**: after one confirm listing them it runs the same `remove` as the row menu for each (preflight, then `POST /api/lanes/:id/remove` with those facts as `expect`) and stops at the first refusal. Forgetting drops AgentHub's records only — nothing on disk is deleted.
|
||||
- **No workspaces yet** — the page shows a single banner (`lane-empty`): icon, one line of what a workspace is, the CLI equivalent (`agenthub lanes add --cwd $(pwd)`) and an **Add workspace** affordance. The whole banner IS the button — clicking anywhere on it opens the Add-workspace modal. The banner stays compact on purpose: the run setup form below it is what needs the height.
|
||||
- **Reordering** — drag a row onto another within its project, or press **Alt+↑/↓** with a row focused. The order is a **per-viewer preference**, not a workspace fact: it persists to `localStorage` (`agenthub.workspace.laneOrder`) and writes nothing server-side — `GET /api/lanes` and `agenthub lanes` keep answering in id order, and the order does not follow you to another browser. A workspace the stored order has never seen lands at the END of its project; a stored id whose workspace is gone is skipped.
|
||||
- **Reordering** — drag a row onto another, or press **Alt+↑/↓** with a row focused. The order is a **per-viewer preference**, not a workspace fact: it persists to `localStorage` (`agenthub.workspace.laneOrder`) and writes nothing server-side — `GET /api/lanes` and `agenthub lanes` keep answering in id order, and the order does not follow you to another browser. A workspace the stored order has never seen lands at the END; a stored id whose workspace is gone is skipped.
|
||||
- **Renaming a workspace** — double-click its sidebar row (or Rename in the row menu) to edit in place. **Enter** saves via `PATCH /api/lanes/:id` with `{title}`, **Escape** and clicking away both cancel, and saving an empty name clears the title, which puts the folder name back in its place. Renaming is display-only — a managed workspace's branch name was slugified from its title once, at creation.
|
||||
- **Workspace-detail header row** — one row, and every per-workspace control lives on it: liveness + status, the workspace's name (read-only here — renaming is on the sidebar row; no "Workspace 23 ·" prefix — the id is on the row), time-on-stage, the pipeline picker, the Model (AI Router) select, the feature picker (time-on-stage and both pickers only while a pipeline template is installed; there are no stage chips here — the declared stage and the `auto: <stage>` detection both render in the PipelineMap directly below, so the chips were a second, smaller copy of the same two facts), and on the right the **Tools** menu (mcp sync / sync-base check / create-update readme, hidden when the workspace declares no runtime profile) and the split-view toggle. The detail block under it (the workspace card, the PipelineMap, the proof gallery) is always shown — there is no info toggle any more — and Delete lives in the sidebar row menu. Tools sits here rather than in Settings because every one of its actions runs against one workspace's `cwd` — in Settings it would need a workspace picker just to say which. **Create/update readme file** (Claude workspaces only — Codex has no slash commands) starts the workspace's terminal run with `/sg-all:sg-md-writing` as its first message, so the workspace opens already writing/refreshing its `CLAUDE.md`/`AGENTS.md` set; it needs the `sg-all` plugin installed in Claude Code, and without it the session just reports an unknown command. It used to be a checkbox in the Add-workspace modal: creating a workspace and writing its docs are separate decisions, and the second is worth re-running long after the first.
|
||||
- **Detail panel** — the selected workspace's declared stage, its inferred stage when detection leads, a full-width pipeline map, and a legend naming all five node states plus the dashed-amber inferred treatment Hidden while no pipeline template is installed (see "Pipeline stages and the five node states").
|
||||
|
||||
Reference in New Issue
Block a user