ca821008e2
Measured on a copy of a real 1.2 GB / 253K-event database, 10 simulated browsers + 10 hooks/s for 90 s: event-loop delay p99 1239 ms → 32 ms (max 2.0 s → 94 ms), 266 failed requests → 0, 74 → 111 requests/s served, server RSS 633 → 346 MB. Entry bundle 975 KB raw → 394 KB (106 KB brotli). Server - /api/analytics: the events-table scans run in the read worker (scoped-stats eventAggregates, read-pool job analyticsEvents); runRead takes maxAgeMs — analytics 10 s, /api/workflows 30 s — keyed by the args - /api/workflows/skill-usage: +event_type and a tool_name range instead of LIKE, so it uses idx_events_tool (784 ms → a few ms) - idx_events_session_type_tool (session_id, event_type, tool_name, created_at) replaces idx_events_session_type: covering reads for session stats, the hook dedupes and the import resume point (1.2 s → 20 ms); 64 MB cache_size and 256 MB mmap_size on both connections - WAL: the server sets wal_autocheckpoint = 0 and checkpoints PASSIVE every DASHBOARD_WAL_CHECKPOINT_MS (5 s) from the read worker's own writable connection; a failed checkpoint restores the automatic one (hook COMMIT p95 109 ms → 15 ms at 100 hooks/s) - tmux: one list-sessions answers a whole GET /api/lanes and the heal beat (liveSessions), shared for 1 s and cleared by this process's new/kill — GET /api/run's sync spawns had blocked 60 s of a 90 s run; the heal beat skips tmux when nothing runs; attach options chained into 2 spawns - hooks: TurnDuration/APIError deduped against one read per hook instead of a query per transcript entry (23 ms/hook on a 763-turn session); one lane scan per hook; hourly upserts skip rows that did not grow; frames are sent after the transaction commits (and after the pane binding); new_event carries its row id; a split pane's (slot 1–9) hooks no longer rebind the lane's session_id - watchdog liveness probe runs ps asynchronously; transcript newline scan via indexOf; startup subagent backfill fingerprints the files it already checked - pty attach: backpressure (pause past 1 MB unsent, resume under 256 KB) - static: hashed assets served from their pre-compressed .br/.gz sibling Client - hidden tabs neither refetch on WS events nor poll (useLiveRefresh, panes, lane cards, git header, changes panel, settings); no per-tab hourly /updates/check (the server already checks) - refetch storms throttled (workspace tree/session list 3 s, /api/run only on a run-key change, notification pane lookup only when shown); WS reconnect jitter; terminal retry backoff 1.5 → 30 s, stops on 1008 - heavier throttles for aggregates (15 s) and detail views; Monitor keepIfSame; GET in-flight dedupe in request() - lazy Dashboard/History, xterm (with the first terminal), lane modals, Sagi (only while enabled); only the active locale loads; Workspace chunk prefetched on a /workspace deep link - Sagi: no work while disabled, pupil via CSS variables, memo avatar - build: .br/.gz siblings, sourcemap hidden; service worker no longer caches /assets/ (cache v3 drops the old one) Tests: scripts/test-server.js drops the GIT_DIR-family variables a hook exports, so a suite run from a linked worktree's pre-commit can no longer commit into the real repository or set core.bare. Docs: ARCHITECTURE, DATABASE, API, DEPLOYMENT, client README, docs/plans/perf/README.md (audit, checklist, results).
712 lines
28 KiB
TypeScript
712 lines
28 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) — 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** — and under the sessions the last
|
|
* `CLOSED_TERMINALS` closed ones (`GET /api/run/history?tool=shell`), whose
|
|
* click opens a NEW shell tab (a shell cannot be resumed) and whose hover X or
|
|
* right-click menu forgets the row (`DELETE /api/run/history/:id`); Ctrl/Cmd+click opens
|
|
* it in a split beside the focused pane) — 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. 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.
|
|
* @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, X } from "lucide-react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { Link, useNavigate } from "react-router-dom";
|
|
import { api, type RunHandle, type RunHistoryItem } from "../../lib/api";
|
|
import { toast } from "../../lib/toast";
|
|
import { startInFreeTab } from "./startSession";
|
|
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 { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
|
|
import { SessionMenu } from "./SessionMenu";
|
|
|
|
export const SESSION_LIMIT = 6;
|
|
/** Closed Terminal tabs listed under a workspace's sessions. */
|
|
export const CLOSED_TERMINALS = 3;
|
|
|
|
/**
|
|
* 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,
|
|
}: {
|
|
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;
|
|
}) {
|
|
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}`;
|
|
// A Terminal tab closing is when one joins the history.
|
|
const shellKey = liveShells.map((r) => r.id).join(",");
|
|
const [closedShells, setClosedShells] = useState<RunHistoryItem[]>([]);
|
|
useEffect(() => {
|
|
if (hidden) return;
|
|
let live = true;
|
|
api.run
|
|
// Live ones come back too (isLive): ask for enough to fill the list anyway.
|
|
.history({ laneId: lane.id, tool: "shell", limit: CLOSED_TERMINALS + 10 })
|
|
.then(
|
|
(r) => live && setClosedShells(r.items.filter((x) => !x.isLive).slice(0, CLOSED_TERMINALS))
|
|
)
|
|
.catch(() => undefined);
|
|
return () => {
|
|
live = false;
|
|
};
|
|
}, [lane.id, shellKey, hidden]);
|
|
const reopenShell = async (split: boolean) => {
|
|
try {
|
|
const runId = await startInFreeTab(lane.id, { shell: true });
|
|
navigate(
|
|
`/workspace?lane=${lane.id}&run=${encodeURIComponent(runId)}`,
|
|
split ? { state: { split: true } } : undefined
|
|
);
|
|
} catch (err) {
|
|
toast.error(err);
|
|
}
|
|
};
|
|
const closedCtx = useContextMenu<RunHistoryItem>();
|
|
const forgetShell = async (r: RunHistoryItem) => {
|
|
try {
|
|
await api.run.forgetHistory(r.id);
|
|
setClosedShells((all) => all.filter((x) => x.id !== r.id));
|
|
} catch (err) {
|
|
toast.error(err);
|
|
}
|
|
};
|
|
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 shown = (sessions ?? []).filter(
|
|
(s) => !q || (s.name ?? "").toLowerCase().includes(q) || s.id.startsWith(q)
|
|
);
|
|
const runFor = (s: Session) => liveRunFor(lane, liveRuns, s.id);
|
|
// 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
|
|
? []
|
|
: [
|
|
...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>
|
|
);
|
|
// Not during a search, like the live ones: they have no name to match.
|
|
const closed = q ? [] : closedShells;
|
|
if (sessions !== null && shown.length === 0 && fresh.length === 0 && closed.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;
|
|
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={() => startRename(s)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "F2") {
|
|
e.preventDefault();
|
|
startRename(s);
|
|
}
|
|
}}
|
|
onContextMenu={(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) })}`
|
|
: ""
|
|
}\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 group-hover:invisible"
|
|
/>
|
|
</button>
|
|
<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>
|
|
);
|
|
})}
|
|
{closed.map((r) => (
|
|
<li key={r.id} className="group relative">
|
|
<button
|
|
type="button"
|
|
data-testid={`workspace-terminal-closed-${r.id}`}
|
|
onClick={(e) => void reopenShell(e.ctrlKey || e.metaKey)}
|
|
onContextMenu={(e) => closedCtx.open(e, r)}
|
|
title={t("tree.closedTerminal")}
|
|
className="flex w-full items-center gap-2 rounded-md py-1 pl-2 pr-2 text-left text-[12px] text-fg-muted hover:bg-surface-3 hover:text-fg-primary"
|
|
>
|
|
<span
|
|
aria-hidden
|
|
className="h-1.5 w-1.5 shrink-0 rounded-full border border-fg-muted"
|
|
/>
|
|
<span className="sr-only">{t("tree.state.idle")}</span>
|
|
<span className="truncate">{t("tabs.terminal")}</span>
|
|
<span className="ml-auto shrink-0 text-[11px]">
|
|
{timeAgo(r.ended_at ?? r.started_at, true)}
|
|
</span>
|
|
<SquareTerminal
|
|
aria-hidden
|
|
className="h-3.5 w-3.5 shrink-0 opacity-70 group-hover:invisible"
|
|
/>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
data-testid={`workspace-terminal-forget-${r.id}`}
|
|
onClick={() => void forgetShell(r)}
|
|
title={t("tree.forgetTerminal")}
|
|
aria-label={t("tree.forgetTerminal")}
|
|
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"
|
|
>
|
|
<X 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>
|
|
)}
|
|
{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}
|
|
/>
|
|
)}
|
|
{closedCtx.menu && (
|
|
<ContextMenu
|
|
x={closedCtx.menu.x}
|
|
y={closedCtx.menu.y}
|
|
onClose={closedCtx.close}
|
|
testId={`terminal-menu-${closedCtx.menu.item.id}`}
|
|
>
|
|
<MenuItem
|
|
onSelect={() => {
|
|
closedCtx.close();
|
|
void reopenShell(false);
|
|
}}
|
|
>
|
|
{t("tree.reopenTerminal")}
|
|
</MenuItem>
|
|
<MenuItem
|
|
onSelect={() => {
|
|
closedCtx.close();
|
|
void reopenShell(true);
|
|
}}
|
|
>
|
|
{t("sessionMenu.openSplit")}
|
|
</MenuItem>
|
|
<MenuSeparator />
|
|
<MenuItem
|
|
danger
|
|
onSelect={() => {
|
|
const item = closedCtx.menu!.item;
|
|
closedCtx.close();
|
|
void forgetShell(item);
|
|
}}
|
|
>
|
|
{t("tree.forgetTerminal")}
|
|
</MenuItem>
|
|
</ContextMenu>
|
|
)}
|
|
{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)}
|
|
/>
|
|
)}
|
|
</>
|
|
);
|
|
}
|