feat(workspace): no double resume, server-picked tabs, terminal a11y

- A resume of a session a live pane already holds is refused (409
  ESESSIONLIVE with error.run_id) and the client opens that pane, pasting
  any typed message there; catalog tools refuse resumeSessionId
  (EUNSUPPORTED) instead of silently starting fresh.
- POST /api/lanes/:id/start takes slot: "auto": the server picks the free
  tab under the lane lock (components/lanes/startSession.ts on the client,
  kept out of WorkspaceHome so the sidebar does not pull react-markdown
  into the main bundle).
- Unsaved whole-file edits are guarded on every exit (Back, another file,
  tab switch, close, lane/session change, beforeunload).
- Closing an AI tab asks first; hiding a pane has its own icon. Tabs and
  pane headers show the session name, a state dot, rename by double-click.
- Terminal: Ctrl+M toggles Tab-moves-focus (no keyboard trap), Cmd+F /
  Ctrl+Shift+F find bar, clickable links (noopener), a write-only OSC 52
  clipboard, a Reconnecting overlay, debounced resizes, optional screen
  reader mode. Alt+1-4 / Alt+\ / Alt+W focus, split and hide panes.
- Changes panel: draggable splitter, wrapping git bar, history paging,
  disarming discard/undo/abort, inline diff comments pasted only into a
  visible pane; drag MIME guard on sidebar rows; IME-safe Enter; Show
  more in the sidebar and Recents; idle dots readable with sr-only text.
