Files
AgentHub/client/src/components/lanes/WorkspaceSessionList.tsx
T
nntrivi2001 fa156d6a59 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.
2026-10-09 16:35:07 +07:00

616 lines
25 KiB
TypeScript

/**
* @file WorkspaceSessionList — the sessions under an expanded workspace in the
* sidebar: the ones the workspace OWNS and has not archived
* (`GET /api/sessions?lane=<id>&archived=0`, newest first, the ones with a live
* pane above the ended ones) — never the other
* sessions that ran in its folder. A row's dot
* shows its state (`sessionDot`: live pane, needs you, error) and its right
* edge the AI tool the session ran on (`sessions.tool`); double-click
* renames it and its hover button archives it (`PATCH {name}` / `{archived}`,
* which deletes nothing); a right-click opens the same actions and more
* (`SessionMenu`). 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>`, its tab in the session view; a live tab whose
* session has no row yet — a bare tab before its first prompt — is listed as
* **New session** from the run itself, and a live Terminal tab (`tool: "shell"`,
* which never has a session) as **Terminal** while it is open — a closed one
* leaves no row; Ctrl/Cmd+click opens
* it in a split in the tab on screen) — attaching, never starting or
* resuming anything;
* any other session opens in its workspace too (`?lane=<id>&session=<id>`,
* `SessionReplay`): its conversation with a prompt box, resumed only when a
* message is SENT, because a resume re-reads the whole transcript into context.
* The list shows `SESSION_LIMIT` rows and a **See all (N more)** under them that
* loads every one, then a **Show less** that goes back to `SESSION_LIMIT`. A
* live tab whose session was archived is not brought back as a New session,
* and archiving a live one asks first. F2 renames the focused row too; each
* dot carries its meaning as screen-reader text. `readOnly` (another user's
* workspace, for an analyst): no live tabs, no rename/archive/menu, and a row
* opens the session's analysis page (`/sessions/<id>`) — never a pane or a resume.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState, type ReactNode } from "react";
import { confirmDialog } from "../../lib/confirm";
import { isSubmitEnter } from "../../lib/keys";
import { Archive, GitBranch, SquareTerminal } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Link, useNavigate } from "react-router-dom";
import { api, type RunHandle } from "../../lib/api";
import { isSessionAwaitingInput, type Lane, type Session } from "../../lib/types";
import { timeAgo } from "../../lib/format";
import { ConfirmModal } from "../ConfirmModal";
import { AiToolIcon } from "../AiToolIcon";
import { useAiTools } from "../../hooks/useAiTools";
import { useThrottledValue } from "../../hooks/useThrottledValue";
import { useContextMenu } from "../ContextMenu";
import { SessionMenu } from "./SessionMenu";
export const SESSION_LIMIT = 6;
/**
* The live pane holding a session: the run the hooks bound it to
* (`dashboard_runs.session_id`), else — a pane started before the hooks
* reported it — the lane's own pane when the lane's bound session is this one.
*/
export function liveRunFor(lane: Lane, runs: RunHandle[], sessionId: string): RunHandle | null {
const live = runs.filter((r) => r.laneId === lane.id && r.status === "running");
return (
live.find((r) => r.sessionId === sessionId) ??
(lane.session_id === sessionId ? (live.find((r) => r.id === lane.run_id) ?? null) : null)
);
}
/** The name of the git worktree a session ran in (`claude --worktree` puts it
* at `<repo>/.claude/worktrees/<name>`), or null for one in the folder. */
export function sessionWorktreeName(lane: Lane, s: Session): string | null {
// Either separator: a Windows cwd is `D:\repo\.claude\worktrees\<name>`.
const strip = (p: string) => p.replace(/[\\/]+$/, "");
if (!s.cwd || strip(s.cwd) === strip(lane.cwd)) return null;
return /[\\/]\.claude[\\/]worktrees[\\/]([^\\/]+)[\\/]?$/.exec(s.cwd)?.[1] ?? null;
}
/** A session row's dot: waiting for the user, failed, held by a live pane, or
* at rest (a ring, which reads on every theme). */
export function sessionDot(session: Session, live: boolean): string {
if (isSessionAwaitingInput(session)) return "bg-status-warning";
if (session.status === "error") return "bg-status-danger";
if (live) return "bg-status-success";
return "border border-fg-muted";
}
/** What the dot means, for a screen reader (`tree.state.*`). */
export function sessionDotLabel(session: Session, live: boolean): string {
if (isSessionAwaitingInput(session)) return "awaiting";
if (session.status === "error") return "error";
return live ? "live" : "idle";
}
export type SessionPage = { sessions: Session[]; total: number };
/** One page of `GET /api/sessions`, re-read whenever `refreshKey` changes. */
export function useSessionPage(
params: Parameters<typeof api.sessions.list>[0],
refreshKey: string
): SessionPage | null {
const [page, setPage] = useState<SessionPage | null>(null);
const key = JSON.stringify(params);
useEffect(() => {
let live = true;
api.sessions
.list(JSON.parse(key))
.then((r) => live && setPage({ sessions: r.sessions, total: r.total ?? r.sessions.length }))
.catch(() => live && setPage(null));
return () => {
live = false;
};
}, [key, refreshKey]);
return page;
}
/** A folded list of sessions under a one-line toggle, each with one action
* (Unarchive); `onDone` re-reads the lists after it. */
export function SessionFold({
testId,
page,
label,
hint,
actionLabel,
actionIcon,
onAction,
onDone,
indent = "",
laneId,
}: {
testId: string;
page: SessionPage | null;
label: (count: number) => string;
hint?: string;
actionLabel: string;
actionIcon: ReactNode;
onAction: (s: Session) => Promise<unknown>;
onDone: () => void;
/** Left padding of the rows, to line up with the list above. */
indent?: string;
/** The rows' workspace: given, a right-click opens the session menu. */
laneId?: number;
}) {
const [open, setOpen] = useState(false);
const [error, setError] = useState<string | null>(null);
const ctx = useContextMenu<Session>();
if (!page || page.total === 0) return null;
const act = async (s: Session) => {
setError(null);
try {
await onAction(s);
onDone();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
}
};
return (
<div data-testid={testId}>
<button
type="button"
aria-expanded={open}
onClick={() => setOpen((o) => !o)}
title={hint}
className={`w-full py-1 pr-2 text-left text-[11px] text-fg-muted hover:text-fg-primary ${indent}`}
>
{open ? "▾" : "▸"} {label(page.total)}
</button>
{open && (
<ul className="space-y-px">
{page.sessions.map((s) => (
<li
key={s.id}
className={`flex items-center gap-1 pr-1 ${indent}`}
onContextMenu={laneId === undefined ? undefined : (e) => ctx.open(e, s)}
>
<Link
// An archived session opens in its workspace like any other
// (its replay), not on the analysis page.
to={
laneId === undefined
? `/sessions/${s.id}`
: `/workspace?lane=${laneId}&session=${encodeURIComponent(s.id)}`
}
title={`${s.name || s.id}\n${timeAgo(s.last_activity ?? s.started_at)}`}
className="min-w-0 flex-1 truncate rounded-md py-1 text-[12px] text-fg-muted hover:text-fg-primary"
>
{s.name || s.id.slice(0, 8)}
</Link>
<button
type="button"
data-testid={`${testId}-action-${s.id}`}
onClick={() => void act(s)}
title={actionLabel}
aria-label={actionLabel}
className="shrink-0 rounded p-0.5 text-fg-muted hover:bg-surface-3 hover:text-fg-primary"
>
{actionIcon}
</button>
</li>
))}
</ul>
)}
{error && (
<p role="alert" className={`text-[11px] text-status-danger ${indent}`}>
{error}
</p>
)}
{ctx.menu && laneId !== undefined && (
<SessionMenu
x={ctx.menu.x}
y={ctx.menu.y}
laneId={laneId}
session={ctx.menu.item}
run={null}
onClose={ctx.close}
onUnarchive={() => void act(ctx.menu!.item)}
/>
)}
</div>
);
}
export function WorkspaceSessionList({
lane,
liveRuns,
query,
selectedRunId,
selectedSessionId = null,
onMatches,
hidden = false,
readOnly = false,
}: {
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;
/** The ended session the Workspace page is showing (`session`), likewise. */
selectedSessionId?: string | null;
/** How many sessions match `query`, once they are loaded — lets the
* sidebar search find a workspace by a session's name. */
onMatches?: (count: number) => void;
/** Mounted only to load and count, during a search that hides the row. */
hidden?: boolean;
/** A workspace this user may only read. */
readOnly?: boolean;
}) {
const { t } = useTranslation("lanes");
const navigate = useNavigate();
const tools = useAiTools();
// Bumped after a rename or an archive, so the list re-reads.
const [version, setVersion] = useState(0);
const [renaming, setRenaming] = useState<string | null>(null);
const [draft, setDraft] = useState("");
const [error, setError] = useState<string | null>(null);
// A worktree session asked to be archived: Claude Desktop removes its
// worktree too, so that is confirmed first.
const [archiving, setArchiving] = useState<Session | null>(null);
const [busy, setBusy] = useState(false);
const ctx = useContextMenu<RunHandle>();
const sessionCtx = useContextMenu<Session>();
// Rows shown; See all raises it to the whole list.
const [limit, setLimit] = useState(SESSION_LIMIT);
// This workspace's live AI tabs. A tab started bare (no prompt) has its
// session id from the start, but no session row until a hook creates one.
const laneRuns = liveRuns.filter(
(r) => r.laneId === lane.id && r.status === "running" && r.tool !== "shell"
);
const liveShells = liveRuns.filter(
(r) => r.laneId === lane.id && r.status === "running" && r.tool === "shell"
);
// The lane's own session/status/needs-you move on every hook, which is when
// a session can appear or change state, and a tab opening or getting its
// session is too — re-read then, not on a timer. Throttled to one re-read per
// 3 s: two sessions in one workspace flip these on every hook. A rename or an
// archive (`version`) re-reads at once.
const hookKey = useThrottledValue(
`${lane.session_id}:${lane.status}:${lane.needs_action}:${laneRuns
.map((r) => `${r.id}=${r.sessionId ?? ""}`)
.join(",")}`,
3000
);
const refreshKey = `${hookKey}:${version}`;
const own = useSessionPage({ lane: lane.id, archived: false, limit }, refreshKey);
const sessions = own?.sessions ?? (own === null ? null : []);
const more = own ? Math.max(0, own.total - own.sessions.length) : 0;
const q = query.trim().toLowerCase();
const runFor = (s: Session) => liveRunFor(lane, liveRuns, s.id);
// Live sessions first, ended ones under them — each group stays newest
// first (the sort is stable over the server's order).
const shown = (sessions ?? [])
.filter((s) => !q || (s.name ?? "").toLowerCase().includes(q) || s.id.startsWith(q))
.sort((a, b) => Number(!!runFor(b)) - Number(!!runFor(a)));
// A live tab whose session has no row yet: shown as a New session at the
// top, like Claude Desktop, until its first hook brings the real row. Not
// during a search — it has no name to match.
// A tab whose session HAS a row the list does not show is archived (or
// past the page): looked up once, so archiving a live session never brings
// it back as a New session.
const orphanIds =
sessions === null
? ""
: laneRuns
.filter((r) => r.sessionId && !sessions.some((s) => s.id === r.sessionId))
.map((r) => r.sessionId)
.join(",");
const [hasRow, setHasRow] = useState<Set<string>>(new Set());
useEffect(() => {
if (!orphanIds) return;
let live = true;
void Promise.all(
orphanIds.split(",").map((id) =>
api.sessions
// The row only: a session's events are its whole history.
.get(id, { events: false })
.then(() => id)
.catch(() => null)
)
).then((found) => live && setHasRow(new Set(found.filter((x): x is string => !!x))));
return () => {
live = false;
};
}, [orphanIds]);
const fresh =
sessions === null || q || readOnly
? []
: [
...laneRuns.filter(
(r) =>
!r.sessionId ||
(!sessions.some((s) => s.id === r.sessionId) && !hasRow.has(r.sessionId))
),
// Live Terminal tabs: no session row ever, so listed from the run.
...liveShells,
];
const count = sessions === null ? null : shown.length;
useEffect(() => {
if (count !== null) onMatches?.(count);
}, [count, onMatches]);
const patch = async (s: Session, body: { name?: string; archived?: boolean }) => {
setError(null);
try {
await api.sessions.patch(s.id, body);
setVersion((v) => v + 1);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
}
};
// Archive: a worktree session with no live pane loses its worktree first
// (never forced — git refuses unsaved work, which then stops the archive);
// one Claude already cleaned up is just archived.
const startRename = (s: Session) => {
setDraft(s.name || "");
setRenaming(s.id);
};
const archive = async (s: Session) => {
if (sessionWorktreeName(lane, s) && !runFor(s)) return setArchiving(s);
// A live one keeps running in its tab; archiving only hides its row.
if (
runFor(s) &&
!(await confirmDialog({
title: t("tree.archiveLiveTitle"),
message: t("tree.archiveLiveBody"),
confirmLabel: t("sessionMenu.archive"),
destructive: false,
}))
)
return;
await patch(s, { archived: true });
};
const archiveWithWorktree = async (s: Session) => {
setBusy(true);
setError(null);
try {
await api.lanes.removeSessionWorktree(lane.id, s.id).catch((err: unknown) => {
if ((err as { code?: string }).code !== "ENOWORKTREE") throw err;
});
await patch(s, { archived: true });
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
setArchiving(null);
}
};
const confirmArchive = (
<ConfirmModal
open={archiving !== null}
title={t("tree.archiveWorktreeTitle")}
message={t("tree.archiveWorktreeBody", {
name: archiving ? sessionWorktreeName(lane, archiving) : "",
})}
confirmLabel={t("tree.archiveWorktreeConfirm")}
cancelLabel={t("home.cancel")}
destructive
busy={busy}
onConfirm={() => archiving && void archiveWithWorktree(archiving)}
onCancel={() => setArchiving(null)}
/>
);
if (hidden) return null;
const failure = error && (
<p role="alert" className="pl-7 text-[11px] text-status-danger">
{error}
</p>
);
if (sessions !== null && shown.length === 0 && fresh.length === 0) {
return (
<>
<p className="py-1 pl-[42px] text-[11px] text-fg-muted">{t("tree.noSessions")}</p>
{failure}
{confirmArchive}
</>
);
}
return (
<>
<ul
data-testid={`workspace-sessions-${lane.id}`}
// A guide line under the workspace's chevron; the rows start past it.
className="relative space-y-px pl-5 before:pointer-events-none before:absolute before:inset-y-0 before:left-[13px] before:w-px before:bg-border before:content-['']"
>
{fresh.map((r) => (
<li key={r.id}>
<button
type="button"
data-testid={`workspace-session-new-${r.id}`}
data-live="true"
onClick={(e) =>
navigate(
`/workspace?lane=${lane.id}&run=${encodeURIComponent(r.id)}`,
e.ctrlKey || e.metaKey ? { state: { split: true } } : undefined
)
}
onContextMenu={(e) => ctx.open(e, r)}
title={`${r.tool === "shell" ? t("tabs.terminal") : t("tree.newSession")}\n${t("tree.splitHint")}`}
className={`flex w-full items-center gap-2 rounded-md py-1 pl-2 pr-2 text-left text-[12px] ${
r.id === selectedRunId
? "bg-accent/10 text-fg-primary"
: "text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
}`}
>
<span aria-hidden className="h-1.5 w-1.5 shrink-0 rounded-full bg-status-success" />
<span className="sr-only">{t("tree.state.live")}</span>
<span className={`truncate ${r.tool === "shell" ? "" : "italic"}`}>
{r.tool === "shell" ? t("tabs.terminal") : t("tree.newSession")}
</span>
<span className="ml-auto shrink-0 text-[11px] text-fg-muted">
{r.startedAt ? timeAgo(r.startedAt, true) : null}
</span>
{r.tool === "shell" ? (
<SquareTerminal aria-hidden className="h-3.5 w-3.5 shrink-0 opacity-70" />
) : (
<AiToolIcon
tool={{ id: r.tool, label: tools.label(r.tool) }}
className="h-3.5 w-3.5 shrink-0 !p-0 opacity-70"
/>
)}
</button>
</li>
))}
{shown.map((s) => {
const run = runFor(s);
const selected = run ? run.id === selectedRunId : s.id === selectedSessionId;
if (renaming === s.id) {
return (
<li key={s.id} className="py-0.5 pr-2">
<input
data-testid={`workspace-session-rename-${s.id}`}
aria-label={t("tree.renameSession")}
autoFocus
value={draft}
maxLength={200}
onChange={(e) => setDraft(e.target.value)}
// Blur CANCELS — Enter is the only save, like a workspace row.
onBlur={() => setRenaming(null)}
onKeyDown={(e) => {
if (e.key === "Escape") setRenaming(null);
if (!isSubmitEnter(e)) return;
setRenaming(null);
const name = draft.trim();
if (name && name !== s.name) void patch(s, { name });
}}
className="w-full rounded border border-accent bg-surface-1 px-1.5 py-0.5 text-[12px] text-fg-primary"
/>
</li>
);
}
return (
<li key={s.id} className="group relative">
<button
type="button"
data-testid={`workspace-session-${s.id}`}
data-live={run ? "true" : undefined}
onClick={(e) => {
// A double-click's second click renames; it must not
// navigate (a second history entry) on top of the first.
if (e.detail > 1) return;
if (readOnly) return navigate(`/sessions/${encodeURIComponent(s.id)}`);
navigate(
run
? `/workspace?lane=${lane.id}&run=${encodeURIComponent(run.id)}`
: `/workspace?lane=${lane.id}&session=${encodeURIComponent(s.id)}`,
// Ctrl/Cmd+click a live one: open it in a split beside the
// focused pane (Workspace reads this state, not the URL).
run && (e.ctrlKey || e.metaKey) ? { state: { split: true } } : undefined
);
}}
onDoubleClick={() => !readOnly && startRename(s)}
onKeyDown={(e) => {
if (e.key === "F2" && !readOnly) {
e.preventDefault();
startRename(s);
}
}}
onContextMenu={readOnly ? undefined : (e) => sessionCtx.open(e, s)}
title={`${s.name || s.id}\n${timeAgo(s.last_activity ?? s.started_at)}${
sessionWorktreeName(lane, s)
? `\n${t("tree.worktreeSession", { name: sessionWorktreeName(lane, s) })}`
: ""
}${readOnly ? "" : `\n${run ? t("tree.splitHint") : t("tree.transcriptHint")}`}`}
className={`flex w-full items-center gap-2 rounded-md py-1 pl-2 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
data-testid={`workspace-session-dot-${s.id}`}
aria-hidden
className={`h-1.5 w-1.5 shrink-0 rounded-full ${sessionDot(s, !!run)}`}
/>
<span className="sr-only">{t(`tree.state.${sessionDotLabel(s, !!run)}`)}</span>
<span className="truncate">{s.name || s.id.slice(0, 8)}</span>
{sessionWorktreeName(lane, s) && (
<GitBranch
data-testid={`workspace-session-worktree-${s.id}`}
aria-label={t("tree.worktreeSession", { name: sessionWorktreeName(lane, s) })}
className="h-3 w-3 shrink-0 text-fg-muted"
/>
)}
<span className="ml-auto shrink-0 text-[11px] tabular-nums text-fg-muted">
{timeAgo(s.last_activity ?? s.started_at, true)}
</span>
<AiToolIcon
tool={{ id: s.tool || "claude", label: tools.label(s.tool || "claude") }}
className={`h-3.5 w-3.5 shrink-0 !p-0 opacity-60 ${readOnly ? "" : "group-hover:invisible"}`}
/>
</button>
{!readOnly && (
<button
type="button"
data-testid={`workspace-session-archive-${s.id}`}
onClick={() => void archive(s)}
title={t("tree.archiveSession")}
aria-label={t("tree.archiveSession")}
// Invisible, not `hidden`: a keyboard user must be able to
// Tab onto it, and it shows when focused.
className="absolute right-1 top-1/2 -translate-y-1/2 rounded p-0.5 text-fg-muted opacity-0 hover:bg-surface-4 hover:text-fg-primary focus:opacity-100 group-hover:opacity-100"
>
<Archive className="h-3.5 w-3.5" />
</button>
)}
</li>
);
})}
</ul>
{more > 0 && !q && (
<button
type="button"
data-testid={`workspace-sessions-more-${lane.id}`}
onClick={() => own && setLimit(own.total)}
className="w-full py-1 pl-[42px] pr-2 text-left text-[11px] text-fg-muted hover:text-fg-primary"
>
{t("tree.seeAll", { count: more })}
</button>
)}
{more === 0 && !q && own && own.total > SESSION_LIMIT && limit > SESSION_LIMIT && (
<button
type="button"
data-testid={`workspace-sessions-less-${lane.id}`}
onClick={() => setLimit(SESSION_LIMIT)}
className="w-full py-1 pl-[42px] pr-2 text-left text-[11px] text-fg-muted hover:text-fg-primary"
>
{t("tree.showLess")}
</button>
)}
{failure}
{confirmArchive}
{ctx.menu && (
<SessionMenu
x={ctx.menu.x}
y={ctx.menu.y}
laneId={lane.id}
session={null}
run={ctx.menu.item}
onClose={ctx.close}
/>
)}
{sessionCtx.menu && (
<SessionMenu
x={sessionCtx.menu.x}
y={sessionCtx.menu.y}
laneId={lane.id}
session={sessionCtx.menu.item}
run={runFor(sessionCtx.menu.item)}
onClose={sessionCtx.close}
onRename={() => startRename(sessionCtx.menu!.item)}
onArchive={() => void archive(sessionCtx.menu!.item)}
/>
)}
</>
);
}