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:
@@ -59,7 +59,7 @@ import {
|
||||
Trash2,
|
||||
UnfoldVertical,
|
||||
} from "lucide-react";
|
||||
import { api } from "../../lib/api";
|
||||
import { api, gitTarget } from "../../lib/api";
|
||||
import { pasteToLane } from "../../lib/panePaste";
|
||||
import { LaneDiffView, LaneFileView, type DiffLayout, type LaneDiffChange } from "./LaneDiffView";
|
||||
import { LaneConflictView } from "./LaneConflictView";
|
||||
@@ -422,11 +422,16 @@ function ChangeRow({
|
||||
|
||||
export function LaneChangesPanel({
|
||||
laneId,
|
||||
session,
|
||||
onNewLane,
|
||||
full = false,
|
||||
tab = "changes",
|
||||
}: {
|
||||
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
|
||||
* on its own (no git header, no mode switcher), `changes` the other four.
|
||||
* At full width both show the explorer sidebar beside the changes. */
|
||||
@@ -440,7 +445,9 @@ export function LaneChangesPanel({
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
// One read of the working copy, shared by the header and every write below:
|
||||
// a stage, a discard or a commit changes the counts the header shows.
|
||||
const [git, refreshGit] = useLaneGitFacts(laneId);
|
||||
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.
|
||||
const comment = useCallback((text: string) => pasteToLane(laneId, text), [laneId]);
|
||||
// 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;
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await api.lanes.changes(laneId, {
|
||||
const result = await api.lanes.changes(target, {
|
||||
range,
|
||||
...(range === "commit" && sha ? { sha } : {}),
|
||||
...(mode === "file" && openFile ? { path: openFile, context } : {}),
|
||||
@@ -591,7 +598,7 @@ export function LaneChangesPanel({
|
||||
useEffect(() => {
|
||||
if (mode !== "history") return;
|
||||
void api.lanes
|
||||
.gitLog(laneId, { limit: 50 })
|
||||
.gitLog(target, { limit: 50 })
|
||||
.then(forLane(laneId, (r) => setCommits(r.commits)))
|
||||
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
|
||||
}, [mode, laneId, reread]);
|
||||
@@ -603,7 +610,7 @@ export function LaneChangesPanel({
|
||||
const wanted = full || (mode === "browse" && !query.trim());
|
||||
if (!wanted || treeRootLoaded) return;
|
||||
void api.lanes
|
||||
.gitTree(laneId, "")
|
||||
.gitTree(target, "")
|
||||
.then(forLane(laneId, (r) => setTreeChildren((prev) => ({ ...prev, "": r.entries }))))
|
||||
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
|
||||
// `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;
|
||||
void api.lanes
|
||||
.gitTree(laneId, path)
|
||||
.gitTree(target, path)
|
||||
.then(forLane(laneId, (r) => setTreeChildren((prev) => ({ ...prev, [path]: r.entries }))))
|
||||
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
|
||||
};
|
||||
@@ -632,7 +639,7 @@ export function LaneChangesPanel({
|
||||
const lane = laneId;
|
||||
setTreeBusy(true);
|
||||
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.
|
||||
if (laneNow.current !== lane) return;
|
||||
setTreeChildren(levels);
|
||||
@@ -655,7 +662,7 @@ export function LaneChangesPanel({
|
||||
let live = true;
|
||||
const timer = window.setTimeout(() => {
|
||||
void api.lanes
|
||||
.gitLs(laneId, q)
|
||||
.gitLs(target, q)
|
||||
.then((r) => live && setHits(r.paths))
|
||||
.catch(() => live && setHits([]));
|
||||
}, 150);
|
||||
@@ -670,7 +677,7 @@ export function LaneChangesPanel({
|
||||
useEffect(() => {
|
||||
if (mode !== "file" || !whole || !openFile) return;
|
||||
void api.lanes
|
||||
.gitFile(laneId, openFile, range === "commit" && sha ? sha : "work")
|
||||
.gitFile(target, openFile, range === "commit" && sha ? sha : "work")
|
||||
.then(
|
||||
forLane(laneId, (r) =>
|
||||
setText(
|
||||
@@ -775,7 +782,7 @@ export function LaneChangesPanel({
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
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 (r.available) {
|
||||
setText({ path: r.path, lines: r.lines, truncated: r.truncated, sha: r.sha });
|
||||
@@ -803,7 +810,7 @@ export function LaneChangesPanel({
|
||||
setError(null);
|
||||
try {
|
||||
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;
|
||||
setConfirmDiscard(null);
|
||||
refreshGit();
|
||||
@@ -824,7 +831,7 @@ export function LaneChangesPanel({
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.lanes.gitAction(lane, action, body);
|
||||
await api.lanes.gitAction(target, action, body);
|
||||
if (laneNow.current !== lane) return;
|
||||
setArmedAbort(false);
|
||||
refreshGit();
|
||||
@@ -849,7 +856,7 @@ export function LaneChangesPanel({
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.lanes.gitAction(lane, action, {
|
||||
await api.lanes.gitAction(target, action, {
|
||||
path,
|
||||
hunk: change.hunk.header,
|
||||
block: change.block,
|
||||
@@ -916,7 +923,7 @@ export function LaneChangesPanel({
|
||||
// `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
|
||||
// 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;
|
||||
setMessage("");
|
||||
refreshGit();
|
||||
@@ -1055,7 +1062,13 @@ export function LaneChangesPanel({
|
||||
)}
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
|
||||
{!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
|
||||
(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 ? (
|
||||
<LaneConflictView
|
||||
laneId={laneId}
|
||||
session={session}
|
||||
path={openFile}
|
||||
onResolved={() => {
|
||||
refreshGit();
|
||||
|
||||
Reference in New Issue
Block a user