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
@@ -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 () => {