feat: workspaces own their sessions; desktop parity; sidebar and settings cleanup

Workspace sessions (docs/plans/desktop-parity, phases 1-5 and 7):
- sessions.lane_id: a workspace lists only the sessions its panes held,
  stamped by bindRunSession (first writer wins, nested `claude -p`
  excluded); GET /api/sessions?lane=&archived=
- session row state dot, double-click rename, archive (sessions.archived_at,
  hides only); the activity bar's duplicate Sessions tab removed
- Fork beside Resume (--fork-session, Claude only), Ctrl/Cmd+click a live
  session opens it in a second pane, a diff line's comment is pasted into
  the workspace's last-focused pane (lib/panePaste.ts)

dashboard_runs stability:
- a pane's previous run is archived as <pane>@<started_at> instead of being
  overwritten by INSERT OR REPLACE
- a run is flagged abandoned only once its tmux session is gone, at boot and
  on the run-heal beat (panes outlive a server restart)

UI:
- sidebar: no footer, language or theme block; small collapse button in the
  brand corner; version + update check in the connection details modal, a
  status-bar dot while a release is out; language in Settings -> General
- one Switch component for every switch, the themed Checkbox for every
  checkbox (no native checkboxes)

Also: AI Tools catalog trimmed to 10 built-ins (custom-file prompt modes
tested through fixtures), and the workspace plans under docs/plans/ (the
multi-user server plan lives in the AgentHub-Server repo).
This commit is contained in:
2026-10-02 12:05:00 +07:00
parent 5b269698f6
commit a6bd33bcff
121 changed files with 2699 additions and 1552 deletions
@@ -10,6 +10,7 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { Checkbox } from "../Checkbox";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { ConfirmModal } from "../ConfirmModal";
@@ -210,15 +211,14 @@ export function DestructiveLaneModal({
</p>
))}
{requiresForce && (
<label className="mt-3 flex items-start gap-2 text-xs text-status-warning">
<input
type="checkbox"
checked={force}
onChange={(event) => setForce(event.target.checked)}
className="mt-0.5"
/>
{t("destructive.force")}
</label>
<Checkbox
className="mt-3 items-start"
tone="warning"
checked={force}
onChange={setForce}
label={t("destructive.force")}
labelClassName="text-xs text-status-warning"
/>
)}
{action === "reset" && (
<p className="mt-3 text-xs text-fg-secondary">{t("destructive.reset.survives")}</p>
@@ -29,6 +29,10 @@
* file is conflicted) and **Abort merge** (two clicks, `confirm: true`), and
* conflicted files get their own group with Keep mine / Take theirs / Mark
* resolved instead of stage — staging one blind would mark it resolved.
*
* A diff line's number opens a comment box (LaneDiffView `onComment`); the
* comment is pasted, as `<path>:<line> — <text>`, into the workspace's pane
* focused last (`lib/panePaste.ts`) and sent only when the user presses Enter.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -56,6 +60,7 @@ import {
UnfoldVertical,
} from "lucide-react";
import { api } from "../../lib/api";
import { pasteToLane } from "../../lib/panePaste";
import { LaneDiffView, LaneFileView, type DiffLayout, type LaneDiffChange } from "./LaneDiffView";
import { LaneConflictView } from "./LaneConflictView";
import { MarkdownPreview, isMarkdown } from "../MarkdownPreview";
@@ -436,6 +441,8 @@ export function LaneChangesPanel({
// One read of the working copy, shared by the header and every write below:
// a stage, a discard or a commit changes the counts the header shows.
const [git, refreshGit] = useLaneGitFacts(laneId);
// A diff line's comment goes into this workspace's pane, as a paste.
const comment = useCallback((text: string) => pasteToLane(laneId, text), [laneId]);
// Bumped by Refresh and by a git bar action: a pull or a switch moves
// history and the tree too, not just the diff `load` re-reads.
const [reread, setReread] = useState(0);
@@ -1558,6 +1565,7 @@ export function LaneChangesPanel({
layout={layout}
fill
hunkActions={whole ? undefined : hunkActions(current)}
onComment={comment}
/>
) : null}
</div>
@@ -1637,7 +1645,12 @@ export function LaneChangesPanel({
)}
</div>
{!shut && (
<LaneDiffView file={f} layout={layout} hunkActions={hunkActions(f)} />
<LaneDiffView
file={f}
layout={layout}
hunkActions={hunkActions(f)}
onComment={comment}
/>
)}
</div>
);
+143 -46
View File
@@ -262,7 +262,28 @@ export function analyseHunk(hunk: LaneDiffHunk): Analysed {
return out;
}
function Gutter({ n }: { n: number | null }) {
/** Set by a LaneDiffView given `onComment`: a click on a NEW line's number
* opens the comment box for that line. */
const CommentLine = createContext<{
open: (line: number) => void;
title: (line: number) => string;
} | null>(null);
function Gutter({ n, commentable = false }: { n: number | null; commentable?: boolean }) {
const comment = useContext(CommentLine);
if (commentable && comment && n != null) {
return (
<button
type="button"
data-testid={`diff-comment-line-${n}`}
onClick={() => comment.open(n)}
title={comment.title(n)}
className="w-10 shrink-0 select-none px-1 text-right text-fg-muted/60 hover:bg-accent/20 hover:text-accent"
>
{n}
</button>
);
}
return (
<span className="w-10 shrink-0 select-none px-1 text-right text-fg-muted/60">{n ?? ""}</span>
);
@@ -462,7 +483,7 @@ function UnifiedLine({
return (
<Row tint={rowTint(line.type)} style={style} anchor={anchor}>
<Gutter n={line.oldLine} />
<Gutter n={line.newLine} />
<Gutter n={line.newLine} commentable />
<span className="whitespace-pre px-2">
<span className={`select-none ${markerClass(line.type)}`}>
{line.type === "add" ? "+" : line.type === "del" ? "-" : " "}
@@ -534,7 +555,10 @@ function SplitLine({
const anchor = useAnchor(start);
return (
<Row tint={rowTint(line?.type ?? null)} style={style} anchor={anchor}>
<Gutter n={(side === "left" ? line?.oldLine : line?.newLine) ?? null} />
<Gutter
n={(side === "left" ? line?.oldLine : line?.newLine) ?? null}
commentable={side === "right"}
/>
<span className="whitespace-pre px-1">
{line ? (
<Code
@@ -700,6 +724,7 @@ export function LaneDiffView({
layout,
fill = false,
hunkActions,
onComment,
}: {
file: LaneChangeFile;
layout: DiffLayout;
@@ -707,9 +732,35 @@ export function LaneDiffView({
fill?: boolean;
/** Controls drawn in each change block's review bar. */
hunkActions?: (change: LaneDiffChange) => ReactNode;
/** Makes each new line's number a button that writes a comment on that line
* and hands `<path>:<line> — <text>` here; false = it reached no pane. */
onComment?: (text: string) => boolean;
}) {
const root = useRef<HTMLDivElement>(null);
const { t } = useTranslation(["lanes"]);
const [commentAt, setCommentAt] = useState<number | null>(null);
const [draft, setDraft] = useState("");
const [undelivered, setUndelivered] = useState(false);
const openComment = useMemo(
() =>
onComment
? {
open: (line: number) => {
setCommentAt(line);
setUndelivered(false);
},
title: (line: number) => t("changes.commentLine", { n: line }),
}
: null,
[onComment, t]
);
const sendComment = () => {
const text = draft.trim();
if (!text || commentAt == null || !onComment) return;
if (!onComment(`${file.path}:${commentAt} — ${text}`)) return setUndelivered(true);
setCommentAt(null);
setDraft("");
};
const lang = langFor(file.path);
const hunks = file.hunks;
const items = useMemo(() => (hunks ? flatten(hunks, "unified") : []), [hunks]);
@@ -755,50 +806,96 @@ export function LaneDiffView({
return <p className="px-2 py-1 text-[10px] text-fg-muted">{t("changes.noHunks")}</p>;
return (
<HunkActions.Provider value={review}>
<div
ref={root}
data-testid="lane-diff-view"
data-layout={layout}
onScroll={(e) => win.onScroll(e.currentTarget)}
className={`flex flex-col font-mono text-[11px] leading-[1.45] ${
layout === "split"
? boxHeight(fill, splitWindowed)
: `overflow-auto ${boxHeight(fill, win.on)}`
}`}
>
{layout === "split" ? (
<SplitView hunks={file.hunks} lang={lang} fill={fill} />
) : win.on ? (
<Windowed count={items.length} range={win.range} minWidth={minWidth}>
{items
.slice(win.range.start, win.range.end)
.map(({ hunk, i }, k) =>
i < 0 ? (
<HunkHeader key={win.range.start + k} hunk={hunk} fixed />
) : (
<UnifiedLine
key={win.range.start + k}
line={hunk.lines[i]!}
lang={lang}
emph={analyseHunk(hunk).emph.get(i)}
style={FIXED}
bar={barAtLine(hunk, i)}
start={startAtLine(hunk, i)}
/>
)
)}
</Windowed>
) : (
file.hunks.map((hunk, i) => (
<UnifiedHunk key={`${hunk.header}:${i}`} hunk={hunk} lang={lang} />
))
<CommentLine.Provider value={openComment}>
<HunkActions.Provider value={review}>
<div
ref={root}
data-testid="lane-diff-view"
data-layout={layout}
onScroll={(e) => win.onScroll(e.currentTarget)}
className={`flex flex-col font-mono text-[11px] leading-[1.45] ${
layout === "split"
? boxHeight(fill, splitWindowed)
: `overflow-auto ${boxHeight(fill, win.on)}`
}`}
>
{layout === "split" ? (
<SplitView hunks={file.hunks} lang={lang} fill={fill} />
) : win.on ? (
<Windowed count={items.length} range={win.range} minWidth={minWidth}>
{items
.slice(win.range.start, win.range.end)
.map(({ hunk, i }, k) =>
i < 0 ? (
<HunkHeader key={win.range.start + k} hunk={hunk} fixed />
) : (
<UnifiedLine
key={win.range.start + k}
line={hunk.lines[i]!}
lang={lang}
emph={analyseHunk(hunk).emph.get(i)}
style={FIXED}
bar={barAtLine(hunk, i)}
start={startAtLine(hunk, i)}
/>
)
)}
</Windowed>
) : (
file.hunks.map((hunk, i) => (
<UnifiedHunk key={`${hunk.header}:${i}`} hunk={hunk} lang={lang} />
))
)}
{file.truncated && (
<p className="px-2 py-1 text-[10px] text-status-warning">
{t("changes.fileTruncated")}
</p>
)}
</div>
{commentAt != null && (
<form
data-testid="diff-comment-box"
className="flex flex-col gap-1 border-t border-border bg-surface-2 p-2"
onSubmit={(e) => {
e.preventDefault();
sendComment();
}}
>
<label className="text-[11px] text-fg-muted">
{t("changes.commentOn", { at: `${file.path}:${commentAt}` })}
<textarea
autoFocus
rows={2}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Escape") setCommentAt(null);
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) sendComment();
}}
className="mt-1 block w-full rounded border border-border bg-surface-1 px-2 py-1 font-sans text-xs text-fg-primary"
/>
</label>
{undelivered && (
<p role="alert" className="text-[11px] text-status-danger">
{t("changes.commentNoPane")}
</p>
)}
<div className="flex justify-end gap-2">
<button
type="button"
className="btn-ghost text-xs"
onClick={() => setCommentAt(null)}
>
{t("changes.commentCancel")}
</button>
<button type="submit" className="btn-primary text-xs" disabled={!draft.trim()}>
{t("changes.commentSend")}
</button>
</div>
</form>
)}
{file.truncated && (
<p className="px-2 py-1 text-[10px] text-status-warning">{t("changes.fileTruncated")}</p>
)}
</div>
</HunkActions.Provider>
</HunkActions.Provider>
</CommentLine.Provider>
);
}
+2 -8
View File
@@ -22,6 +22,7 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { Checkbox } from "../Checkbox";
import { useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
@@ -516,14 +517,7 @@ export function LaneGitBar({
className="min-w-0 flex-1 rounded border border-border-light bg-surface-0 px-2 py-0.5 text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
/>
</div>
<label className="flex items-center gap-1.5 text-fg-secondary">
<input
type="checkbox"
checked={remember}
onChange={(e) => setRemember(e.target.checked)}
/>
{t("gitBar.loginRemember")}
</label>
<Checkbox checked={remember} onChange={setRemember} label={t("gitBar.loginRemember")} />
<p className="text-[10px] text-fg-muted">{t("gitBar.loginTokenHint")}</p>
{/* The token crosses the network in the clear on a plain-http LAN address. */}
{location.protocol === "http:" && !LOCAL_HOST.test(location.hostname) && (
@@ -1,9 +1,11 @@
/**
* @file SessionResumeButton.tsx
* @description The transcript page's explicit Resume for a Claude session
* inside a workspace. Opening a session never resumes it; this is one of the
* two places that do (the session view's Sessions tab is the other), behind a
* warning, because a resume re-reads the whole transcript into context. A
* @description The transcript page's explicit Resume and Fork for a Claude
* session inside a workspace. Opening a session never resumes it; this is the
* one place that does, behind a warning, because a resume re-reads the whole
* transcript into context. Fork resumes it into a NEW session id
* (`forkSession: true` → `claude --resume <id> --fork-session`), leaving the
* original untouched, and is offered even while the original has a live pane. A
* session that still has a live pane offers that pane instead. A resume
* starts in the workspace's first free pane slot (`POST /api/lanes/:id/start
* {slot, resumeSessionId}`, panes re-read first) and opens it — offered only
@@ -15,7 +17,7 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { History, SquareTerminal } from "lucide-react";
import { GitFork, History, SquareTerminal } from "lucide-react";
import { api, type RunHandle } from "../../lib/api";
import type { Lane } from "../../lib/types";
import { useLanes } from "../../hooks/useLanes";
@@ -45,7 +47,7 @@ export function SessionResumeButton({
const navigate = useNavigate();
const { lanes } = useLanes();
const [runs, setRuns] = useState<RunHandle[] | null>(null);
const [confirming, setConfirming] = useState(false);
const [confirming, setConfirming] = useState<"resume" | "fork" | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -60,7 +62,7 @@ export function SessionResumeButton({
};
}, [session.id]);
// Claude only, like the Sessions tab: a Codex session id is not what
// Claude only: a Codex session id is not what
// `codex resume` takes for every kind of thread.
const tool = session.tool || "claude";
const lane = tool === "claude" && session.cwd ? owningLane(session.cwd, tool, lanes) : null;
@@ -72,7 +74,7 @@ export function SessionResumeButton({
const open = (runId: string) =>
navigate(`/workspace?lane=${lane.id}&run=${encodeURIComponent(runId)}`);
const resume = async () => {
const resume = async (fork: boolean) => {
// Busy first: the Confirm button is disabled only through it, and the
// re-read below is a round trip a second click could land in.
setBusy(true);
@@ -86,19 +88,23 @@ export function SessionResumeButton({
.catch(() => runs);
setRuns(fresh);
const live = liveRunFor(lane, fresh, session.id);
if (live) return open(live.id);
if (live && !fork) return open(live.id);
const slot = freeSlot(lane.id, fresh);
if (slot === null) {
setError(t("home.allBusy"));
return;
}
const r = await api.lanes.action(lane.id, "start", { slot, resumeSessionId: session.id });
const r = await api.lanes.action(lane.id, "start", {
slot,
resumeSessionId: session.id,
...(fork ? { forkSession: true } : {}),
});
open(r.run_id ?? paneRunId(lane.id, slot));
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
setConfirming(false);
setConfirming(null);
}
};
@@ -112,7 +118,7 @@ export function SessionResumeButton({
) : (
<button
type="button"
onClick={() => setConfirming(true)}
onClick={() => setConfirming("resume")}
title={t("resume.hint")}
className="btn-ghost text-xs"
>
@@ -120,21 +126,34 @@ export function SessionResumeButton({
{t("sessions.resume")}
</button>
)}
{resumable && (
<button
type="button"
onClick={() => setConfirming("fork")}
title={t("resume.forkHint")}
className="btn-ghost text-xs"
>
<GitFork className="h-4 w-4" />
{t("resume.fork")}
</button>
)}
{error && (
<p role="alert" className="max-w-xs text-right text-[11px] text-status-danger">
{error}
</p>
)}
<ConfirmModal
open={confirming}
title={t("resume.title")}
message={t("resume.body", { workspace: lane.title || lane.cwd })}
confirmLabel={t("resume.confirm")}
open={confirming !== null}
title={t(confirming === "fork" ? "resume.forkTitle" : "resume.title")}
message={t(confirming === "fork" ? "resume.forkBody" : "resume.body", {
workspace: lane.title || lane.cwd,
})}
confirmLabel={t(confirming === "fork" ? "resume.forkConfirm" : "resume.confirm")}
cancelLabel={t("home.cancel")}
destructive={false}
busy={busy}
onConfirm={() => void resume()}
onCancel={() => setConfirming(false)}
onConfirm={() => void resume(confirming === "fork")}
onCancel={() => setConfirming(null)}
/>
</div>
);
+35 -12
View File
@@ -2,9 +2,10 @@
* @file WorkspaceHome — what `/workspace?lane=<id>` opens on, shaped like a
* Claude Desktop project page: the name, a prompt box that starts a NEW
* session in the first free pane slot (0–3, `POST /api/lanes/:id/start`) and
* then opens it (`&run=<runId>`), the workspace's Recents (a live session
* opens its pane, any other its transcript — nothing is resumed), and on the
* right its context: Instructions, Memory (read-only), Context files and
* then opens it (`&run=<runId>`), the workspace's Recents — the sessions it
* owns and has not archived (`?lane=&archived=0`, with each one's state dot),
* then the archived ones folded with Unarchive; a live session opens its pane,
* any other its transcript, nothing is resumed — and on the right its context: Instructions, Memory (read-only), Context files and
* Folders (`GET/PUT/POST/DELETE /api/lanes/:id/sources…`, see
* server/lib/lane-sources.js). Folder suggestions are the repo's untracked
* worktrees from `GET /api/projects`, filtered by Settings → Worktrees.
@@ -14,7 +15,16 @@
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { FileText, Folder, MoreHorizontal, Pencil, Play, Plus, X } from "lucide-react";
import {
ArchiveRestore,
FileText,
Folder,
MoreHorizontal,
Pencil,
Play,
Plus,
X,
} from "lucide-react";
import {
api,
type LaneMemoryFile,
@@ -31,7 +41,7 @@ import { Select } from "../Select";
import { MarkdownPreview } from "../MarkdownPreview";
import { WorkspaceRowMenu, type WorkspaceMenuAction } from "./WorkspaceRowMenu";
import { workspaceName } from "./WorkspaceRow";
import { liveRunFor } from "./WorkspaceSessionList";
import { liveRunFor, SessionFold, sessionDot, useSessionPage } from "./WorkspaceSessionList";
/** The tmux session a pane slot of a lane owns (server/lib/pty-run.js). */
export function paneRunId(laneId: number, slot: number): string {
@@ -103,6 +113,8 @@ function HomeBody({
// A new pane, a pane gone, a session id learned — all move these lane fields.
const runKey = `${lane.status}:${lane.session_id}:${lane.run_id}`;
// Bumped after a session is unarchived, so the lists re-read.
const [version, setVersion] = useState(0);
useEffect(() => {
let live = true;
api.run
@@ -110,13 +122,17 @@ function HomeBody({
.then((r) => live && setLiveRuns(r.items))
.catch(() => undefined);
api.sessions
.list({ cwd: lane.cwd, tool: lane.tool, limit: 30 })
.list({ lane: lane.id, archived: false, limit: 30 })
.then((r) => live && setSessions(r.sessions))
.catch(() => live && setSessions([]));
return () => {
live = false;
};
}, [lane.id, lane.cwd, lane.tool, runKey]);
}, [lane.id, runKey, version]);
const archived = useSessionPage(
{ lane: lane.id, archived: true, limit: 30 },
`${runKey}:${version}`
);
useEffect(() => {
let live = true;
@@ -362,11 +378,7 @@ function HomeBody({
onClick={() => openSession(s)}
className="flex w-full items-center gap-3 py-2.5 text-left hover:bg-surface-3"
>
<span
className={`h-2 w-2 shrink-0 rounded-full ${
live ? "bg-status-success" : "border border-fg-muted"
}`}
/>
<span className={`h-2 w-2 shrink-0 rounded-full ${sessionDot(s, live)}`} />
<span className="min-w-0 flex-1 truncate text-sm text-fg-primary">
{s.name || s.id.slice(0, 8)}
</span>
@@ -383,6 +395,17 @@ function HomeBody({
);
})}
</ul>
<div className="mt-2">
<SessionFold
testId={`workspace-archived-${lane.id}`}
page={archived}
label={(count) => t("tree.archived", { count })}
actionLabel={t("tree.unarchive")}
actionIcon={<ArchiveRestore className="h-3.5 w-3.5" />}
onAction={(s) => api.sessions.patch(s.id, { archived: false })}
onDone={() => setVersion((v) => v + 1)}
/>
</div>
</div>
<aside data-testid="workspace-context" className="card w-80 shrink-0 self-start p-0">
@@ -1,19 +1,26 @@
/**
* @file WorkspaceSessionList — the sessions under an expanded workspace in the
* sidebar (`GET /api/sessions?cwd=&tool=`, newest first). A session with a LIVE
* pane (a `GET /api/run` handle carrying its session id) opens that tmux pane
* on the Workspace page (`?lane=<id>&run=<runId>`) — attaching, never starting
* or resuming anything; any other session opens its read-only transcript
* (`/sessions/:id`), where Resume is a separate, explicit action, because a
* resume re-reads the whole transcript into context.
* sidebar: the ones the workspace OWNS and has not archived
* (`GET /api/sessions?lane=<id>&archived=0`, newest first) — never the other
* sessions that ran in its folder. A row's dot
* shows its state (`sessionDot`: live pane, needs you, error); double-click
* renames it and its hover button archives it (`PATCH {name}` / `{archived}`,
* which deletes nothing). A session with a LIVE pane (a `GET /api/run` handle
* carrying its session id) opens that tmux pane on the Workspace page
* (`?lane=<id>&run=<runId>`, Ctrl/Cmd+click `&pane=1` to open it in a second
* split pane) — attaching, never starting or resuming anything;
* any other session opens its read-only transcript (`/sessions/:id`), where
* Resume is a separate, explicit action, because a resume re-reads the whole
* transcript into context.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { useEffect, useState, type ReactNode } from "react";
import { Archive } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { Link, useNavigate } from "react-router-dom";
import { api, type RunHandle } from "../../lib/api";
import type { Lane, Session } from "../../lib/types";
import { isSessionAwaitingInput, type Lane, type Session } from "../../lib/types";
import { timeAgo } from "../../lib/format";
export const SESSION_LIMIT = 20;
@@ -31,6 +38,118 @@ export function liveRunFor(lane: Lane, runs: RunHandle[], sessionId: string): Ru
);
}
/** A session row's dot: waiting for the user, failed, held by a live pane, or
* at rest. */
export function sessionDot(session: Session, live: boolean): string {
if (isSessionAwaitingInput(session)) return "bg-status-warning";
if (session.status === "error") return "bg-status-danger";
if (live) return "bg-status-success";
return "border border-fg-muted";
}
export type SessionPage = { sessions: Session[]; total: number };
/** One page of `GET /api/sessions`, re-read whenever `refreshKey` changes. */
export function useSessionPage(
params: Parameters<typeof api.sessions.list>[0],
refreshKey: string
): SessionPage | null {
const [page, setPage] = useState<SessionPage | null>(null);
const key = JSON.stringify(params);
useEffect(() => {
let live = true;
api.sessions
.list(JSON.parse(key))
.then((r) => live && setPage({ sessions: r.sessions, total: r.total ?? r.sessions.length }))
.catch(() => live && setPage(null));
return () => {
live = false;
};
}, [key, refreshKey]);
return page;
}
/** A folded list of sessions under a one-line toggle, each with one action
* (Unarchive); `onDone` re-reads the lists after it. */
export function SessionFold({
testId,
page,
label,
hint,
actionLabel,
actionIcon,
onAction,
onDone,
indent = "",
}: {
testId: string;
page: SessionPage | null;
label: (count: number) => string;
hint?: string;
actionLabel: string;
actionIcon: ReactNode;
onAction: (s: Session) => Promise<unknown>;
onDone: () => void;
/** Left padding of the rows, to line up with the list above. */
indent?: string;
}) {
const [open, setOpen] = useState(false);
const [error, setError] = useState<string | null>(null);
if (!page || page.total === 0) return null;
const act = async (s: Session) => {
setError(null);
try {
await onAction(s);
onDone();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
}
};
return (
<div data-testid={testId}>
<button
type="button"
aria-expanded={open}
onClick={() => setOpen((o) => !o)}
title={hint}
className={`w-full py-1 pr-2 text-left text-[11px] text-fg-muted hover:text-fg-primary ${indent}`}
>
{open ? "▾" : "▸"} {label(page.total)}
</button>
{open && (
<ul className="space-y-px">
{page.sessions.map((s) => (
<li key={s.id} className={`flex items-center gap-1 pr-1 ${indent}`}>
<Link
to={`/sessions/${s.id}`}
title={`${s.name || s.id}\n${timeAgo(s.last_activity ?? s.started_at)}`}
className="min-w-0 flex-1 truncate rounded-md py-1 text-[12px] text-fg-muted hover:text-fg-primary"
>
{s.name || s.id.slice(0, 8)}
</Link>
<button
type="button"
data-testid={`${testId}-action-${s.id}`}
onClick={() => void act(s)}
title={actionLabel}
aria-label={actionLabel}
className="shrink-0 rounded p-0.5 text-fg-muted hover:bg-surface-3 hover:text-fg-primary"
>
{actionIcon}
</button>
</li>
))}
</ul>
)}
{error && (
<p role="alert" className={`text-[11px] text-status-danger ${indent}`}>
{error}
</p>
)}
</div>
);
}
export function WorkspaceSessionList({
lane,
liveRuns,
@@ -54,20 +173,17 @@ export function WorkspaceSessionList({
}) {
const { t } = useTranslation("lanes");
const navigate = useNavigate();
const [sessions, setSessions] = useState<Session[] | null>(null);
// Bumped after a rename or an archive, so the list re-reads.
const [version, setVersion] = useState(0);
const [renaming, setRenaming] = useState<string | null>(null);
const [draft, setDraft] = useState("");
const [error, setError] = useState<string | null>(null);
// The lane's own session/status move on every hook, which is when a new
// session can appear — re-read then, not on a timer.
useEffect(() => {
let live = true;
api.sessions
.list({ cwd: lane.cwd, tool: lane.tool, limit: SESSION_LIMIT })
.then((r) => live && setSessions(r.sessions))
.catch(() => live && setSessions([]));
return () => {
live = false;
};
}, [lane.cwd, lane.tool, lane.session_id, lane.status]);
// The lane's own session/status/needs-you move on every hook, which is when
// a session can appear or change state — re-read then, not on a timer.
const refreshKey = `${lane.session_id}:${lane.status}:${lane.needs_action}:${version}`;
const own = useSessionPage({ lane: lane.id, archived: false, limit: SESSION_LIMIT }, refreshKey);
const sessions = own?.sessions ?? (own === null ? null : []);
const q = query.trim().toLowerCase();
const shown = (sessions ?? []).filter(
@@ -79,47 +195,110 @@ export function WorkspaceSessionList({
if (count !== null) onMatches?.(count);
}, [count, onMatches]);
const patch = async (s: Session, body: { name?: string; archived?: boolean }) => {
setError(null);
try {
await api.sessions.patch(s.id, body);
setVersion((v) => v + 1);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
}
};
if (hidden) return null;
const failure = error && (
<p role="alert" className="pl-7 text-[11px] text-status-danger">
{error}
</p>
);
if (sessions !== null && shown.length === 0) {
return <p className="py-1 pl-8 text-[11px] text-fg-muted">{t("tree.noSessions")}</p>;
return (
<>
<p className="py-1 pl-8 text-[11px] text-fg-muted">{t("tree.noSessions")}</p>
{failure}
</>
);
}
return (
<ul data-testid={`workspace-sessions-${lane.id}`} className="space-y-px">
{shown.map((s) => {
const run = runFor(s);
const selected = !!run && run.id === selectedRunId;
return (
<li key={s.id}>
<button
type="button"
data-testid={`workspace-session-${s.id}`}
data-live={run ? "true" : undefined}
onClick={() =>
navigate(
run
? `/workspace?lane=${lane.id}&run=${encodeURIComponent(run.id)}`
: `/sessions/${s.id}`
)
}
title={`${s.name || s.id}\n${timeAgo(s.last_activity ?? s.started_at)}${
run ? "" : `\n${t("tree.transcriptHint")}`
}`}
className={`flex w-full items-center gap-2 rounded-md py-1 pl-7 pr-2 text-left text-[12px] ${
selected
? "bg-accent/10 text-fg-primary"
: "text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
}`}
>
<span
className={`h-1.5 w-1.5 shrink-0 rounded-full ${
run ? "bg-status-success" : "border border-fg-muted"
<>
<ul data-testid={`workspace-sessions-${lane.id}`} className="space-y-px">
{shown.map((s) => {
const run = runFor(s);
const selected = !!run && run.id === selectedRunId;
if (renaming === s.id) {
return (
<li key={s.id} className="py-0.5 pl-7 pr-2">
<input
data-testid={`workspace-session-rename-${s.id}`}
aria-label={t("tree.renameSession")}
autoFocus
value={draft}
maxLength={200}
onChange={(e) => setDraft(e.target.value)}
// Blur CANCELS — Enter is the only save, like a workspace row.
onBlur={() => setRenaming(null)}
onKeyDown={(e) => {
if (e.key === "Escape") setRenaming(null);
if (e.key !== "Enter") return;
setRenaming(null);
const name = draft.trim();
if (name && name !== s.name) void patch(s, { name });
}}
className="w-full rounded border border-accent bg-surface-1 px-1.5 py-0.5 text-[12px] text-fg-primary"
/>
</li>
);
}
return (
<li key={s.id} className="group relative">
<button
type="button"
data-testid={`workspace-session-${s.id}`}
data-live={run ? "true" : undefined}
onClick={(e) =>
navigate(
run
? // Ctrl/Cmd+click opens it beside the current pane.
`/workspace?lane=${lane.id}&run=${encodeURIComponent(run.id)}${
e.ctrlKey || e.metaKey ? "&pane=1" : ""
}`
: `/sessions/${s.id}`
)
}
onDoubleClick={() => {
setDraft(s.name || "");
setRenaming(s.id);
}}
title={`${s.name || s.id}\n${timeAgo(s.last_activity ?? s.started_at)}\n${
run ? t("tree.splitHint") : t("tree.transcriptHint")
}`}
/>
<span className="truncate">{s.name || s.id.slice(0, 8)}</span>
</button>
</li>
);
})}
</ul>
className={`flex w-full items-center gap-2 rounded-md py-1 pl-7 pr-7 text-left text-[12px] ${
selected
? "bg-accent/10 text-fg-primary"
: "text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
}`}
>
<span
data-testid={`workspace-session-dot-${s.id}`}
className={`h-1.5 w-1.5 shrink-0 rounded-full ${sessionDot(s, !!run)}`}
/>
<span className="truncate">{s.name || s.id.slice(0, 8)}</span>
</button>
<button
type="button"
data-testid={`workspace-session-archive-${s.id}`}
onClick={() => void patch(s, { archived: true })}
title={t("tree.archiveSession")}
aria-label={t("tree.archiveSession")}
className="absolute right-1 top-1/2 hidden -translate-y-1/2 rounded p-0.5 text-fg-muted hover:bg-surface-4 hover:text-fg-primary focus:block group-hover:block"
>
<Archive className="h-3.5 w-3.5" />
</button>
</li>
);
})}
</ul>
{failure}
</>
);
}
@@ -1,101 +0,0 @@
/**
* @file WorkspaceSessionsTab — the right panel's Sessions tab: the selected
* workspace's sessions (`GET /api/sessions?cwd=<lane cwd>&tool=<lane tool>`,
* newest first, 50), each with its tool badge, title, model, relative time and
* cost, filtered client-side by a search box. **Resume** (claude only — attach
* and resume go through `claude`) hands the session to the console's slot-0
* pane; **Open** goes to its detail page. The cwd match is exact, so a
* session started in a subdirectory of the workspace is not listed here.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { api } from "../../lib/api";
import type { Lane, Session } from "../../lib/types";
import { fmtCost, formatModelName, timeAgo } from "../../lib/format";
import { SessionToolBadge } from "../SessionToolBadge";
export function WorkspaceSessionsTab({
lane,
onResume,
}: {
lane: Lane;
onResume: (session: Session) => void;
}) {
const { t } = useTranslation("lanes");
const [sessions, setSessions] = useState<Session[] | null>(null);
const [query, setQuery] = useState("");
useEffect(() => {
let live = true;
setSessions(null);
api.sessions
.list({ cwd: lane.cwd, tool: lane.tool, limit: 50 })
.then((r) => live && setSessions(r.sessions))
.catch(() => live && setSessions([]));
return () => {
live = false;
};
}, [lane.cwd, lane.tool]);
const q = query.trim().toLowerCase();
const shown = (sessions ?? []).filter(
(s) => !q || (s.name ?? "").toLowerCase().includes(q) || s.id.includes(q)
);
return (
<div data-testid="workspace-sessions" className="flex min-h-0 flex-1 flex-col gap-2">
<input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t("sessions.search")}
aria-label={t("sessions.search")}
className="rounded border border-border bg-surface-1 px-2 py-1 text-xs text-fg-primary"
/>
{sessions !== null && shown.length === 0 && (
<p className="py-6 text-center text-xs text-fg-muted">{t("sessions.empty")}</p>
)}
<ul className="min-h-0 flex-1 space-y-1 overflow-y-auto">
{shown.map((s) => (
<li
key={s.id}
data-testid={`workspace-session-${s.id}`}
className="rounded-md border border-border bg-surface-2 px-2 py-1.5"
>
<div className="flex items-center gap-1.5">
<SessionToolBadge tool={s.tool} />
<span className="min-w-0 flex-1 truncate text-xs font-medium text-fg-primary">
{s.name || s.id.slice(0, 8)}
</span>
{lane.tool === "claude" && (
<button
type="button"
onClick={() => onResume(s)}
className="shrink-0 rounded border border-border px-1.5 text-[11px] text-fg-secondary hover:bg-surface-4 hover:text-fg-primary"
>
{t("sessions.resume")}
</button>
)}
<Link
to={`/sessions/${s.id}`}
className="shrink-0 rounded border border-border px-1.5 text-[11px] text-fg-secondary hover:bg-surface-4 hover:text-fg-primary"
>
{t("sessions.open")}
</Link>
</div>
<div className="mt-0.5 flex gap-2 text-[11px] text-fg-muted">
<span>{timeAgo(s.last_activity || s.started_at)}</span>
{s.model && <span className="truncate">{formatModelName(s.model)}</span>}
{s.cost !== undefined && (
<span className="ml-auto tabular-nums">{fmtCost(s.cost)}</span>
)}
</div>
</li>
))}
</ul>
</div>
);
}
@@ -502,3 +502,34 @@ describe("LaneDiffView — hunk review bar navigation", () => {
expect(screen.getByTestId("lane-diff-view").className).not.toContain("max-h-[60vh]");
});
});
describe("LaneDiffView — line comments", () => {
it("writes a comment on a new line and hands over `<path>:<line> — <text>`", () => {
const sent: string[] = [];
let delivered = false;
render(
<LaneDiffView
file={FILE}
layout="unified"
onComment={(text) => {
sent.push(text);
return delivered;
}}
/>
);
fireEvent.click(screen.getByTestId("diff-comment-line-1"));
fireEvent.change(screen.getByRole("textbox"), { target: { value: "rename this" } });
fireEvent.submit(screen.getByTestId("diff-comment-box"));
expect(sent).toEqual(["server/lib/git-ops.js:1 — rename this"]);
// No pane took it: the box stays, with the reason.
expect(screen.getByRole("alert")).toBeInTheDocument();
delivered = true;
fireEvent.submit(screen.getByTestId("diff-comment-box"));
expect(screen.queryByTestId("diff-comment-box")).toBeNull();
});
it("offers no comment button without onComment", () => {
render(<LaneDiffView file={FILE} layout="unified" />);
expect(screen.queryByTestId("diff-comment-line-1")).toBeNull();
});
});
@@ -334,7 +334,7 @@ describe("git login (issue #45)", () => {
const form = await screen.findByTestId("lane-git-login");
expect(form).toHaveTextContent("git.smartgift.io.vn");
expect(screen.getByLabelText("Username")).toHaveValue("vi");
expect(screen.getByLabelText(/Remember for everyone/)).toBeChecked();
expect(screen.getByRole("checkbox", { name: /Remember for everyone/ })).toBeChecked();
await user.type(screen.getByLabelText("Password or access token"), "s3cret");
await user.click(screen.getByRole("button", { name: "Log in and retry" }));
@@ -1,10 +1,10 @@
/**
* @file SessionResumeButton.test.tsx
* @description The transcript page's explicit Resume: it shows only for a
* @description The transcript page's explicit Resume and Fork: it shows only for a
* Claude session inside a workspace, a live session offers its pane instead,
* and a resume asks first (it re-reads the whole transcript), then starts in
* the first free pane slot and opens that pane — or refuses when all four are
* busy.
* busy; a Fork asks too and starts a new session id beside a live original.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -114,6 +114,28 @@ describe("SessionResumeButton", () => {
expect(screen.getByTestId("where").textContent).toBe("/workspace?lane=3&run=agenthub-lane-3");
});
it("forks into a free pane after asking, even while the original has a live pane", async () => {
runs = [
{
id: "agenthub-lane-3",
laneId: 3,
slot: 0,
status: "running",
sessionId: "s-1",
} as RunHandle,
];
const user = userEvent.setup();
renderButton({ id: "s-1", cwd: "/r", tool: "claude" });
await user.click(await screen.findByRole("button", { name: "Fork" }));
expect(screen.getByRole("dialog")).toHaveTextContent("NEW session");
await user.click(screen.getByRole("button", { name: "Fork session" }));
expect(api.lanes.action).toHaveBeenCalledWith(3, "start", {
slot: 1,
resumeSessionId: "s-1",
forkSession: true,
});
});
it("offers no Resume for a session started in a subfolder, but opens its live pane", async () => {
const { unmount } = renderButton({ id: "s-9", cwd: "/r/lib", tool: "claude" });
await waitFor(() => expect(api.run.list).toHaveBeenCalled());
@@ -62,7 +62,10 @@ vi.mock("../../../lib/api", async (importOriginal) => {
routerList: vi.fn(async () => ({ routers: [] })),
},
run: { list: vi.fn(async () => ({ items: runs })) },
sessions: { list: vi.fn(async () => ({ sessions })) },
sessions: {
list: vi.fn(async () => ({ sessions })),
patch: vi.fn(async () => ({ session: {} })),
},
projects: {
list: vi.fn(async () => ({
projects: [
@@ -134,6 +137,12 @@ function renderHome(l = lane()) {
beforeEach(() => {
localStorage.clear();
vi.clearAllMocks();
vi.mocked(api.sessions.list).mockImplementation(async () => ({
sessions,
total: sessions.length,
limit: 30,
offset: 0,
}));
view = {
instructions: "",
supported: true,
@@ -196,11 +205,28 @@ describe("WorkspaceHome", () => {
expect(api.lanes.action).not.toHaveBeenCalled();
});
it("folds the archived sessions with Unarchive", async () => {
vi.mocked(api.sessions.list).mockImplementation(async (params) => ({
sessions: (params as { archived?: boolean }).archived ? [sessions[1]!] : [],
total: (params as { archived?: boolean }).archived ? 1 : 0,
limit: 30,
offset: 0,
}));
const user = userEvent.setup();
renderHome();
const fold = await screen.findByTestId("workspace-archived-1");
await user.click(within(fold).getByRole("button", { name: /1 archived session/ }));
await user.click(within(fold).getByTestId("workspace-archived-1-action-s-old"));
expect(api.sessions.patch).toHaveBeenCalledWith("s-old", { archived: false });
});
it("opens a live recent's pane and an ended one's transcript, resuming nothing", async () => {
const user = userEvent.setup();
renderHome();
const live = await screen.findByTestId("workspace-recent-s-live");
await waitFor(() => expect(live).toHaveAttribute("data-live", "true"));
// Recents are the workspace's OWN sessions, not the folder's.
expect(api.sessions.list).toHaveBeenCalledWith({ lane: 1, archived: false, limit: 30 });
await user.click(live);
expect(screen.getByTestId("where").textContent).toBe("/workspace?lane=1&run=agenthub-lane-1");
await user.click(screen.getByTestId("workspace-recent-s-old"));
@@ -1,90 +0,0 @@
/**
* @file WorkspaceSessionsTab.test.tsx
* @description Tests for the Workspace right panel's Sessions tab: it lists
* the workspace's own sessions (cwd + tool), filters them client-side, hands a
* claude session to Resume, and offers no Resume on another tool's workspace.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom";
import type { Lane, Session } from "../../../lib/types";
vi.mock("../../../lib/api", () => ({
api: {
sessions: { list: vi.fn() },
aiTools: { list: vi.fn().mockResolvedValue({ tools: [], default: "claude" }) },
},
}));
import { api } from "../../../lib/api";
import { WorkspaceSessionsTab } from "../WorkspaceSessionsTab";
const session = (id: string, name: string): Session =>
({
id,
name,
status: "completed",
cwd: "/w",
model: "claude-opus-5-5",
started_at: "2026-10-01T00:00:00Z",
ended_at: null,
metadata: null,
cost: 1.5,
tool: "claude",
}) as Session;
const lane = (tool: string) => ({ id: 1, cwd: "/w", tool }) as unknown as Lane;
beforeEach(() => {
vi.mocked(api.sessions.list).mockResolvedValue({
sessions: [session("s-1", "Fix login"), session("s-2", "Add tests")],
} as never);
});
function renderTab(tool: string, onResume = vi.fn()) {
render(
<MemoryRouter>
<WorkspaceSessionsTab lane={lane(tool)} onResume={onResume} />
</MemoryRouter>
);
return onResume;
}
describe("WorkspaceSessionsTab", () => {
it("lists the workspace's sessions by cwd and tool, and filters them", async () => {
const user = userEvent.setup();
renderTab("claude");
expect(await screen.findByText("Fix login")).toBeInTheDocument();
expect(api.sessions.list).toHaveBeenCalledWith({ cwd: "/w", tool: "claude", limit: 50 });
await user.type(screen.getByRole("searchbox"), "tests");
expect(screen.queryByText("Fix login")).toBeNull();
expect(screen.getByText("Add tests")).toBeInTheDocument();
expect(screen.getAllByRole("link", { name: "Open" })[0]).toHaveAttribute(
"href",
"/sessions/s-2"
);
});
it("hands the session to Resume", async () => {
const user = userEvent.setup();
const onResume = renderTab("claude");
await screen.findByText("Fix login");
await user.click(screen.getAllByRole("button", { name: "Resume" })[0]!);
expect(onResume).toHaveBeenCalledWith(expect.objectContaining({ id: "s-1" }));
});
it("offers no Resume on a non-claude workspace", async () => {
renderTab("codex");
await screen.findByText("Fix login");
expect(screen.queryByRole("button", { name: "Resume" })).toBeNull();
});
it("says so when the workspace has no session yet", async () => {
vi.mocked(api.sessions.list).mockResolvedValue({ sessions: [] } as never);
renderTab("claude");
expect(await screen.findByText("No sessions in this workspace yet")).toBeInTheDocument();
});
});