feat(workspace): a session in its own worktree, Claude's way
Desktop-parity phase 6, following Claude Desktop: - the workspace home's Worktree checkbox starts the pane with `claude --worktree=<name>`, so Claude Code makes, populates (.worktreeinclude, WorktreeCreate hook) and cleans up <repo>/.claude/worktrees/<name>; AgentHub only excludes /.claude/worktrees/ at the repo root (Claude does not) - server/lib/session-worktree.js resolves a session's worktree from the session row only (owned by the workspace, registered by git worktree list); every lane git route takes ?session= and answers ENOWORKTREE instead of falling back to the main folder - the session view's Changes panel works in the opened run's worktree (dashboard_runs.worktree, GitTarget in the client api) - Resume/Fork start in the session's worktree; a gone one is ENOWORKTREE - Archive of a worktree session removes the worktree after asking: git worktree remove without --force, git branch -d (unmerged work keeps its branch), a lock held by a live pid refused, a stale one cleared - listWorktrees reads `locked <reason>` locks - tests scrub GIT_* env so the pre-commit suite cannot act on this repo
This commit is contained in:
@@ -78,6 +78,8 @@ export interface CheckboxProps {
|
|||||||
* (bypass permissions, purge data, force). */
|
* (bypass permissions, purge data, force). */
|
||||||
tone?: "accent" | "danger" | "warning";
|
tone?: "accent" | "danger" | "warning";
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
/** Tooltip (the app turns every `title` into its themed bubble). */
|
||||||
|
title?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TONE = {
|
const TONE = {
|
||||||
@@ -99,6 +101,7 @@ export function Checkbox({
|
|||||||
labelClassName,
|
labelClassName,
|
||||||
tone = "accent",
|
tone = "accent",
|
||||||
disabled = false,
|
disabled = false,
|
||||||
|
title,
|
||||||
}: CheckboxProps) {
|
}: CheckboxProps) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -106,6 +109,7 @@ export function Checkbox({
|
|||||||
role="checkbox"
|
role="checkbox"
|
||||||
aria-checked={checked}
|
aria-checked={checked}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
title={title}
|
||||||
onClick={() => onChange(!checked)}
|
onClick={() => onChange(!checked)}
|
||||||
className={`group inline-flex items-center gap-2 text-left disabled:cursor-not-allowed disabled:opacity-50 focus-visible:outline-none ${className ?? ""}`}
|
className={`group inline-flex items-center gap-2 text-left disabled:cursor-not-allowed disabled:opacity-50 focus-visible:outline-none ${className ?? ""}`}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -53,6 +53,11 @@ vi.mock("../../lib/api", async (importOriginal) => {
|
|||||||
list: vi.fn(async () => ({ lanes, counts: {} })),
|
list: vi.fn(async () => ({ lanes, counts: {} })),
|
||||||
update: vi.fn(async () => ({ lane: lanes[0] })),
|
update: vi.fn(async () => ({ lane: lanes[0] })),
|
||||||
action: vi.fn(async () => ({ ok: true })),
|
action: vi.fn(async () => ({ ok: true })),
|
||||||
|
removeSessionWorktree: vi.fn(async () => ({
|
||||||
|
removed: true,
|
||||||
|
branch: "x",
|
||||||
|
branchKept: false,
|
||||||
|
})),
|
||||||
routerList: vi.fn(async () => ({ routers: [] })),
|
routerList: vi.fn(async () => ({ routers: [] })),
|
||||||
},
|
},
|
||||||
sessions: {
|
sessions: {
|
||||||
@@ -158,6 +163,50 @@ describe("WorkspaceTree", () => {
|
|||||||
expect(api.sessions.patch).toHaveBeenCalledWith("s-live", { archived: true });
|
expect(api.sessions.patch).toHaveBeenCalledWith("s-live", { archived: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("archiving a worktree session removes its worktree first, after asking", async () => {
|
||||||
|
vi.mocked(api.sessions.list).mockImplementation(async () => ({
|
||||||
|
sessions: [
|
||||||
|
{ ...session("s-wt", "In a worktree"), cwd: "/r/.claude/worktrees/ah-1-x" } as Session,
|
||||||
|
],
|
||||||
|
total: 1,
|
||||||
|
limit: 20,
|
||||||
|
offset: 0,
|
||||||
|
}));
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderTree();
|
||||||
|
await user.click(await screen.findByTestId("workspace-toggle-1"));
|
||||||
|
expect(await screen.findByTestId("workspace-session-worktree-s-wt")).toBeInTheDocument();
|
||||||
|
await user.click(screen.getByTestId("workspace-session-archive-s-wt"));
|
||||||
|
expect(api.sessions.patch).not.toHaveBeenCalled();
|
||||||
|
expect(screen.getByRole("dialog")).toHaveTextContent(".claude/worktrees/ah-1-x");
|
||||||
|
await user.click(screen.getByRole("button", { name: "Archive and remove" }));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(api.sessions.patch).toHaveBeenCalledWith("s-wt", { archived: true })
|
||||||
|
);
|
||||||
|
expect(api.lanes.removeSessionWorktree).toHaveBeenCalledWith(1, "s-wt");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a worktree session when git refuses to remove its worktree", async () => {
|
||||||
|
vi.mocked(api.sessions.list).mockImplementation(async () => ({
|
||||||
|
sessions: [
|
||||||
|
{ ...session("s-wt", "In a worktree"), cwd: "/r/.claude/worktrees/ah-1-x" } as Session,
|
||||||
|
],
|
||||||
|
total: 1,
|
||||||
|
limit: 20,
|
||||||
|
offset: 0,
|
||||||
|
}));
|
||||||
|
vi.mocked(api.lanes.removeSessionWorktree).mockRejectedValueOnce(
|
||||||
|
Object.assign(new Error("contains modified or untracked files"), { code: "EGIT" })
|
||||||
|
);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderTree();
|
||||||
|
await user.click(await screen.findByTestId("workspace-toggle-1"));
|
||||||
|
await user.click(await screen.findByTestId("workspace-session-archive-s-wt"));
|
||||||
|
await user.click(screen.getByRole("button", { name: "Archive and remove" }));
|
||||||
|
expect(await screen.findByRole("alert")).toHaveTextContent("modified or untracked");
|
||||||
|
expect(api.sessions.patch).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it("dots a session waiting for the user amber and a failed one red", async () => {
|
it("dots a session waiting for the user amber and a failed one red", async () => {
|
||||||
vi.mocked(api.sessions.list).mockImplementation(async () => ({
|
vi.mocked(api.sessions.list).mockImplementation(async () => ({
|
||||||
sessions: [
|
sessions: [
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ import {
|
|||||||
Trash2,
|
Trash2,
|
||||||
UnfoldVertical,
|
UnfoldVertical,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { api } from "../../lib/api";
|
import { api, gitTarget } from "../../lib/api";
|
||||||
import { pasteToLane } from "../../lib/panePaste";
|
import { pasteToLane } from "../../lib/panePaste";
|
||||||
import { LaneDiffView, LaneFileView, type DiffLayout, type LaneDiffChange } from "./LaneDiffView";
|
import { LaneDiffView, LaneFileView, type DiffLayout, type LaneDiffChange } from "./LaneDiffView";
|
||||||
import { LaneConflictView } from "./LaneConflictView";
|
import { LaneConflictView } from "./LaneConflictView";
|
||||||
@@ -422,11 +422,16 @@ function ChangeRow({
|
|||||||
|
|
||||||
export function LaneChangesPanel({
|
export function LaneChangesPanel({
|
||||||
laneId,
|
laneId,
|
||||||
|
session,
|
||||||
onNewLane,
|
onNewLane,
|
||||||
full = false,
|
full = false,
|
||||||
tab = "changes",
|
tab = "changes",
|
||||||
}: {
|
}: {
|
||||||
laneId: number;
|
laneId: number;
|
||||||
|
/** 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. */
|
||||||
|
session?: string;
|
||||||
/** The Workspace right panel's activity-bar tab: `files` is the browse mode
|
/** The Workspace right panel's activity-bar tab: `files` is the browse mode
|
||||||
* on its own (no git header, no mode switcher), `changes` the other four.
|
* on its own (no git header, no mode switcher), `changes` the other four.
|
||||||
* At full width both show the explorer sidebar beside the changes. */
|
* At full width both show the explorer sidebar beside the changes. */
|
||||||
@@ -440,7 +445,9 @@ export function LaneChangesPanel({
|
|||||||
const { t } = useTranslation(["lanes"]);
|
const { t } = useTranslation(["lanes"]);
|
||||||
// One read of the working copy, shared by the header and every write below:
|
// 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.
|
// a stage, a discard or a commit changes the counts the header shows.
|
||||||
const [git, refreshGit] = useLaneGitFacts(laneId);
|
const [git, refreshGit] = useLaneGitFacts(laneId, session);
|
||||||
|
// 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.
|
// 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) => pasteToLane(laneId, text), [laneId]);
|
||||||
// Bumped by Refresh and by a git bar action: a pull or a switch moves
|
// Bumped by Refresh and by a git bar action: a pull or a switch moves
|
||||||
@@ -544,7 +551,7 @@ export function LaneChangesPanel({
|
|||||||
const seq = ++loadSeq.current;
|
const seq = ++loadSeq.current;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const result = await api.lanes.changes(laneId, {
|
const result = await api.lanes.changes(target, {
|
||||||
range,
|
range,
|
||||||
...(range === "commit" && sha ? { sha } : {}),
|
...(range === "commit" && sha ? { sha } : {}),
|
||||||
...(mode === "file" && openFile ? { path: openFile, context } : {}),
|
...(mode === "file" && openFile ? { path: openFile, context } : {}),
|
||||||
@@ -591,7 +598,7 @@ export function LaneChangesPanel({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (mode !== "history") return;
|
if (mode !== "history") return;
|
||||||
void api.lanes
|
void api.lanes
|
||||||
.gitLog(laneId, { limit: 50 })
|
.gitLog(target, { limit: 50 })
|
||||||
.then(forLane(laneId, (r) => setCommits(r.commits)))
|
.then(forLane(laneId, (r) => setCommits(r.commits)))
|
||||||
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
|
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
|
||||||
}, [mode, laneId, reread]);
|
}, [mode, laneId, reread]);
|
||||||
@@ -603,7 +610,7 @@ export function LaneChangesPanel({
|
|||||||
const wanted = full || (mode === "browse" && !query.trim());
|
const wanted = full || (mode === "browse" && !query.trim());
|
||||||
if (!wanted || treeRootLoaded) return;
|
if (!wanted || treeRootLoaded) return;
|
||||||
void api.lanes
|
void api.lanes
|
||||||
.gitTree(laneId, "")
|
.gitTree(target, "")
|
||||||
.then(forLane(laneId, (r) => setTreeChildren((prev) => ({ ...prev, "": r.entries }))))
|
.then(forLane(laneId, (r) => setTreeChildren((prev) => ({ ...prev, "": r.entries }))))
|
||||||
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
|
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
|
||||||
// `treeChildren` itself is the cache this fills — a dep on it would loop.
|
// `treeChildren` itself is the cache this fills — a dep on it would loop.
|
||||||
@@ -619,7 +626,7 @@ export function LaneChangesPanel({
|
|||||||
});
|
});
|
||||||
if (path in treeChildren) return;
|
if (path in treeChildren) return;
|
||||||
void api.lanes
|
void api.lanes
|
||||||
.gitTree(laneId, path)
|
.gitTree(target, path)
|
||||||
.then(forLane(laneId, (r) => setTreeChildren((prev) => ({ ...prev, [path]: r.entries }))))
|
.then(forLane(laneId, (r) => setTreeChildren((prev) => ({ ...prev, [path]: r.entries }))))
|
||||||
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
|
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
|
||||||
};
|
};
|
||||||
@@ -632,7 +639,7 @@ export function LaneChangesPanel({
|
|||||||
const lane = laneId;
|
const lane = laneId;
|
||||||
setTreeBusy(true);
|
setTreeBusy(true);
|
||||||
try {
|
try {
|
||||||
const { levels } = await api.lanes.gitTreeAll(lane);
|
const { levels } = await api.lanes.gitTreeAll(target);
|
||||||
// A lane switch mid-read must not land lane A's tree in lane B.
|
// A lane switch mid-read must not land lane A's tree in lane B.
|
||||||
if (laneNow.current !== lane) return;
|
if (laneNow.current !== lane) return;
|
||||||
setTreeChildren(levels);
|
setTreeChildren(levels);
|
||||||
@@ -655,7 +662,7 @@ export function LaneChangesPanel({
|
|||||||
let live = true;
|
let live = true;
|
||||||
const timer = window.setTimeout(() => {
|
const timer = window.setTimeout(() => {
|
||||||
void api.lanes
|
void api.lanes
|
||||||
.gitLs(laneId, q)
|
.gitLs(target, q)
|
||||||
.then((r) => live && setHits(r.paths))
|
.then((r) => live && setHits(r.paths))
|
||||||
.catch(() => live && setHits([]));
|
.catch(() => live && setHits([]));
|
||||||
}, 150);
|
}, 150);
|
||||||
@@ -670,7 +677,7 @@ export function LaneChangesPanel({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (mode !== "file" || !whole || !openFile) return;
|
if (mode !== "file" || !whole || !openFile) return;
|
||||||
void api.lanes
|
void api.lanes
|
||||||
.gitFile(laneId, openFile, range === "commit" && sha ? sha : "work")
|
.gitFile(target, openFile, range === "commit" && sha ? sha : "work")
|
||||||
.then(
|
.then(
|
||||||
forLane(laneId, (r) =>
|
forLane(laneId, (r) =>
|
||||||
setText(
|
setText(
|
||||||
@@ -775,7 +782,7 @@ export function LaneChangesPanel({
|
|||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const r = await api.lanes.gitSaveFile(lane, text.path, draft, text.sha);
|
const r = await api.lanes.gitSaveFile(target, text.path, draft, text.sha);
|
||||||
if (laneNow.current !== lane) return;
|
if (laneNow.current !== lane) return;
|
||||||
if (r.available) {
|
if (r.available) {
|
||||||
setText({ path: r.path, lines: r.lines, truncated: r.truncated, sha: r.sha });
|
setText({ path: r.path, lines: r.lines, truncated: r.truncated, sha: r.sha });
|
||||||
@@ -803,7 +810,7 @@ export function LaneChangesPanel({
|
|||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const paths = Array.isArray(path) ? path : [path];
|
const paths = Array.isArray(path) ? path : [path];
|
||||||
await api.lanes.gitAction(lane, action, { paths, ...(confirm ? { confirm } : {}) });
|
await api.lanes.gitAction(target, action, { paths, ...(confirm ? { confirm } : {}) });
|
||||||
if (laneNow.current !== lane) return;
|
if (laneNow.current !== lane) return;
|
||||||
setConfirmDiscard(null);
|
setConfirmDiscard(null);
|
||||||
refreshGit();
|
refreshGit();
|
||||||
@@ -824,7 +831,7 @@ export function LaneChangesPanel({
|
|||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
await api.lanes.gitAction(lane, action, body);
|
await api.lanes.gitAction(target, action, body);
|
||||||
if (laneNow.current !== lane) return;
|
if (laneNow.current !== lane) return;
|
||||||
setArmedAbort(false);
|
setArmedAbort(false);
|
||||||
refreshGit();
|
refreshGit();
|
||||||
@@ -849,7 +856,7 @@ export function LaneChangesPanel({
|
|||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
await api.lanes.gitAction(lane, action, {
|
await api.lanes.gitAction(target, action, {
|
||||||
path,
|
path,
|
||||||
hunk: change.hunk.header,
|
hunk: change.hunk.header,
|
||||||
block: change.block,
|
block: change.block,
|
||||||
@@ -916,7 +923,7 @@ export function LaneChangesPanel({
|
|||||||
// `only: "staged"` — the whole point of staging file by file is that the
|
// `only: "staged"` — the whole point of staging file by file is that the
|
||||||
// commit takes the index as it stands, with no `add -A` sweeping the
|
// commit takes the index as it stands, with no `add -A` sweeping the
|
||||||
// rest of the tree in behind it.
|
// rest of the tree in behind it.
|
||||||
await api.lanes.gitAction(lane, "commit", { message: message.trim(), only: "staged" });
|
await api.lanes.gitAction(target, "commit", { message: message.trim(), only: "staged" });
|
||||||
if (laneNow.current !== lane) return;
|
if (laneNow.current !== lane) return;
|
||||||
setMessage("");
|
setMessage("");
|
||||||
refreshGit();
|
refreshGit();
|
||||||
@@ -1055,7 +1062,13 @@ export function LaneChangesPanel({
|
|||||||
)}
|
)}
|
||||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
|
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
|
||||||
{!filesTab && (
|
{!filesTab && (
|
||||||
<LaneGitHeader laneId={laneId} git={git} onChanged={rereadAll} onNewLane={onNewLane} />
|
<LaneGitHeader
|
||||||
|
laneId={laneId}
|
||||||
|
session={session}
|
||||||
|
git={git}
|
||||||
|
onChanged={rereadAll}
|
||||||
|
onNewLane={onNewLane}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{/* ONE wrapping row: where you are (mode) then how you are looking at it
|
{/* ONE wrapping row: where you are (mode) then how you are looking at it
|
||||||
(range, layout, context, whole file), refresh last. Each group is
|
(range, layout, context, whole file), refresh last. Each group is
|
||||||
@@ -1430,6 +1443,7 @@ export function LaneChangesPanel({
|
|||||||
) : mode === "file" && range === "work" && current?.conflicted && !rawEdit && openFile ? (
|
) : mode === "file" && range === "work" && current?.conflicted && !rawEdit && openFile ? (
|
||||||
<LaneConflictView
|
<LaneConflictView
|
||||||
laneId={laneId}
|
laneId={laneId}
|
||||||
|
session={session}
|
||||||
path={openFile}
|
path={openFile}
|
||||||
onResolved={() => {
|
onResolved={() => {
|
||||||
refreshGit();
|
refreshGit();
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ChevronDown, ChevronUp } from "lucide-react";
|
import { ChevronDown, ChevronUp } from "lucide-react";
|
||||||
import { api } from "../../lib/api";
|
import { api, gitTarget } from "../../lib/api";
|
||||||
import {
|
import {
|
||||||
applyChoices,
|
applyChoices,
|
||||||
parseConflicts,
|
parseConflicts,
|
||||||
@@ -37,11 +37,14 @@ function Lines({ lines, className = "" }: { lines: string[]; className?: string
|
|||||||
|
|
||||||
export function LaneConflictView({
|
export function LaneConflictView({
|
||||||
laneId,
|
laneId,
|
||||||
|
session,
|
||||||
path,
|
path,
|
||||||
onResolved,
|
onResolved,
|
||||||
onEdit,
|
onEdit,
|
||||||
}: {
|
}: {
|
||||||
laneId: number;
|
laneId: number;
|
||||||
|
/** Work in that session's own worktree (see `gitTarget`). */
|
||||||
|
session?: string;
|
||||||
path: string;
|
path: string;
|
||||||
/** The file left the conflict (resolved, or a whole side taken). */
|
/** The file left the conflict (resolved, or a whole side taken). */
|
||||||
onResolved: () => void;
|
onResolved: () => void;
|
||||||
@@ -62,7 +65,7 @@ export function LaneConflictView({
|
|||||||
setChoices([]);
|
setChoices([]);
|
||||||
setError(null);
|
setError(null);
|
||||||
api.lanes
|
api.lanes
|
||||||
.gitFile(laneId, path, "work")
|
.gitFile(gitTarget(laneId, session), path, "work")
|
||||||
.then((r) => {
|
.then((r) => {
|
||||||
if (!live) return;
|
if (!live) return;
|
||||||
setFile(
|
setFile(
|
||||||
@@ -75,7 +78,7 @@ export function LaneConflictView({
|
|||||||
return () => {
|
return () => {
|
||||||
live = false;
|
live = false;
|
||||||
};
|
};
|
||||||
}, [laneId, path]);
|
}, [laneId, session, path]);
|
||||||
|
|
||||||
const parts: ConflictPart[] | null = file?.usable ? parseConflicts(file.lines) : null;
|
const parts: ConflictPart[] | null = file?.usable ? parseConflicts(file.lines) : null;
|
||||||
const total = parts ? parts.filter((p) => p.kind === "conflict").length : 0;
|
const total = parts ? parts.filter((p) => p.kind === "conflict").length : 0;
|
||||||
@@ -98,11 +101,16 @@ export function LaneConflictView({
|
|||||||
const save = () =>
|
const save = () =>
|
||||||
run(async () => {
|
run(async () => {
|
||||||
if (!parts || !file?.sha) return;
|
if (!parts || !file?.sha) return;
|
||||||
await api.lanes.gitSaveFile(laneId, path, applyChoices(parts, choices).join("\n"), file.sha);
|
await api.lanes.gitSaveFile(
|
||||||
await api.lanes.gitAction(laneId, "resolve", { path, side: "done" });
|
gitTarget(laneId, session),
|
||||||
|
path,
|
||||||
|
applyChoices(parts, choices).join("\n"),
|
||||||
|
file.sha
|
||||||
|
);
|
||||||
|
await api.lanes.gitAction(gitTarget(laneId, session), "resolve", { path, side: "done" });
|
||||||
});
|
});
|
||||||
const takeAll = (side: "ours" | "theirs") =>
|
const takeAll = (side: "ours" | "theirs") =>
|
||||||
run(() => api.lanes.gitAction(laneId, "resolve", { path, side }));
|
run(() => api.lanes.gitAction(gitTarget(laneId, session), "resolve", { path, side }));
|
||||||
|
|
||||||
const choose = (i: number, c: ConflictChoice | undefined) =>
|
const choose = (i: number, c: ConflictChoice | undefined) =>
|
||||||
setChoices((prev) => {
|
setChoices((prev) => {
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ import {
|
|||||||
Trash2,
|
Trash2,
|
||||||
Upload,
|
Upload,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { api, type GitAuthNeeded } from "../../lib/api";
|
import { api, gitTarget, type GitAuthNeeded } from "../../lib/api";
|
||||||
import type { LaneGitFacts, LaneWorktree } from "../../lib/types";
|
import type { LaneGitFacts, LaneWorktree } from "../../lib/types";
|
||||||
|
|
||||||
type Action =
|
type Action =
|
||||||
@@ -89,12 +89,15 @@ function Btn({
|
|||||||
|
|
||||||
export function LaneGitBar({
|
export function LaneGitBar({
|
||||||
laneId,
|
laneId,
|
||||||
|
session,
|
||||||
git,
|
git,
|
||||||
onChanged,
|
onChanged,
|
||||||
onNewLane,
|
onNewLane,
|
||||||
lead,
|
lead,
|
||||||
}: {
|
}: {
|
||||||
laneId: number;
|
laneId: number;
|
||||||
|
/** Work in that session's own worktree (see `gitTarget`). */
|
||||||
|
session?: string;
|
||||||
git: LaneGitFacts;
|
git: LaneGitFacts;
|
||||||
/** Rendered at the start of the action row: the header's branch line, so
|
/** Rendered at the start of the action row: the header's branch line, so
|
||||||
* the facts and the buttons that act on them read as one row. */
|
* the facts and the buttons that act on them read as one row. */
|
||||||
@@ -157,7 +160,7 @@ export function LaneGitBar({
|
|||||||
setBusy(action);
|
setBusy(action);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const res = await api.lanes.gitAction(lane, action, body || {});
|
const res = await api.lanes.gitAction(gitTarget(lane, session), action, body || {});
|
||||||
// Landed after a lane switch: every write below belongs to the lane
|
// Landed after a lane switch: every write below belongs to the lane
|
||||||
// no longer shown.
|
// no longer shown.
|
||||||
if (laneNow.current !== lane) return;
|
if (laneNow.current !== lane) return;
|
||||||
@@ -208,7 +211,7 @@ export function LaneGitBar({
|
|||||||
|
|
||||||
const refreshBranches = async () => {
|
const refreshBranches = async () => {
|
||||||
try {
|
try {
|
||||||
const r = await api.lanes.gitBranches(laneId);
|
const r = await api.lanes.gitBranches(gitTarget(laneId, session));
|
||||||
if (laneNow.current !== laneId) return;
|
if (laneNow.current !== laneId) return;
|
||||||
setBranches(r.branches);
|
setBranches(r.branches);
|
||||||
setWorktrees(r.worktrees || []);
|
setWorktrees(r.worktrees || []);
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { createPortal } from "react-dom";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { GitBranch } from "lucide-react";
|
import { GitBranch } from "lucide-react";
|
||||||
import { LaneGitBar } from "./LaneGitBar";
|
import { LaneGitBar } from "./LaneGitBar";
|
||||||
import { api } from "../../lib/api";
|
import { api, gitTarget } from "../../lib/api";
|
||||||
import { keepIfSame } from "../../lib/keepIfSame";
|
import { keepIfSame } from "../../lib/keepIfSame";
|
||||||
import type { LaneGitFacts, LaneGitFile } from "../../lib/types";
|
import type { LaneGitFacts, LaneGitFile } from "../../lib/types";
|
||||||
|
|
||||||
@@ -29,7 +29,11 @@ const GIT_REFRESH_MS = 30_000;
|
|||||||
* into the polled lane list. Absent facts are not an error state: a lane may point at a
|
* into the polled lane list. Absent facts are not an error state: a lane may point at a
|
||||||
* plain directory, and it renders with no git header.
|
* plain directory, and it renders with no git header.
|
||||||
*/
|
*/
|
||||||
export function useLaneGitFacts(laneId: number): [LaneGitFacts | null, () => void] {
|
export function useLaneGitFacts(
|
||||||
|
laneId: number,
|
||||||
|
/** Read that session's own worktree instead (`?session=`). */
|
||||||
|
session?: string
|
||||||
|
): [LaneGitFacts | null, () => void] {
|
||||||
const [facts, setFacts] = useState<LaneGitFacts | null>(null);
|
const [facts, setFacts] = useState<LaneGitFacts | null>(null);
|
||||||
// Bumped by the git action bar: a commit or a pull changes every fact on
|
// Bumped by the git action bar: a commit or a pull changes every fact on
|
||||||
// this row, and waiting out the 30s poll makes the button look inert.
|
// this row, and waiting out the 30s poll makes the button look inert.
|
||||||
@@ -46,7 +50,7 @@ export function useLaneGitFacts(laneId: number): [LaneGitFacts | null, () => voi
|
|||||||
let alive = true;
|
let alive = true;
|
||||||
const read = () => {
|
const read = () => {
|
||||||
api.lanes
|
api.lanes
|
||||||
.git(laneId)
|
.git(gitTarget(laneId, session))
|
||||||
.then((f) => {
|
.then((f) => {
|
||||||
if (alive) setFacts(keepIfSame(f));
|
if (alive) setFacts(keepIfSame(f));
|
||||||
})
|
})
|
||||||
@@ -62,7 +66,7 @@ export function useLaneGitFacts(laneId: number): [LaneGitFacts | null, () => voi
|
|||||||
alive = false;
|
alive = false;
|
||||||
clearInterval(timer);
|
clearInterval(timer);
|
||||||
};
|
};
|
||||||
}, [laneId, nonce]);
|
}, [laneId, session, nonce]);
|
||||||
|
|
||||||
return [facts, () => setNonce((n) => n + 1)];
|
return [facts, () => setNonce((n) => n + 1)];
|
||||||
}
|
}
|
||||||
@@ -202,11 +206,14 @@ function LaneGitFilesPanel({
|
|||||||
|
|
||||||
export function LaneGitHeader({
|
export function LaneGitHeader({
|
||||||
laneId,
|
laneId,
|
||||||
|
session,
|
||||||
git,
|
git,
|
||||||
onChanged,
|
onChanged,
|
||||||
onNewLane,
|
onNewLane,
|
||||||
}: {
|
}: {
|
||||||
laneId: number;
|
laneId: number;
|
||||||
|
/** Work in that session's own worktree (see `gitTarget`). */
|
||||||
|
session?: string;
|
||||||
git: LaneGitFacts | null;
|
git: LaneGitFacts | null;
|
||||||
/** Called after a git bar action: the facts AND the panel's diff moved. */
|
/** Called after a git bar action: the facts AND the panel's diff moved. */
|
||||||
onChanged: () => void;
|
onChanged: () => void;
|
||||||
@@ -275,6 +282,7 @@ export function LaneGitHeader({
|
|||||||
>
|
>
|
||||||
<LaneGitBar
|
<LaneGitBar
|
||||||
laneId={laneId}
|
laneId={laneId}
|
||||||
|
session={session}
|
||||||
git={git}
|
git={git}
|
||||||
onChanged={onChanged}
|
onChanged={onChanged}
|
||||||
onNewLane={onNewLane}
|
onNewLane={onNewLane}
|
||||||
|
|||||||
@@ -19,11 +19,11 @@ import { useNavigate } from "react-router-dom";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { GitFork, History, SquareTerminal } from "lucide-react";
|
import { GitFork, History, SquareTerminal } from "lucide-react";
|
||||||
import { api, type RunHandle } from "../../lib/api";
|
import { api, type RunHandle } from "../../lib/api";
|
||||||
import type { Lane } from "../../lib/types";
|
import type { Lane, Session } from "../../lib/types";
|
||||||
import { useLanes } from "../../hooks/useLanes";
|
import { useLanes } from "../../hooks/useLanes";
|
||||||
import { ConfirmModal } from "../ConfirmModal";
|
import { ConfirmModal } from "../ConfirmModal";
|
||||||
import { freeSlot, paneRunId } from "./WorkspaceHome";
|
import { freeSlot, paneRunId } from "./WorkspaceHome";
|
||||||
import { liveRunFor } from "./WorkspaceSessionList";
|
import { liveRunFor, sessionWorktreeName } from "./WorkspaceSessionList";
|
||||||
|
|
||||||
/** The workspace of this tool whose folder holds `cwd` most deeply — the
|
/** The workspace of this tool whose folder holds `cwd` most deeply — the
|
||||||
* same path-boundary rule the server binds hook sessions with. */
|
* same path-boundary rule the server binds hook sessions with. */
|
||||||
@@ -41,7 +41,7 @@ export function owningLane(cwd: string, tool: string, lanes: Lane[]): Lane | nul
|
|||||||
export function SessionResumeButton({
|
export function SessionResumeButton({
|
||||||
session,
|
session,
|
||||||
}: {
|
}: {
|
||||||
session: { id: string; cwd: string | null; tool?: string };
|
session: { id: string; cwd: string | null; tool?: string; lane_id?: number | null };
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation("lanes");
|
const { t } = useTranslation("lanes");
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -65,11 +65,21 @@ export function SessionResumeButton({
|
|||||||
// Claude only: a Codex session id is not what
|
// Claude only: a Codex session id is not what
|
||||||
// `codex resume` takes for every kind of thread.
|
// `codex resume` takes for every kind of thread.
|
||||||
const tool = session.tool || "claude";
|
const tool = session.tool || "claude";
|
||||||
const lane = tool === "claude" && session.cwd ? owningLane(session.cwd, tool, lanes) : null;
|
// The workspace that owns it, else the one whose folder holds it.
|
||||||
|
const lane =
|
||||||
|
tool !== "claude" || !session.cwd
|
||||||
|
? null
|
||||||
|
: (lanes.find((l) => l.id === session.lane_id && l.tool === tool) ??
|
||||||
|
owningLane(session.cwd, tool, lanes));
|
||||||
if (!lane || runs === null) return null;
|
if (!lane || runs === null) return null;
|
||||||
const run = liveRunFor(lane, runs, session.id);
|
const run = liveRunFor(lane, runs, session.id);
|
||||||
const same = (a: string, b: string) => a.replace(/\/+$/, "") === b.replace(/\/+$/, "");
|
const same = (a: string, b: string) => a.replace(/\/+$/, "") === b.replace(/\/+$/, "");
|
||||||
const resumable = !!session.cwd && same(session.cwd, lane.cwd);
|
// Its own worktree counts too: the server resumes it there (`claude
|
||||||
|
// --worktree` sessions), and refuses one whose worktree is gone.
|
||||||
|
const resumable =
|
||||||
|
!!session.cwd &&
|
||||||
|
(same(session.cwd, lane.cwd) ||
|
||||||
|
sessionWorktreeName(lane, { cwd: session.cwd } as Session) !== null);
|
||||||
if (!run && !resumable) return null;
|
if (!run && !resumable) return null;
|
||||||
const open = (runId: string) =>
|
const open = (runId: string) =>
|
||||||
navigate(`/workspace?lane=${lane.id}&run=${encodeURIComponent(runId)}`);
|
navigate(`/workspace?lane=${lane.id}&run=${encodeURIComponent(runId)}`);
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { Checkbox } from "../Checkbox";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
@@ -19,6 +20,7 @@ import {
|
|||||||
ArchiveRestore,
|
ArchiveRestore,
|
||||||
FileText,
|
FileText,
|
||||||
Folder,
|
Folder,
|
||||||
|
GitBranch,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
Pencil,
|
Pencil,
|
||||||
Play,
|
Play,
|
||||||
@@ -41,7 +43,13 @@ import { Select } from "../Select";
|
|||||||
import { MarkdownPreview } from "../MarkdownPreview";
|
import { MarkdownPreview } from "../MarkdownPreview";
|
||||||
import { WorkspaceRowMenu, type WorkspaceMenuAction } from "./WorkspaceRowMenu";
|
import { WorkspaceRowMenu, type WorkspaceMenuAction } from "./WorkspaceRowMenu";
|
||||||
import { workspaceName } from "./WorkspaceRow";
|
import { workspaceName } from "./WorkspaceRow";
|
||||||
import { liveRunFor, SessionFold, sessionDot, useSessionPage } from "./WorkspaceSessionList";
|
import {
|
||||||
|
liveRunFor,
|
||||||
|
SessionFold,
|
||||||
|
sessionDot,
|
||||||
|
sessionWorktreeName,
|
||||||
|
useSessionPage,
|
||||||
|
} from "./WorkspaceSessionList";
|
||||||
|
|
||||||
/** The tmux session a pane slot of a lane owns (server/lib/pty-run.js). */
|
/** The tmux session a pane slot of a lane owns (server/lib/pty-run.js). */
|
||||||
export function paneRunId(laneId: number, slot: number): string {
|
export function paneRunId(laneId: number, slot: number): string {
|
||||||
@@ -96,6 +104,9 @@ function HomeBody({
|
|||||||
const [model, setModel] = useState(defaultModel);
|
const [model, setModel] = useState(defaultModel);
|
||||||
useEffect(() => setModel(defaultModel), [defaultModel]);
|
useEffect(() => setModel(defaultModel), [defaultModel]);
|
||||||
const [mode, setMode] = useState<PermissionMode>("auto");
|
const [mode, setMode] = useState<PermissionMode>("auto");
|
||||||
|
// Claude Desktop's worktree toggle: the new session gets a git worktree of
|
||||||
|
// its own (`claude --worktree`), made and cleaned up by Claude Code.
|
||||||
|
const [inWorktree, setInWorktree] = useState(false);
|
||||||
const [starting, setStarting] = useState(false);
|
const [starting, setStarting] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [liveRuns, setLiveRuns] = useState<RunHandle[]>([]);
|
const [liveRuns, setLiveRuns] = useState<RunHandle[]>([]);
|
||||||
@@ -219,6 +230,7 @@ function HomeBody({
|
|||||||
prompt: prompt.trim(),
|
prompt: prompt.trim(),
|
||||||
...(model ? { model } : {}),
|
...(model ? { model } : {}),
|
||||||
...(claude ? { permissionMode: mode } : {}),
|
...(claude ? { permissionMode: mode } : {}),
|
||||||
|
...(claude && inWorktree && project?.root ? { worktree: true } : {}),
|
||||||
});
|
});
|
||||||
setPrompt("");
|
setPrompt("");
|
||||||
const runId = r.run_id ?? paneRunId(lane.id, slot);
|
const runId = r.run_id ?? paneRunId(lane.id, slot);
|
||||||
@@ -344,6 +356,15 @@ function HomeBody({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{claude && project?.root && (
|
||||||
|
<Checkbox
|
||||||
|
checked={inWorktree}
|
||||||
|
onChange={setInWorktree}
|
||||||
|
label={t("home.worktree")}
|
||||||
|
title={t("home.worktreeHint")}
|
||||||
|
className="ml-1"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-testid="workspace-home-start"
|
data-testid="workspace-home-start"
|
||||||
@@ -382,6 +403,15 @@ function HomeBody({
|
|||||||
<span className="min-w-0 flex-1 truncate text-sm text-fg-primary">
|
<span className="min-w-0 flex-1 truncate text-sm text-fg-primary">
|
||||||
{s.name || s.id.slice(0, 8)}
|
{s.name || s.id.slice(0, 8)}
|
||||||
</span>
|
</span>
|
||||||
|
{sessionWorktreeName(lane, s) && (
|
||||||
|
<span
|
||||||
|
title={t("tree.worktreeSession", { name: sessionWorktreeName(lane, s) })}
|
||||||
|
className="inline-flex shrink-0 items-center gap-1 font-mono text-[11px] text-fg-muted"
|
||||||
|
>
|
||||||
|
<GitBranch className="h-3 w-3" />
|
||||||
|
{sessionWorktreeName(lane, s)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{!!s.cost && s.cost > 0 && (
|
{!!s.cost && s.cost > 0 && (
|
||||||
<span className="shrink-0 text-xs tabular-nums text-fg-muted">
|
<span className="shrink-0 text-xs tabular-nums text-fg-muted">
|
||||||
{fmtCost(s.cost)}
|
{fmtCost(s.cost)}
|
||||||
|
|||||||
@@ -16,12 +16,13 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useState, type ReactNode } from "react";
|
import { useEffect, useState, type ReactNode } from "react";
|
||||||
import { Archive } from "lucide-react";
|
import { Archive, GitBranch } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Link, useNavigate } from "react-router-dom";
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
import { api, type RunHandle } from "../../lib/api";
|
import { api, type RunHandle } from "../../lib/api";
|
||||||
import { isSessionAwaitingInput, type Lane, type Session } from "../../lib/types";
|
import { isSessionAwaitingInput, type Lane, type Session } from "../../lib/types";
|
||||||
import { timeAgo } from "../../lib/format";
|
import { timeAgo } from "../../lib/format";
|
||||||
|
import { ConfirmModal } from "../ConfirmModal";
|
||||||
|
|
||||||
export const SESSION_LIMIT = 20;
|
export const SESSION_LIMIT = 20;
|
||||||
|
|
||||||
@@ -38,6 +39,14 @@ export function liveRunFor(lane: Lane, runs: RunHandle[], sessionId: string): Ru
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The name of the git worktree a session ran in (`claude --worktree` puts it
|
||||||
|
* at `<repo>/.claude/worktrees/<name>`), or null for one in the folder. */
|
||||||
|
export function sessionWorktreeName(lane: Lane, s: Session): string | null {
|
||||||
|
const strip = (p: string) => p.replace(/\/+$/, "");
|
||||||
|
if (!s.cwd || strip(s.cwd) === strip(lane.cwd)) return null;
|
||||||
|
return /\/\.claude\/worktrees\/([^/]+)\/?$/.exec(s.cwd)?.[1] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
/** A session row's dot: waiting for the user, failed, held by a live pane, or
|
/** A session row's dot: waiting for the user, failed, held by a live pane, or
|
||||||
* at rest. */
|
* at rest. */
|
||||||
export function sessionDot(session: Session, live: boolean): string {
|
export function sessionDot(session: Session, live: boolean): string {
|
||||||
@@ -178,6 +187,10 @@ export function WorkspaceSessionList({
|
|||||||
const [renaming, setRenaming] = useState<string | null>(null);
|
const [renaming, setRenaming] = useState<string | null>(null);
|
||||||
const [draft, setDraft] = useState("");
|
const [draft, setDraft] = useState("");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
// A worktree session asked to be archived: Claude Desktop removes its
|
||||||
|
// worktree too, so that is confirmed first.
|
||||||
|
const [archiving, setArchiving] = useState<Session | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
// The lane's own session/status/needs-you move on every hook, which is when
|
// 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.
|
// a session can appear or change state — re-read then, not on a timer.
|
||||||
@@ -205,6 +218,44 @@ export function WorkspaceSessionList({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Archive: a worktree session with no live pane loses its worktree first
|
||||||
|
// (never forced — git refuses unsaved work, which then stops the archive);
|
||||||
|
// one Claude already cleaned up is just archived.
|
||||||
|
const archive = (s: Session) =>
|
||||||
|
sessionWorktreeName(lane, s) && !runFor(s)
|
||||||
|
? setArchiving(s)
|
||||||
|
: void patch(s, { archived: true });
|
||||||
|
const archiveWithWorktree = async (s: Session) => {
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await api.lanes.removeSessionWorktree(lane.id, s.id).catch((err: unknown) => {
|
||||||
|
if ((err as { code?: string }).code !== "ENOWORKTREE") throw err;
|
||||||
|
});
|
||||||
|
await patch(s, { archived: true });
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
setArchiving(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const confirmArchive = (
|
||||||
|
<ConfirmModal
|
||||||
|
open={archiving !== null}
|
||||||
|
title={t("tree.archiveWorktreeTitle")}
|
||||||
|
message={t("tree.archiveWorktreeBody", {
|
||||||
|
name: archiving ? sessionWorktreeName(lane, archiving) : "",
|
||||||
|
})}
|
||||||
|
confirmLabel={t("tree.archiveWorktreeConfirm")}
|
||||||
|
cancelLabel={t("home.cancel")}
|
||||||
|
destructive
|
||||||
|
busy={busy}
|
||||||
|
onConfirm={() => archiving && void archiveWithWorktree(archiving)}
|
||||||
|
onCancel={() => setArchiving(null)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
if (hidden) return null;
|
if (hidden) return null;
|
||||||
const failure = error && (
|
const failure = error && (
|
||||||
<p role="alert" className="pl-7 text-[11px] text-status-danger">
|
<p role="alert" className="pl-7 text-[11px] text-status-danger">
|
||||||
@@ -216,6 +267,7 @@ export function WorkspaceSessionList({
|
|||||||
<>
|
<>
|
||||||
<p className="py-1 pl-8 text-[11px] text-fg-muted">{t("tree.noSessions")}</p>
|
<p className="py-1 pl-8 text-[11px] text-fg-muted">{t("tree.noSessions")}</p>
|
||||||
{failure}
|
{failure}
|
||||||
|
{confirmArchive}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -269,9 +321,11 @@ export function WorkspaceSessionList({
|
|||||||
setDraft(s.name || "");
|
setDraft(s.name || "");
|
||||||
setRenaming(s.id);
|
setRenaming(s.id);
|
||||||
}}
|
}}
|
||||||
title={`${s.name || s.id}\n${timeAgo(s.last_activity ?? s.started_at)}\n${
|
title={`${s.name || s.id}\n${timeAgo(s.last_activity ?? s.started_at)}${
|
||||||
run ? t("tree.splitHint") : t("tree.transcriptHint")
|
sessionWorktreeName(lane, s)
|
||||||
}`}
|
? `\n${t("tree.worktreeSession", { name: sessionWorktreeName(lane, s) })}`
|
||||||
|
: ""
|
||||||
|
}\n${run ? t("tree.splitHint") : t("tree.transcriptHint")}`}
|
||||||
className={`flex w-full items-center gap-2 rounded-md py-1 pl-7 pr-7 text-left text-[12px] ${
|
className={`flex w-full items-center gap-2 rounded-md py-1 pl-7 pr-7 text-left text-[12px] ${
|
||||||
selected
|
selected
|
||||||
? "bg-accent/10 text-fg-primary"
|
? "bg-accent/10 text-fg-primary"
|
||||||
@@ -283,11 +337,18 @@ export function WorkspaceSessionList({
|
|||||||
className={`h-1.5 w-1.5 shrink-0 rounded-full ${sessionDot(s, !!run)}`}
|
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>
|
<span className="truncate">{s.name || s.id.slice(0, 8)}</span>
|
||||||
|
{sessionWorktreeName(lane, s) && (
|
||||||
|
<GitBranch
|
||||||
|
data-testid={`workspace-session-worktree-${s.id}`}
|
||||||
|
aria-label={t("tree.worktreeSession", { name: sessionWorktreeName(lane, s) })}
|
||||||
|
className="h-3 w-3 shrink-0 text-fg-muted"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-testid={`workspace-session-archive-${s.id}`}
|
data-testid={`workspace-session-archive-${s.id}`}
|
||||||
onClick={() => void patch(s, { archived: true })}
|
onClick={() => archive(s)}
|
||||||
title={t("tree.archiveSession")}
|
title={t("tree.archiveSession")}
|
||||||
aria-label={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"
|
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"
|
||||||
@@ -299,6 +360,7 @@ export function WorkspaceSessionList({
|
|||||||
})}
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
{failure}
|
{failure}
|
||||||
|
{confirmArchive}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { api } from "../../../lib/api";
|
|||||||
import type { LaneChanges } from "../../../lib/types";
|
import type { LaneChanges } from "../../../lib/types";
|
||||||
|
|
||||||
vi.mock("../../../lib/api", () => ({
|
vi.mock("../../../lib/api", () => ({
|
||||||
|
gitTarget: (lane: number, session?: string | null) => (session ? { lane, session } : lane),
|
||||||
api: {
|
api: {
|
||||||
lanes: {
|
lanes: {
|
||||||
git: vi.fn(),
|
git: vi.fn(),
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { LaneConflictView } from "../LaneConflictView";
|
|||||||
import { api } from "../../../lib/api";
|
import { api } from "../../../lib/api";
|
||||||
|
|
||||||
vi.mock("../../../lib/api", () => ({
|
vi.mock("../../../lib/api", () => ({
|
||||||
|
gitTarget: (lane: number, session?: string | null) => (session ? { lane, session } : lane),
|
||||||
api: { lanes: { gitFile: vi.fn(), gitSaveFile: vi.fn(), gitAction: vi.fn() } },
|
api: { lanes: { gitFile: vi.fn(), gitSaveFile: vi.fn(), gitAction: vi.fn() } },
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { api } from "../../../lib/api";
|
|||||||
import type { LaneGitFacts } from "../../../lib/types";
|
import type { LaneGitFacts } from "../../../lib/types";
|
||||||
|
|
||||||
vi.mock("../../../lib/api", () => ({
|
vi.mock("../../../lib/api", () => ({
|
||||||
|
gitTarget: (lane: number, session?: string | null) => (session ? { lane, session } : lane),
|
||||||
api: { lanes: { gitAction: vi.fn(), gitBranches: vi.fn() } },
|
api: { lanes: { gitAction: vi.fn(), gitBranches: vi.fn() } },
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { LaneGitHeader, useLaneGitFacts } from "../LaneGitHeader";
|
|||||||
import { api } from "../../../lib/api";
|
import { api } from "../../../lib/api";
|
||||||
|
|
||||||
vi.mock("../../../lib/api", () => ({
|
vi.mock("../../../lib/api", () => ({
|
||||||
|
gitTarget: (lane: number, session?: string | null) => (session ? { lane, session } : lane),
|
||||||
api: { lanes: { git: vi.fn(), gitAction: vi.fn(), gitBranches: vi.fn() } },
|
api: { lanes: { git: vi.fn(), gitAction: vi.fn(), gitBranches: vi.fn() } },
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,12 @@ function Where() {
|
|||||||
return <span data-testid="where">{loc.pathname + loc.search}</span>;
|
return <span data-testid="where">{loc.pathname + loc.search}</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderButton(session: { id: string; cwd: string | null; tool?: string }) {
|
function renderButton(session: {
|
||||||
|
id: string;
|
||||||
|
cwd: string | null;
|
||||||
|
tool?: string;
|
||||||
|
lane_id?: number;
|
||||||
|
}) {
|
||||||
return render(
|
return render(
|
||||||
<MemoryRouter initialEntries={["/sessions/x"]}>
|
<MemoryRouter initialEntries={["/sessions/x"]}>
|
||||||
<Routes>
|
<Routes>
|
||||||
@@ -136,6 +141,17 @@ describe("SessionResumeButton", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("resumes a session in its own worktree through the workspace that owns it", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderButton({ id: "s-wt", cwd: "/r/.claude/worktrees/ah-3-x", tool: "claude", lane_id: 3 });
|
||||||
|
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,
|
||||||
|
resumeSessionId: "s-wt",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("offers no Resume for a session started in a subfolder, but opens its live pane", async () => {
|
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" });
|
const { unmount } = renderButton({ id: "s-9", cwd: "/r/lib", tool: "claude" });
|
||||||
await waitFor(() => expect(api.run.list).toHaveBeenCalled());
|
await waitFor(() => expect(api.run.list).toHaveBeenCalled());
|
||||||
|
|||||||
@@ -193,6 +193,21 @@ describe("WorkspaceHome", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("starts the session in its own worktree when Worktree is ticked (a repo only)", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderHome();
|
||||||
|
await screen.findByTestId("workspace-recent-s-live");
|
||||||
|
await user.click(await screen.findByRole("checkbox", { name: "Worktree" }));
|
||||||
|
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,
|
||||||
|
prompt: "try it",
|
||||||
|
permissionMode: "auto",
|
||||||
|
worktree: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("refuses to start when all four panes are busy", async () => {
|
it("refuses to start when all four panes are busy", async () => {
|
||||||
runs = [0, 1, 2, 3].map(
|
runs = [0, 1, 2, 3].map(
|
||||||
(slot) => ({ id: `r${slot}`, laneId: 1, slot, status: "running" }) as RunHandle
|
(slot) => ({ id: `r${slot}`, laneId: 1, slot, status: "running" }) as RunHandle
|
||||||
|
|||||||
@@ -351,6 +351,10 @@
|
|||||||
"tree.splitHint": "Live pane — Ctrl/Cmd+click opens it in a second pane",
|
"tree.splitHint": "Live pane — Ctrl/Cmd+click opens it in a second pane",
|
||||||
"tree.renameSession": "Rename session",
|
"tree.renameSession": "Rename session",
|
||||||
"tree.archiveSession": "Archive — hide from this workspace (deletes nothing)",
|
"tree.archiveSession": "Archive — hide from this workspace (deletes nothing)",
|
||||||
|
"tree.worktreeSession": "In its own worktree: .claude/worktrees/{{name}}",
|
||||||
|
"tree.archiveWorktreeTitle": "Archive and remove its worktree?",
|
||||||
|
"tree.archiveWorktreeBody": "Like Claude Desktop, archiving removes the session's worktree .claude/worktrees/{{name}} and its branch. Git refuses if it holds uncommitted changes, and a branch with commits merged nowhere is kept — nothing unsaved is lost.",
|
||||||
|
"tree.archiveWorktreeConfirm": "Archive and remove",
|
||||||
"tree.archived_one": "{{count}} archived session",
|
"tree.archived_one": "{{count}} archived session",
|
||||||
"tree.archived_other": "{{count}} archived sessions",
|
"tree.archived_other": "{{count}} archived sessions",
|
||||||
"tree.unarchive": "Unarchive",
|
"tree.unarchive": "Unarchive",
|
||||||
@@ -361,6 +365,8 @@
|
|||||||
"home.worktreeCount_other": "{{count}} worktrees",
|
"home.worktreeCount_other": "{{count}} worktrees",
|
||||||
"home.placeholder": "How can I help you today?",
|
"home.placeholder": "How can I help you today?",
|
||||||
"home.start": "Start",
|
"home.start": "Start",
|
||||||
|
"home.worktree": "Worktree",
|
||||||
|
"home.worktreeHint": "Run this session in its own git worktree — <repo>/.claude/worktrees/, a branch of its own, made and cleaned up by Claude Code",
|
||||||
"home.allBusy": "All four panes of this workspace are busy — stop one before starting another session.",
|
"home.allBusy": "All four panes of this workspace are busy — stop one before starting another session.",
|
||||||
"home.recents": "Recents",
|
"home.recents": "Recents",
|
||||||
"home.noRecents": "No sessions yet — start one above.",
|
"home.noRecents": "No sessions yet — start one above.",
|
||||||
|
|||||||
@@ -351,6 +351,10 @@
|
|||||||
"tree.splitHint": "Pane đang chạy — Ctrl/Cmd+click để mở ở pane thứ hai",
|
"tree.splitHint": "Pane đang chạy — Ctrl/Cmd+click để mở ở pane thứ hai",
|
||||||
"tree.renameSession": "Đổi tên session",
|
"tree.renameSession": "Đổi tên session",
|
||||||
"tree.archiveSession": "Lưu trữ — ẩn khỏi workspace này (không xoá gì)",
|
"tree.archiveSession": "Lưu trữ — ẩn khỏi workspace này (không xoá gì)",
|
||||||
|
"tree.worktreeSession": "Trong worktree riêng: .claude/worktrees/{{name}}",
|
||||||
|
"tree.archiveWorktreeTitle": "Lưu trữ và gỡ worktree của session?",
|
||||||
|
"tree.archiveWorktreeBody": "Giống Claude Desktop, lưu trữ sẽ gỡ worktree .claude/worktrees/{{name}} của session cùng branch của nó. Git sẽ từ chối nếu còn thay đổi chưa commit, và branch có commit chưa merge vào đâu sẽ được giữ lại — không mất gì chưa lưu.",
|
||||||
|
"tree.archiveWorktreeConfirm": "Lưu trữ và gỡ",
|
||||||
"tree.archived_one": "{{count}} session đã lưu trữ",
|
"tree.archived_one": "{{count}} session đã lưu trữ",
|
||||||
"tree.archived_other": "{{count}} session đã lưu trữ",
|
"tree.archived_other": "{{count}} session đã lưu trữ",
|
||||||
"tree.unarchive": "Bỏ lưu trữ",
|
"tree.unarchive": "Bỏ lưu trữ",
|
||||||
@@ -361,6 +365,8 @@
|
|||||||
"home.worktreeCount_other": "{{count}} worktree",
|
"home.worktreeCount_other": "{{count}} worktree",
|
||||||
"home.placeholder": "Tôi có thể giúp gì cho bạn hôm nay?",
|
"home.placeholder": "Tôi có thể giúp gì cho bạn hôm nay?",
|
||||||
"home.start": "Bắt đầu",
|
"home.start": "Bắt đầu",
|
||||||
|
"home.worktree": "Worktree",
|
||||||
|
"home.worktreeHint": "Chạy session này trong worktree git riêng — <repo>/.claude/worktrees/, branch riêng, do Claude Code tạo và dọn",
|
||||||
"home.allBusy": "Cả bốn pane của workspace này đang bận — hãy dừng một pane trước khi mở session mới.",
|
"home.allBusy": "Cả bốn pane của workspace này đang bận — hãy dừng một pane trước khi mở session mới.",
|
||||||
"home.recents": "Gần đây",
|
"home.recents": "Gần đây",
|
||||||
"home.noRecents": "Chưa có session nào — bắt đầu ở ô phía trên.",
|
"home.noRecents": "Chưa có session nào — bắt đầu ở ô phía trên.",
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
/**
|
||||||
|
* @file Pins where a workspace git call goes: the workspace's own routes, or —
|
||||||
|
* for a session's worktree target — the same routes with `?session=`, which
|
||||||
|
* the server resolves to that session's worktree.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||||
|
import { api, gitTarget } from "../api";
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
function stubFetch() {
|
||||||
|
const spy = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
headers: { get: () => "application/json" },
|
||||||
|
json: async () => ({}),
|
||||||
|
});
|
||||||
|
vi.stubGlobal("fetch", spy);
|
||||||
|
return spy;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("git targets", () => {
|
||||||
|
it("adds session= for a worktree target and nothing for the workspace", async () => {
|
||||||
|
const spy = stubFetch();
|
||||||
|
await api.lanes.changes(3, { range: "work" });
|
||||||
|
await api.lanes.changes(gitTarget(3, "s-1"), { range: "work" });
|
||||||
|
await api.lanes.gitAction(gitTarget(3, "s-1"), "stage", { paths: ["a"] });
|
||||||
|
await api.lanes.git(gitTarget(3, null));
|
||||||
|
const urls = spy.mock.calls.map((c) => String(c[0]));
|
||||||
|
expect(urls[0]).toMatch(/\/lanes\/3\/git\/changes\?range=work$/);
|
||||||
|
expect(urls[1]).toMatch(/\/lanes\/3\/git\/changes\?range=work&session=s-1$/);
|
||||||
|
expect(urls[2]).toMatch(/\/lanes\/3\/git\/stage\?session=s-1$/);
|
||||||
|
expect(urls[3]).toMatch(/\/lanes\/3\/git$/);
|
||||||
|
});
|
||||||
|
});
|
||||||
+52
-23
@@ -565,6 +565,27 @@ async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
|||||||
* JSON-encoding, and error-throwing behavior documented on that helper — the
|
* JSON-encoding, and error-throwing behavior documented on that helper — the
|
||||||
* per-method docs below focus on the specific route, params, and response shape.
|
* per-method docs below focus on the specific route, params, and response shape.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* What a workspace git call works on: the workspace (its id) or, with
|
||||||
|
* `session`, that session's own worktree (`claude --worktree`) — the server
|
||||||
|
* resolves the tree from the session row (`?session=`), never from a path.
|
||||||
|
*/
|
||||||
|
export type GitTarget = number | { lane: number; session?: string };
|
||||||
|
|
||||||
|
/** `/lanes/<id>/git<sub>` plus `q` and, for a session target, `session=`. */
|
||||||
|
function gitUrl(target: GitTarget, sub: string, q?: URLSearchParams): string {
|
||||||
|
const params = new URLSearchParams(q);
|
||||||
|
const lane = typeof target === "number" ? target : target.lane;
|
||||||
|
if (typeof target !== "number" && target.session) params.set("session", target.session);
|
||||||
|
const qs = params.toString();
|
||||||
|
return `/lanes/${lane}/git${sub}${qs ? `?${qs}` : ""}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The target for `laneId`, in `session`'s worktree when one is given. */
|
||||||
|
export function gitTarget(laneId: number, session?: string | null): GitTarget {
|
||||||
|
return session ? { lane: laneId, session } : laneId;
|
||||||
|
}
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
// ───────────────────────── Updates / self-update API ─────────────────────────
|
// ───────────────────────── Updates / self-update API ─────────────────────────
|
||||||
/** Self-update status: whether this install is a git clone and, if so,
|
/** Self-update status: whether this install is a git clone and, if so,
|
||||||
@@ -2542,7 +2563,7 @@ export const api = {
|
|||||||
* @returns {@link LaneGitFacts} — `available: false` when the cwd is not a
|
* @returns {@link LaneGitFacts} — `available: false` when the cwd is not a
|
||||||
* readable git repository, which is a normal state rather than an error.
|
* readable git repository, which is a normal state rather than an error.
|
||||||
*/
|
*/
|
||||||
git: (id: number) => request<LaneGitFacts>(`/lanes/${id}/git`),
|
git: (id: GitTarget) => request<LaneGitFacts>(gitUrl(id, "")),
|
||||||
/**
|
/**
|
||||||
* GET /api/lanes/:id/git/changes — the lane's code changes, at the depth
|
* GET /api/lanes/:id/git/changes — the lane's code changes, at the depth
|
||||||
* the Changes panel asks for.
|
* the Changes panel asks for.
|
||||||
@@ -2553,7 +2574,7 @@ export const api = {
|
|||||||
* @returns {@link LaneChanges} — `available: false` for a cwd git cannot read.
|
* @returns {@link LaneChanges} — `available: false` for a cwd git cannot read.
|
||||||
*/
|
*/
|
||||||
changes: (
|
changes: (
|
||||||
id: number,
|
id: GitTarget,
|
||||||
opts: {
|
opts: {
|
||||||
range?: LaneChangeRange;
|
range?: LaneChangeRange;
|
||||||
path?: string;
|
path?: string;
|
||||||
@@ -2568,20 +2589,18 @@ export const api = {
|
|||||||
if (opts.full) q.set("full", "1");
|
if (opts.full) q.set("full", "1");
|
||||||
if (opts.sha) q.set("sha", opts.sha);
|
if (opts.sha) q.set("sha", opts.sha);
|
||||||
if (opts.context != null) q.set("context", String(opts.context));
|
if (opts.context != null) q.set("context", String(opts.context));
|
||||||
return request<LaneChanges>(`/lanes/${id}/git/changes?${q.toString()}`);
|
return request<LaneChanges>(gitUrl(id, "/changes", q));
|
||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
* GET /api/lanes/:id/git/log — the lane's recent commits, newest first.
|
* GET /api/lanes/:id/git/log — the lane's recent commits, newest first.
|
||||||
* @param id The lane id.
|
* @param id The lane id.
|
||||||
* @param opts `file` narrows the list to the commits touching one path.
|
* @param opts `file` narrows the list to the commits touching one path.
|
||||||
*/
|
*/
|
||||||
gitLog: (id: number, opts: { limit?: number; file?: string } = {}) => {
|
gitLog: (id: GitTarget, opts: { limit?: number; file?: string } = {}) => {
|
||||||
const q = new URLSearchParams();
|
const q = new URLSearchParams();
|
||||||
if (opts.limit) q.set("limit", String(opts.limit));
|
if (opts.limit) q.set("limit", String(opts.limit));
|
||||||
if (opts.file) q.set("file", opts.file);
|
if (opts.file) q.set("file", opts.file);
|
||||||
return request<{ available: boolean; commits: LaneCommit[] }>(
|
return request<{ available: boolean; commits: LaneCommit[] }>(gitUrl(id, "/log", q));
|
||||||
`/lanes/${id}/git/log?${q.toString()}`
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
* GET /api/lanes/:id/git/file — one file's whole content at a revision.
|
* GET /api/lanes/:id/git/file — one file's whole content at a revision.
|
||||||
@@ -2589,17 +2608,15 @@ export const api = {
|
|||||||
* @param path Repo-relative path.
|
* @param path Repo-relative path.
|
||||||
* @param rev `work` (the file on disk), `HEAD`, or a commit-ish.
|
* @param rev `work` (the file on disk), `HEAD`, or a commit-ish.
|
||||||
*/
|
*/
|
||||||
gitFile: (id: number, path: string, rev = "work") =>
|
gitFile: (id: GitTarget, path: string, rev = "work") =>
|
||||||
request<LaneFileContent>(
|
request<LaneFileContent>(gitUrl(id, "/file", new URLSearchParams({ path, rev }))),
|
||||||
`/lanes/${id}/git/file?path=${encodeURIComponent(path)}&rev=${encodeURIComponent(rev)}`
|
|
||||||
),
|
|
||||||
/**
|
/**
|
||||||
* PUT /api/lanes/:id/git/file — save the editor's text over the working
|
* PUT /api/lanes/:id/git/file — save the editor's text over the working
|
||||||
* file. `baseSha` is the `sha` it was opened at; a file that changed on
|
* file. `baseSha` is the `sha` it was opened at; a file that changed on
|
||||||
* disk since answers 409 ESTALE rather than being overwritten.
|
* disk since answers 409 ESTALE rather than being overwritten.
|
||||||
*/
|
*/
|
||||||
gitSaveFile: (id: number, path: string, content: string, baseSha: string) =>
|
gitSaveFile: (id: GitTarget, path: string, content: string, baseSha: string) =>
|
||||||
request<LaneFileContent>(`/lanes/${id}/git/file`, {
|
request<LaneFileContent>(gitUrl(id, "/file"), {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
body: JSON.stringify({ path, content, baseSha }),
|
body: JSON.stringify({ path, content, baseSha }),
|
||||||
}),
|
}),
|
||||||
@@ -2609,9 +2626,9 @@ export const api = {
|
|||||||
* @param id The lane id.
|
* @param id The lane id.
|
||||||
* @param q The substring typed into the quick-open box.
|
* @param q The substring typed into the quick-open box.
|
||||||
*/
|
*/
|
||||||
gitLs: (id: number, q: string) =>
|
gitLs: (id: GitTarget, q: string) =>
|
||||||
request<{ available: boolean; paths: string[]; truncated: boolean; total: number }>(
|
request<{ available: boolean; paths: string[]; truncated: boolean; total: number }>(
|
||||||
`/lanes/${id}/git/ls?q=${encodeURIComponent(q)}`
|
gitUrl(id, "/ls", new URLSearchParams({ q }))
|
||||||
),
|
),
|
||||||
/**
|
/**
|
||||||
* GET /api/lanes/:id/git/tree — one directory level of tracked files.
|
* GET /api/lanes/:id/git/tree — one directory level of tracked files.
|
||||||
@@ -2622,31 +2639,41 @@ export const api = {
|
|||||||
* GET /api/lanes/:id/git/tree-all — every level of the tracked tree in one
|
* GET /api/lanes/:id/git/tree-all — every level of the tracked tree in one
|
||||||
* read, keyed by directory ("" is the root), each shaped like `gitTree`'s.
|
* read, keyed by directory ("" is the root), each shaped like `gitTree`'s.
|
||||||
*/
|
*/
|
||||||
gitTreeAll: (id: number) =>
|
gitTreeAll: (id: GitTarget) =>
|
||||||
request<{
|
request<{
|
||||||
available: boolean;
|
available: boolean;
|
||||||
levels: Record<string, LaneTreeEntry[]>;
|
levels: Record<string, LaneTreeEntry[]>;
|
||||||
truncated: boolean;
|
truncated: boolean;
|
||||||
}>(`/lanes/${id}/git/tree-all`),
|
}>(gitUrl(id, "/tree-all")),
|
||||||
gitTree: (id: number, path = "") =>
|
gitTree: (id: GitTarget, path = "") =>
|
||||||
request<{
|
request<{
|
||||||
available: boolean;
|
available: boolean;
|
||||||
path: string;
|
path: string;
|
||||||
entries: LaneTreeEntry[];
|
entries: LaneTreeEntry[];
|
||||||
truncated: boolean;
|
truncated: boolean;
|
||||||
}>(`/lanes/${id}/git/tree?path=${encodeURIComponent(path)}`),
|
}>(gitUrl(id, "/tree", new URLSearchParams({ path }))),
|
||||||
/**
|
/**
|
||||||
* GET /api/lanes/:id/git/branches — the lane repo's local branches, for
|
* GET /api/lanes/:id/git/branches — the lane repo's local branches, for
|
||||||
* the branch switcher. The repo comes from the lane row, never the query.
|
* the branch switcher. The repo comes from the lane row, never the query.
|
||||||
* @param id The lane id.
|
* @param id The lane id.
|
||||||
*/
|
*/
|
||||||
gitBranches: (id: number) =>
|
/**
|
||||||
|
* POST /api/lanes/:id/session-worktree/remove — Claude Desktop's archive of a
|
||||||
|
* worktree session: removes the session's own worktree (never forced; git
|
||||||
|
* refuses a dirty tree) and its branch when merged (`branchKept` otherwise).
|
||||||
|
*/
|
||||||
|
removeSessionWorktree: (id: number, session: string) =>
|
||||||
|
request<{ removed: true; branch: string | null; branchKept: boolean }>(
|
||||||
|
`/lanes/${id}/session-worktree/remove`,
|
||||||
|
{ method: "POST", body: JSON.stringify({ session, confirm: true }) }
|
||||||
|
),
|
||||||
|
gitBranches: (id: GitTarget) =>
|
||||||
request<{
|
request<{
|
||||||
available: boolean;
|
available: boolean;
|
||||||
branches: string[];
|
branches: string[];
|
||||||
current: string | null;
|
current: string | null;
|
||||||
worktrees: LaneWorktree[];
|
worktrees: LaneWorktree[];
|
||||||
}>(`/lanes/${id}/git/branches`),
|
}>(gitUrl(id, "/branches")),
|
||||||
/**
|
/**
|
||||||
* POST /api/lanes/:id/git/:action — one git write on the lane's worktree.
|
* POST /api/lanes/:id/git/:action — one git write on the lane's worktree.
|
||||||
* @param id The lane id.
|
* @param id The lane id.
|
||||||
@@ -2661,7 +2688,7 @@ export const api = {
|
|||||||
* @returns `{action, ok, output}` — `output` is git's own text, shown as-is.
|
* @returns `{action, ok, output}` — `output` is git's own text, shown as-is.
|
||||||
*/
|
*/
|
||||||
gitAction: (
|
gitAction: (
|
||||||
id: number,
|
id: GitTarget,
|
||||||
action:
|
action:
|
||||||
| "fetch"
|
| "fetch"
|
||||||
| "pull"
|
| "pull"
|
||||||
@@ -2702,7 +2729,7 @@ export const api = {
|
|||||||
} = {}
|
} = {}
|
||||||
) =>
|
) =>
|
||||||
request<{ action: string; ok: true; output: string; branch?: string; conflicts?: string[] }>(
|
request<{ action: string; ok: true; output: string; branch?: string; conflicts?: string[] }>(
|
||||||
`/lanes/${id}/git/${action}`,
|
gitUrl(id, `/${action}`),
|
||||||
{ method: "POST", body: JSON.stringify(body) }
|
{ method: "POST", body: JSON.stringify(body) }
|
||||||
),
|
),
|
||||||
/**
|
/**
|
||||||
@@ -3916,6 +3943,8 @@ export interface RunHandle {
|
|||||||
resumeSessionId: string | null;
|
resumeSessionId: string | null;
|
||||||
/** Claude Code session id this run created/resumed, once known. */
|
/** Claude Code session id this run created/resumed, once known. */
|
||||||
sessionId: string | null;
|
sessionId: string | null;
|
||||||
|
/** The pane works in a git worktree of its own (`claude --worktree`). */
|
||||||
|
worktree?: boolean;
|
||||||
/** ISO timestamp the tmux session was created. */
|
/** ISO timestamp the tmux session was created. */
|
||||||
startedAt: string | null;
|
startedAt: string | null;
|
||||||
/** Initial prompt preview (first 500 chars), null when not provided. */
|
/** Initial prompt preview (first 500 chars), null when not provided. */
|
||||||
|
|||||||
@@ -493,6 +493,11 @@ export function Workspace() {
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// The session the opened run holds, when that run works in a worktree of
|
||||||
|
// its own (`claude --worktree`): the Changes panel shows that tree.
|
||||||
|
const openedRun = runParam ? activeRuns?.items.find((r) => r.id === runParam) : undefined;
|
||||||
|
const gitSession = openedRun?.worktree && openedRun.sessionId ? openedRun.sessionId : undefined;
|
||||||
|
|
||||||
// A pane the layout does not show yet (`&pane=1` on one pane) widens the
|
// A pane the layout does not show yet (`&pane=1` on one pane) widens the
|
||||||
// split so the run lands where it was asked for — once per request, so the
|
// split so the run lands where it was asked for — once per request, so the
|
||||||
// user can still go back to one pane afterwards.
|
// user can still go back to one pane afterwards.
|
||||||
@@ -1065,7 +1070,11 @@ export function Workspace() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<LaneChangesPanel
|
<LaneChangesPanel
|
||||||
|
// A new tree is a new panel: no read for the previous one
|
||||||
|
// can land in it.
|
||||||
|
key={`${currentLane.id}:${gitSession ?? ""}`}
|
||||||
laneId={currentLane.id}
|
laneId={currentLane.id}
|
||||||
|
session={gitSession}
|
||||||
tab={panelTab}
|
tab={panelTab}
|
||||||
full={changesSize === "full"}
|
full={changesSize === "full"}
|
||||||
// A sibling worktree forks from the lane's SOURCE repo when
|
// A sibling worktree forks from the lane's SOURCE repo when
|
||||||
|
|||||||
@@ -722,6 +722,34 @@ describe("Workspace — home and session view", () => {
|
|||||||
await userEvent.setup().click(screen.getByTestId("workspace-back-home"));
|
await userEvent.setup().click(screen.getByTestId("workspace-back-home"));
|
||||||
expect(await screen.findByTestId("workspace-home")).toBeInTheDocument();
|
expect(await screen.findByTestId("workspace-home")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
it("points the Changes panel at the opened run's worktree session", async () => {
|
||||||
|
localStorage.setItem("agenthub.workspace.changesOpen", "1");
|
||||||
|
const original = vi.mocked(api.run.list).getMockImplementation();
|
||||||
|
vi.mocked(api.run.list).mockResolvedValue({
|
||||||
|
runs: [],
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: "agenthub-lane-1-1",
|
||||||
|
laneId: 1,
|
||||||
|
slot: 1,
|
||||||
|
status: "running",
|
||||||
|
sessionId: "s-wt",
|
||||||
|
worktree: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as never);
|
||||||
|
render(
|
||||||
|
<MemoryRouter initialEntries={["/workspace?lane=1&run=agenthub-lane-1-1"]}>
|
||||||
|
<Workspace />
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
await settle();
|
||||||
|
await settle();
|
||||||
|
await waitFor(() => expect(api.lanes.git).toHaveBeenCalledWith({ lane: 1, session: "s-wt" }));
|
||||||
|
vi.mocked(api.run.list).mockImplementation(original!);
|
||||||
|
localStorage.removeItem("agenthub.workspace.changesOpen");
|
||||||
|
});
|
||||||
|
|
||||||
it("widens one pane to two for &pane=1, so the run opens beside the first", async () => {
|
it("widens one pane to two for &pane=1, so the run opens beside the first", async () => {
|
||||||
localStorage.removeItem("agenthub.workspace.splitView");
|
localStorage.removeItem("agenthub.workspace.splitView");
|
||||||
render(
|
render(
|
||||||
|
|||||||
+28
@@ -272,6 +272,7 @@ All fields are optional. The tool (CLI invoked) comes from the workspace itself,
|
|||||||
- **`approval`** — one of `on-request` or `never`, or omitted/empty for no approval prompt. Passed to `codex -a <value>`.
|
- **`approval`** — one of `on-request` or `never`, or omitted/empty for no approval prompt. Passed to `codex -a <value>`.
|
||||||
- **`resumeSessionId`** or **`resumeLast: true`** — resume an earlier run instead of starting new. For Codex workspaces specifically, `resumeLast: true` uses `codex resume --last`; `resumeSessionId` uses `codex resume <sessionId>`. For Claude workspaces, `resumeSessionId` re-attaches a Claude session by id.
|
- **`resumeSessionId`** or **`resumeLast: true`** — resume an earlier run instead of starting new. For Codex workspaces specifically, `resumeLast: true` uses `codex resume --last`; `resumeSessionId` uses `codex resume <sessionId>`. For Claude workspaces, `resumeSessionId` re-attaches a Claude session by id.
|
||||||
- **`forkSession: true`** (with `resumeSessionId`, Claude workspaces only) — resume into a NEW session id (`claude --resume <id> --fork-session`), leaving the original untouched; the run records no session until the fork's first hook. Without `resumeSessionId` it is `400 EBADSESSION`, on any other tool `400 EUNSUPPORTED`.
|
- **`forkSession: true`** (with `resumeSessionId`, Claude workspaces only) — resume into a NEW session id (`claude --resume <id> --fork-session`), leaving the original untouched; the run records no session until the fork's first hook. Without `resumeSessionId` it is `400 EBADSESSION`, on any other tool `400 EUNSUPPORTED`.
|
||||||
|
- **`worktree: true`** (Claude workspaces, no `resumeSessionId`) — a NEW session in a git worktree of its own, Claude Code's way: the pane runs `claude --worktree=ah-<id>-<base36 time>` (the `=` form, since `-w [name]` would take the prompt as the name; the name is made by the server), so Claude itself creates `<repo>/.claude/worktrees/<name>` on branch `worktree-<name>`, copies `.worktreeinclude`, runs the user's `WorktreeCreate` hook and cleans up on exit. `/.claude/worktrees/` is added to the repo root's `info/exclude`; the run is recorded with `worktree: 1` (and the run handle carries `worktree: true`). `400 EUNSUPPORTED` on any other tool. A `resumeSessionId` (or fork) of a session that ran in such a worktree starts the pane IN that worktree; one whose worktree is gone is `400 ENOWORKTREE`.
|
||||||
|
|
||||||
- **`slot`** — which Workspace split pane's session to start, `0`–`3` (default `0`, else `400 EBADSLOT`). Slot 0 is the workspace's own `agenthub-lane-<id>` and is what `run_id` records; slots 1–3 are extra sessions on the same workspace, `agenthub-lane-<id>-<slot>`, and never touch `run_id`/`status`. The response adds `run_id`: the session this start opened. `stop`, `reset` and `remove` kill every slot's session, not only `run_id`.
|
- **`slot`** — which Workspace split pane's session to start, `0`–`3` (default `0`, else `400 EBADSLOT`). Slot 0 is the workspace's own `agenthub-lane-<id>` and is what `run_id` records; slots 1–3 are extra sessions on the same workspace, `agenthub-lane-<id>-<slot>`, and never touch `run_id`/`status`. The response adds `run_id`: the session this start opened. `stop`, `reset` and `remove` kill every slot's session, not only `run_id`.
|
||||||
|
|
||||||
@@ -576,6 +577,33 @@ comes from the workspace row, never from the query (that is what `GET
|
|||||||
/api/lanes/branches` is for, which takes a candidate path for a workspace that does
|
/api/lanes/branches` is for, which takes a candidate path for a workspace that does
|
||||||
not exist yet).
|
not exist yet).
|
||||||
|
|
||||||
|
#### A session's own worktree (`?session=`)
|
||||||
|
|
||||||
|
Every `/api/lanes/:id/git…` route above and below takes `?session=<id>`: the
|
||||||
|
read or write then works in that session's worktree instead of the
|
||||||
|
workspace's folder. The tree is resolved from the SESSION ROW
|
||||||
|
(`server/lib/session-worktree.js`): the session must be owned by this
|
||||||
|
workspace (`sessions.lane_id`), its `cwd` must not be the workspace's own
|
||||||
|
folder, and `git worktree list` of the workspace's repo must register it —
|
||||||
|
otherwise `400 ENOWORKTREE`, never a fall back to the main folder. With it,
|
||||||
|
`GET /git` also answers `worktree: {path, branch}`.
|
||||||
|
|
||||||
|
#### Remove a session's worktree
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/lanes/:id/session-worktree/remove
|
||||||
|
```
|
||||||
|
|
||||||
|
Body `{"session": "<id>", "confirm": true}` (same-origin). Claude Desktop's
|
||||||
|
archive of a worktree session: `git worktree remove <path>` WITHOUT force
|
||||||
|
(git refuses modified or untracked files: `409 EGIT` with git's message), then
|
||||||
|
`git branch -d` of its branch (`-d`, never `-D`: a branch with commits merged
|
||||||
|
nowhere is kept, `branchKept: true`). A lock held by a live process is `409
|
||||||
|
ELOCKED`; a stale one (the pid in Claude's `claude session <name> (pid …)`
|
||||||
|
reason is gone) is cleared first. A live pane still holding the session is
|
||||||
|
`409 ELIVE`; no `confirm` is `400 ECONFIRM`; a session with no worktree here
|
||||||
|
`400 ENOWORKTREE`. Answers `{removed: true, branch, branchKept}`.
|
||||||
|
|
||||||
#### Act on a workspace's working copy
|
#### Act on a workspace's working copy
|
||||||
|
|
||||||
```http
|
```http
|
||||||
|
|||||||
@@ -394,6 +394,7 @@ The dashboard web UI merges workspaces and runs into a single **Workspace** page
|
|||||||
- **A workspace's sessions** — the chevron expands the row to the sessions the workspace OWNS and has not archived (`WorkspaceSessionList.tsx`: `GET /api/sessions?lane=<id>&archived=0`, the newest 20, re-read when the workspace's status or session moves; which rows are expanded is kept in `agenthub.sidebar.projects` → `expanded`). A workspace owns a session its pane held — `bindRunSession` stamps `sessions.lane_id` from the pane's run, first writer wins, a nested `claude -p` excluded — and lists nothing else: a `claude` typed into a terminal in the same folder, or history from before the workspace existed, belongs to no workspace and is found on the Sessions page. Sessions older than this ownership column are not guessed: only those `dashboard_runs` still named were backfilled. A session started by a tool with no hooks (Codex without its hooks, any catalog tool) is never stamped. A row's dot is the session's state (`sessionDot`: amber waiting for you, red failed, green a live pane, hollow at rest); double-click renames it (`PATCH {name}`), and its hover button **archives** it (`PATCH {archived: true}` — hidden from the workspace, nothing deleted; the home lists the archived ones folded with **Unarchive**). A filled dot marks a session that still has a live pane (a `GET /api/run` handle carrying its session id — every pane the dashboard starts carries `AGENTHUB_RUN_ID=<its tmux name>`, the hook handler forwards it as `run_id`, and `bindRunSession` in `server/routes/hooks.js` records the hook's session on that run: the first session always, afterwards — for a Claude pane — only a `SessionStart` that is not a fresh `startup` (`/clear`, `/resume`), so a `claude -p` the agent runs inside the pane never takes it over; the rule is Claude-only, and a Codex or Antigravity pane follows every new session): clicking it opens `/workspace?lane=<id>&run=<runId>`, which ATTACHES pane 0 to that tmux session (`attachRequest` on `LaneConsolePane`) — it starts nothing and resumes nothing. Ctrl/Cmd+click adds `&pane=1`: the session view attaches pane 1 instead, widening a one-pane layout to two once for that request. Any other session opens its read-only transcript at `/sessions/<id>`; resuming it is a separate, explicit action there, because a resume re-reads the whole transcript into context.
|
- **A workspace's sessions** — the chevron expands the row to the sessions the workspace OWNS and has not archived (`WorkspaceSessionList.tsx`: `GET /api/sessions?lane=<id>&archived=0`, the newest 20, re-read when the workspace's status or session moves; which rows are expanded is kept in `agenthub.sidebar.projects` → `expanded`). A workspace owns a session its pane held — `bindRunSession` stamps `sessions.lane_id` from the pane's run, first writer wins, a nested `claude -p` excluded — and lists nothing else: a `claude` typed into a terminal in the same folder, or history from before the workspace existed, belongs to no workspace and is found on the Sessions page. Sessions older than this ownership column are not guessed: only those `dashboard_runs` still named were backfilled. A session started by a tool with no hooks (Codex without its hooks, any catalog tool) is never stamped. A row's dot is the session's state (`sessionDot`: amber waiting for you, red failed, green a live pane, hollow at rest); double-click renames it (`PATCH {name}`), and its hover button **archives** it (`PATCH {archived: true}` — hidden from the workspace, nothing deleted; the home lists the archived ones folded with **Unarchive**). A filled dot marks a session that still has a live pane (a `GET /api/run` handle carrying its session id — every pane the dashboard starts carries `AGENTHUB_RUN_ID=<its tmux name>`, the hook handler forwards it as `run_id`, and `bindRunSession` in `server/routes/hooks.js` records the hook's session on that run: the first session always, afterwards — for a Claude pane — only a `SessionStart` that is not a fresh `startup` (`/clear`, `/resume`), so a `claude -p` the agent runs inside the pane never takes it over; the rule is Claude-only, and a Codex or Antigravity pane follows every new session): clicking it opens `/workspace?lane=<id>&run=<runId>`, which ATTACHES pane 0 to that tmux session (`attachRequest` on `LaneConsolePane`) — it starts nothing and resumes nothing. Ctrl/Cmd+click adds `&pane=1`: the session view attaches pane 1 instead, widening a one-pane layout to two once for that request. Any other session opens its read-only transcript at `/sessions/<id>`; resuming it is a separate, explicit action there, because a resume re-reads the whole transcript into context.
|
||||||
- **Row menu** — right-click a row, or its `⋯` button: Start/Stop (slot 0), Rename, the Model (AI Router) select, Create/update readme file (Claude only), New workspace here…, Copy path, Reset worktree… (managed only) and Delete workspace…, each the call the rest of the UI already makes. Delete is record-only exactly as before (see "Removing a workspace").
|
- **Row menu** — right-click a row, or its `⋯` button: Start/Stop (slot 0), Rename, the Model (AI Router) select, Create/update readme file (Claude only), New workspace here…, Copy path, Reset worktree… (managed only) and Delete workspace…, each the call the rest of the UI already makes. Delete is record-only exactly as before (see "Removing a workspace").
|
||||||
- **The workspace home** — `/workspace?lane=<id>` opens on the workspace's home (`client/src/components/lanes/WorkspaceHome.tsx`), shaped like a Claude Desktop project page: the name (rename from its `⋯` menu, which is the sidebar row menu) with the repository it belongs to under it (root, the main worktree's branch, the worktree count, from `GET /api/projects` — the facts Settings → Projects used to show), a prompt box with the tool's model picker (and Claude's permission mode) whose **Start** opens a NEW session in the first pane slot with no live run (0–3, `POST /api/lanes/:id/start {slot}`; all four busy is refused) and then shows it, and **Recents** — the workspace's own sessions (`?lane=<id>`) with their cost and time, a live one opening its pane and any other its read-only transcript, exactly like the sidebar. The transcript page (`/sessions/<id>`) of a Claude session started in a workspace's own folder carries an explicit **Resume** (`client/src/components/lanes/SessionResumeButton.tsx`, the one place that resumes) and beside it **Fork**, which resumes the history into a NEW session id (`{forkSession: true}` → `--fork-session`, the original untouched, offered even while the original has a live pane): it warns that a resume re-reads the whole transcript into context, re-reads the live panes, then starts it in the workspace's first free pane (`POST /api/lanes/:id/start {slot, resumeSessionId}`) and opens it. A session from a SUBFOLDER gets no Resume — the pane starts in the workspace's folder and Claude finds a session by the folder it ran in — and a session that still has a live pane shows **Open pane** instead. On the right sit the workspace's own context: **Instructions** (appended to every new session's system prompt), **Memory** (read-only: the folder's CLAUDE.md / AGENTS.md family and Claude's auto memory), **Context** (uploaded files, ≤ 10 MB each, stored by AgentHub) and **Folder** (the workspace's own folder plus extra ones, with the repo's untracked worktrees suggested). Only Claude and Codex panes can take them (`--add-dir`, `--append-system-prompt-file=` / `-c developer_instructions=`); for any other tool the page says so and its panes start without them. See `docs/API.md` → Workspace context. The home HIDES the session view with CSS — the console never unmounts behind it.
|
- **The workspace home** — `/workspace?lane=<id>` opens on the workspace's home (`client/src/components/lanes/WorkspaceHome.tsx`), shaped like a Claude Desktop project page: the name (rename from its `⋯` menu, which is the sidebar row menu) with the repository it belongs to under it (root, the main worktree's branch, the worktree count, from `GET /api/projects` — the facts Settings → Projects used to show), a prompt box with the tool's model picker (and Claude's permission mode) whose **Start** opens a NEW session in the first pane slot with no live run (0–3, `POST /api/lanes/:id/start {slot}`; all four busy is refused) and then shows it, and **Recents** — the workspace's own sessions (`?lane=<id>`) with their cost and time, a live one opening its pane and any other its read-only transcript, exactly like the sidebar. The transcript page (`/sessions/<id>`) of a Claude session started in a workspace's own folder carries an explicit **Resume** (`client/src/components/lanes/SessionResumeButton.tsx`, the one place that resumes) and beside it **Fork**, which resumes the history into a NEW session id (`{forkSession: true}` → `--fork-session`, the original untouched, offered even while the original has a live pane): it warns that a resume re-reads the whole transcript into context, re-reads the live panes, then starts it in the workspace's first free pane (`POST /api/lanes/:id/start {slot, resumeSessionId}`) and opens it. A session from a SUBFOLDER gets no Resume — the pane starts in the workspace's folder and Claude finds a session by the folder it ran in — and a session that still has a live pane shows **Open pane** instead. On the right sit the workspace's own context: **Instructions** (appended to every new session's system prompt), **Memory** (read-only: the folder's CLAUDE.md / AGENTS.md family and Claude's auto memory), **Context** (uploaded files, ≤ 10 MB each, stored by AgentHub) and **Folder** (the workspace's own folder plus extra ones, with the repo's untracked worktrees suggested). Only Claude and Codex panes can take them (`--add-dir`, `--append-system-prompt-file=` / `-c developer_instructions=`); for any other tool the page says so and its panes start without them. See `docs/API.md` → Workspace context. The home HIDES the session view with CSS — the console never unmounts behind it.
|
||||||
|
- **A session in its own worktree** — Claude Desktop's worktree toggle: the home's prompt box has a **Worktree** checkbox (Claude workspaces in a git repo), and the new session's pane runs `claude --worktree=<name>`, so CLAUDE CODE makes `<repo>/.claude/worktrees/<name>` on branch `worktree-<name>`, copies `.worktreeinclude`, runs your `WorktreeCreate` hook, and on exit removes a worktree with no changes (or asks). AgentHub adds `/.claude/worktrees/` to the repo's `info/exclude` (Claude does not, and the main repo would list it). The session stays this workspace's own — listed with a branch icon and the worktree's name — and the session view's Changes panel works in ITS worktree (every git route with `?session=`, resolved from the session row: owned by the workspace, registered by `git worktree list`; `ENOWORKTREE` otherwise, never the main folder). Resume and Fork start in that worktree. **Archive** of a worktree session with no live pane asks first and removes the worktree like Claude Desktop — `git worktree remove` without force (git refuses unsaved work, and then nothing is archived) and `git branch -d` (unmerged work keeps its branch); a lock held by a live process refuses, a stale one is cleared. The workspace's own folder is never removed.
|
||||||
- **The session view** — `/workspace?lane=<id>&run=<runId>` (a live session picked in the sidebar or Recents, or one just started) shows today's console, split panes and the Files / Changes panel, with pane 0 attached to that run; the header's back arrow returns to the home. A browser notification for a workspace with a live pane opens it here directly.
|
- **The session view** — `/workspace?lane=<id>&run=<runId>` (a live session picked in the sidebar or Recents, or one just started) shows today's console, split panes and the Files / Changes panel, with pane 0 attached to that run; the header's back arrow returns to the home. A browser notification for a workspace with a live pane opens it here directly.
|
||||||
- **Untracked worktrees** are no longer listed in the sidebar; Settings → Git → **Worktrees** (`showUntracked`, hidden sources `claude` = `<repo>/.claude/worktrees/*` and `agenthub` = `LANES_ROOT`) now decides which of them the home's Add folder suggests.
|
- **Untracked worktrees** are no longer listed in the sidebar; Settings → Git → **Worktrees** (`showUntracked`, hidden sources `claude` = `<repo>/.claude/worktrees/*` and `agenthub` = `LANES_ROOT`) now decides which of them the home's Add folder suggests.
|
||||||
- **No workspaces yet** — the page shows a single banner (`lane-empty`): icon, one line of what a workspace is, the CLI equivalent (`agenthub lanes add --cwd $(pwd)`) and an **Add workspace** affordance. The whole banner IS the button — clicking anywhere on it opens the Add-workspace modal. The banner stays compact on purpose: the run setup form below it is what needs the height.
|
- **No workspaces yet** — the page shows a single banner (`lane-empty`): icon, one line of what a workspace is, the CLI equivalent (`agenthub lanes add --cwd $(pwd)`) and an **Add workspace** affordance. The whole banner IS the button — clicking anywhere on it opens the Add-workspace modal. The banner stays compact on purpose: the run setup form below it is what needs the height.
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
# Desktop parity — what the workspace still lacks next to Claude Desktop / Codex app
|
# Desktop parity — what the workspace still lacks next to Claude Desktop / Codex app
|
||||||
|
|
||||||
_Status: written 2026-10-02. Phases 1–5 and 7 implemented 2026-10-02
|
_Status: written 2026-10-02. Phases 1–5 and 7 implemented and committed
|
||||||
(uncommitted); phase 6 waits on decision D4. Phases run in order, each one change-set on `main`.
|
(a6bd33b); phase 6 implemented 2026-10-02 the Claude way (D4). Phases run in order, each one change-set on `main`.
|
||||||
Comes BEFORE `docs/plans/multi-user-server/` — that plan's owner column hangs
|
Comes BEFORE the multi-user server plan (now in the AgentHub-Server repo,
|
||||||
|
https://git.smartgift.io.vn/Smartgift-AI/AgentHub-Server) — that plan's owner column hangs
|
||||||
off the session ownership built in phase 1 here._
|
off the session ownership built in phase 1 here._
|
||||||
|
|
||||||
## Where we stand
|
## Where we stand
|
||||||
@@ -36,7 +37,7 @@ archived, and give a session — not a project — its own worktree.
|
|||||||
| D1 | Sessions in the folder that the workspace did not start | **Not listed** (user's call, 2026-10-02): a workspace shows only its own sessions; the rest stay on the Sessions page. The fold and `?unowned=` / `PATCH {lane_id}` built first were removed again. |
|
| D1 | Sessions in the folder that the workspace did not start | **Not listed** (user's call, 2026-10-02): a workspace shows only its own sessions; the rest stay on the Sessions page. The fold and `?unowned=` / `PATCH {lane_id}` built first were removed again. |
|
||||||
| D2 | What "belongs to a workspace" means | `sessions.lane_id` = the lane whose pane held the session (stamped from the hook's `run_id`), or one the user added by hand. First writer wins; only the user moves it. |
|
| D2 | What "belongs to a workspace" means | `sessions.lane_id` = the lane whose pane held the session (stamped from the hook's `run_id`), or one the user added by hand. First writer wins; only the user moves it. |
|
||||||
| D3 | Old history | Not guessed. Only the sessions `dashboard_runs` still names are backfilled; everything older is not listed in any workspace. |
|
| D3 | Old history | Not guessed. Only the sessions `dashboard_runs` still names are backfilled; everything older is not listed in any workspace. |
|
||||||
| D4 | Per-session worktree (phase 6) | A **child workspace**: a worktree lane with `parent_lane_id`, listed under its parent in the sidebar. Reuses every lane rule (git surface, destroy guard) instead of giving a session its own cwd. Confirm with the user before phase 6. |
|
| D4 | Per-session worktree (phase 6) | **Claude's model** (user's call, 2026-10-02): the pane runs `claude --worktree=<name>`, so Claude Code itself creates `<repo>/.claude/worktrees/<name>` on branch `worktree-<name>`, copies `.worktreeinclude`, runs the user's `WorktreeCreate` hook and cleans up on exit. The session stays the workspace's own (no child workspace). Archive removes a CLEAN worktree, like Claude Desktop. |
|
||||||
| D5 | Tool chosen per session instead of per workspace | **Out of scope here.** `lanes.tool` stays immutable. Revisit with the user after phase 6. |
|
| D5 | Tool chosen per session instead of per workspace | **Out of scope here.** `lanes.tool` stays immutable. Revisit with the user after phase 6. |
|
||||||
| D6 | Environments (SSH/cloud), Routines/Automations, cross-session messaging | **Not built.** AgentHub runs locally and does not orchestrate (`CLAUDE.md`). |
|
| D6 | Environments (SSH/cloud), Routines/Automations, cross-session messaging | **Not built.** AgentHub runs locally and does not orchestrate (`CLAUDE.md`). |
|
||||||
|
|
||||||
@@ -121,15 +122,48 @@ Ctrl/Cmd+click on a live session row → `/workspace?lane=<id>&run=<runId>&pane=
|
|||||||
`Workspace.tsx` switches a 1-pane layout to 2 and hands `attachRequest` to that
|
`Workspace.tsx` switches a 1-pane layout to 2 and hands `attachRequest` to that
|
||||||
pane instead of pane 0. A plain click is unchanged.
|
pane instead of pane 0. A plain click is unchanged.
|
||||||
|
|
||||||
### Phase 6 — worktree per session (needs D4 confirmed)
|
### Phase 6 — worktree per session, Claude's way
|
||||||
|
|
||||||
- `lanes.parent_lane_id INTEGER` (nullable).
|
Verified 2026-10-02 on Claude Code 2.1.286: `claude -w probe1` makes
|
||||||
- Home prompt box: a **Worktree** toggle (git repos only). Run → `POST
|
`<repo>/.claude/worktrees/probe1` on `worktree-probe1` (from origin/HEAD, or
|
||||||
/api/lanes/worktree` with a generated branch + `parent_lane_id`, then start the
|
`worktree.baseRef`), locks it with `claude session probe1 (pid …)`, and its
|
||||||
prompt in the child's slot 0.
|
hooks report that worktree as `cwd`. It does NOT exclude `.claude/worktrees/`,
|
||||||
- Sidebar: a child lane is listed under its parent's sessions, not as a
|
so the main repo shows `?? .claude/`.
|
||||||
top-level row. Archive on it = the existing Remove (record only — the
|
|
||||||
worktree stays the user's, per the remove rule) plus a hint to delete it.
|
- **Start:** the home prompt box gets a **Worktree** checkbox (Claude
|
||||||
|
workspaces in a git repo). `POST /api/lanes/:id/start {slot, worktree: true}`
|
||||||
|
→ `spawnRun` puts `--worktree=<name>` in the argv (`=` form: `-w [name]`
|
||||||
|
takes an optional value and would eat the prompt as the name); the name is
|
||||||
|
server-made (`ah-<lane>-<base36 time>`), never from the request. Claude only
|
||||||
|
(`EUNSUPPORTED` elsewhere). `.claude/worktrees/` goes into the common git
|
||||||
|
dir's `info/exclude` at the repo root (`excludeFromGit`).
|
||||||
|
`dashboard_runs.worktree` = 1 marks the run.
|
||||||
|
- **Which worktree a session is in:** `server/lib/session-worktree.js`
|
||||||
|
`sessionWorktree(lane, sessionId)` → `{path, branch, locked}` only when the
|
||||||
|
session is OWNED by the lane, its `cwd` is not the lane's own folder, and
|
||||||
|
`git worktree list` of the lane's repo registers that path. Nothing else
|
||||||
|
turns a request into a path.
|
||||||
|
- **Git panel:** every `/:id/git*` route takes `?session=<id>`; with it the
|
||||||
|
route works in that session's worktree, and answers `400 ENOWORKTREE`
|
||||||
|
instead of falling back to the main folder (a commit must never land in the
|
||||||
|
wrong tree). The session view passes the session of the attached run when
|
||||||
|
that run is a worktree run.
|
||||||
|
- **Resume / Fork** of a worktree session start the pane in its worktree;
|
||||||
|
one whose worktree is gone is refused (`ENOWORKTREE`) — Claude finds a
|
||||||
|
session by the folder it ran in.
|
||||||
|
- **Sidebar / Recents:** a worktree session shows a branch icon and its
|
||||||
|
branch in the tooltip.
|
||||||
|
- **Archive = remove the worktree** (Claude Desktop): for a worktree session
|
||||||
|
with no live pane the archive button asks first, then
|
||||||
|
`POST /api/lanes/:id/session-worktree/remove {session, confirm: true}` runs
|
||||||
|
`git worktree remove <path>` WITHOUT force (git refuses a dirty tree) and
|
||||||
|
`git branch -d` (refuses unmerged work — the branch is then kept and
|
||||||
|
reported); a lock held by a live process is refused (`ELOCKED`), a stale
|
||||||
|
one (dead pid) unlocked first. Only after that succeeds is the session
|
||||||
|
archived. The workspace's own folder and any worktree no session of this
|
||||||
|
workspace ran in are never touched.
|
||||||
|
- **Not built:** auto-archive on PR merge (no PR integration), a branch prefix
|
||||||
|
setting (the CLI has none), worktrees for Codex.
|
||||||
|
|
||||||
### Phase 7 — diff comment to prompt (optional)
|
### Phase 7 — diff comment to prompt (optional)
|
||||||
|
|
||||||
|
|||||||
@@ -1841,3 +1841,96 @@ describe("/api/lanes tool tests", () => {
|
|||||||
await request("DELETE", `/api/lanes/${codexId}`);
|
await request("DELETE", `/api/lanes/${codexId}`);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("a session's own worktree (claude --worktree)", () => {
|
||||||
|
const { execFileSync } = require("child_process");
|
||||||
|
const tmux = require("../lib/tmux");
|
||||||
|
const { db } = require("../db");
|
||||||
|
// Inside a git hook GIT_DIR / GIT_INDEX_FILE name the repo being committed.
|
||||||
|
const gitEnv = Object.fromEntries(
|
||||||
|
Object.entries(process.env).filter(([k]) => !k.startsWith("GIT_"))
|
||||||
|
);
|
||||||
|
const gitIn = (dir, ...args) =>
|
||||||
|
execFileSync("git", ["-C", dir, "-c", "user.name=t", "-c", "user.email=t@t", ...args], {
|
||||||
|
encoding: "utf8",
|
||||||
|
env: gitEnv,
|
||||||
|
});
|
||||||
|
let repo, wtDir, laneId;
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
repo = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "lane-session-wt-")));
|
||||||
|
gitIn(repo, "init", "-q", "-b", "main");
|
||||||
|
fs.writeFileSync(path.join(repo, "a.txt"), "a\n");
|
||||||
|
gitIn(repo, "add", ".");
|
||||||
|
gitIn(repo, "commit", "-qm", "init");
|
||||||
|
gitIn(repo, "worktree", "add", "-q", "-b", "worktree-x", ".claude/worktrees/x");
|
||||||
|
wtDir = path.join(repo, ".claude", "worktrees", "x");
|
||||||
|
laneId = (await request("POST", "/api/lanes", { cwd: repo })).body.lane.id;
|
||||||
|
db.prepare(
|
||||||
|
"INSERT INTO sessions (id, name, status, cwd, lane_id, started_at, updated_at) VALUES ('wt-sess-1', 'w', 'completed', ?, ?, '2026-10-02T00:00:00Z', '2026-10-02T00:00:00Z')"
|
||||||
|
).run(wtDir, laneId);
|
||||||
|
});
|
||||||
|
after(() => fs.rmSync(repo, { recursive: true, force: true }));
|
||||||
|
|
||||||
|
it("reads and writes git in the session's worktree, never falling back", async () => {
|
||||||
|
fs.writeFileSync(path.join(wtDir, "only-in-wt.txt"), "w\n");
|
||||||
|
const inWt = await request("GET", `/api/lanes/${laneId}/git/changes?session=wt-sess-1`);
|
||||||
|
assert.ok(inWt.body.files.some((f) => f.path === "only-in-wt.txt"));
|
||||||
|
const facts = await request("GET", `/api/lanes/${laneId}/git?session=wt-sess-1`);
|
||||||
|
assert.equal(facts.body.branch, "worktree-x");
|
||||||
|
assert.equal(facts.body.worktree.branch, "worktree-x");
|
||||||
|
const main = await request("GET", `/api/lanes/${laneId}/git/changes`);
|
||||||
|
assert.ok(!main.body.files.some((f) => f.path === "only-in-wt.txt"));
|
||||||
|
const bad = await request("POST", `/api/lanes/${laneId}/git/stage?session=nope`, {
|
||||||
|
paths: ["only-in-wt.txt"],
|
||||||
|
});
|
||||||
|
assert.equal(bad.status, 400);
|
||||||
|
assert.equal(bad.body.error.code, "ENOWORKTREE");
|
||||||
|
const ok = await request("POST", `/api/lanes/${laneId}/git/stage?session=wt-sess-1`, {
|
||||||
|
paths: ["only-in-wt.txt"],
|
||||||
|
});
|
||||||
|
assert.equal(ok.status, 200);
|
||||||
|
assert.match(gitIn(wtDir, "diff", "--cached", "--name-only"), /only-in-wt\.txt/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resumes a worktree session in its worktree", async () => {
|
||||||
|
let cwd = null;
|
||||||
|
tmux.__setExecImpl((args) => {
|
||||||
|
if (args[0] === "has-session") throw Object.assign(new Error("gone"), { status: 1 });
|
||||||
|
if (args[0] === "new-session") cwd = args[args.indexOf("-c") + 1];
|
||||||
|
return "";
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const r = await request("POST", `/api/lanes/${laneId}/start`, {
|
||||||
|
slot: 2,
|
||||||
|
resumeSessionId: "wt-sess-1",
|
||||||
|
});
|
||||||
|
assert.equal(r.status, 200);
|
||||||
|
assert.equal(cwd, wtDir);
|
||||||
|
} finally {
|
||||||
|
tmux.__reset();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes it only when asked, and only when git agrees it is clean", async () => {
|
||||||
|
const url = `/api/lanes/${laneId}/session-worktree/remove`;
|
||||||
|
let r = await request("POST", url, { session: "wt-sess-1" });
|
||||||
|
assert.equal(r.body.error.code, "ECONFIRM");
|
||||||
|
r = await request("POST", url, { session: "wt-sess-1", confirm: true });
|
||||||
|
assert.equal(r.status, 409); // a staged file: git refuses
|
||||||
|
assert.ok(fs.existsSync(wtDir));
|
||||||
|
gitIn(wtDir, "reset", "-q");
|
||||||
|
fs.rmSync(path.join(wtDir, "only-in-wt.txt"));
|
||||||
|
r = await request("POST", url, { session: "wt-sess-1", confirm: true });
|
||||||
|
assert.equal(r.status, 200);
|
||||||
|
assert.deepEqual(r.body, { removed: true, branch: "worktree-x", branchKept: false });
|
||||||
|
assert.equal(fs.existsSync(wtDir), false);
|
||||||
|
// Its worktree is gone: nothing to resume it in.
|
||||||
|
r = await request("POST", `/api/lanes/${laneId}/start`, {
|
||||||
|
slot: 3,
|
||||||
|
resumeSessionId: "wt-sess-1",
|
||||||
|
});
|
||||||
|
assert.equal(r.status, 400);
|
||||||
|
assert.equal(r.body.error.code, "ENOWORKTREE");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -230,6 +230,43 @@ describe("pty-run", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("spawnRun starts a Claude session in its own worktree, Claude's way", () => {
|
||||||
|
let newSessionArgv = null;
|
||||||
|
tmux.__setExecImpl((args) => {
|
||||||
|
if (args[0] === "has-session") {
|
||||||
|
const e = new Error("gone");
|
||||||
|
e.status = 1;
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
if (args[0] === "new-session") newSessionArgv = args;
|
||||||
|
return "";
|
||||||
|
});
|
||||||
|
const repo = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "pty-wt-")));
|
||||||
|
// Inside a git hook GIT_DIR names the repo being committed; drop it.
|
||||||
|
const env = Object.fromEntries(
|
||||||
|
Object.entries(process.env).filter(([k]) => !k.startsWith("GIT_"))
|
||||||
|
);
|
||||||
|
require("node:child_process").execFileSync("git", ["init", "-q", repo], { env });
|
||||||
|
try {
|
||||||
|
pty.spawnRun({ laneId: 41, cwd: repo, worktree: true, initialPrompt: "fix it" });
|
||||||
|
// `=` form: `-w [name]` takes an optional value and would eat the prompt.
|
||||||
|
const flag = newSessionArgv.find((a) => a.startsWith("--worktree="));
|
||||||
|
assert.match(flag, /^--worktree=ah-41-[a-z0-9]+$/);
|
||||||
|
assert.equal(newSessionArgv.at(-1), "fix it");
|
||||||
|
// Claude does not exclude its worktrees; the main repo would list them.
|
||||||
|
const exclude = fs.readFileSync(path.join(repo, ".git", "info", "exclude"), "utf8");
|
||||||
|
assert.ok(exclude.split("\n").includes("/.claude/worktrees/"), exclude);
|
||||||
|
const dashboardRuns = require("../lib/dashboard-runs");
|
||||||
|
assert.equal(dashboardRuns.getRun("agenthub-lane-41").worktree, 1);
|
||||||
|
assert.throws(
|
||||||
|
() => pty.spawnRun({ laneId: 42, cwd: repo, tool: "codex", worktree: true }),
|
||||||
|
(e) => e.code === "EUNSUPPORTED"
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(repo, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("spawnRun offers bypass in the pane's shift+tab cycle without starting in it", () => {
|
it("spawnRun offers bypass in the pane's shift+tab cycle without starting in it", () => {
|
||||||
let newSessionArgv = null;
|
let newSessionArgv = null;
|
||||||
tmux.__setExecImpl((args) => {
|
tmux.__setExecImpl((args) => {
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
/**
|
||||||
|
* @file Tests for a session's own worktree (`server/lib/session-worktree.js`):
|
||||||
|
* it is resolved only for a session the workspace OWNS whose cwd git registers
|
||||||
|
* as a worktree of the workspace's repo, and removing it never forces — git
|
||||||
|
* refuses a dirty tree, `branch -d` keeps unmerged work, a lock held by a live
|
||||||
|
* process is refused and a stale one cleared first.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { describe, it, before, after } = require("node:test");
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const os = require("node:os");
|
||||||
|
const path = require("node:path");
|
||||||
|
const { execFileSync } = require("node:child_process");
|
||||||
|
|
||||||
|
const ROOT = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "session-wt-")));
|
||||||
|
process.env.DASHBOARD_DB_PATH = path.join(ROOT, "t.db");
|
||||||
|
|
||||||
|
const REPO = path.join(ROOT, "repo");
|
||||||
|
// A git hook (the pre-commit suite) exports GIT_DIR / GIT_INDEX_FILE for the
|
||||||
|
// repo being committed; inherited, every git call below would act on THAT repo.
|
||||||
|
const ENV = Object.fromEntries(Object.entries(process.env).filter(([k]) => !k.startsWith("GIT_")));
|
||||||
|
const git = (...args) =>
|
||||||
|
execFileSync("git", ["-C", REPO, ...args], {
|
||||||
|
encoding: "utf8",
|
||||||
|
env: {
|
||||||
|
...ENV,
|
||||||
|
GIT_AUTHOR_NAME: "t",
|
||||||
|
GIT_AUTHOR_EMAIL: "t@t",
|
||||||
|
GIT_COMMITTER_NAME: "t",
|
||||||
|
GIT_COMMITTER_EMAIL: "t@t",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let db;
|
||||||
|
let wt;
|
||||||
|
let lane;
|
||||||
|
|
||||||
|
function addWorktree(name) {
|
||||||
|
git("worktree", "add", "-q", "-b", `worktree-${name}`, `.claude/worktrees/${name}`);
|
||||||
|
return path.join(REPO, ".claude", "worktrees", name);
|
||||||
|
}
|
||||||
|
|
||||||
|
function session(id, cwd, laneId) {
|
||||||
|
db.prepare(
|
||||||
|
"INSERT INTO sessions (id, name, status, cwd, lane_id, started_at, updated_at) VALUES (?, ?, 'active', ?, ?, '2026-10-02T00:00:00Z', '2026-10-02T00:00:00Z')"
|
||||||
|
).run(id, id, cwd, laneId);
|
||||||
|
}
|
||||||
|
|
||||||
|
before(() => {
|
||||||
|
fs.mkdirSync(REPO);
|
||||||
|
git("init", "-q", "-b", "main");
|
||||||
|
fs.writeFileSync(path.join(REPO, "a.txt"), "a\n");
|
||||||
|
git("add", ".");
|
||||||
|
git("commit", "-qm", "init");
|
||||||
|
({ db } = require("../db"));
|
||||||
|
wt = require("../lib/session-worktree");
|
||||||
|
const id = db
|
||||||
|
.prepare("INSERT INTO lanes (cwd, tool) VALUES (?, 'claude')")
|
||||||
|
.run(REPO).lastInsertRowid;
|
||||||
|
lane = db.prepare("SELECT * FROM lanes WHERE id = ?").get(id);
|
||||||
|
});
|
||||||
|
|
||||||
|
after(() => fs.rmSync(ROOT, { recursive: true, force: true }));
|
||||||
|
|
||||||
|
describe("sessionWorktree", () => {
|
||||||
|
it("resolves an owned session's registered worktree", async () => {
|
||||||
|
const dir = addWorktree("one");
|
||||||
|
session("s-one", dir, lane.id);
|
||||||
|
const found = await wt.sessionWorktree(lane, "s-one");
|
||||||
|
assert.equal(found.path, dir);
|
||||||
|
assert.equal(found.branch, "worktree-one");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a session it does not own, its own folder, and an unregistered path", async () => {
|
||||||
|
const dir = addWorktree("two");
|
||||||
|
session("s-other", dir, lane.id + 100);
|
||||||
|
session("s-root", REPO, lane.id);
|
||||||
|
fs.mkdirSync(path.join(REPO, "plain"));
|
||||||
|
session("s-plain", path.join(REPO, "plain"), lane.id);
|
||||||
|
for (const id of ["s-other", "s-root", "s-plain", "s-missing"]) {
|
||||||
|
await assert.rejects(wt.sessionWorktree(lane, id), (e) => e.code === "ENOWORKTREE", id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("removeSessionWorktree", () => {
|
||||||
|
it("asks for confirmation", async () => {
|
||||||
|
await assert.rejects(wt.removeSessionWorktree(lane, "s-one", {}), (e) => e.code === "ECONFIRM");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes a clean worktree and its merged branch", async () => {
|
||||||
|
const out = await wt.removeSessionWorktree(lane, "s-one", { confirm: true });
|
||||||
|
assert.deepEqual(out, { removed: true, branch: "worktree-one", branchKept: false });
|
||||||
|
assert.equal(fs.existsSync(path.join(REPO, ".claude/worktrees/one")), false);
|
||||||
|
assert.equal(git("branch", "--list", "worktree-one").trim(), "");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a dirty worktree and leaves it as it was", async () => {
|
||||||
|
const dir = addWorktree("dirty");
|
||||||
|
session("s-dirty", dir, lane.id);
|
||||||
|
fs.writeFileSync(path.join(dir, "a.txt"), "changed\n");
|
||||||
|
await assert.rejects(wt.removeSessionWorktree(lane, "s-dirty", { confirm: true }));
|
||||||
|
assert.equal(fs.readFileSync(path.join(dir, "a.txt"), "utf8"), "changed\n");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a branch that holds unmerged commits", async () => {
|
||||||
|
const dir = addWorktree("work");
|
||||||
|
session("s-work", dir, lane.id);
|
||||||
|
fs.writeFileSync(path.join(dir, "b.txt"), "b\n");
|
||||||
|
execFileSync("git", ["-C", dir, "add", "."], { env: ENV });
|
||||||
|
execFileSync(
|
||||||
|
"git",
|
||||||
|
["-C", dir, "-c", "user.name=t", "-c", "user.email=t@t", "commit", "-qm", "b"],
|
||||||
|
{ env: ENV }
|
||||||
|
);
|
||||||
|
const out = await wt.removeSessionWorktree(lane, "s-work", { confirm: true });
|
||||||
|
assert.equal(out.branchKept, true);
|
||||||
|
assert.equal(git("branch", "--list", "worktree-work").trim(), "worktree-work");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a lock held by a live process and clears a stale one", async () => {
|
||||||
|
const dir = addWorktree("locked");
|
||||||
|
session("s-locked", dir, lane.id);
|
||||||
|
git("worktree", "lock", "--reason", `claude session locked (pid ${process.pid} start 1)`, dir);
|
||||||
|
await assert.rejects(
|
||||||
|
wt.removeSessionWorktree(lane, "s-locked", { confirm: true }),
|
||||||
|
(e) => e.code === "ELOCKED"
|
||||||
|
);
|
||||||
|
git("worktree", "unlock", dir);
|
||||||
|
// A pid that cannot be alive: past the kernel's pid_max.
|
||||||
|
git("worktree", "lock", "--reason", "claude session locked (pid 2147483646 start 1)", dir);
|
||||||
|
const out = await wt.removeSessionWorktree(lane, "s-locked", { confirm: true });
|
||||||
|
assert.equal(out.removed, true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -575,6 +575,15 @@ try {
|
|||||||
db.prepare("ALTER TABLE dashboard_runs ADD COLUMN tmux_session TEXT").run();
|
db.prepare("ALTER TABLE dashboard_runs ADD COLUMN tmux_session TEXT").run();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Migrate: add `worktree` to dashboard_runs — 1 when the run's pane works in a
|
||||||
|
// git worktree of its own (`claude --worktree=<name>`, or a resume of such a
|
||||||
|
// session), so the session view points its git panel there.
|
||||||
|
try {
|
||||||
|
db.prepare("SELECT worktree FROM dashboard_runs LIMIT 1").get();
|
||||||
|
} catch {
|
||||||
|
db.prepare("ALTER TABLE dashboard_runs ADD COLUMN worktree INTEGER NOT NULL DEFAULT 0").run();
|
||||||
|
}
|
||||||
|
|
||||||
// Migrate: add stage-detection columns to lanes. Inference is never evidence —
|
// Migrate: add stage-detection columns to lanes. Inference is never evidence —
|
||||||
// these are additive columns separate from `stage` (the declared stage), so
|
// these are additive columns separate from `stage` (the declared stage), so
|
||||||
// `stage`'s meaning is untouched and turning the feature off loses nothing.
|
// `stage`'s meaning is untouched and turning the feature off loses nothing.
|
||||||
|
|||||||
@@ -32,11 +32,11 @@ const insertStmt = db.prepare(`
|
|||||||
INSERT OR REPLACE INTO dashboard_runs (
|
INSERT OR REPLACE INTO dashboard_runs (
|
||||||
id, session_id, mode, cwd, model, permission_mode, effort,
|
id, session_id, mode, cwd, model, permission_mode, effort,
|
||||||
resume_session_id, prompt_preview, status, exit_code, started_at, ended_at,
|
resume_session_id, prompt_preview, status, exit_code, started_at, ended_at,
|
||||||
lane_id, tool
|
lane_id, tool, worktree
|
||||||
) VALUES (
|
) VALUES (
|
||||||
@id, @session_id, @mode, @cwd, @model, @permission_mode, @effort,
|
@id, @session_id, @mode, @cwd, @model, @permission_mode, @effort,
|
||||||
@resume_session_id, @prompt_preview, @status, @exit_code, @started_at, @ended_at,
|
@resume_session_id, @prompt_preview, @status, @exit_code, @started_at, @ended_at,
|
||||||
@lane_id, @tool
|
@lane_id, @tool, @worktree
|
||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
@@ -51,7 +51,7 @@ const updateStmt = db.prepare(`
|
|||||||
|
|
||||||
const RUN_COLUMNS = `id, session_id, mode, cwd, model, permission_mode, effort,
|
const RUN_COLUMNS = `id, session_id, mode, cwd, model, permission_mode, effort,
|
||||||
resume_session_id, prompt_preview, status, exit_code,
|
resume_session_id, prompt_preview, status, exit_code,
|
||||||
started_at, ended_at, lane_id, tool`;
|
started_at, ended_at, lane_id, tool, worktree`;
|
||||||
|
|
||||||
const listStmt = db.prepare(`
|
const listStmt = db.prepare(`
|
||||||
SELECT ${RUN_COLUMNS}
|
SELECT ${RUN_COLUMNS}
|
||||||
@@ -114,6 +114,7 @@ function recordRun(handle) {
|
|||||||
ended_at: endedAt,
|
ended_at: endedAt,
|
||||||
lane_id: typeof handle.laneId === "number" ? handle.laneId : null,
|
lane_id: typeof handle.laneId === "number" ? handle.laneId : null,
|
||||||
tool: typeof handle.tool === "string" && handle.tool ? handle.tool : "claude",
|
tool: typeof handle.tool === "string" && handle.tool ? handle.tool : "claude",
|
||||||
|
worktree: handle.worktree ? 1 : 0,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Best-effort by design (a live run must never fail because sqlite did),
|
// Best-effort by design (a live run must never fail because sqlite did),
|
||||||
|
|||||||
@@ -58,4 +58,25 @@ function excludeFromGit(laneDir, relPaths) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { excludeFromGit };
|
/**
|
||||||
|
* The same, anchored at the REPOSITORY root rather than `dir` — for paths a
|
||||||
|
* tool creates at the root whatever subdirectory it was started in (Claude
|
||||||
|
* Code's `.claude/worktrees/`). Best effort, like `excludeFromGit`.
|
||||||
|
*/
|
||||||
|
function excludeFromRepoRoot(dir, relPaths) {
|
||||||
|
try {
|
||||||
|
const env = { ...process.env };
|
||||||
|
for (const name of REPO_VARS) delete env[name];
|
||||||
|
const root = execFileSync("git", ["rev-parse", "--show-toplevel"], {
|
||||||
|
cwd: dir,
|
||||||
|
env,
|
||||||
|
encoding: "utf8",
|
||||||
|
stdio: ["ignore", "pipe", "ignore"],
|
||||||
|
}).trim();
|
||||||
|
if (root) excludeFromGit(root, relPaths);
|
||||||
|
} catch {
|
||||||
|
/* not a repo */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { excludeFromGit, excludeFromRepoRoot };
|
||||||
|
|||||||
@@ -107,6 +107,7 @@ function buildClaudeArgv({
|
|||||||
effort,
|
effort,
|
||||||
resumeSessionId,
|
resumeSessionId,
|
||||||
forkSession = false,
|
forkSession = false,
|
||||||
|
worktreeName = null,
|
||||||
initialPrompt,
|
initialPrompt,
|
||||||
addDirs = [],
|
addDirs = [],
|
||||||
systemPromptFile = null,
|
systemPromptFile = null,
|
||||||
@@ -117,6 +118,11 @@ function buildClaudeArgv({
|
|||||||
// always ends it — after the prompt it would eat the prompt as a directory.
|
// always ends it — after the prompt it would eat the prompt as a directory.
|
||||||
if (addDirs.length) argv.push("--add-dir", ...addDirs);
|
if (addDirs.length) argv.push("--add-dir", ...addDirs);
|
||||||
argv.push("--permission-mode", permissionMode || "auto");
|
argv.push("--permission-mode", permissionMode || "auto");
|
||||||
|
// Claude Code makes the worktree itself (`.claude/worktrees/<name>`, branch
|
||||||
|
// `worktree-<name>`, `.worktreeinclude`, the user's WorktreeCreate hook).
|
||||||
|
// `=` form: `-w [name]` takes an OPTIONAL value and would otherwise swallow
|
||||||
|
// the prompt as the worktree's name.
|
||||||
|
if (worktreeName) argv.push(`--worktree=${worktreeName}`);
|
||||||
// From a file: the instructions can be long, and the whole argv has to fit
|
// From a file: the instructions can be long, and the whole argv has to fit
|
||||||
// in one tmux message. `=` form, so the path is never read as an option.
|
// in one tmux message. `=` form, so the path is never read as an option.
|
||||||
if (systemPromptFile) argv.push(`--append-system-prompt-file=${systemPromptFile}`);
|
if (systemPromptFile) argv.push(`--append-system-prompt-file=${systemPromptFile}`);
|
||||||
@@ -228,6 +234,10 @@ function buildArgv(args, entry) {
|
|||||||
* @param {string} [args.resumeSessionId]
|
* @param {string} [args.resumeSessionId]
|
||||||
* @param {boolean} [args.forkSession] resume `resumeSessionId` into a new
|
* @param {boolean} [args.forkSession] resume `resumeSessionId` into a new
|
||||||
* session id (`claude --fork-session`); Claude only.
|
* session id (`claude --fork-session`); Claude only.
|
||||||
|
* @param {boolean} [args.worktree] start a NEW Claude session in its own git
|
||||||
|
* worktree (`claude --worktree=<name>`, name made here); Claude only.
|
||||||
|
* @param {boolean} [args.inWorktree] `cwd` is a session's worktree (a resume
|
||||||
|
* into it, resolved by the caller from the session row) — recorded only.
|
||||||
* @param {boolean} [args.resumeLast]
|
* @param {boolean} [args.resumeLast]
|
||||||
* @param {boolean} [args.bypass] the user ticked "bypass permissions" for THIS
|
* @param {boolean} [args.bypass] the user ticked "bypass permissions" for THIS
|
||||||
* run; appends the catalog's bypass args/env. Claude has no catalog bypass.
|
* run; appends the catalog's bypass args/env. Claude has no catalog bypass.
|
||||||
@@ -248,6 +258,8 @@ function spawnRun(args) {
|
|||||||
approval,
|
approval,
|
||||||
resumeSessionId,
|
resumeSessionId,
|
||||||
forkSession,
|
forkSession,
|
||||||
|
worktree,
|
||||||
|
inWorktree,
|
||||||
resumeLast,
|
resumeLast,
|
||||||
bypass,
|
bypass,
|
||||||
initialPrompt,
|
initialPrompt,
|
||||||
@@ -300,6 +312,9 @@ function spawnRun(args) {
|
|||||||
) {
|
) {
|
||||||
throw makeErr("EBADSESSION", "resumeSessionId is not a valid session id");
|
throw makeErr("EBADSESSION", "resumeSessionId is not a valid session id");
|
||||||
}
|
}
|
||||||
|
if (worktree === true && entry.launcher !== "claude") {
|
||||||
|
throw makeErr("EUNSUPPORTED", `${tool} cannot start a session in its own worktree`);
|
||||||
|
}
|
||||||
if (forkSession === true) {
|
if (forkSession === true) {
|
||||||
if (!resumeSessionId) throw makeErr("EBADSESSION", "a fork needs resumeSessionId");
|
if (!resumeSessionId) throw makeErr("EBADSESSION", "a fork needs resumeSessionId");
|
||||||
if (entry.launcher !== "claude") {
|
if (entry.launcher !== "claude") {
|
||||||
@@ -324,6 +339,11 @@ function spawnRun(args) {
|
|||||||
|
|
||||||
const id = runIdForLane(laneId, slot);
|
const id = runIdForLane(laneId, slot);
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
|
// Claude Code does not exclude the worktrees it makes, so the main repo
|
||||||
|
// would list `.claude/` as untracked; the root's info/exclude hides them.
|
||||||
|
if (worktree === true) {
|
||||||
|
require("./git-exclude").excludeFromRepoRoot(cwd, [".claude/worktrees/"]);
|
||||||
|
}
|
||||||
// The workspace's own folders, context files and instructions — read from
|
// The workspace's own folders, context files and instructions — read from
|
||||||
// its rows here, the one place both spawn paths pass through, never from a
|
// its rows here, the one place both spawn paths pass through, never from a
|
||||||
// request (server/lib/lane-sources.js).
|
// request (server/lib/lane-sources.js).
|
||||||
@@ -344,6 +364,7 @@ function spawnRun(args) {
|
|||||||
approval,
|
approval,
|
||||||
resumeSessionId,
|
resumeSessionId,
|
||||||
forkSession: forkSession === true,
|
forkSession: forkSession === true,
|
||||||
|
worktreeName: worktree === true ? `ah-${laneId}-${Date.now().toString(36)}` : null,
|
||||||
resumeLast,
|
resumeLast,
|
||||||
bypass: bypass === true,
|
bypass: bypass === true,
|
||||||
initialPrompt,
|
initialPrompt,
|
||||||
@@ -372,6 +393,7 @@ function spawnRun(args) {
|
|||||||
permissionMode: permissionMode || "auto",
|
permissionMode: permissionMode || "auto",
|
||||||
effort: effort || null,
|
effort: effort || null,
|
||||||
resumeSessionId: resumeSessionId || null,
|
resumeSessionId: resumeSessionId || null,
|
||||||
|
worktree: worktree === true || inWorktree === true,
|
||||||
prompt: initialPrompt || "",
|
prompt: initialPrompt || "",
|
||||||
status: "running",
|
status: "running",
|
||||||
startedAt,
|
startedAt,
|
||||||
@@ -488,6 +510,8 @@ function publicRun(id) {
|
|||||||
effort: row?.effort || null,
|
effort: row?.effort || null,
|
||||||
resumeSessionId: row?.resume_session_id || null,
|
resumeSessionId: row?.resume_session_id || null,
|
||||||
sessionId: row?.session_id || null,
|
sessionId: row?.session_id || null,
|
||||||
|
// The pane works in a git worktree of its own (`claude --worktree`).
|
||||||
|
worktree: !!row?.worktree,
|
||||||
startedAt: row?.started_at || null,
|
startedAt: row?.started_at || null,
|
||||||
promptPreview: row?.prompt_preview || null,
|
promptPreview: row?.prompt_preview || null,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
/**
|
||||||
|
* @file The git worktree a workspace session runs in — Claude Code's own
|
||||||
|
* `claude --worktree=<name>` (`<repo>/.claude/worktrees/<name>` on branch
|
||||||
|
* `worktree-<name>`), started from a workspace pane. A session's worktree is
|
||||||
|
* resolved ONLY for a session the workspace owns (`sessions.lane_id`) whose
|
||||||
|
* recorded cwd is not the workspace's own folder and is registered by
|
||||||
|
* `git worktree list` of the workspace's repo: that is the whole authorization
|
||||||
|
* for a git route, a resume or a removal to work there, and nothing in a
|
||||||
|
* request is ever read as a path. Removal is Claude Desktop's "archive a
|
||||||
|
* worktree session" and never forces: `git worktree remove` refuses a dirty
|
||||||
|
* tree, `git branch -d` keeps unmerged work, a lock held by a live process is
|
||||||
|
* refused and only a stale one (dead pid) is cleared.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const { db } = require("../db");
|
||||||
|
const { git, listWorktrees } = require("./worktree");
|
||||||
|
|
||||||
|
const getSession = db.prepare("SELECT id, cwd, lane_id FROM sessions WHERE id = ?");
|
||||||
|
|
||||||
|
function makeErr(code, message) {
|
||||||
|
return Object.assign(new Error(message), { code });
|
||||||
|
}
|
||||||
|
|
||||||
|
const real = (p) => {
|
||||||
|
try {
|
||||||
|
return fs.realpathSync(p);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns {Promise<{path: string, branch: string|null, locked: boolean, lockReason: string|null}>}
|
||||||
|
* @throws ENOWORKTREE when the session is not this workspace's or not in a worktree of its repo.
|
||||||
|
*/
|
||||||
|
async function sessionWorktree(lane, sessionId) {
|
||||||
|
const none = () => makeErr("ENOWORKTREE", "that session has no worktree in this workspace");
|
||||||
|
const s = typeof sessionId === "string" && sessionId ? getSession.get(sessionId) : null;
|
||||||
|
if (!s || s.lane_id !== lane.id || typeof s.cwd !== "string" || !s.cwd) throw none();
|
||||||
|
const dir = real(s.cwd);
|
||||||
|
if (!dir || dir === real(lane.cwd)) throw none();
|
||||||
|
const found = (await listWorktrees(lane.cwd).catch(() => [])).find((w) => real(w.path) === dir);
|
||||||
|
if (!found) throw none();
|
||||||
|
return {
|
||||||
|
path: dir,
|
||||||
|
branch: found.branch || null,
|
||||||
|
locked: !!found.locked,
|
||||||
|
lockReason: found.lockReason || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A lock git can name a holder for (`… (pid N …)`) whose process is gone. */
|
||||||
|
function staleLock(reason) {
|
||||||
|
const pid = Number(/\bpid (\d+)\b/.exec(reason || "")?.[1]);
|
||||||
|
if (!pid) return false; // no holder to check: treat it as held
|
||||||
|
try {
|
||||||
|
process.kill(pid, 0);
|
||||||
|
return false;
|
||||||
|
} catch (err) {
|
||||||
|
return err.code === "ESRCH";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes a session's worktree and, when git agrees it is merged, its branch.
|
||||||
|
* @returns {Promise<{removed: true, branch: string|null, branchKept: boolean}>}
|
||||||
|
*/
|
||||||
|
async function removeSessionWorktree(lane, sessionId, { confirm } = {}) {
|
||||||
|
if (confirm !== true) throw makeErr("ECONFIRM", "removing a worktree needs confirm: true");
|
||||||
|
const wt = await sessionWorktree(lane, sessionId);
|
||||||
|
if (wt.locked) {
|
||||||
|
if (!staleLock(wt.lockReason)) {
|
||||||
|
throw makeErr("ELOCKED", "the worktree is locked by a running session — exit it first");
|
||||||
|
}
|
||||||
|
await git(lane.cwd, ["worktree", "unlock", wt.path]);
|
||||||
|
}
|
||||||
|
// No --force: git refuses modified or untracked files, which is the point.
|
||||||
|
await git(lane.cwd, ["worktree", "remove", wt.path]);
|
||||||
|
let branchKept = false;
|
||||||
|
if (wt.branch) {
|
||||||
|
// -d, never -D: a branch with commits merged nowhere stays.
|
||||||
|
branchKept = await git(lane.cwd, ["branch", "-d", "--", wt.branch]).then(
|
||||||
|
() => false,
|
||||||
|
() => true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { removed: true, branch: wt.branch, branchKept };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { sessionWorktree, removeSessionWorktree };
|
||||||
@@ -192,7 +192,7 @@ function slugify(text) {
|
|||||||
* Records are separated by blank lines, with keys like:
|
* Records are separated by blank lines, with keys like:
|
||||||
* - worktree <path>
|
* - worktree <path>
|
||||||
* - branch refs/heads/<name>
|
* - branch refs/heads/<name>
|
||||||
* - locked (optional, bare line)
|
* - locked [<reason>] (optional)
|
||||||
*/
|
*/
|
||||||
async function listWorktrees(sourceRepo) {
|
async function listWorktrees(sourceRepo) {
|
||||||
const result = await git(sourceRepo, ["worktree", "list", "--porcelain"]);
|
const result = await git(sourceRepo, ["worktree", "list", "--porcelain"]);
|
||||||
@@ -215,8 +215,11 @@ async function listWorktrees(sourceRepo) {
|
|||||||
const branchPath = line.slice("branch ".length);
|
const branchPath = line.slice("branch ".length);
|
||||||
// Strip refs/heads/ prefix
|
// Strip refs/heads/ prefix
|
||||||
current.branch = branchPath.replace(/^refs\/heads\//, "");
|
current.branch = branchPath.replace(/^refs\/heads\//, "");
|
||||||
} else if (line === "locked") {
|
} else if (line === "locked" || line.startsWith("locked ")) {
|
||||||
|
// `locked <reason>` when one was given (Claude Code locks its session
|
||||||
|
// worktrees with `claude session <name> (pid …)`).
|
||||||
current.locked = true;
|
current.locked = true;
|
||||||
|
if (line.length > "locked".length) current.lockReason = line.slice("locked ".length);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+137
-36
@@ -14,6 +14,7 @@ const os = require("node:os");
|
|||||||
const path = require("node:path");
|
const path = require("node:path");
|
||||||
const { db } = require("../db");
|
const { db } = require("../db");
|
||||||
const lanesLib = require("../lib/lanes");
|
const lanesLib = require("../lib/lanes");
|
||||||
|
const { sessionWorktree, removeSessionWorktree } = require("../lib/session-worktree");
|
||||||
const aiTools = require("../lib/ai-tools");
|
const aiTools = require("../lib/ai-tools");
|
||||||
const {
|
const {
|
||||||
listPipelines,
|
listPipelines,
|
||||||
@@ -834,11 +835,14 @@ router.get("/:id/integrations/:name", (req, res) => {
|
|||||||
* registration after it: the suite stayed green.
|
* registration after it: the suite stayed green.
|
||||||
*/
|
*/
|
||||||
router.get("/:id/git", async (req, res) => {
|
router.get("/:id/git", async (req, res) => {
|
||||||
const lane = lanesLib.getLane(req.params.id);
|
const t = await gitTargetOr404(req, res);
|
||||||
if (!lane)
|
if (!t) return;
|
||||||
return res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } });
|
|
||||||
try {
|
try {
|
||||||
res.json({ available: true, ...(await gitFacts(lane.cwd)) });
|
res.json({
|
||||||
|
available: true,
|
||||||
|
...(await gitFacts(t.cwd)),
|
||||||
|
...(t.worktree ? { worktree: t.worktree } : {}),
|
||||||
|
});
|
||||||
} catch {
|
} catch {
|
||||||
res.json({ available: false });
|
res.json({ available: false });
|
||||||
}
|
}
|
||||||
@@ -860,13 +864,14 @@ router.get("/:id/git", async (req, res) => {
|
|||||||
* GET /:id/git. See server/lib/git-diff.js for the caps.
|
* GET /:id/git. See server/lib/git-diff.js for the caps.
|
||||||
*/
|
*/
|
||||||
router.get("/:id/git/changes", async (req, res) => {
|
router.get("/:id/git/changes", async (req, res) => {
|
||||||
const lane = laneOr404(req, res);
|
const t = await gitTargetOr404(req, res);
|
||||||
if (!lane) return;
|
if (!t) return;
|
||||||
|
const { lane, cwd } = t;
|
||||||
const range = ["branch", "commit"].includes(req.query.range) ? req.query.range : "work";
|
const range = ["branch", "commit"].includes(req.query.range) ? req.query.range : "work";
|
||||||
const file = typeof req.query.path === "string" && req.query.path ? req.query.path : null;
|
const file = typeof req.query.path === "string" && req.query.path ? req.query.path : null;
|
||||||
const full = req.query.full === "1" || req.query.full === "true";
|
const full = req.query.full === "1" || req.query.full === "true";
|
||||||
try {
|
try {
|
||||||
const changes = await collectChanges(lane.cwd, {
|
const changes = await collectChanges(cwd, {
|
||||||
range,
|
range,
|
||||||
base: lane.base_branch,
|
base: lane.base_branch,
|
||||||
sha: typeof req.query.sha === "string" ? req.query.sha : null,
|
sha: typeof req.query.sha === "string" ? req.query.sha : null,
|
||||||
@@ -891,10 +896,11 @@ router.get("/:id/git/changes", async (req, res) => {
|
|||||||
* offers. Reading a commit's own diff is `GET /:id/git/changes?range=commit`.
|
* offers. Reading a commit's own diff is `GET /:id/git/changes?range=commit`.
|
||||||
*/
|
*/
|
||||||
router.get("/:id/git/log", async (req, res) => {
|
router.get("/:id/git/log", async (req, res) => {
|
||||||
const lane = laneOr404(req, res);
|
const t = await gitTargetOr404(req, res);
|
||||||
if (!lane) return;
|
if (!t) return;
|
||||||
|
const { lane, cwd } = t;
|
||||||
try {
|
try {
|
||||||
const commits = await listCommits(lane.cwd, {
|
const commits = await listCommits(cwd, {
|
||||||
limit: req.query.limit,
|
limit: req.query.limit,
|
||||||
file: typeof req.query.file === "string" && req.query.file ? req.query.file : null,
|
file: typeof req.query.file === "string" && req.query.file ? req.query.file : null,
|
||||||
});
|
});
|
||||||
@@ -915,11 +921,12 @@ router.get("/:id/git/log", async (req, res) => {
|
|||||||
* included). `.claude/settings.local.json` is 403 ESECRET at every rev.
|
* included). `.claude/settings.local.json` is 403 ESECRET at every rev.
|
||||||
*/
|
*/
|
||||||
router.get("/:id/git/file", async (req, res) => {
|
router.get("/:id/git/file", async (req, res) => {
|
||||||
const lane = laneOr404(req, res);
|
const t = await gitTargetOr404(req, res);
|
||||||
if (!lane) return;
|
if (!t) return;
|
||||||
|
const { lane, cwd } = t;
|
||||||
try {
|
try {
|
||||||
const file = await readFileAt(
|
const file = await readFileAt(
|
||||||
lane.cwd,
|
cwd,
|
||||||
typeof req.query.path === "string" ? req.query.path : "",
|
typeof req.query.path === "string" ? req.query.path : "",
|
||||||
typeof req.query.rev === "string" && req.query.rev ? req.query.rev : "work"
|
typeof req.query.rev === "string" && req.query.rev ? req.query.rev : "work"
|
||||||
);
|
);
|
||||||
@@ -942,12 +949,13 @@ router.get("/:id/git/file", async (req, res) => {
|
|||||||
* a `baseSha` stale check). Answers the file re-read, fresh `sha` included.
|
* a `baseSha` stale check). Answers the file re-read, fresh `sha` included.
|
||||||
*/
|
*/
|
||||||
router.put("/:id/git/file", sameOriginGuard, async (req, res) => {
|
router.put("/:id/git/file", sameOriginGuard, async (req, res) => {
|
||||||
const lane = laneOr404(req, res);
|
const t = await gitTargetOr404(req, res);
|
||||||
if (!lane) return;
|
if (!t) return;
|
||||||
|
const { cwd } = t;
|
||||||
const body = req.body || {};
|
const body = req.body || {};
|
||||||
try {
|
try {
|
||||||
await writeLaneFile(lane.cwd, body.path, body.content, body.baseSha);
|
await writeLaneFile(cwd, body.path, body.content, body.baseSha);
|
||||||
res.json({ available: true, ...(await readFileAt(lane.cwd, body.path, "work")) });
|
res.json({ available: true, ...(await readFileAt(cwd, body.path, "work")) });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const status =
|
const status =
|
||||||
{ ESECRET: 403, ESTALE: 409 }[err.code] ??
|
{ ESECRET: 403, ESTALE: 409 }[err.code] ??
|
||||||
@@ -958,10 +966,11 @@ router.put("/:id/git/file", sameOriginGuard, async (req, res) => {
|
|||||||
|
|
||||||
/** Tracked paths matching `?q=`, for quick-open. */
|
/** Tracked paths matching `?q=`, for quick-open. */
|
||||||
router.get("/:id/git/ls", async (req, res) => {
|
router.get("/:id/git/ls", async (req, res) => {
|
||||||
const lane = laneOr404(req, res);
|
const t = await gitTargetOr404(req, res);
|
||||||
if (!lane) return;
|
if (!t) return;
|
||||||
|
const { cwd } = t;
|
||||||
try {
|
try {
|
||||||
res.json({ available: true, ...(await listPaths(lane.cwd, { q: req.query.q })) });
|
res.json({ available: true, ...(await listPaths(cwd, { q: req.query.q })) });
|
||||||
} catch {
|
} catch {
|
||||||
res.json({ available: false, paths: [], truncated: false, total: 0 });
|
res.json({ available: false, paths: [], truncated: false, total: 0 });
|
||||||
}
|
}
|
||||||
@@ -969,10 +978,11 @@ router.get("/:id/git/ls", async (req, res) => {
|
|||||||
|
|
||||||
/** Every level of the tracked tree in one read, for "expand all folders". */
|
/** Every level of the tracked tree in one read, for "expand all folders". */
|
||||||
router.get("/:id/git/tree-all", async (req, res) => {
|
router.get("/:id/git/tree-all", async (req, res) => {
|
||||||
const lane = laneOr404(req, res);
|
const t = await gitTargetOr404(req, res);
|
||||||
if (!lane) return;
|
if (!t) return;
|
||||||
|
const { cwd } = t;
|
||||||
try {
|
try {
|
||||||
res.json({ available: true, ...(await listTreeAll(lane.cwd)) });
|
res.json({ available: true, ...(await listTreeAll(cwd)) });
|
||||||
} catch {
|
} catch {
|
||||||
res.json({ available: false, levels: {}, truncated: false });
|
res.json({ available: false, levels: {}, truncated: false });
|
||||||
}
|
}
|
||||||
@@ -980,11 +990,12 @@ router.get("/:id/git/tree-all", async (req, res) => {
|
|||||||
|
|
||||||
/** One directory level of the explorer tree (tracked + untracked-not-ignored), status-decorated. */
|
/** One directory level of the explorer tree (tracked + untracked-not-ignored), status-decorated. */
|
||||||
router.get("/:id/git/tree", async (req, res) => {
|
router.get("/:id/git/tree", async (req, res) => {
|
||||||
const lane = laneOr404(req, res);
|
const t = await gitTargetOr404(req, res);
|
||||||
if (!lane) return;
|
if (!t) return;
|
||||||
|
const { lane, cwd } = t;
|
||||||
try {
|
try {
|
||||||
const subdir = typeof req.query.path === "string" ? req.query.path : "";
|
const subdir = typeof req.query.path === "string" ? req.query.path : "";
|
||||||
res.json({ available: true, ...(await listTree(lane.cwd, { subdir })) });
|
res.json({ available: true, ...(await listTree(cwd, { subdir })) });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err.code === "EBADPATH") {
|
if (err.code === "EBADPATH") {
|
||||||
return res.status(400).json({ error: { code: err.code, message: err.message } });
|
return res.status(400).json({ error: { code: err.code, message: err.message } });
|
||||||
@@ -1000,11 +1011,12 @@ router.get("/:id/git/tree", async (req, res) => {
|
|||||||
* so no path ever comes from the request.
|
* so no path ever comes from the request.
|
||||||
*/
|
*/
|
||||||
router.get("/:id/git/branches", async (req, res) => {
|
router.get("/:id/git/branches", async (req, res) => {
|
||||||
const lane = laneOr404(req, res);
|
const t = await gitTargetOr404(req, res);
|
||||||
if (!lane) return;
|
if (!t) return;
|
||||||
|
const { lane, cwd } = t;
|
||||||
try {
|
try {
|
||||||
const { branches, current } = await listBranches(lane.cwd);
|
const { branches, current } = await listBranches(cwd);
|
||||||
const worktrees = await listWorktrees(lane.cwd).catch(() => []);
|
const worktrees = await listWorktrees(cwd).catch(() => []);
|
||||||
res.json({ available: true, branches, current, worktrees });
|
res.json({ available: true, branches, current, worktrees });
|
||||||
} catch {
|
} catch {
|
||||||
res.json({ available: false, branches: [], current: null, worktrees: [] });
|
res.json({ available: false, branches: [], current: null, worktrees: [] });
|
||||||
@@ -1022,19 +1034,20 @@ router.get("/:id/git/branches", async (req, res) => {
|
|||||||
* `git pull` is --ff-only and `switch` refuses a dirty tree, by design there.
|
* `git pull` is --ff-only and `switch` refuses a dirty tree, by design there.
|
||||||
*/
|
*/
|
||||||
router.post("/:id/git/:action", sameOriginGuard, async (req, res) => {
|
router.post("/:id/git/:action", sameOriginGuard, async (req, res) => {
|
||||||
const lane = laneOr404(req, res);
|
const t = await gitTargetOr404(req, res);
|
||||||
if (!lane) return;
|
if (!t) return;
|
||||||
|
const { lane, cwd } = t;
|
||||||
try {
|
try {
|
||||||
// Keyed on the INDEX, not the lane id: a claude and a codex lane on one
|
// Keyed on the INDEX, not the lane id: a claude and a codex lane on one
|
||||||
// cwd — or a root lane and a subdirectory lane of one repo — share one
|
// cwd — or a root lane and a subdirectory lane of one repo — share one
|
||||||
// index, and their writes would otherwise race on index.lock. The git dir
|
// index, and their writes would otherwise race on index.lock. The git dir
|
||||||
// is per worktree, so sibling worktree lanes still run in parallel; a
|
// is per worktree, so sibling worktree lanes still run in parallel; a
|
||||||
// cwd that is not a repo falls back to its real path.
|
// cwd that is not a repo falls back to its real path.
|
||||||
const gitDir = await git(lane.cwd, ["rev-parse", "--absolute-git-dir"])
|
const gitDir = await git(cwd, ["rev-parse", "--absolute-git-dir"])
|
||||||
.then((r) => r.stdout.trim())
|
.then((r) => r.stdout.trim())
|
||||||
.catch(() => fs.promises.realpath(lane.cwd).catch(() => lane.cwd));
|
.catch(() => fs.promises.realpath(cwd).catch(() => cwd));
|
||||||
const result = await withLaneLock(`git:${gitDir}`, () =>
|
const result = await withLaneLock(`git:${gitDir}`, () =>
|
||||||
runGitAction(lane.cwd, req.params.action, req.body || {})
|
runGitAction(cwd, req.params.action, req.body || {})
|
||||||
);
|
);
|
||||||
res.json(result);
|
res.json(result);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -1090,6 +1103,46 @@ router.post("/:id/git/:action", sameOriginGuard, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Claude Desktop's "archive a worktree session": remove the git worktree a
|
||||||
|
* session of this workspace ran in (`claude --worktree`), then its branch when
|
||||||
|
* git agrees it is merged. The session is named, never a path; the tree comes
|
||||||
|
* from `sessionWorktree`. Refused while a live pane holds the session
|
||||||
|
* (`ELIVE`), and every refusal git itself makes — a dirty tree, an unmerged
|
||||||
|
* branch (kept and reported) — stands: nothing here forces.
|
||||||
|
*/
|
||||||
|
router.post("/:id/session-worktree/remove", sameOriginGuard, async (req, res) => {
|
||||||
|
const lane = laneOr404(req, res);
|
||||||
|
if (!lane) return;
|
||||||
|
const sessionId = typeof req.body?.session === "string" ? req.body.session : "";
|
||||||
|
const held = runs
|
||||||
|
.listRuns()
|
||||||
|
.some((r) => r.laneId === lane.id && r.status === "running" && r.sessionId === sessionId);
|
||||||
|
if (held) {
|
||||||
|
return res.status(409).json({
|
||||||
|
error: { code: "ELIVE", message: "a pane is still running that session — exit it first" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
res.json(await removeSessionWorktree(lane, sessionId, { confirm: req.body?.confirm }));
|
||||||
|
} catch (err) {
|
||||||
|
// git ran and refused (a dirty tree): the repository answering, a 409.
|
||||||
|
const refused = !!err.git;
|
||||||
|
const status = ["ECONFIRM", "ENOWORKTREE"].includes(err.code)
|
||||||
|
? 400
|
||||||
|
: err.code === "ELOCKED" || refused
|
||||||
|
? 409
|
||||||
|
: 500;
|
||||||
|
const detail = refused ? (err.git.stderr || "").trim() : "";
|
||||||
|
res.status(status).json({
|
||||||
|
error: {
|
||||||
|
code: refused ? "EGIT" : err.code || "EGIT",
|
||||||
|
message: detail ? `${err.message}\n${detail}` : err.message,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A single GitHub Actions status snapshot for one ref (branch or commit sha)
|
* A single GitHub Actions status snapshot for one ref (branch or commit sha)
|
||||||
* in the lane's repo — NOT a poll loop. ship-feature-lane's Stage 13 owns
|
* in the lane's repo — NOT a poll loop. ship-feature-lane's Stage 13 owns
|
||||||
@@ -1422,6 +1475,28 @@ function gitErrorCode(err) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Resolve `:id` or answer 404. Returns null once the response has been sent. */
|
/** Resolve `:id` or answer 404. Returns null once the response has been sent. */
|
||||||
|
/**
|
||||||
|
* Which tree a git route works in: the workspace's own folder, or — with
|
||||||
|
* `?session=<id>` — that session's worktree (`claude --worktree`), resolved by
|
||||||
|
* `sessionWorktree` from the session ROW (owned by this workspace, registered
|
||||||
|
* by `git worktree list`), never from a path in the request. An unresolvable
|
||||||
|
* session is a 400, not a silent fall back to the main folder: a commit or a
|
||||||
|
* discard must never land in the wrong tree.
|
||||||
|
*/
|
||||||
|
async function gitTargetOr404(req, res) {
|
||||||
|
const lane = laneOr404(req, res);
|
||||||
|
if (!lane) return null;
|
||||||
|
const session = req.query.session;
|
||||||
|
if (session === undefined) return { lane, cwd: lane.cwd, worktree: null };
|
||||||
|
try {
|
||||||
|
const wt = await sessionWorktree(lane, String(session));
|
||||||
|
return { lane, cwd: wt.path, worktree: { path: wt.path, branch: wt.branch } };
|
||||||
|
} catch (err) {
|
||||||
|
res.status(400).json({ error: { code: err.code, message: err.message } });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function laneOr404(req, res) {
|
function laneOr404(req, res) {
|
||||||
const lane = lanesLib.getLane(req.params.id);
|
const lane = lanesLib.getLane(req.params.id);
|
||||||
if (!lane) {
|
if (!lane) {
|
||||||
@@ -1752,6 +1827,29 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
|
|||||||
error: { code: "EBADSLOT", message: `slot must be an integer 0–${runs.MAX_PANE_SLOT}` },
|
error: { code: "EBADSLOT", message: `slot must be an integer 0–${runs.MAX_PANE_SLOT}` },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
// A session that ran in a worktree of its own resumes (or forks) THERE:
|
||||||
|
// Claude finds a session by the folder it ran in. Resolved from the
|
||||||
|
// session row, before the lock; anything else starts as it always did.
|
||||||
|
let resumeWorktree = null;
|
||||||
|
if (typeof body.resumeSessionId === "string" && body.resumeSessionId) {
|
||||||
|
resumeWorktree = await sessionWorktree(lane, body.resumeSessionId).catch(() => null);
|
||||||
|
const row = resumeWorktree
|
||||||
|
? null
|
||||||
|
: db
|
||||||
|
.prepare("SELECT cwd, lane_id FROM sessions WHERE id = ?")
|
||||||
|
.get(body.resumeSessionId);
|
||||||
|
// One of this workspace's worktree sessions whose worktree is gone
|
||||||
|
// (Claude removed it on exit): there is no folder to resume it in.
|
||||||
|
if (
|
||||||
|
row &&
|
||||||
|
row.lane_id === lane.id &&
|
||||||
|
/\/\.claude\/worktrees\/[^/]+\/?$/.test(row.cwd || "")
|
||||||
|
) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: { code: "ENOWORKTREE", message: "that session's worktree no longer exists" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
// Overwriting run_id while its child is alive orphans that child: a later
|
// Overwriting run_id while its child is alive orphans that child: a later
|
||||||
// reset would kill and await only the RECORDED run, then `git clean -fd`
|
// reset would kill and await only the RECORDED run, then `git clean -fd`
|
||||||
// the directory the orphan is still writing into. Stop the first run before starting a
|
// the directory the orphan is still writing into. Stop the first run before starting a
|
||||||
@@ -1774,7 +1872,10 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
|
|||||||
laneId: current.id,
|
laneId: current.id,
|
||||||
slot,
|
slot,
|
||||||
initialPrompt: body.prompt || "",
|
initialPrompt: body.prompt || "",
|
||||||
cwd: current.cwd,
|
cwd: resumeWorktree ? resumeWorktree.path : current.cwd,
|
||||||
|
inWorktree: !!resumeWorktree,
|
||||||
|
// A NEW session in a worktree of its own (`claude --worktree`).
|
||||||
|
worktree: body.worktree === true && !body.resumeSessionId,
|
||||||
// The tool is the LANE's fact, never the request's: a client that
|
// The tool is the LANE's fact, never the request's: a client that
|
||||||
// asked for the other CLI would otherwise get a pane that doesn't
|
// asked for the other CLI would otherwise get a pane that doesn't
|
||||||
// match the lane's badge, its history, or its binary probe.
|
// match the lane's badge, its history, or its binary probe.
|
||||||
|
|||||||
Reference in New Issue
Block a user