fix: v2.6.1 — panes start in their folder again after an update (#69), Changes panel stacks every repo, clickable paths in chat

- #69: tmux applies a pane's `-c` only while its server's own cwd exists.
  The server was forked from the dashboard's cwd (the plugin cache dir),
  outlives updates, and the old cache dir is pruned — after that every new
  pane started in a deleted directory, where `claude` died at once ("run not
  found") and a shell printed getcwd errors. tmux calls now run from `/`,
  and the pane's sh wrapper cds into its folder itself, which also heals a
  tmux server that is already stuck.
- Changes panel: a workspace's extra folders are sections stacked under the
  workspace folder (VS Code Source Control style), with Close repository /
  Close other repositories on a header's right-click.
- Chat: URLs and absolute paths are links; Claude Code's `[Image: source:]`
  line folds into its turn; uploads under `<data dir>/drops/` are listed.
- Update check: a checkout holding commits a newer release lacks reports
  `diverged_from_release` instead of offering a fast-forward that can only fail.
This commit is contained in:
2026-10-09 17:24:14 +07:00
parent fa156d6a59
commit 96e4d28f7c
25 changed files with 589 additions and 208 deletions
+187 -98
View File
@@ -55,7 +55,15 @@ import { runWorktreeSession, type RunWorktree } from "../lib/runWorktree";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useLocation, useNavigate, useSearchParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { PanelLeftClose, PanelRightClose, Maximize2, Minimize2, ChartColumn } from "lucide-react";
import {
PanelLeftClose,
PanelRightClose,
Maximize2,
Minimize2,
ChartColumn,
ChevronDown,
ChevronRight,
} from "lucide-react";
import { api } from "../lib/api";
import type {
CwdSuggestion,
@@ -66,6 +74,7 @@ import type {
} from "../lib/api";
import type { Lane, LaneFeature, ProofFeature, Session } from "../lib/types";
import { toast } from "../lib/toast";
import { ContextMenu, MenuItem, useContextMenu } from "../components/ContextMenu";
import { confirmDialog, confirmDiscard } from "../lib/confirm";
import { keepIfSame } from "../lib/keepIfSame";
import { useWorkLanes } from "../hooks/useLanes";
@@ -709,9 +718,27 @@ export function Workspace() {
? sessionParam
: undefined;
// The workspace's extra folders: each is its own repo the Changes panel can
// show instead (`?source=`), picked in its header. Read per workspace, and
// again from the home's Folder list whenever it adds or removes one.
// An unsaved whole-file edit in a Changes panel section, by section key. While
// one is open the panel stays on the tree it was opened in — moving it
// (another workspace, session or worktree) would remount it and drop the
// edit — until the user says to discard it, or saves.
const [dirtyBy, setDirtyBy] = useState<Record<string, boolean>>({});
const changesDirty = Object.values(dirtyBy).some(Boolean);
const dirtyFor = useCallback(
(key: string) => (dirty: boolean) => setDirtyBy((d) => ({ ...d, [key]: dirty })),
[]
);
const wantPanel =
selectedLaneId === null ? null : { laneId: selectedLaneId, session: gitSession };
const keyOf = (p: typeof wantPanel) => (p ? `${p.laneId}:${p.session ?? ""}` : "");
const wantPanelKey = keyOf(wantPanel);
const [panelAt, setPanelAt] = useState(wantPanel);
const panelKey = keyOf(panelAt);
// The workspace's extra folders: each is its own repo, stacked under the
// workspace folder's in the Changes panel (`?source=`). Read for the
// workspace the panel shows, and again from the home's Folder list whenever
// it adds or removes one.
const [gitFolders, setGitFolders] = useState<{ laneId: number; items: LaneSource[] } | null>(
null
);
@@ -723,35 +750,37 @@ export function Workspace() {
}),
[]
);
const panelLaneId = panelAt?.laneId ?? null;
useEffect(() => {
if (selectedLaneId === null) return;
api.lanes.sources(selectedLaneId).then(
(v) => takeGitFolders(selectedLaneId, v),
() => setGitFolders({ laneId: selectedLaneId, items: [] })
if (panelLaneId === null) return;
api.lanes.sources(panelLaneId).then(
(v) => takeGitFolders(panelLaneId, v),
() => setGitFolders({ laneId: panelLaneId, items: [] })
);
}, [selectedLaneId, takeGitFolders]);
const folders = gitFolders?.laneId === selectedLaneId ? gitFolders.items : [];
const [gitSourcePick, setGitSourcePick] = useState<{ laneId: number; id: number } | null>(null);
// A pick holds for its own workspace, while that folder is still listed.
const gitSource =
gitSourcePick?.laneId === selectedLaneId && folders.some((f) => f.id === gitSourcePick.id)
? gitSourcePick.id
: undefined;
// An unsaved whole-file edit in the Changes panel. While it is open the
// panel stays on the tree it was opened in — moving it (another workspace,
// session, worktree or folder) would remount it and drop the edit — until
// the user says to discard it, or saves.
const [changesDirty, setChangesDirty] = useState(false);
const wantPanel =
selectedLaneId === null
? null
: { laneId: selectedLaneId, session: gitSession, source: gitSource };
const keyOf = (p: typeof wantPanel) =>
p ? `${p.laneId}:${p.session ?? ""}:${p.source ?? ""}` : "";
const wantPanelKey = keyOf(wantPanel);
const [panelAt, setPanelAt] = useState(wantPanel);
const panelKey = keyOf(panelAt);
}, [panelLaneId, takeGitFolders]);
const folders = gitFolders?.laneId === panelLaneId ? gitFolders.items : [];
// Sections folded shut ("" = the workspace folder, else the folder's id).
const [foldedSources, setFoldedSources] = useState<Set<string>>(new Set());
// Right-click on a section: close extra folders — which REMOVES them from the
// workspace (the folder stays on disk). The workspace folder never closes.
const sourceCtx = useContextMenu<string>();
// Bumped after a close here, so the home's Folder list re-reads.
const [sourcesVersion, setSourcesVersion] = useState(0);
const closeSources = async (laneId: number, keys: string[]) => {
if (keys.some((k) => dirtyBy[k]) && !(await confirmDiscard())) return;
try {
let view: LaneSourcesView | null = null;
for (const k of keys) view = await api.lanes.removeSource(laneId, Number(k));
if (view) takeGitFolders(laneId, view);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
api.lanes.sources(laneId).then(
(v) => takeGitFolders(laneId, v),
() => undefined
);
}
setSourcesVersion((n) => n + 1);
};
const asking = useRef(false);
useEffect(() => {
if (wantPanelKey === panelKey) return;
@@ -764,7 +793,7 @@ export function Workspace() {
void confirmDiscard().then((ok) => {
asking.current = false;
if (!ok) return;
setChangesDirty(false);
setDirtyBy({});
setPanelAt(wantPanel);
});
// eslint-disable-next-line react-hooks/exhaustive-deps -- keyed on the keys
@@ -780,8 +809,12 @@ export function Workspace() {
const onOpen = (e: Event) => {
const { laneId, path } = (e as CustomEvent<OpenInChanges>).detail;
if (laneId !== panelLaneRef.current) return;
// Its path is the workspace folder's: back from an extra folder first.
setGitSourcePick(null);
// Its path is the workspace folder's: unfold that section.
setFoldedSources((prev) => {
const next = new Set(prev);
next.delete("");
return next;
});
setChangesOpen(true);
setChangesRequest((r) => ({ path, nonce: (r?.nonce ?? 0) + 1 }));
};
@@ -792,10 +825,7 @@ export function Workspace() {
useEffect(() => {
if (!changesOpen) setChangesRequest(null);
}, [changesOpen]);
// Not on a folder pick: the request above makes one, to land in the panel
// it remounts; the picker drops the request itself.
const panelTreeKey = panelAt ? `${panelAt.laneId}:${panelAt.session ?? ""}` : "";
useEffect(() => setChangesRequest(null), [panelTreeKey]);
useEffect(() => setChangesRequest(null), [panelKey]);
/** Runs `fn` once an unsaved edit (if any) may be dropped. */
const afterDrop = (fn: () => void) => {
if (!changesDirty) return fn();
@@ -1036,6 +1066,7 @@ export function Workspace() {
lane={currentLane}
onRenamed={() => void refreshLanes()}
onSourcesChange={(v) => takeGitFolders(currentLane.id, v)}
sourcesVersion={sourcesVersion}
onMenuAction={(action) => {
if (action === "new-here") {
setAddLanePrefill({ sourceRepo: currentLane.source_repo || currentLane.cwd });
@@ -1268,34 +1299,6 @@ export function Workspace() {
<span className="px-1 text-xs font-medium text-fg-secondary">
{tLanes("panel.changes")}
</span>
{/* Which repo the panel shows: the workspace's folder or
one of its extra folders. Only when it has any. */}
{folders.length > 0 && (
<select
data-testid="panel-git-folder"
value={gitSource ?? ""}
onChange={(e) => {
setChangesRequest(null);
setGitSourcePick(
e.target.value
? { laneId: currentLane.id, id: Number(e.target.value) }
: null
);
}}
aria-label={tLanes("panel.folder")}
title={tLanes("panel.folder")}
className="min-w-0 max-w-[12rem] truncate rounded-md border border-border bg-surface-2 px-1.5 py-0.5 text-xs text-fg-primary"
>
<option value="">
{currentLane.cwd.split(/[\\/]/).filter(Boolean).pop() || currentLane.cwd}
</option>
{folders.map((f) => (
<option key={f.id} value={f.id}>
{f.name}
</option>
))}
</select>
)}
{/* The opened session's analysis page — a page of its own. */}
<button
type="button"
@@ -1350,30 +1353,114 @@ export function Workspace() {
</button>
</div>
<div className="flex min-h-0 flex-1 flex-col px-3 pt-2">
{panelAt && (
<LaneChangesPanel
// A new tree is a new panel: no read for the previous one
// can land in it. `panelAt` holds still while an edit is
// unsaved (see above).
key={panelKey}
laneId={panelAt.laneId}
session={panelAt.session}
source={panelAt.source}
full={changesFull}
wide={changesRatio(changesSize) >= 0.45}
onDirtyChange={setChangesDirty}
openRequest={panelAt.source ? null : changesRequest}
// A sibling worktree forks from the lane's SOURCE repo when
// it has one (this lane is itself a worktree of it); an
// adopted lane is its own repo.
onNewLane={(branch) => {
setAddLanePrefill({
sourceRepo: currentLane.source_repo || currentLane.cwd,
branch,
});
setAddLaneOpen(true);
}}
/>
{panelAt &&
[
{ key: "", source: undefined as number | undefined },
...folders.map((f) => ({ key: String(f.id), source: f.id })),
].map(({ key, source }, i) => {
const panel = (
<LaneChangesPanel
// A new tree is a new panel: no read for the previous
// one can land in it. `panelAt` holds still while an
// edit is unsaved (see above).
key={`${panelKey}:${key}`}
laneId={panelAt.laneId}
session={panelAt.session}
source={source}
full={changesFull}
wide={changesRatio(changesSize) >= 0.45}
onDirtyChange={dirtyFor(key)}
openRequest={source ? null : changesRequest}
// A sibling worktree forks from the lane's SOURCE repo
// when it has one (this lane is itself a worktree of
// it); an adopted lane is its own repo.
onNewLane={(branch) => {
setAddLanePrefill({
sourceRepo: currentLane.source_repo || currentLane.cwd,
branch,
});
setAddLaneOpen(true);
}}
/>
);
// No extra folders: the one panel, as it always was.
if (!folders.length) return panel;
// Else every repo is a section, stacked like VS Code's
// Source Control; the open ones share the height. A
// folded one stays mounted (CSS), so an unsaved edit in
// it survives.
const folded = foldedSources.has(key);
const name = source
? folders[i - 1]!.name
: currentLane.cwd.split(/[\\/]/).filter(Boolean).pop() || currentLane.cwd;
return (
<section
key={key}
data-testid="panel-git-source"
className={`flex flex-col border-b border-border-light ${
folded ? "shrink-0" : "min-h-0 flex-1"
}`}
>
<button
type="button"
aria-expanded={!folded}
onContextMenu={(e) => sourceCtx.open(e, key)}
onClick={() =>
setFoldedSources((prev) => {
const next = new Set(prev);
if (!next.delete(key)) next.add(key);
return next;
})
}
className="flex shrink-0 items-center gap-1 py-1 text-left text-xs font-medium text-fg-secondary hover:text-fg-primary"
>
{folded ? (
<ChevronRight className="h-3.5 w-3.5 shrink-0" />
) : (
<ChevronDown className="h-3.5 w-3.5 shrink-0" />
)}
<span className="truncate">{name}</span>
</button>
<div className={folded ? "hidden" : "flex min-h-0 flex-1 flex-col"}>
{panel}
</div>
</section>
);
})}
{panelAt && sourceCtx.menu && (
<ContextMenu
x={sourceCtx.menu.x}
y={sourceCtx.menu.y}
onClose={sourceCtx.close}
testId="panel-git-source-menu"
>
{sourceCtx.menu.item !== "" && (
<MenuItem
testId="panel-git-source-close"
onSelect={() => {
const key = sourceCtx.menu!.item;
sourceCtx.close();
void closeSources(panelAt.laneId, [key]);
}}
>
{tLanes("changes.closeRepo")}
</MenuItem>
)}
<MenuItem
testId="panel-git-source-close-others"
disabled={!folders.some((f) => String(f.id) !== sourceCtx.menu!.item)}
onSelect={() => {
const key = sourceCtx.menu!.item;
sourceCtx.close();
void closeSources(
panelAt.laneId,
folders.map((f) => String(f.id)).filter((k) => k !== key)
);
}}
>
{tLanes("changes.closeOtherRepos")}
</MenuItem>
</ContextMenu>
)}
</div>
</aside>
@@ -1397,14 +1484,16 @@ export function Workspace() {
{tLanes("changes.title")}
</span>
</button>
{panelAt && (
<LaneGitRailCounts
laneId={panelAt.laneId}
session={panelAt.session}
source={panelAt.source}
onShowAll={() => setChangesOpen(true)}
/>
)}
{panelAt &&
[undefined, ...folders.map((f) => f.id)].map((source) => (
<LaneGitRailCounts
key={source ?? ""}
laneId={panelAt.laneId}
session={panelAt.session}
source={source}
onShowAll={() => setChangesOpen(true)}
/>
))}
</div>
))}
</div>
@@ -95,6 +95,10 @@ vi.mock("../../lib/api", async (importOriginal) => {
: [],
})
),
// Closing a folder's section removes it from the workspace.
removeSource: r({
sources: [{ id: 8, kind: "file", path: "/d/notes.md", name: "notes.md", missing: false }],
}),
gitTree: r({ entries: [] }),
// A file opened in the panel on a chat's request.
changes: r({ available: false }),
@@ -244,32 +248,50 @@ describe("Workspace — the Changes panel's width", () => {
expect(api.lanes.gitFile).toHaveBeenCalledWith(1, "src/a.ts", "work");
});
it("shows an extra folder's repo when picked, and only a workspace's own folders", async () => {
await openPanel();
const pick = screen.getByTestId("panel-git-folder") as HTMLSelectElement;
// The workspace folder, then its folders — never a context file.
expect([...pick.options].map((o) => o.text)).toEqual(["alpha", "shop"]);
it("stacks every extra folder's repo under the workspace folder's, and only its own folders", async () => {
vi.mocked(api.lanes.git).mockClear();
fireEvent.change(pick, { target: { value: "7" } });
await settle();
await openPanel();
const sections = screen.getAllByTestId("panel-git-source");
// The workspace folder, then its folders — never a context file.
expect(sections.map((s) => s.querySelector("button")!.textContent)).toEqual(["alpha", "shop"]);
expect(api.lanes.git).toHaveBeenCalledWith(1);
expect(api.lanes.git).toHaveBeenCalledWith({ lane: 1, source: 7 });
// A chat's file is the workspace folder's: the panel goes back there.
// A folded section keeps its panel mounted, only hidden.
const toggle = sections[0]!.querySelector("button")!;
fireEvent.click(toggle);
expect(toggle).toHaveAttribute("aria-expanded", "false");
expect(sections[0]!.querySelector('[data-testid="lane-changes"]')).not.toBeNull();
// A chat's file is the workspace folder's: its section unfolds.
act(() => {
openInChanges(1, "/workspace/alpha", "/workspace/alpha/src/b.ts");
});
await settle();
expect(pick.value).toBe("");
expect(toggle).toHaveAttribute("aria-expanded", "true");
expect(api.lanes.gitFile).toHaveBeenCalledWith(1, "src/b.ts", "work");
});
it("has no folder picker for a workspace with no extra folders", async () => {
it("closes an extra folder from its right-click menu, removing it from the workspace — never the workspace folder", async () => {
await openPanel();
const [main, shop] = screen.getAllByTestId("panel-git-source");
fireEvent.contextMenu(main!.querySelector("button")!);
expect(screen.queryByTestId("panel-git-source-close")).toBeNull();
fireEvent.keyDown(document, { key: "Escape" });
fireEvent.contextMenu(shop!.querySelector("button")!);
fireEvent.click(screen.getByTestId("panel-git-source-close"));
await settle();
expect(api.lanes.removeSource).toHaveBeenCalledWith(1, 7);
// The last folder gone: back to the one plain panel.
expect(screen.queryByTestId("panel-git-source")).toBeNull();
});
it("is one plain panel for a workspace with no extra folders", async () => {
render(
<MemoryRouter initialEntries={["/workspace?lane=2"]}>
<Workspace />
</MemoryRouter>
);
await settle();
expect(screen.queryByTestId("panel-git-folder")).toBeNull();
expect(screen.queryByTestId("panel-git-source")).toBeNull();
});
it("collapses from its narrow width too, back to the rail", async () => {
@@ -7174,7 +7174,7 @@ exports[`screen snapshots > Settings — about 1`] = `
<span
class="text-status-warning/90"
>
UI build v2.6.0 (rebuild client to match)
UI build v2.6.1 (rebuild client to match)
</span>
</p>
</div>