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