- Adds @xterm/addon-clipboard, @xterm/addon-search, @xterm/addon-web-links.
This commit is contained in:
2026-10-05 13:22:12 +07:00
parent 6715fbd971
commit efa417488a
43 changed files with 2252 additions and 507 deletions
+3 -3
View File
@@ -65,7 +65,7 @@ function Seg({
aria-pressed={active}
className={`flex-1 rounded px-2 py-1 text-xs font-medium transition-colors ${
active
? "bg-accent text-white shadow-sm"
? "bg-accent-solid text-white shadow-sm"
: "text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
}`}
>
@@ -567,7 +567,7 @@ export function AddLaneModal({
id="add-lane-base"
value={base}
onChange={(e) => setBase(e.target.value)}
className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 text-xs text-fg-primary focus:border-accent focus:outline-none"
className="select w-full !py-1.5 !text-xs"
>
{branches.map((b) => (
<option key={b} value={b}>
@@ -606,7 +606,7 @@ export function AddLaneModal({
id="add-lane-existing"
value={branch}
onChange={(e) => setBranch(e.target.value)}
className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 font-mono text-xs text-fg-primary focus:border-accent focus:outline-none"
className="select w-full !py-1.5 font-mono !text-xs"
>
{branches.map((b) => {
const busyAt = busyBranches.get(b);
@@ -8,10 +8,11 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { FolderOpen, FolderGit2, ArrowUp } from "lucide-react";
import { api } from "../../lib/api";
import { useDialog } from "../../hooks/useDialog";
export function FolderBrowseModal({
open,
@@ -45,14 +46,10 @@ export function FolderBrowseModal({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [open, onClose]);
// The shared dialog contract: focus in, Tab trapped, Escape closes (only
// this one when it sits over the Add-workspace modal), focus restored.
const panel = useRef<HTMLDivElement>(null);
useDialog(open, panel, onClose);
if (!open) return null;
@@ -63,6 +60,7 @@ export function FolderBrowseModal({
role="presentation"
>
<div
ref={panel}
className="relative flex max-h-[70vh] w-full max-w-md flex-col rounded-xl border border-border bg-surface-1 shadow-xl shadow-black/40"
onClick={(e) => e.stopPropagation()}
role="dialog"
@@ -99,7 +97,7 @@ export function FolderBrowseModal({
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs text-fg-primary hover:bg-surface-2"
>
{entry.isGitRepo ? (
<FolderGit2 className="h-3.5 w-3.5 text-blue-700 dark:text-blue-400" />
<FolderGit2 className="h-3.5 w-3.5 text-accent" />
) : (
<FolderOpen className="h-3.5 w-3.5 text-fg-muted" />
)}
+26 -40
View File
@@ -100,7 +100,8 @@ function useLaneLocks(laneSlot: number | null): NamedLock[] {
export const LIVENESS_DOT: Record<Lane["liveness"], string> = {
active: "bg-status-success",
idle: "bg-surface-4",
// A ring: a surface-4 fill vanished on the light themes.
idle: "border border-fg-muted",
dead: "bg-status-danger",
};
@@ -128,7 +129,6 @@ export default function LaneCard({
}) {
const { t } = useTranslation(["lanes"]);
const [destructiveAction, setDestructiveAction] = useState<"reset" | null>(null);
const [menuOpen, setMenuOpen] = useState(false);
const [menuAt, setMenuAt] = useState<MenuPoint | null>(null);
const [runtimeBump, setRuntimeBump] = useState(0);
const [bootLine, setBootLine] = useState<string | null>(null);
@@ -203,9 +203,13 @@ export default function LaneCard({
<div key={name} className="flex items-center gap-1.5 truncate">
<span
className={`h-1.5 w-1.5 shrink-0 rounded-full ${
info.listening ? "bg-status-success" : "bg-surface-4"
info.listening ? "bg-status-success" : "border border-fg-muted"
}`}
aria-hidden
/>
<span className="sr-only">
{info.listening ? t("runtime.listening") : t("runtime.notListening")}
</span>
<span className="text-fg-secondary">{name}</span>
<span className="text-fg-muted">:{info.port ?? "—"}</span>
{info.port !== null && info.port !== info.expected && (
@@ -265,7 +269,7 @@ export default function LaneCard({
<button
type="button"
onClick={() => onSelectLane?.(w.id)}
className="truncate text-left text-blue-700 dark:text-blue-400 hover:underline"
className="truncate text-left text-accent hover:underline"
title={w.cwd}
>
#{w.id} {w.title || w.cwd} · {w.status}
@@ -283,42 +287,23 @@ export default function LaneCard({
applies to managed lanes and is rare enough to stay behind ⋯. */}
{lane.kind === "managed" && (
<div className="mt-auto flex items-center justify-end gap-1 border-t border-border pt-2 text-xs">
<div className="relative">
<button
type="button"
data-testid="lane-more"
aria-haspopup="menu"
aria-expanded={menuOpen}
onClick={(e) => {
e.stopPropagation();
setMenuOpen((v) => !v);
}}
className="rounded px-2 py-1 text-fg-muted hover:bg-surface-2 hover:text-fg-secondary"
title={t("moreActions")}
>
⋯
</button>
{menuOpen && (
<div
role="menu"
className="absolute right-0 z-10 mt-1 min-w-40 overflow-hidden rounded-md border border-border-light bg-surface-0 py-1 shadow-lg shadow-black/40"
>
<button
type="button"
role="menuitem"
data-testid="lane-action-reset"
onClick={(e) => {
e.stopPropagation();
setMenuOpen(false);
setDestructiveAction("reset");
}}
className="block w-full px-3 py-1.5 text-left text-status-warning hover:bg-status-warning/10"
>
{t("action.reset")}
</button>
</div>
)}
</div>
{/* The shared menu shell: outside click, Escape and focus. */}
<button
type="button"
data-testid="lane-more"
aria-haspopup="menu"
aria-expanded={!!menuAt}
aria-label={t("moreActions")}
onClick={(e) => {
e.stopPropagation();
const r = e.currentTarget.getBoundingClientRect();
setMenuAt(menuAt ? null : { x: r.right - 160, y: r.bottom + 4 });
}}
className="rounded px-2 py-1 text-fg-muted hover:bg-surface-2 hover:text-fg-secondary"
title={t("moreActions")}
>
⋯
</button>
</div>
)}
</div>
@@ -327,6 +312,7 @@ export default function LaneCard({
<ContextMenu x={menuAt.x} y={menuAt.y} onClose={() => setMenuAt(null)}>
<MenuItem
danger
testId="lane-action-reset"
onSelect={() => {
setMenuAt(null);
setDestructiveAction("reset");
+110 -22
View File
@@ -32,7 +32,15 @@
*
* 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.
* focused last (`lib/panePaste.ts`) and sent only when the user presses Enter;
* a toast says which tab took it, and a console not on screen takes nothing.
*
* An unsaved whole-file edit is never dropped silently: Back, another file,
* Cancel and a quick-open pick all ask once (`confirmDiscard`), closing or
* reloading the browser tab asks the browser's own question, and
* `onDirtyChange` tells the Workspace, which asks before closing the panel,
* switching its tab, or moving it to another workspace or session. Two-click
* actions (discard, a hunk's Undo, Abort merge) disarm after 3 s (`useArmed`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -61,6 +69,12 @@ import {
} from "lucide-react";
import { api, gitTarget } from "../../lib/api";
import { pasteToLane } from "../../lib/panePaste";
import { confirmDiscard as askDiscard } from "../../lib/confirm";
import { copyText } from "../../lib/clipboard";
import { isSubmitEnter } from "../../lib/keys";
import { toast } from "../../lib/toast";
import { useArmed } from "../../hooks/useArmed";
import { useUnsavedGuard } from "../../hooks/useUnsavedGuard";
import { LaneDiffView, LaneFileView, type DiffLayout, type LaneDiffChange } from "./LaneDiffView";
import { LaneConflictView } from "./LaneConflictView";
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
@@ -428,14 +442,21 @@ function ChangeRow({
);
}
/** History's page size, and the most the server reads (`listCommits`). */
const HISTORY_PAGE = 50;
const HISTORY_MAX = 200;
export function LaneChangesPanel({
laneId,
session,
onNewLane,
full = false,
tab = "changes",
onDirtyChange,
}: {
laneId: number;
/** The whole-file editor holds unsaved text (true) or not (false). */
onDirtyChange?: (dirty: boolean) => void;
/** Show and act on that session's own worktree (`claude --worktree`)
* instead of the workspace's folder. The Workspace keys the panel on it,
* so a different session is a fresh panel. */
@@ -457,7 +478,17 @@ export function LaneChangesPanel({
// Every git read and write below goes to the same tree as those facts.
const target = gitTarget(laneId, session);
// A diff line's comment goes into this workspace's pane, as a paste.
const comment = useCallback((text: string) => pasteToLane(laneId, text), [laneId]);
const comment = useCallback(
(text: string) => {
const runId = pasteToLane(laneId, text);
if (runId) {
const slot = Number(/-([1-3])$/.exec(runId)?.[1] ?? 0);
toast.success(t("changes.commentPasted", { tab: slot + 1 }));
}
return runId !== null;
},
[laneId, t]
);
// 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);
@@ -523,12 +554,21 @@ export function LaneChangesPanel({
}, [text]);
const [loading, setLoading] = useState(false);
const [busy, setBusy] = useState(false);
const [confirmDiscard, setConfirmDiscard] = useState<string | null>(null);
const [armedAbort, setArmedAbort] = useState(false);
// Two-click arming that disarms by itself after 3 s: the armed path, the
// armed hunk (`${path}\n${header}\n${block}`) and the armed merge abort.
const discardArm = useArmed();
const confirmDiscard = discardArm.armed;
const setConfirmDiscard = (path: string | null) =>
path ? discardArm.arm(path) : discardArm.disarm();
const abortArm = useArmed();
const armedAbort = abortArm.armed !== null;
const setArmedAbort = (on: boolean) => (on ? abortArm.arm("abort") : abortArm.disarm());
// A conflicted file opens in the conflict view; true = the plain editor.
const [rawEdit, setRawEdit] = useState(false);
// The hunk whose Undo was clicked once: `${path}\n${header}`.
const [armedHunk, setArmedHunk] = useState<string | null>(null);
// The hunk whose Undo was clicked once: `${path}\n${header}\n${block}`.
const hunkArm = useArmed();
const armedHunk = hunkArm.armed;
const setArmedHunk = (key: string | null) => (key ? hunkArm.arm(key) : hunkArm.disarm());
// Files folded shut in review mode. A change-set is read file by file, and
// a file already read is in the way of the next one — so the set is what is
// COLLAPSED, and a file arriving later is open by default.
@@ -603,13 +643,19 @@ export function LaneChangesPanel({
// History and the file tree are their own reads, each fetched when its mode
// is entered rather than alongside the diff nobody is looking at.
// History reads HISTORY_PAGE commits, and Load more asks for the next page
// (up to the server's 200).
const [historyLimit, setHistoryLimit] = useState(HISTORY_PAGE);
const [historyLoading, setHistoryLoading] = useState(false);
useEffect(() => {
if (mode !== "history") return;
setHistoryLoading(true);
void api.lanes
.gitLog(target, { limit: 50 })
.gitLog(target, { limit: historyLimit })
.then(forLane(laneId, (r) => setCommits(r.commits)))
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
}, [mode, laneId, reread]);
.catch((err) => setError(err instanceof Error ? err.message : String(err)))
.finally(() => laneNow.current === laneId && setHistoryLoading(false));
}, [mode, laneId, reread, historyLimit]);
// Re-run when a lane switch empties the cache too: the Files tab stays in
// `browse` across the switch, so the mode alone never changes.
@@ -739,8 +785,14 @@ export function LaneChangesPanel({
pendingScroll.current = null;
}, [laneId]);
// After the lane reset above: each tab opens on its own mode.
// Leaving the open file this way (the Workspace asked first when it was
// edited) drops it whole: a draft left behind would keep `dirty` true.
useEffect(() => {
if (!full) setMode(tab === "files" ? "browse" : "overview");
if (full) return;
setMode(tab === "files" ? "browse" : "overview");
setOpenFile(null);
setWhole(false);
setDraft(null);
}, [tab, laneId, full]);
const filesTab = tab === "files" && !full;
@@ -756,7 +808,11 @@ export function LaneChangesPanel({
pendingScroll.current = null;
}, [mode, data]);
const show = (path: string) => {
// Every way out of an open, edited file asks once; nothing else does.
const dirtyRef = useRef(false);
const confirmDrop = async () => !dirtyRef.current || (await askDiscard());
const showNow = (path: string) => {
// Opening a file FROM a file (the review list is gone by then) must not
// overwrite the list position that is still waiting to be restored.
if (mode !== "file") {
@@ -773,13 +829,21 @@ export function LaneChangesPanel({
setMode("file");
};
const goBack = () => {
const show = (path: string) => {
void confirmDrop().then((ok) => ok && showNow(path));
};
const goBackNow = () => {
const back = returnTo.current;
pendingScroll.current = back?.scroll ?? 0;
returnTo.current = null;
setMode(back?.mode ?? (range === "commit" ? "review" : "overview"));
setOpenFile(null);
setWhole(false);
setDraft(null);
};
const goBack = () => {
void confirmDrop().then((ok) => ok && goBackNow());
};
// Save the editor over exactly the bytes it opened (`sha`): a file the agent
@@ -860,7 +924,7 @@ export function LaneChangesPanel({
setRange("commit");
setMode("review");
};
const copy = (text: string) => void navigator.clipboard.writeText(text);
const copy = (text: string) => void copyText(text);
// A menu item: close its menu, then act.
const pick = (close: () => void, fn: () => void) => () => {
close();
@@ -952,7 +1016,7 @@ export function LaneChangesPanel({
testId="lane-changes-tree-menu"
>
{!treeCtx.menu.item.dir && (
<MenuItem onSelect={pick(treeCtx.close, () => openWhole(treeCtx.menu!.item.path))}>
<MenuItem onSelect={pick(treeCtx.close, () => void openWhole(treeCtx.menu!.item.path))}>
{t("changes.openFile", { path: treeCtx.menu.item.name })}
</MenuItem>
)}
@@ -1090,6 +1154,14 @@ export function LaneChangesPanel({
});
const dirty = draft !== null && draft !== text?.lines.join("\n");
dirtyRef.current = dirty;
useUnsavedGuard(dirty);
const dirtyChangeRef = useRef(onDirtyChange);
dirtyChangeRef.current = onDirtyChange;
useEffect(() => dirtyChangeRef.current?.(dirty), [dirty]);
// Unmounted with an edit open (the panel closed or moved on, after the
// Workspace asked): nothing is dirty any more.
useEffect(() => () => dirtyChangeRef.current?.(false), []);
const files = data?.available ? data.files : [];
const allCollapsed = files.length > 0 && files.every((f) => collapsed.has(f.path));
@@ -1110,9 +1182,9 @@ export function LaneChangesPanel({
// A tree or quick-open pick opens the whole file — and, with an edit open,
// would drop it, so a dirty draft asks first.
const openWhole = (p: string) => {
if (dirty && !window.confirm(t("changes.dropEdits"))) return;
show(p);
const openWhole = async (p: string) => {
if (!(await confirmDrop())) return;
showNow(p);
setWhole(true);
};
@@ -1156,7 +1228,7 @@ export function LaneChangesPanel({
<li key={p}>
<button
type="button"
onClick={() => openWhole(p)}
onClick={() => void openWhole(p)}
className="w-full truncate rounded px-1 py-0.5 text-left font-mono text-[11px] text-fg-primary hover:bg-surface-2"
>
{p}
@@ -1175,7 +1247,7 @@ export function LaneChangesPanel({
children={treeChildren}
expanded={expandedDirs}
onToggleDir={toggleDir}
onOpenFile={openWhole}
onOpenFile={(p) => void openWhole(p)}
onMenu={treeCtx.open}
active={full && mode === "file" ? openFile : null}
/>
@@ -1235,7 +1307,7 @@ export function LaneChangesPanel({
onClick={() => setMode(m)}
className={`rounded px-2 py-0.5 text-[11px] font-medium transition-colors ${
mode === m
? "bg-accent text-white"
? "bg-accent-solid text-white"
: "text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
}`}
>
@@ -1437,9 +1509,25 @@ export function LaneChangesPanel({
</button>
</li>
))}
{commits === null && (
<li className="text-[11px] text-fg-muted">{t("changes.loading")}</li>
)}
{commits && commits.length === 0 && (
<li className="text-[11px] text-fg-muted">{t("changes.noCommits")}</li>
)}
{commits && commits.length >= historyLimit && historyLimit < HISTORY_MAX && (
<li>
<button
type="button"
data-testid="lane-changes-history-more"
disabled={historyLoading}
onClick={() => setHistoryLimit((n) => Math.min(n + HISTORY_PAGE, HISTORY_MAX))}
className="mt-1 w-full rounded px-1 py-1 text-[11px] text-fg-secondary hover:bg-surface-2 hover:text-fg-primary disabled:opacity-40"
>
{historyLoading ? t("changes.loading") : t("changes.loadMore")}
</button>
</li>
)}
</ul>
) : mode === "browse" && !full ? (
browsePane
@@ -1469,7 +1557,7 @@ export function LaneChangesPanel({
value={message}
onChange={(e) => setMessage(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && message.trim() && !busy) void commitStaged();
if (isSubmitEnter(e) && message.trim() && !busy) void commitStaged();
}}
placeholder={t("changes.commitPlaceholder")}
aria-label={t("changes.commitPlaceholder")}
@@ -1670,7 +1758,7 @@ export function LaneChangesPanel({
<button
type="button"
disabled={busy}
onClick={() => setDraft(null)}
onClick={() => void confirmDrop().then((ok) => ok && setDraft(null))}
className="shrink-0 self-center rounded border border-border-light px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-2 disabled:opacity-40"
>
{t("changes.cancelEdit")}
+41 -6
View File
@@ -18,12 +18,16 @@
* so every windowed row is pinned to `ROW_PX` and the range is pure arithmetic
* (`visibleRange`); the content's min-width comes from the longest line, so
* the horizontal extent does not jump as rows come and go.
* A line's comment box floats just under the clicked line number (fixed,
* re-placed as the diff scrolls), not under the whole diff.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
@@ -265,7 +269,7 @@ export function analyseHunk(hunk: LaneDiffHunk): Analysed {
/** 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;
open: (line: number, anchor?: HTMLElement) => void;
title: (line: number) => string;
} | null>(null);
@@ -276,7 +280,7 @@ function Gutter({ n, commentable = false }: { n: number | null; commentable?: bo
<button
type="button"
data-testid={`diff-comment-line-${n}`}
onClick={() => comment.open(n)}
onClick={(e) => comment.open(n, e.currentTarget)}
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"
>
@@ -741,18 +745,34 @@ export function LaneDiffView({
const [commentAt, setCommentAt] = useState<number | null>(null);
const [draft, setDraft] = useState("");
const [undelivered, setUndelivered] = useState(false);
// The clicked line number: the box floats just under it, where the eye is,
// instead of under the whole (possibly 2000-line) diff.
const anchorEl = useRef<HTMLElement | null>(null);
const [anchor, setAnchor] = useState<{ top: number; left: number } | null>(null);
const place = useCallback(() => {
const r = anchorEl.current?.isConnected ? anchorEl.current.getBoundingClientRect() : null;
if (r) setAnchor({ top: r.bottom + 2, left: r.left });
}, []);
useEffect(() => {
if (commentAt == null) return;
window.addEventListener("resize", place);
return () => window.removeEventListener("resize", place);
}, [commentAt, place]);
const openComment = useMemo(
() =>
onComment
? {
open: (line: number) => {
open: (line: number, el?: HTMLElement) => {
anchorEl.current = el ?? null;
setAnchor(null);
place();
setCommentAt(line);
setUndelivered(false);
},
title: (line: number) => t("changes.commentLine", { n: line }),
}
: null,
[onComment, t]
[onComment, t, place]
);
const sendComment = () => {
const text = draft.trim();
@@ -812,7 +832,10 @@ export function LaneDiffView({
ref={root}
data-testid="lane-diff-view"
data-layout={layout}
onScroll={(e) => win.onScroll(e.currentTarget)}
onScroll={(e) => {
win.onScroll(e.currentTarget);
if (commentAt != null) place();
}}
className={`flex flex-col font-mono text-[11px] leading-[1.45] ${
layout === "split"
? boxHeight(fill, splitWindowed)
@@ -855,7 +878,19 @@ export function LaneDiffView({
{commentAt != null && (
<form
data-testid="diff-comment-box"
className="flex flex-col gap-1 border-t border-border bg-surface-2 p-2"
style={
anchor
? {
position: "fixed",
top: Math.min(anchor.top, window.innerHeight - 180),
left: anchor.left,
width: "min(28rem, calc(100vw - 2rem))",
}
: undefined
}
className={`z-30 flex flex-col gap-1 border-border bg-surface-2 p-2 ${
anchor ? "rounded-lg border shadow-lg" : "border-t"
}`}
onSubmit={(e) => {
e.preventDefault();
sendComment();
+22 -10
View File
@@ -19,6 +19,8 @@
* is `EGITAUTH` (issue #45): the dashboard's git has no terminal to prompt
* in, so a remote wanting a login opens a form here and the SAME action is
* resent with the typed credential — the token is never rendered back.
* Fetch, pull, push and publish say so in a toast when they succeed; a merge
* asks through the themed confirm; the rows wrap in a narrow panel.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -37,6 +39,9 @@ import {
Upload,
} from "lucide-react";
import { api, gitTarget, type GitAuthNeeded } from "../../lib/api";
import { toast } from "../../lib/toast";
import { confirmDialog } from "../../lib/confirm";
import { isSubmitEnter } from "../../lib/keys";
import type { LaneGitFacts, LaneWorktree } from "../../lib/types";
type Action =
@@ -170,6 +175,8 @@ export function LaneGitBar({
setError(t("gitBar.mergeConflicts", { count: res.conflicts.length }));
setLogin(null);
setPassword("");
if (action === "fetch" || action === "pull" || action === "push" || action === "publish")
toast.success(t(`gitBar.done.${action}`));
// The branch list this bar is showing is stale the moment a branch is
// created, deleted or switched to, so it is re-read rather than patched.
if (action === "publish") {
@@ -227,9 +234,14 @@ export function LaneGitBar({
await refreshBranches();
};
const merge = (branch: string) => {
if (!window.confirm(t("gitBar.mergeConfirm", { branch, current: git.branch }))) return;
void run("merge", { branch });
const merge = async (branch: string) => {
const ok = await confirmDialog({
title: t("gitBar.merge"),
message: t("gitBar.mergeConfirm", { branch, current: git.branch }),
confirmLabel: t("gitBar.merge"),
destructive: false,
});
if (ok) void run("merge", { branch });
};
const heldBy = new Map(
@@ -238,7 +250,7 @@ export function LaneGitBar({
return (
<div data-testid="lane-git-bar" className="space-y-1">
<div className="flex min-w-0 items-center gap-2">
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
{lead}
{/* Ahead/behind first: it is the fact that decides which of the
buttons beside it the user actually wants. */}
@@ -285,7 +297,7 @@ export function LaneGitBar({
busy={busy === "merge"}
disabled={!!busy || dirty > 0}
title={dirty > 0 ? t("gitBar.switchDirty") : t("gitBar.mergeUpstreamHint")}
onClick={() => merge(git.upstream as string)}
onClick={() => void merge(git.upstream as string)}
/>
)}
@@ -362,13 +374,13 @@ export function LaneGitBar({
</div>
{switching && (
<div className="flex items-center gap-1.5">
<div className="flex flex-wrap items-center gap-1.5">
<select
aria-label={t("gitBar.switchLabel")}
value={picked}
disabled={!!busy}
onChange={(e) => setPicked(e.target.value)}
className="min-w-0 flex-1 rounded border border-border-light bg-surface-0 px-2 py-0.5 font-mono text-[11px] text-fg-primary focus:border-accent focus:outline-none disabled:opacity-40"
className="select min-w-[8rem] flex-1 !rounded !py-0.5 !pl-2 !pr-6 font-mono !text-[11px]"
>
<option value="">{t("gitBar.switchLabel")}</option>
{(branches || []).map((b) => {
@@ -396,7 +408,7 @@ export function LaneGitBar({
busy={busy === "merge"}
disabled={!picked || picked === git.branch || dirty > 0 || !!busy}
title={dirty > 0 ? t("gitBar.switchDirty") : t("gitBar.mergeHint")}
onClick={() => merge(picked)}
onClick={() => void merge(picked)}
/>
{onNewLane && (
<Btn
@@ -436,7 +448,7 @@ export function LaneGitBar({
value={newBranch}
onChange={(e) => setNewBranch(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && newBranch.trim())
if (isSubmitEnter(e) && newBranch.trim())
void run("branch-create", { branch: newBranch.trim() });
}}
placeholder={t("gitBar.branchNewPlaceholder")}
@@ -462,7 +474,7 @@ export function LaneGitBar({
value={url}
onChange={(e) => setUrl(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && url.trim()) void run("publish", { url: url.trim() });
if (isSubmitEnter(e) && url.trim()) void run("publish", { url: url.trim() });
}}
// The server decides which URLs are acceptable (https without a
// token, ssh, user@host:path); this only names the two usual ones.
@@ -267,7 +267,13 @@ export function LaneGitHeader({
// keyboard or a touchscreen has.
onFocus={openFiles}
onBlur={closeFiles}
onClick={() => setFilesOpen((v) => !v)}
// Click only OPENS: hover (or a tap's focus) has already opened it,
// and a toggle here shut it again on the same gesture. Leaving,
// blurring or Escape closes it.
onClick={openFiles}
onKeyDown={(e) => {
if (e.key === "Escape") setFilesOpen(false);
}}
className="shrink-0 cursor-help rounded-full border border-status-warning/40 bg-status-warning/10 px-2 py-px text-[10.5px] text-status-warning"
>
{t("git.uncommitted", { dirty: git.dirty, untracked: git.untracked })}
+1 -1
View File
@@ -20,7 +20,7 @@ import type { LaneNode } from "../../lib/types";
const STATE_CLASS: Record<LaneNode["state"], string> = {
done: "border-status-success/60 text-status-success bg-status-success/10",
current: "border-accent bg-accent text-white ring-2 ring-accent/30 shadow-sm",
current: "border-accent bg-accent-solid text-white ring-2 ring-accent/30 shadow-sm",
"passed-no-evidence": "border-status-warning/60 text-status-warning bg-status-warning/10",
failed: "border-status-danger/60 text-status-danger bg-status-danger/10",
pending: "border-border-light text-fg-muted bg-transparent",
+17 -12
View File
@@ -6,16 +6,18 @@
* opens its conversation — never a resume), open a live one in a split,
* rename / archive / unarchive when the list offers them, copy its id, open
* its analysis page, and close its live tab (`DELETE /api/run/:id`, what the
* tab's X does).
* tab's X does — after the same confirm for an AI tab, which may be mid-turn).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { api, type RunHandle } from "../../lib/api";
import type { Session } from "../../lib/types";
import { ContextMenu, MenuItem, MenuSeparator, type MenuPoint } from "../ContextMenu";
import { confirmDialog } from "../../lib/confirm";
import { copyText } from "../../lib/clipboard";
import { toast } from "../../lib/toast";
export function SessionMenu({
laneId,
@@ -39,7 +41,6 @@ export function SessionMenu({
}) {
const { t } = useTranslation("lanes");
const navigate = useNavigate();
const [error, setError] = useState<string | null>(null);
const runUrl = run && `/workspace?lane=${laneId}&run=${encodeURIComponent(run.id)}`;
const pick = (fn: () => unknown) => () => {
@@ -48,12 +49,21 @@ export function SessionMenu({
};
const closeTab = async () => {
if (!run) return;
setError(null);
onClose();
if (
run.tool !== "shell" &&
!(await confirmDialog({
title: t("tabs.stopTitle", { name: session?.name || t("tree.newSession") }),
message: t("tabs.stopBody"),
confirmLabel: t("tabs.stopConfirm"),
destructive: true,
}))
)
return;
try {
await api.run.kill(run.id);
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
toast.error(err);
}
};
@@ -79,7 +89,7 @@ export function SessionMenu({
<MenuItem onSelect={pick(() => navigate(`/sessions/${session.id}`))}>
{t("sessionMenu.details")}
</MenuItem>
<MenuItem onSelect={pick(() => void navigator.clipboard.writeText(session.id))}>
<MenuItem onSelect={pick(() => void copyText(session.id))}>
{t("sessionMenu.copyId")}
</MenuItem>
</>
@@ -92,11 +102,6 @@ export function SessionMenu({
{t("tabs.close")}
</MenuItem>
)}
{error && (
<p role="alert" className="px-2 py-1 text-[11px] text-status-danger">
{error}
</p>
)}
</ContextMenu>
);
}
+128 -52
View File
@@ -2,22 +2,52 @@
* @file SessionReplay — an ENDED session opened in its workspace
* (`/workspace?lane=<id>&session=<sessionId>`), the way Claude Desktop opens
* an old one: its conversation, read-only (`ConversationView`), and a prompt
* box under it (a pasted image rides along as a thumbnail). Opening it resumes nothing; only SENDING a message does —
* `POST /api/lanes/:id/start` with `resumeSessionId` and that prompt, in the
* first free pane slot, after which `onResumed` opens the pane (`&run=`). The
* session's tool and folder come from its row (`GET /api/sessions/:id`), so a
* resume runs on the tool the session ran on (the server enforces that too).
* box under it (a pasted image rides along as a thumbnail). Opening it resumes
* nothing; only SENDING a message does — `POST /api/lanes/:id/start` with
* `resumeSessionId`, that prompt and `slot: "auto"` (the server picks the free
* tab), after which `onResumed` opens the pane (`&run=`). The panes are re-read
* first, and a session some tab already holds opens THAT tab instead of a
* second `--resume` (the server refuses one too, `ESESSIONLIVE`) — the typed
* message is pasted there (never sent: Enter is the user's), with a toast; while
* it is live the composer is off. Only Claude and Codex can resume a session by id —
* any other tool's session offers a new session from the workspace home
* instead. The header carries the transcript page's Resume/Fork
* (`SessionResumeButton`, behind its warning). The session's tool and folder
* come from its row (`GET /api/sessions/:id`), so a resume runs on the tool the
* session ran on (the server enforces that too).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { ArrowUp } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { ArrowUp, SquareTerminal } from "lucide-react";
import { api, type RunHandle } from "../../lib/api";
import type { Lane, Session } from "../../lib/types";
import { ConversationView } from "../conversation/ConversationView";
import { PromptEditor, promptWithImages, type PromptImage } from "../run/RunSetup";
import { freeSlot, paneRunId } from "./WorkspaceHome";
import { startInFreeTabDetailed } from "./startSession";
import { pasteToRun } from "../../lib/panePaste";
import { toast } from "../../lib/toast";
import i18n from "../../i18n";
import { liveRunFor } from "./WorkspaceSessionList";
import { SessionResumeButton } from "./SessionResumeButton";
/** A message for a session that a tab already holds goes into THAT tab as a
* paste, once its terminal is on screen (it mounts after the navigation);
* a toast says where it went — or, failing that, carries the text. */
function pasteWhenShown(runId: string, text: string) {
let tries = 0;
const tick = () => {
if (pasteToRun(runId, text)) toast.success(i18n.t("lanes:replay.pasted"));
else if (++tries < 30) window.setTimeout(tick, 100);
else toast.error(i18n.t("lanes:replay.notPasted", { text }));
};
window.setTimeout(tick, 0);
}
/** The tools whose launcher resumes a session BY ID (server/lib/pty-run.js). */
const RESUMABLE = new Set(["claude", "codex"]);
export function SessionReplay({
lane,
@@ -27,15 +57,18 @@ export function SessionReplay({
}: {
lane: Lane;
sessionId: string;
/** Every live dashboard pane, to pick a free slot. */
/** Every live dashboard pane: a session one of them holds opens that pane. */
liveRuns: RunHandle[];
/** Opens the pane the resume started in. */
onResumed: (runId: string) => void;
/** Opens the pane the resume started in — of `laneId` when it is another
* (nested) workspace's pane that holds the session. */
onResumed: (runId: string, laneId?: number) => void;
}) {
const { t } = useTranslation("lanes");
const navigate = useNavigate();
const [session, setSession] = useState<Session | null>(null);
const [prompt, setPrompt] = useState("");
const [images, setImages] = useState<PromptImage[]>([]);
const [uploading, setUploading] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -46,7 +79,7 @@ export function SessionReplay({
setImages([]);
setError(null);
api.sessions
.get(sessionId)
.get(sessionId, { events: false })
.then((r) => live && setSession(r.session))
.catch(() => undefined);
return () => {
@@ -54,26 +87,32 @@ export function SessionReplay({
};
}, [sessionId]);
const tool = session?.tool || "claude";
const resumable = RESUMABLE.has(tool);
const livePane = liveRunFor(lane, liveRuns, sessionId);
const text = promptWithImages(prompt, images);
const send = async () => {
const text = promptWithImages(prompt, images);
if (!text || busy) return;
const slot = freeSlot(lane.id, liveRuns);
if (slot === null) {
setError(t("home.allBusy"));
return;
}
if (!text || busy || uploading || livePane || !resumable) return;
setBusy(true);
setError(null);
try {
const r = await api.lanes.action(lane.id, "start", {
slot,
resumeSessionId: sessionId,
prompt: text,
});
// Re-read: a tab that took this session since the list was read is
// opened, never resumed a second time.
const fresh = await api.run
.list()
.then((r) => r.items ?? [])
.catch(() => liveRuns);
const holder = liveRunFor(lane, fresh, sessionId);
const r = holder
? { runId: holder.id, laneId: lane.id, held: true }
: await startInFreeTabDetailed(lane.id, { resumeSessionId: sessionId, prompt: text });
setPrompt("");
images.forEach((i) => URL.revokeObjectURL(i.url));
setImages([]);
onResumed(r.run_id ?? paneRunId(lane.id, slot));
onResumed(r.runId, r.laneId);
// Held already: the prompt reached nothing yet — paste it there.
if (r.held) pasteWhenShown(r.runId, text);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
@@ -83,42 +122,79 @@ export function SessionReplay({
return (
<div data-testid="session-replay" className="flex min-h-0 flex-1 flex-col gap-2">
{session && (
<div className="flex justify-end">
<SessionResumeButton session={session} />
</div>
)}
<div className="min-h-0 flex-1 overflow-hidden">
<ConversationView sessionId={sessionId} />
</div>
<div className="rounded-2xl border border-border p-2 transition-colors focus-within:border-accent/50">
<PromptEditor
bare
images={images}
onImagesChange={setImages}
value={prompt}
onChange={setPrompt}
onSubmit={() => void send()}
placeholder={t("replay.placeholder")}
rows={2}
slash={(session?.tool ?? "claude") === "claude"}
fileCwd={session?.cwd || lane.cwd}
/>
<div className="mt-1 flex items-center gap-2">
<span className="text-[11px] text-fg-muted">{t("replay.hint")}</span>
{livePane ? (
<div
data-testid="session-replay-live"
className="flex items-center gap-2 rounded-2xl border border-border p-3 text-xs text-fg-secondary"
>
<span className="min-w-0 flex-1">{t("replay.live")}</span>
<button
type="button"
data-testid="session-replay-send"
onClick={() => void send()}
disabled={busy || (!prompt.trim() && images.length === 0)}
aria-label={t("replay.send")}
title={t("replay.send")}
className="ml-auto flex h-8 w-8 items-center justify-center rounded-full bg-accent text-white transition-opacity hover:opacity-90 disabled:opacity-40"
onClick={() => onResumed(livePane.id)}
className="btn-ghost text-xs"
>
<ArrowUp className="h-4 w-4" />
<SquareTerminal className="h-4 w-4" />
{t("resume.openPane")}
</button>
</div>
{error && (
<p role="alert" className="mt-1 text-xs text-status-danger">
{error}
</p>
)}
</div>
) : session && !resumable ? (
<div
data-testid="session-replay-no-resume"
className="flex items-center gap-2 rounded-2xl border border-border p-3 text-xs text-fg-secondary"
>
<span className="min-w-0 flex-1">{t("replay.noResume", { tool })}</span>
<button
type="button"
onClick={() => navigate(`/workspace?lane=${lane.id}`)}
className="btn-ghost text-xs"
>
{t("replay.newSession")}
</button>
</div>
) : (
<div className="rounded-2xl border border-border p-2 transition-colors focus-within:border-accent/50">
<PromptEditor
bare
images={images}
onImagesChange={setImages}
onBusyChange={setUploading}
value={prompt}
onChange={setPrompt}
onSubmit={() => void send()}
placeholder={t("replay.placeholder")}
rows={2}
slash={tool === "claude"}
fileCwd={session?.cwd || lane.cwd}
/>
<div className="mt-1 flex items-center gap-2">
<span className="text-[11px] text-fg-muted">{t("replay.hint")}</span>
<button
type="button"
data-testid="session-replay-send"
onClick={() => void send()}
disabled={busy || uploading || !text}
aria-label={t("replay.send")}
title={t("replay.send")}
className="ml-auto flex h-8 w-8 items-center justify-center rounded-full bg-accent-solid text-white transition-opacity hover:opacity-90 disabled:opacity-40"
>
<ArrowUp className="h-4 w-4" />
</button>
</div>
{error && (
<p role="alert" className="mt-1 text-xs text-status-danger">
{error}
</p>
)}
</div>
)}
</div>
);
}
@@ -7,8 +7,9 @@
* (`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
* starts in the workspace's first free pane slot, picked by the server
* (`POST /api/lanes/:id/start {slot: "auto", resumeSessionId}`, panes re-read
* first) and opens it — offered only
* for a session whose folder IS the workspace's, because the pane starts in
* the workspace's folder and Claude finds a session by the folder it ran in.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
@@ -22,7 +23,7 @@ import { api, type RunHandle } from "../../lib/api";
import type { Lane, Session } from "../../lib/types";
import { useLanes } from "../../hooks/useLanes";
import { ConfirmModal } from "../ConfirmModal";
import { freeSlot, paneRunId } from "./WorkspaceHome";
import { startInFreeTabDetailed } from "./startSession";
import { liveRunFor, sessionWorktreeName } from "./WorkspaceSessionList";
/** The workspace of this tool whose folder holds `cwd` most deeply — the
@@ -81,8 +82,8 @@ export function SessionResumeButton({
(same(session.cwd, lane.cwd) ||
sessionWorktreeName(lane, { cwd: session.cwd } as Session) !== null);
if (!run && !resumable) return null;
const open = (runId: string) =>
navigate(`/workspace?lane=${lane.id}&run=${encodeURIComponent(runId)}`);
const open = (runId: string, laneId = lane.id) =>
navigate(`/workspace?lane=${laneId}&run=${encodeURIComponent(runId)}`);
const resume = async (fork: boolean) => {
// Busy first: the Confirm button is disabled only through it, and the
@@ -99,17 +100,12 @@ export function SessionResumeButton({
setRuns(fresh);
const live = liveRunFor(lane, fresh, session.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,
// A pane of another (nested) workspace may hold it: open it there.
const r = await startInFreeTabDetailed(lane.id, {
resumeSessionId: session.id,
...(fork ? { forkSession: true } : {}),
});
open(r.run_id ?? paneRunId(lane.id, slot));
open(r.runId, r.laneId);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
+178 -37
View File
@@ -10,8 +10,9 @@
* router's models, plus Effort); under it the quieter choices — AI Router
* (Native or a profile), Permission (Claude's mode, Codex's sandbox/approval,
* a catalog tool's bypass) and Local / New worktree — 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,
* owns and has not archived (`?lane=&archived=0`, with each one's state dot,
* **Show more** past the first page, and the sidebar row's Rename/Archive in
* its right-click menu), then the archived ones folded with Unarchive; a live session opens its pane,
* any other its conversation (`&session=`, `SessionReplay`), 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
@@ -20,6 +21,10 @@
*/
import { Checkbox } from "../Checkbox";
import { copyText } from "../../lib/clipboard";
import { isSubmitEnter } from "../../lib/keys";
import { confirmDialog } from "../../lib/confirm";
import { useDialog } from "../../hooks/useDialog";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
@@ -64,22 +69,19 @@ import {
liveRunFor,
SessionFold,
sessionDot,
sessionDotLabel,
sessionWorktreeName,
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 {
return slot ? `agenthub-lane-${laneId}-${slot}` : `agenthub-lane-${laneId}`;
}
/** The first pane slot with no live run, or null when all four are busy. */
export function freeSlot(laneId: number, runs: RunHandle[]): number | null {
const busy = new Set(
runs.filter((r) => r.laneId === laneId && r.status === "running").map((r) => r.slot ?? 0)
);
return [0, 1, 2, 3].find((s) => !busy.has(s)) ?? null;
}
export {
freeSlot,
laneOfRun,
paneRunId,
startInFreeTab,
startInFreeTabDetailed,
} from "./startSession";
import { startInFreeTab } from "./startSession";
function fmtBytes(n: number | null): string {
if (!n) return "";
@@ -88,6 +90,9 @@ function fmtBytes(n: number | null): string {
return `${(n / 1024 / 1024).toFixed(1)} MB`;
}
/** Recents' page size; Show more adds another. */
const RECENT_PAGE = 30;
const message = (err: unknown) => (err instanceof Error ? err.message : String(err));
type HomeProps = Parameters<typeof HomeBody>[0];
@@ -105,8 +110,9 @@ function HomeBody({
onRenamed,
}: {
lane: Lane;
/** Row-menu items the page carries out (its modals); rename is done here. */
onMenuAction: (action: WorkspaceMenuAction) => void;
/** Row-menu items the page carries out (its modals); rename is done here.
* `started` comes with the run it opened. */
onMenuAction: (action: WorkspaceMenuAction, runId?: string) => void;
/** Called after a rename, so the page re-reads the lane list. */
onRenamed?: () => void;
}) {
@@ -158,9 +164,17 @@ function HomeBody({
// its own (`claude --worktree`), made and cleaned up by Claude Code.
const [inWorktree, setInWorktree] = useState(false);
const [starting, setStarting] = useState(false);
const startingRef = useRef(false);
// An image still uploading: its path is not in the prompt yet.
const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [liveRuns, setLiveRuns] = useState<RunHandle[]>([]);
const [sessions, setSessions] = useState<Session[] | null>(null);
const [sessionTotal, setSessionTotal] = useState(0);
const [recentLimit, setRecentLimit] = useState(RECENT_PAGE);
// A Recents row being renamed (its session id) and the draft.
const [renamingId, setRenamingId] = useState<string | null>(null);
const [nameDraft, setNameDraft] = useState("");
const [view, setView] = useState<LaneSourcesView | null>(null);
const [editing, setEditing] = useState<string | null>(null);
const [memory, setMemory] = useState<LaneMemoryFile[] | null>(null);
@@ -172,6 +186,8 @@ function HomeBody({
const [menuAt, setMenuAt] = useState<{ x: number; y: number } | null>(null);
const [title, setTitle] = useState<string | null>(null);
const fileInput = useRef<HTMLInputElement>(null);
const memoryPanel = useRef<HTMLDivElement>(null);
useDialog(memoryOpen && !!memory, memoryPanel, () => setMemoryOpen(false));
const recentCtx = useContextMenu<Session>();
const sourceCtx = useContextMenu<LaneSource>();
@@ -186,13 +202,17 @@ function HomeBody({
.then((r) => live && setLiveRuns(r.items))
.catch(() => undefined);
api.sessions
.list({ lane: lane.id, archived: false, limit: 30 })
.then((r) => live && setSessions(r.sessions))
.list({ lane: lane.id, archived: false, limit: recentLimit })
.then((r) => {
if (!live) return;
setSessions(r.sessions);
setSessionTotal(r.total ?? r.sessions.length);
})
.catch(() => live && setSessions([]));
return () => {
live = false;
};
}, [lane.id, runKey, version]);
}, [lane.id, runKey, version, recentLimit]);
const archived = useSessionPage(
{ lane: lane.id, archived: true, limit: 30 },
`${runKey}:${version}`
@@ -269,18 +289,17 @@ function HomeBody({
}
};
const text = promptWithImages(prompt, images);
const start = async () => {
const slot = freeSlot(lane.id, liveRuns);
if (slot === null) {
setError(t("home.allBusy"));
return;
}
// Enter reaches here past the disabled button: the same guards again.
// A ref, not only the state: two Enters in one tick both read `starting` false.
if (startingRef.current || uploading || !text) return;
startingRef.current = true;
setStarting(true);
setError(null);
try {
const r = await api.lanes.action(lane.id, "start", {
slot,
prompt: promptWithImages(prompt, images),
const runId = await startInFreeTab(lane.id, {
prompt: text,
// Only a different tool is named: the workspace's own keeps starting
// even after it was disabled in Settings.
...(tool !== lane.tool ? { tool } : {}),
@@ -295,15 +314,61 @@ function HomeBody({
setPrompt("");
images.forEach((i) => URL.revokeObjectURL(i.url));
setImages([]);
const runId = r.run_id ?? paneRunId(lane.id, slot);
navigate(`/workspace?lane=${lane.id}&run=${encodeURIComponent(runId)}`);
} catch (err) {
setError(message(err));
} finally {
startingRef.current = false;
setStarting(false);
}
};
// The sidebar row's Rename and Archive, for a Recents row.
const patchSession = async (s: Session, body: { name?: string; archived?: boolean }) => {
try {
await api.sessions.patch(s.id, body);
setVersion((v) => v + 1);
} catch (err) {
setError(message(err));
}
};
const archiveSession = async (s: Session) => {
const live = !!liveRunFor(lane, liveRuns, s.id);
const wt = sessionWorktreeName(lane, s);
const ok = await confirmDialog(
wt && !live
? {
title: t("tree.archiveWorktreeTitle"),
message: t("tree.archiveWorktreeBody", { name: wt }),
confirmLabel: t("tree.archiveWorktreeConfirm"),
}
: live
? {
title: t("tree.archiveLiveTitle"),
message: t("tree.archiveLiveBody"),
confirmLabel: t("sessionMenu.archive"),
destructive: false,
}
: {
title: t("tree.archiveSession"),
confirmLabel: t("sessionMenu.archive"),
destructive: false,
}
);
if (!ok) return;
if (wt && !live) {
try {
await api.lanes.removeSessionWorktree(lane.id, s.id).catch((err: unknown) => {
if ((err as { code?: string }).code !== "ENOWORKTREE") throw err;
});
} catch (err) {
setError(message(err));
return;
}
}
await patchSession(s, { archived: true });
};
const openSession = (s: Session) => {
const r = liveRunFor(lane, liveRuns, s.id);
navigate(
@@ -364,7 +429,7 @@ function HomeBody({
onBlur={() => setTitle(null)}
onKeyDown={async (e) => {
if (e.key === "Escape") setTitle(null);
if (e.key !== "Enter") return;
if (!isSubmitEnter(e)) return;
const next = title.trim();
setTitle(null);
if (next === (lane.title || "")) return;
@@ -421,6 +486,7 @@ function HomeBody({
fileCwd={lane.cwd}
images={images}
onImagesChange={setImages}
onBusyChange={setUploading}
bare
/>
<div className="mt-1 flex items-center gap-1">
@@ -459,10 +525,10 @@ function HomeBody({
type="button"
data-testid="workspace-home-start"
onClick={() => void start()}
disabled={starting}
disabled={starting || uploading || !text}
aria-label={t("home.start")}
title={t("home.start")}
className="flex h-8 w-8 items-center justify-center rounded-full bg-accent text-white transition-opacity hover:opacity-90 disabled:opacity-40"
className="flex h-8 w-8 items-center justify-center rounded-full bg-accent-solid text-white transition-opacity hover:opacity-90 disabled:opacity-40"
>
<ArrowUp className="h-4 w-4" />
</button>
@@ -582,6 +648,29 @@ function HomeBody({
<ul data-testid="workspace-recents" className="mt-2 divide-y divide-border/60">
{(sessions ?? []).map((s) => {
const live = !!liveRunFor(lane, liveRuns, s.id);
if (renamingId === s.id) {
return (
<li key={s.id} className="py-1.5">
<input
data-testid={`workspace-recent-rename-${s.id}`}
aria-label={t("tree.renameSession")}
autoFocus
value={nameDraft}
maxLength={200}
onChange={(e) => setNameDraft(e.target.value)}
onBlur={() => setRenamingId(null)}
onKeyDown={(e) => {
if (e.key === "Escape") setRenamingId(null);
if (!isSubmitEnter(e)) return;
setRenamingId(null);
const name = nameDraft.trim();
if (name && name !== s.name) void patchSession(s, { name });
}}
className="w-full rounded border border-accent bg-surface-1 px-2 py-1 text-sm text-fg-primary"
/>
</li>
);
}
return (
<li key={s.id}>
<button
@@ -592,7 +681,11 @@ function HomeBody({
onContextMenu={(e) => recentCtx.open(e, 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 ${sessionDot(s, live)}`} />
<span
aria-hidden
className={`h-2 w-2 shrink-0 rounded-full ${sessionDot(s, live)}`}
/>
<span className="sr-only">{t(`tree.state.${sessionDotLabel(s, live)}`)}</span>
<span className="min-w-0 flex-1 truncate text-sm text-fg-primary">
{s.name || s.id.slice(0, 8)}
</span>
@@ -618,6 +711,16 @@ function HomeBody({
);
})}
</ul>
{sessions !== null && sessionTotal > sessions.length && (
<button
type="button"
data-testid="workspace-recents-more"
onClick={() => setRecentLimit((n) => n + RECENT_PAGE)}
className="mt-1 text-xs text-fg-muted hover:text-fg-primary"
>
{t("tree.showMore", { count: Math.min(sessionTotal - sessions.length, RECENT_PAGE) })}
</button>
)}
<div className="mt-2">
<SessionFold
testId={`workspace-archived-${lane.id}`}
@@ -632,7 +735,11 @@ function HomeBody({
</div>
</div>
<aside data-testid="workspace-context" className="card w-80 shrink-0 self-start p-0">
<aside
data-testid="workspace-context"
aria-label={t("home.contextPanel")}
className="card w-80 shrink-0 self-start p-0"
>
{view && !view.supported && (
<p className="border-b border-border px-4 py-3 text-xs text-status-warning">
{t("home.unsupported", { tool: aiTools.label(lane.tool) })}
@@ -887,11 +994,11 @@ function HomeBody({
onClick={() => setMemoryOpen(false)}
>
<div
ref={memoryPanel}
role="dialog"
aria-modal="true"
aria-label={t("home.memory")}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.key === "Escape" && setMemoryOpen(false)}
className="flex max-h-[85vh] w-full max-w-3xl flex-col rounded-xl border border-border bg-surface-1 p-5 shadow-xl"
>
<div className="mb-3 flex shrink-0 items-center justify-between">
@@ -919,9 +1026,30 @@ function HomeBody({
key={f.path}
type="button"
role="tab"
id={`workspace-memory-tab-${i}`}
aria-controls="workspace-memory-panel"
tabIndex={i === memoryTab ? 0 : -1}
title={f.path}
aria-selected={i === memoryTab}
onClick={() => setMemoryTab(i)}
// APG tabs: arrows move (and select), one tab stop.
onKeyDown={(e) => {
const n = memory.length;
const to =
e.key === "ArrowRight"
? (i + 1) % n
: e.key === "ArrowLeft"
? (i - 1 + n) % n
: e.key === "Home"
? 0
: e.key === "End"
? n - 1
: null;
if (to === null) return;
e.preventDefault();
setMemoryTab(to);
document.getElementById(`workspace-memory-tab-${to}`)?.focus();
}}
className={`flex-shrink-0 whitespace-nowrap rounded-lg border px-3 py-1.5 font-mono text-xs transition-colors ${
i === memoryTab
? "border-accent/30 bg-accent/15 text-accent"
@@ -933,7 +1061,12 @@ function HomeBody({
))}
</div>
{memory[memoryTab] && (
<div role="tabpanel" className="min-h-0 flex-1 overflow-y-auto">
<div
role="tabpanel"
id="workspace-memory-panel"
aria-labelledby={`workspace-memory-tab-${memoryTab}`}
className="min-h-0 flex-1 overflow-y-auto"
>
<MarkdownPreview text={memory[memoryTab].content} />
</div>
)}
@@ -949,6 +1082,12 @@ function HomeBody({
session={recentCtx.menu.item}
run={liveRunFor(lane, liveRuns, recentCtx.menu.item.id)}
onClose={recentCtx.close}
onRename={() => {
const s = recentCtx.menu!.item;
setNameDraft(s.name || "");
setRenamingId(s.id);
}}
onArchive={() => void archiveSession(recentCtx.menu!.item)}
/>
)}
{sourceCtx.menu && (
@@ -960,7 +1099,7 @@ function HomeBody({
>
<MenuItem
onSelect={() => {
void navigator.clipboard.writeText(sourceCtx.menu!.item.path);
void copyText(sourceCtx.menu!.item.path);
sourceCtx.close();
}}
>
@@ -985,7 +1124,9 @@ function HomeBody({
x={menuAt.x}
y={menuAt.y}
onClose={() => setMenuAt(null)}
onAction={(a) => (a === "rename" ? setTitle(lane.title || "") : onMenuAction(a))}
onAction={(a, runId) =>
a === "rename" ? setTitle(lane.title || "") : onMenuAction(a, runId)
}
/>
)}
</div>
+38 -8
View File
@@ -6,7 +6,9 @@
* session there) and `⋯` (the row menu, `WorkspaceRowMenu.tsx`, also on
* right-click). Dragging it, or Alt+↑/↓, reorders the list — a viewer
* preference kept in localStorage (`laneOrderStorage.ts`), never written to
* the lane row.
* the lane row. Only a workspace row's own drag (`LANE_DRAG_TYPE`) is taken —
* a file, a tab or a text dragged over is not a reorder. F2 renames too; the
* dot's meaning is screen-reader text, and an idle dot is a ring.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -14,6 +16,12 @@ import { useState, type MouseEvent } from "react";
import { useTranslation } from "react-i18next";
import { ChevronDown, ChevronRight, MoreHorizontal, Plus } from "lucide-react";
import type { Lane } from "../../lib/types";
import { isSubmitEnter } from "../../lib/keys";
/** What a workspace row's drag carries: its index in the list. */
export const LANE_DRAG_TYPE = "application/x-agenthub-lane";
const isLaneDrag = (e: { dataTransfer: DataTransfer | null }) =>
!!e.dataTransfer?.types?.includes(LANE_DRAG_TYPE);
/** The row's dot: the lane's liveness, plus a running pulse and needs-you. */
export function workspaceDot(lane: Lane): string {
@@ -21,7 +29,17 @@ export function workspaceDot(lane: Lane): string {
if (lane.liveness === "dead" || lane.status === "error") return "bg-status-danger";
if (lane.status === "running") return "bg-status-success animate-pulse-slow";
if (lane.liveness === "active") return "bg-status-success";
return "bg-surface-4";
// A ring, not a fill: an idle surface-4 dot vanished on the light themes.
return "border border-fg-muted";
}
/** What the dot means (`row.state.*`), for a screen reader. */
export function workspaceDotLabel(lane: Lane): string {
if (lane.needs_action) return "needsYou";
if (lane.liveness === "dead" || lane.status === "error") return "error";
if (lane.status === "running") return "running";
if (lane.liveness === "active") return "active";
return "idle";
}
export function workspaceName(lane: Lane): string {
@@ -78,19 +96,23 @@ export function WorkspaceRow({
draggable={!renaming}
onDragStart={(e) => {
// Firefox starts no drag at all without a setData call.
e.dataTransfer.setData("text/plain", String(index));
e.dataTransfer.setData(LANE_DRAG_TYPE, String(index));
e.dataTransfer.effectAllowed = "move";
}}
onDragOver={(e) => {
if (!isLaneDrag(e)) return;
e.preventDefault();
setDropTarget(true);
}}
onDragLeave={() => setDropTarget(false)}
onDragEnd={() => setDropTarget(false)}
onDrop={(e) => {
e.preventDefault();
setDropTarget(false);
const from = Number(e.dataTransfer.getData("text/plain"));
if (!isLaneDrag(e)) return;
e.preventDefault();
const raw = e.dataTransfer.getData(LANE_DRAG_TYPE);
// Number("") is 0: an empty payload must not move the first row.
const from = raw === "" ? NaN : Number(raw);
if (Number.isInteger(from)) onReorder(from, index);
}}
onContextMenu={openMenu}
@@ -125,7 +147,7 @@ export function WorkspaceRow({
// Blur CANCELS — Enter is the only save, so one edit fires one PATCH.
onBlur={() => onRenameEnd(null)}
onKeyDown={(e) => {
if (e.key === "Enter") onRenameEnd(draft.trim());
if (isSubmitEnter(e)) onRenameEnd(draft.trim());
if (e.key === "Escape") onRenameEnd(null);
}}
className="my-1 min-w-0 flex-1 rounded border border-accent bg-surface-1 px-1.5 py-0.5 text-[13px] text-fg-primary"
@@ -135,12 +157,19 @@ export function WorkspaceRow({
type="button"
data-testid={`workspace-open-${lane.id}`}
aria-current={selected ? "true" : undefined}
onClick={onSelect}
// A double-click's second click renames instead of opening again.
onClick={(e) => e.detail < 2 && onSelect()}
onDoubleClick={() => {
setDraft(lane.title || "");
onRenameStart();
}}
onKeyDown={(e) => {
if (e.key === "F2") {
e.preventDefault();
setDraft(lane.title || "");
onRenameStart();
return;
}
if (!e.altKey || (e.key !== "ArrowUp" && e.key !== "ArrowDown")) return;
e.preventDefault();
onReorder(index, index + (e.key === "ArrowUp" ? -1 : 1));
@@ -148,7 +177,8 @@ export function WorkspaceRow({
title={`${lane.cwd}\n${t("row.hint")}`}
className="flex min-w-0 flex-1 items-center gap-2 py-1.5 text-left"
>
<span className={`h-2 w-2 shrink-0 rounded-full ${workspaceDot(lane)}`} />
<span aria-hidden className={`h-2 w-2 shrink-0 rounded-full ${workspaceDot(lane)}`} />
<span className="sr-only">{t(`row.state.${workspaceDotLabel(lane)}`)}</span>
<span className="truncate text-[13px] text-fg-primary">{name}</span>
</button>
)}
@@ -1,7 +1,10 @@
/**
* @file WorkspaceRowMenu — the sidebar workspace row's context menu, opened at
* the pointer (right-click) or under the row's `⋯` button. Every item is an
* existing call: Start/Stop (`POST /api/lanes/:id/start` slot 0 / `stop`),
* existing call: Start (`POST /api/lanes/:id/start`, the free tab the server
* picks — then the session opens, `onAction("started", runId)`) / Stop
* (`stop`, every tab of the workspace — counted from `GET /api/run` when the
* menu opens, and confirmed: "Stop N sessions?"),
* Rename (the row's inline field), New workspace here (the Add modal prefilled),
* Copy path, and Reset/Remove through `DestructiveLaneModal` — Reset only on a
* managed lane. The server's guards are the real ones; the menu only hides
@@ -10,11 +13,15 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useState } from "react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { api } from "../../lib/api";
import type { Lane } from "../../lib/types";
import { ContextMenu, MenuItem, MenuSeparator } from "../ContextMenu";
import { confirmDialog } from "../../lib/confirm";
import { copyText } from "../../lib/clipboard";
import { toast } from "../../lib/toast";
import { startInFreeTab } from "./startSession";
export type WorkspaceMenuAction = "rename" | "new-here" | "reset" | "remove" | "started";
@@ -29,37 +36,70 @@ export function WorkspaceRowMenu({
x: number;
y: number;
onClose: () => void;
/** Items the tree itself carries out (rename field, modals, selection). */
onAction: (action: WorkspaceMenuAction) => void;
/** Items the tree itself carries out (rename field, modals, selection);
* `started` comes with the run it opened. */
onAction: (action: WorkspaceMenuAction, runId?: string) => void;
}) {
const { t } = useTranslation("lanes");
const [error, setError] = useState<string | null>(null);
// Every live tab of the workspace — slot 0 alone undercounts a split.
const [liveCount, setLiveCount] = useState<number | null>(null);
useEffect(() => {
let alive = true;
api.run
.list()
.then(
(r) =>
alive &&
setLiveCount(
(r.items ?? []).filter((x) => x.laneId === lane.id && x.status === "running").length
)
)
.catch(() => undefined);
return () => {
alive = false;
};
}, [lane.id]);
const live = liveCount === null ? !!lane.run_id || lane.status === "running" : liveCount > 0;
const run = async (fn: () => Promise<unknown>, then?: WorkspaceMenuAction) => {
setError(null);
const stop = async () => {
onClose();
const n = liveCount ?? 1;
if (
!(await confirmDialog({
title: t("menu.stopTitle", { count: n }),
message: t("menu.stopBody"),
confirmLabel: t("menu.stop"),
destructive: true,
}))
)
return;
try {
await fn();
if (then) onAction(then);
onClose();
await api.lanes.action(lane.id, "stop");
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
toast.error(err);
}
};
const start = async () => {
onClose();
try {
onAction("started", await startInFreeTab(lane.id, { prompt: "" }));
} catch (err) {
toast.error(err);
}
};
const live = !!lane.run_id || lane.status === "running";
return (
<ContextMenu x={x} y={y} onClose={onClose} testId={`workspace-row-menu-${lane.id}`}>
<MenuItem
onSelect={() =>
live
? void run(() => api.lanes.action(lane.id, "stop"))
: void run(() => api.lanes.action(lane.id, "start", { slot: 0, prompt: "" }), "started")
}
>
<MenuItem onSelect={() => void (live ? stop() : start())}>
{live ? t("menu.stop") : t("menu.start")}
</MenuItem>
<MenuItem onSelect={() => onAction("rename")}>{t("menu.rename")}</MenuItem>
<MenuItem onSelect={() => onAction("new-here")}>{t("menu.newHere")}</MenuItem>
<MenuItem onSelect={() => void run(() => navigator.clipboard.writeText(lane.cwd))}>
<MenuItem
onSelect={() => {
onClose();
void copyText(lane.cwd);
}}
>
{t("menu.copyPath")}
</MenuItem>
<MenuSeparator />
@@ -71,11 +111,6 @@ export function WorkspaceRowMenu({
<MenuItem danger onSelect={() => onAction("remove")}>
{t("menu.remove")}
</MenuItem>
{error && (
<p role="alert" className="px-2 py-1 text-[11px] text-status-danger">
{error}
</p>
)}
</ContextMenu>
);
}
@@ -17,10 +17,16 @@
* 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 **Show N more** under them. 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 } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Link, useNavigate } from "react-router-dom";
@@ -57,7 +63,7 @@ export function sessionWorktreeName(lane: Lane, s: Session): string | null {
}
/** A session row's dot: waiting for the user, failed, held by a live pane, or
* at rest. */
* 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";
@@ -65,6 +71,13 @@ export function sessionDot(session: Session, live: boolean): string {
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. */
@@ -147,7 +160,13 @@ export function SessionFold({
onContextMenu={laneId === undefined ? undefined : (e) => ctx.open(e, s)}
>
<Link
to={`/sessions/${s.id}`}
// 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"
>
@@ -225,6 +244,8 @@ export function WorkspaceSessionList({
const [busy, setBusy] = useState(false);
const ctx = useContextMenu<RunHandle>();
const sessionCtx = useContextMenu<Session>();
// Rows shown; Show more adds a page.
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.
@@ -237,8 +258,9 @@ export function WorkspaceSessionList({
const refreshKey = `${lane.session_id}:${lane.status}:${lane.needs_action}:${version}:${laneRuns
.map((r) => `${r.id}=${r.sessionId ?? ""}`)
.join(",")}`;
const own = useSessionPage({ lane: lane.id, archived: false, limit: SESSION_LIMIT }, refreshKey);
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(
@@ -248,10 +270,41 @@ export function WorkspaceSessionList({
// 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));
: laneRuns.filter(
(r) =>
!r.sessionId ||
(!sessions.some((s) => s.id === r.sessionId) && !hasRow.has(r.sessionId))
);
const count = sessions === null ? null : shown.length;
useEffect(() => {
if (count !== null) onMatches?.(count);
@@ -274,10 +327,21 @@ export function WorkspaceSessionList({
setDraft(s.name || "");
setRenaming(s.id);
};
const archive = (s: Session) =>
sessionWorktreeName(lane, s) && !runFor(s)
? setArchiving(s)
: void patch(s, { archived: true });
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);
@@ -347,7 +411,8 @@ export function WorkspaceSessionList({
: "text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
}`}
>
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-status-success" />
<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 italic">{t("tree.newSession")}</span>
<AiToolIcon
tool={{ id: r.tool, label: tools.label(r.tool) }}
@@ -373,7 +438,7 @@ export function WorkspaceSessionList({
onBlur={() => setRenaming(null)}
onKeyDown={(e) => {
if (e.key === "Escape") setRenaming(null);
if (e.key !== "Enter") return;
if (!isSubmitEnter(e)) return;
setRenaming(null);
const name = draft.trim();
if (name && name !== s.name) void patch(s, { name });
@@ -389,7 +454,10 @@ export function WorkspaceSessionList({
type="button"
data-testid={`workspace-session-${s.id}`}
data-live={run ? "true" : undefined}
onClick={(e) =>
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)}`
@@ -397,9 +465,15 @@ export function WorkspaceSessionList({
// 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)
@@ -414,8 +488,10 @@ export function WorkspaceSessionList({
>
<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
@@ -432,10 +508,12 @@ export function WorkspaceSessionList({
<button
type="button"
data-testid={`workspace-session-archive-${s.id}`}
onClick={() => archive(s)}
onClick={() => void archive(s)}
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"
// 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>
@@ -443,6 +521,16 @@ export function WorkspaceSessionList({
);
})}
</ul>
{more > 0 && !q && (
<button
type="button"
data-testid={`workspace-sessions-more-${lane.id}`}
onClick={() => setLimit((n) => n + SESSION_LIMIT)}
className="w-full py-1 pl-8 pr-2 text-left text-[11px] text-fg-muted hover:text-fg-primary"
>
{t("tree.showMore", { count: Math.min(more, SESSION_LIMIT) })}
</button>
)}
{failure}
{confirmArchive}
{ctx.menu && (
@@ -464,7 +552,7 @@ export function WorkspaceSessionList({
run={runFor(sessionCtx.menu.item)}
onClose={sessionCtx.close}
onRename={() => startRename(sessionCtx.menu!.item)}
onArchive={() => archive(sessionCtx.menu!.item)}
onArchive={() => void archive(sessionCtx.menu!.item)}
/>
)}
</>
@@ -15,9 +15,15 @@ import { render, screen, waitFor, act, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useState } from "react";
import { LaneChangesPanel } from "../LaneChangesPanel";
import { confirmDiscard } from "../../../lib/confirm";
import { api } from "../../../lib/api";
import type { LaneChanges } from "../../../lib/types";
// The themed confirm needs its host (App); answer it here.
vi.mock("../../../lib/confirm", () => ({
confirmDiscard: vi.fn(async () => true),
confirmDialog: vi.fn(async () => true),
}));
vi.mock("../../../lib/api", () => ({
gitTarget: (lane: number, session?: string | null) => (session ? { lane, session } : lane),
api: {
@@ -134,7 +140,13 @@ beforeEach(() => {
/** The Workspace right panel's Files/Changes activity-bar buttons, around the
* panel — the tab is the Workspace's state, passed in as `tab`. */
function Tabbed({ laneId }: { laneId: number }) {
function Tabbed({
laneId,
onDirtyChange,
}: {
laneId: number;
onDirtyChange?: (dirty: boolean) => void;
}) {
const [tab, setTab] = useState<"files" | "changes">("changes");
return (
<>
@@ -144,7 +156,7 @@ function Tabbed({ laneId }: { laneId: number }) {
<button type="button" onClick={() => setTab("changes")}>
Changes
</button>
<LaneChangesPanel laneId={laneId} tab={tab} />
<LaneChangesPanel laneId={laneId} tab={tab} onDirtyChange={onDirtyChange} />
</>
);
}
@@ -404,7 +416,12 @@ describe("LaneChangesPanel", () => {
expect(screen.getByTestId("lane-file-save")).toBeDisabled();
await user.type(editor, "x");
expect(screen.getByTestId("lane-file-save")).toBeEnabled();
// Cancel drops the edit and goes back to reading.
// Cancel asks before dropping an edit: "keep" keeps it…
vi.mocked(confirmDiscard).mockClear().mockResolvedValueOnce(false);
await user.click(screen.getByRole("button", { name: "Cancel" }));
expect(confirmDiscard).toHaveBeenCalledTimes(1);
expect(screen.getByTestId("lane-file-editor")).toHaveValue("const whole = 1;x");
// …and "discard" drops it and goes back to reading.
await user.click(screen.getByRole("button", { name: "Cancel" }));
expect(await screen.findByTestId("lane-file-view")).toHaveTextContent("const whole = 1;");
expect(screen.queryByTestId("lane-file-editor")).toBeNull();
@@ -739,6 +756,33 @@ describe("LaneChangesPanel", () => {
expect(screen.queryByTestId("lane-file-editor")).toBeNull();
});
// The Workspace asked before switching tab; the edit must then be GONE, or
// `dirty` stays true and keeps the panel pinned and the unload prompt on.
it("drops an edited file when the tab switches away from it", async () => {
vi.mocked(api.lanes.gitFile).mockResolvedValue({
available: true,
path: "src/main.ts",
rev: "work",
binary: false,
truncated: false,
lines: ["const whole = 1;"],
sha: "b".repeat(40),
});
const dirty = vi.fn();
const user = userEvent.setup();
render(<Tabbed laneId={7} onDirtyChange={dirty} />);
await screen.findByTestId("lane-changes-overview");
await user.click(screen.getByRole("button", { name: "Files" }));
await user.type(await screen.findByLabelText("Find a file by path…"), "main");
await user.click(await screen.findByText("src/main.ts"));
await user.click(await screen.findByTestId("lane-file-view"));
await user.type(await screen.findByTestId("lane-file-editor"), "x");
expect(dirty).toHaveBeenLastCalledWith(true);
await user.click(screen.getByRole("button", { name: "Changes" }));
await waitFor(() => expect(dirty).toHaveBeenLastCalledWith(false));
expect(screen.queryByTestId("lane-file-editor")).toBeNull();
});
it("says why a file cannot be edited instead of offering a dead Edit button", async () => {
vi.mocked(api.lanes.gitFile).mockResolvedValue({
available: true,
@@ -16,6 +16,8 @@ import { LaneGitBar } from "../LaneGitBar";
import { api } from "../../../lib/api";
import type { LaneGitFacts } from "../../../lib/types";
// A merge asks through the themed confirm (its host lives in App): yes.
vi.mock("../../../lib/confirm", () => ({ confirmDialog: vi.fn(async () => true) }));
vi.mock("../../../lib/api", () => ({
gitTarget: (lane: number, session?: string | null) => (session ? { lane, session } : lane),
api: { lanes: { gitAction: vi.fn(), gitBranches: vi.fn() } },
@@ -371,7 +373,6 @@ describe("git login (issue #45)", () => {
describe("merge (issue #45)", () => {
it("merges the picked branch into the current one after a confirm", async () => {
const confirm = vi.spyOn(window, "confirm").mockReturnValue(true);
const user = userEvent.setup();
render(<LaneGitBar laneId={3} git={facts({ dirty: 0, untracked: 0 })} onChanged={vi.fn()} />);
await user.click(screen.getByRole("button", { name: /Branch/ }));
@@ -380,11 +381,9 @@ describe("merge (issue #45)", () => {
await waitFor(() =>
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "merge", { branch: "development" })
);
confirm.mockRestore();
});
it("offers to merge the upstream when the branch has diverged", async () => {
vi.spyOn(window, "confirm").mockReturnValue(true);
const user = userEvent.setup();
render(<LaneGitBar laneId={3} git={facts({ dirty: 0, untracked: 0 })} onChanged={vi.fn()} />);
await user.click(screen.getByRole("button", { name: "Merge origin/feat/thing" }));
@@ -2,14 +2,16 @@
* @file SessionReplay.test.tsx
* @description An ended session opened in its workspace: its conversation and
* a prompt box; opening it starts nothing, and only a SENT message resumes it
* (`resumeSessionId` + the prompt, in the first free pane slot), after which
* the pane opens.
* (`resumeSessionId` + the prompt, in the free tab the server picks), after
* which the pane opens; a session a tab already holds opens that tab, and a
* tool that cannot resume by id offers a new session instead.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom";
import type { Lane } from "../../../lib/types";
import type { RunHandle } from "../../../lib/api";
@@ -33,45 +35,112 @@ vi.mock("../../run/RunSetup", () => ({
<textarea aria-label={placeholder} value={value} onChange={(e) => onChange(e.target.value)} />
),
}));
vi.mock("../WorkspaceHome", () => ({
freeSlot: (_id: number, runs: RunHandle[]) => (runs.length ? 1 : 0),
paneRunId: (id: number, slot: number) =>
slot ? `agenthub-lane-${id}-${slot}` : `agenthub-lane-${id}`,
vi.mock("../startSession", () => ({
startInFreeTabDetailed: vi.fn(async () => ({
runId: "agenthub-lane-1-1",
laneId: 1,
held: false,
})),
}));
vi.mock("../../../lib/panePaste", () => ({ pasteToRun: vi.fn(() => true) }));
vi.mock("../SessionResumeButton", () => ({ SessionResumeButton: () => null }));
vi.mock("../../../lib/api", () => ({
api: {
sessions: {
get: vi.fn(async () => ({ session: { id: "s-old", tool: "claude", cwd: "/r" } })),
},
lanes: { action: vi.fn(async () => ({ run_id: "agenthub-lane-1-1" })) },
run: { list: vi.fn(async () => ({ items: [] })) },
},
}));
import { api } from "../../../lib/api";
import { startInFreeTabDetailed as startInFreeTab } from "../startSession";
import { SessionReplay } from "../SessionReplay";
import { pasteToRun } from "../../../lib/panePaste";
const lane = { id: 1, cwd: "/r", tool: "claude" } as unknown as Lane;
const busy = [{ id: "agenthub-lane-1", laneId: 1, slot: 0, status: "running" }] as RunHandle[];
const holding = (sessionId: string) =>
[{ id: "agenthub-lane-1-2", laneId: 1, slot: 2, status: "running", sessionId }] as RunHandle[];
beforeEach(() => vi.clearAllMocks());
const renderIn = (ui: React.ReactElement) => render(<MemoryRouter>{ui}</MemoryRouter>);
describe("SessionReplay", () => {
it("shows the conversation and resumes nothing until a message is sent", async () => {
const onResumed = vi.fn();
const user = userEvent.setup();
render(<SessionReplay lane={lane} sessionId="s-old" liveRuns={busy} onResumed={onResumed} />);
renderIn(<SessionReplay lane={lane} sessionId="s-old" liveRuns={busy} onResumed={onResumed} />);
expect(screen.getByTestId("conversation")).toHaveTextContent("s-old");
await waitFor(() => expect(api.sessions.get).toHaveBeenCalledWith("s-old"));
// The row only — never the session's whole event history.
await waitFor(() => expect(api.sessions.get).toHaveBeenCalledWith("s-old", { events: false }));
expect(screen.getByTestId("session-replay-send")).toBeDisabled();
expect(api.lanes.action).not.toHaveBeenCalled();
expect(startInFreeTab).not.toHaveBeenCalled();
await user.type(screen.getByLabelText("Message to continue this session…"), "carry on");
await user.click(screen.getByTestId("session-replay-send"));
expect(api.lanes.action).toHaveBeenCalledWith(1, "start", {
slot: 1,
resumeSessionId: "s-old",
prompt: "carry on",
// The server picks the free tab (`slot: "auto"`, inside startInFreeTab).
await waitFor(() =>
expect(startInFreeTab).toHaveBeenCalledWith(1, {
resumeSessionId: "s-old",
prompt: "carry on",
})
);
await waitFor(() => expect(onResumed).toHaveBeenCalledWith("agenthub-lane-1-1", 1));
});
it("opens the tab that already holds the session instead of resuming it again", async () => {
const onResumed = vi.fn();
const user = userEvent.setup();
renderIn(
<SessionReplay
lane={lane}
sessionId="s-old"
liveRuns={holding("s-old")}
onResumed={onResumed}
/>
);
expect(screen.queryByTestId("session-replay-send")).toBeNull();
await user.click(await screen.findByRole("button", { name: "Open pane" }));
expect(onResumed).toHaveBeenCalledWith("agenthub-lane-1-2");
expect(startInFreeTab).not.toHaveBeenCalled();
});
it("re-reads the panes on send: a tab that took the session meanwhile is opened", async () => {
vi.mocked(api.run.list).mockResolvedValueOnce({ items: holding("s-old") } as never);
const onResumed = vi.fn();
const user = userEvent.setup();
renderIn(<SessionReplay lane={lane} sessionId="s-old" liveRuns={[]} onResumed={onResumed} />);
await user.type(screen.getByLabelText("Message to continue this session…"), "go");
await user.click(screen.getByTestId("session-replay-send"));
await waitFor(() => expect(onResumed).toHaveBeenCalledWith("agenthub-lane-1-2", 1));
expect(startInFreeTab).not.toHaveBeenCalled();
// The typed message goes into that tab once it is on screen.
await waitFor(() => expect(pasteToRun).toHaveBeenCalledWith("agenthub-lane-1-2", "go"));
});
it("opens the pane the server says holds it (even another workspace's) and pastes there", async () => {
vi.mocked(startInFreeTab).mockResolvedValueOnce({
runId: "agenthub-lane-9-1",
laneId: 9,
held: true,
});
await waitFor(() => expect(onResumed).toHaveBeenCalledWith("agenthub-lane-1-1"));
const onResumed = vi.fn();
const user = userEvent.setup();
renderIn(<SessionReplay lane={lane} sessionId="s-old" liveRuns={[]} onResumed={onResumed} />);
await user.type(screen.getByLabelText("Message to continue this session…"), "hi");
await user.click(screen.getByTestId("session-replay-send"));
await waitFor(() => expect(onResumed).toHaveBeenCalledWith("agenthub-lane-9-1", 9));
await waitFor(() => expect(pasteToRun).toHaveBeenCalledWith("agenthub-lane-9-1", "hi"));
});
it("offers a new session, not a resume, for a tool that cannot resume by id", async () => {
vi.mocked(api.sessions.get).mockResolvedValueOnce({
session: { id: "s-old", tool: "gemini", cwd: "/r" },
} as never);
renderIn(<SessionReplay lane={lane} sessionId="s-old" liveRuns={[]} onResumed={vi.fn()} />);
expect(await screen.findByTestId("session-replay-no-resume")).toBeInTheDocument();
expect(screen.queryByTestId("session-replay-send")).toBeNull();
});
});
@@ -3,8 +3,8 @@
* @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; a Fork asks too and starts a new session id beside a live original.
* the free pane slot the server picks (`slot: "auto"`) and opens that pane —
* or says so when all four are 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>
*/
@@ -81,7 +81,7 @@ describe("SessionResumeButton", () => {
expect(screen.getByRole("dialog")).toHaveTextContent("whole transcript");
await user.click(screen.getByRole("button", { name: "Resume session" }));
expect(api.lanes.action).toHaveBeenCalledWith(3, "start", {
slot: 1,
slot: "auto",
resumeSessionId: "s-1",
});
await waitFor(() =>
@@ -97,7 +97,7 @@ describe("SessionResumeButton", () => {
await user.click(await screen.findByRole("button", { name: "Resume" }));
await user.click(screen.getByRole("button", { name: "Resume session" }));
expect(api.lanes.action).toHaveBeenCalledWith(4, "start", {
slot: 0,
slot: "auto",
resumeSessionId: "s-2",
});
});
@@ -135,7 +135,7 @@ describe("SessionResumeButton", () => {
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,
slot: "auto",
resumeSessionId: "s-1",
forkSession: true,
});
@@ -147,7 +147,7 @@ describe("SessionResumeButton", () => {
await user.click(await screen.findByRole("button", { name: "Resume" }));
await user.click(screen.getByRole("button", { name: "Resume session" }));
expect(api.lanes.action).toHaveBeenCalledWith(3, "start", {
slot: 1,
slot: "auto",
resumeSessionId: "s-wt",
});
});
@@ -178,7 +178,12 @@ describe("SessionResumeButton", () => {
(slot) => ({ id: `r${slot}`, laneId: 3, slot, status: "running" }) as RunHandle
);
await user.click(screen.getByRole("button", { name: "Resume session" }));
expect(api.lanes.action).toHaveBeenCalledWith(3, "start", { slot: 2, resumeSessionId: "s-1" });
// The server picks the free tab under its lock; the client only re-reads
// to open a pane that took this session meanwhile.
expect(api.lanes.action).toHaveBeenCalledWith(3, "start", {
slot: "auto",
resumeSessionId: "s-1",
});
});
it("starts once on a double click while the panes are being re-read", async () => {
@@ -216,16 +221,31 @@ describe("SessionResumeButton", () => {
expect(screen.getByTestId("where").textContent).toBe("/workspace?lane=3&run=agenthub-lane-3-2");
});
it("refuses when all four panes are busy", async () => {
runs = [0, 1, 2, 3].map(
(slot) => ({ id: `r${slot}`, laneId: 3, slot, status: "running" }) as RunHandle
it("says so when the server finds all four panes busy", async () => {
vi.mocked(api.lanes.action).mockRejectedValueOnce(
Object.assign(new Error("every tab of this workspace is busy"), { code: "ERUNLIVE" })
);
const user = userEvent.setup();
renderButton({ id: "s-1", cwd: "/r", tool: "claude" });
await user.click(await screen.findByRole("button", { name: "Resume" }));
await user.click(screen.getByRole("button", { name: "Resume session" }));
expect(screen.getByRole("alert")).toHaveTextContent("All four panes");
expect(api.lanes.action).not.toHaveBeenCalled();
expect(await screen.findByRole("alert")).toHaveTextContent("All four panes");
});
it("opens the pane the server names when the session is already live there", async () => {
vi.mocked(api.lanes.action).mockRejectedValueOnce(
// A nested workspace's pane holds it: opened in THAT workspace.
Object.assign(new Error("open"), { code: "ESESSIONLIVE", runId: "agenthub-lane-4-3" })
);
const user = userEvent.setup();
renderButton({ id: "s-1", cwd: "/r", tool: "claude" });
await user.click(await screen.findByRole("button", { name: "Resume" }));
await user.click(screen.getByRole("button", { name: "Resume session" }));
await waitFor(() =>
expect(screen.getByTestId("where").textContent).toBe(
"/workspace?lane=4&run=agenthub-lane-4-3"
)
);
});
it("shows nothing outside a workspace or for a non-Claude session", async () => {
@@ -24,13 +24,24 @@ vi.mock("../../run/RunSetup", () => ({
PromptEditor: ({
value,
onChange,
onSubmit,
placeholder,
}: {
value: string;
onChange: (s: string) => void;
onSubmit: () => void;
placeholder: string;
}) => (
<textarea aria-label={placeholder} value={value} onChange={(e) => onChange(e.target.value)} />
<textarea
aria-label={placeholder}
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={(e) => {
if (e.key !== "Enter") return;
e.preventDefault();
onSubmit();
}}
/>
),
ModelMenu: ({
value,
@@ -199,7 +210,7 @@ describe("WorkspaceHome", () => {
await user.type(screen.getByLabelText("How can I help you today?"), "fix the login");
await user.click(screen.getByTestId("workspace-home-start"));
expect(api.lanes.action).toHaveBeenCalledWith(1, "start", {
slot: 1,
slot: "auto",
prompt: "fix the login",
permissionMode: "auto",
});
@@ -248,7 +259,7 @@ describe("WorkspaceHome", () => {
await user.type(screen.getByLabelText("How can I help you today?"), "port it");
await user.click(screen.getByTestId("workspace-home-start"));
expect(api.lanes.action).toHaveBeenCalledWith(1, "start", {
slot: 1,
slot: "auto",
prompt: "port it",
tool: "codex",
sandbox: "workspace-write",
@@ -300,23 +311,40 @@ describe("WorkspaceHome", () => {
await user.type(screen.getByLabelText("How can I help you today?"), "try it");
await user.click(screen.getByTestId("workspace-home-start"));
expect(api.lanes.action).toHaveBeenCalledWith(1, "start", {
slot: 1,
slot: "auto",
prompt: "try it",
permissionMode: "auto",
worktree: true,
});
});
it("refuses to start when all four panes are busy", async () => {
runs = [0, 1, 2, 3].map(
(slot) => ({ id: `r${slot}`, laneId: 1, slot, status: "running" }) as RunHandle
it("says so when the server finds all four panes busy", async () => {
vi.mocked(api.lanes.action).mockRejectedValueOnce(
Object.assign(new Error("every tab of this workspace is busy"), { code: "ERUNLIVE" })
);
const user = userEvent.setup();
renderHome();
await screen.findByTestId("workspace-recent-s-old");
await user.type(screen.getByLabelText("How can I help you today?"), "go");
await user.click(screen.getByTestId("workspace-home-start"));
expect(screen.getByRole("alert")).toHaveTextContent("All four panes");
expect(await screen.findByRole("alert")).toHaveTextContent("All four panes");
});
it("starts nothing on an empty prompt, and only once on a double Enter", async () => {
let release: (v: { run_id: string }) => void = () => undefined;
vi.mocked(api.lanes.action).mockImplementationOnce(
() => new Promise((r) => (release = r)) as never
);
const user = userEvent.setup();
renderHome();
await screen.findByTestId("workspace-recent-s-old");
const box = screen.getByLabelText("How can I help you today?");
expect(screen.getByTestId("workspace-home-start")).toBeDisabled();
await user.type(box, "{Enter}");
expect(api.lanes.action).not.toHaveBeenCalled();
await user.type(box, "go{Enter}{Enter}");
release({ run_id: "agenthub-lane-1-1" });
await waitFor(() => expect(api.lanes.action).toHaveBeenCalledTimes(1));
});
it("folds the archived sessions with Unarchive", async () => {
@@ -0,0 +1,63 @@
/**
* @file startSession.ts — starting a session in a workspace's first free tab
* (`POST /api/lanes/:id/start` with `slot: "auto"`) and the small pane-slot
* helpers around it. Kept out of `WorkspaceHome.tsx` so the sidebar's row menu
* can start a session without pulling the whole home (and react-markdown) into
* the main bundle.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import i18n from "../../i18n";
import { api, type RunHandle } from "../../lib/api";
/** The tmux session a pane slot of a lane owns (server/lib/pty-run.js). */
export function paneRunId(laneId: number, slot: number): string {
return slot ? `agenthub-lane-${laneId}-${slot}` : `agenthub-lane-${laneId}`;
}
/**
* Start a session in the workspace's first free tab. The SERVER picks the slot
* (`slot: "auto"`, under its lane lock), because any list the client holds may
* already be stale. Resolves to the run id it opened — or, when the session to
* resume is already open in a tab (`ESESSIONLIVE`), to that tab, so a second
* `--resume` of one session never starts.
*/
export async function startInFreeTab(
laneId: number,
body: Record<string, unknown>
): Promise<string> {
return (await startInFreeTabDetailed(laneId, body)).runId;
}
/** The workspace a pane belongs to, from its tmux name `agenthub-lane-<id>[-slot]`. */
export function laneOfRun(runId: string): number | null {
const m = /^agenthub-lane-(\d+)(?:-[1-3])?$/.exec(runId);
return m ? Number(m[1]) : null;
}
/** `startInFreeTab`, saying also whether the run was STARTED or an existing
* pane already held the session (`held` — nothing was sent to it), and the
* workspace that pane belongs to (a nested folder's workspace may hold it). */
export async function startInFreeTabDetailed(
laneId: number,
body: Record<string, unknown>
): Promise<{ runId: string; laneId: number; held: boolean }> {
try {
const r = await api.lanes.action(laneId, "start", { ...body, slot: "auto" });
return { runId: r.run_id ?? paneRunId(laneId, 0), laneId, held: false };
} catch (err) {
const e = err as { code?: string; runId?: string };
if (e.code === "ESESSIONLIVE" && e.runId)
return { runId: e.runId, laneId: laneOfRun(e.runId) ?? laneId, held: true };
if (e.code === "ERUNLIVE") throw new Error(i18n.t("lanes:home.allBusy"));
throw err;
}
}
/** The first pane slot with no live run, or null when all four are busy. */
export function freeSlot(laneId: number, runs: RunHandle[]): number | null {
const busy = new Set(
runs.filter((r) => r.laneId === laneId && r.status === "running").map((r) => r.slot ?? 0)
);
return [0, 1, 2, 3].find((s) => !busy.has(s)) ?? null;
}