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
@@ -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}
</>
);
}