chore: v2.8.23 — workspace groups, inner repos in Changes (opt-in), sub-agent tokens counted

Workspace groups: named, foldable, ordered sidebar sections kept per
account (agenthub.workspace.groups); a workspace joins one from its menu,
a drop on the header, a drag under a row, or Alt+Up/Down past a group's
edge; headers drag to reorder; a folded header shows its most urgent
state; New workspace in this group; fold/unfold all.

Inner repos: GET /api/lanes/:id/repos + ?repo= on git/fs routes; the
Changes panel shows a parent folder's repos only when Settings -> Git ->
Changes panel turns it on (agenthub.changes.innerRepos, off by default).

Cost: the hook writer adds a session's sub-agent transcripts to its
tokens and hours (server/lib/subagent-usage.js) — a sub-agent on the
session's own model was never counted live (~8% of a week here); an
imported session is re-raised on every pass, so a claude -p run first
seen with an empty transcript no longer stays at $0 (~10%). A one-time
raise-only catch-up (user_version 5) re-reads ~/.claude/projects.

Also: omp install script may redirect to its exact install.redirect URL
(#85, repo-links test exempts it), native paste in the terminal.
This commit is contained in:
2026-10-23 16:41:45 +07:00
parent 65a001dad4
commit c8bc4a95a9
54 changed files with 2521 additions and 112 deletions
@@ -101,6 +101,8 @@ vi.mock("../../lib/api", async (importOriginal) => {
: [],
})
),
// The repos inside the workspace folder (`?repo=`): none by default.
repos: vi.fn().mockResolvedValue({ root: true, items: [], truncated: false }),
// 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 }],
@@ -330,6 +332,79 @@ describe("Workspace — the Changes panel's width", () => {
expect(screen.queryByTestId("panel-git-source")).toBeNull();
});
it("neither walks a parent folder nor shows its repos until the account turns it on", async () => {
vi.mocked(api.lanes.repos).mockClear();
vi.mocked(api.lanes.repos).mockResolvedValue({
root: false,
items: [{ path: "api", name: "api" }],
truncated: false,
});
render(
<MemoryRouter initialEntries={["/workspace?lane=2"]}>
<Workspace />
</MemoryRouter>
);
await settle();
const rail = screen.queryByTestId("lane-changes-expand");
if (rail) {
fireEvent.click(rail);
await settle();
}
expect(api.lanes.repos).not.toHaveBeenCalled();
expect(screen.queryByTestId("panel-git-source")).toBeNull();
vi.mocked(api.lanes.repos).mockResolvedValue({ root: true, items: [], truncated: false });
});
it("gives each repo inside a parent folder its own section, without the folder's own", async () => {
// Settings → Git → Changes panel: show the repositories inside.
window.localStorage.setItem("agenthub.changes.innerRepos", "all");
vi.mocked(api.lanes.repos).mockResolvedValue({
root: false,
items: [
{ path: "api", name: "api" },
{ path: "apps/web", name: "apps/web" },
],
truncated: false,
});
vi.mocked(api.lanes.git).mockClear();
render(
<MemoryRouter initialEntries={["/workspace?lane=2"]}>
<Workspace />
</MemoryRouter>
);
await settle();
const rail = screen.queryByTestId("lane-changes-expand");
if (rail) {
fireEvent.click(rail);
await settle();
}
const sections = screen.getAllByTestId("panel-git-source");
expect(sections.map((s) => s.querySelector("button")!.textContent)).toEqual([
"api",
"apps/web",
]);
expect(api.lanes.git).toHaveBeenCalledWith({ lane: 2, repo: "apps/web" });
// An inner repo is the folder's own: its menu rescans, never closes it.
fireEvent.contextMenu(sections[1]!.querySelector("button")!);
expect(screen.queryByTestId("panel-git-source-close")).toBeNull();
fireEvent.click(screen.getByTestId("panel-git-source-rescan"));
expect(api.lanes.repos).toHaveBeenLastCalledWith(2, true);
// A chat's file inside an inner repo opens in that repo's section.
fireEvent.click(sections[1]!.querySelector("button")!);
expect(sections[1]!.querySelector("button")).toHaveAttribute("aria-expanded", "false");
act(() => {
openInChanges(2, "/workspace/beta", "/workspace/beta/apps/web/src/x.ts");
});
await settle();
expect(sections[1]!.querySelector("button")).toHaveAttribute("aria-expanded", "true");
expect(api.lanes.gitFile).toHaveBeenCalledWith(
{ lane: 2, repo: "apps/web" },
"src/x.ts",
"work"
);
vi.mocked(api.lanes.repos).mockResolvedValue({ root: true, items: [], truncated: false });
});
it("is one plain panel for a workspace with no extra folders", async () => {
render(
<MemoryRouter initialEntries={["/workspace?lane=2"]}>