feat: workspace aliases, tab groups of split panes, native path picker (v2.1.0)

- /api/workspaces and `agenthub workspace(s)` / `--workspace` as aliases of
  /api/lanes and `agenthub lanes` / `--lane`; old names print one stderr note.
  REPL `help lanes|workspace|workspaces` resolves through the aliases.
- A workspace holds up to ten sessions (slots 0-9); every console tab is a
  group of 1-4 split panes (PaneGroups, agenthub.workspace.paneLayout);
  parseGroups keeps the stored active tab when an earlier one is dropped.
- Browse button beside path fields: POST /api/lanes/pick-path opens the OS
  picker on the server's desktop — loopback only and no forwarding header
  (EREMOTE), a display failure is ENODIALOG not a Cancel, the dialog is
  killed when the request closes, a folder field falls back to the in-app
  browser on any failure.
- Changes rail counts chip, image viewer (/git/raw), status bar session
  counts and per-day cost (`date=` on /api/pricing/cost).
- /agenthub-open and /agenthub-doctor run from the INSTALLED build
  (supersededBy), doctor offers fixes behind two confirmations; plugin-open
  treats a locked bootstrap as non-fatal.
- New-terminal shell waits for the first attach (issue #55).
- repo-onboarding / debug-live-issue skills removed.
- OpenAPI: pick-path, lane/date on /pricing/cost, tool on /run/history,
  /api/workspaces mount.
This commit is contained in:
2026-10-06 11:31:26 +07:00
parent c067abc68f
commit 6c605849d8
104 changed files with 3478 additions and 1396 deletions
@@ -414,7 +414,7 @@ export function AiToolsSettings() {
>
<pre
data-testid="ai-tool-confirm-command"
className="whitespace-pre-wrap rounded-md border border-border bg-surface-1 p-2 font-mono text-[11px] text-fg-primary"
className="mt-3 whitespace-pre-wrap rounded-md border border-border bg-surface-1 p-2 font-mono text-[11px] text-fg-primary"
>
{confirmLines.join("\n")}
</pre>
@@ -17,6 +17,7 @@ import { AlertTriangle, FolderOpen } from "lucide-react";
import { confirmDialog } from "../../lib/confirm";
import { isSubmitEnter } from "../../lib/keys";
import { SettingsSection } from "./SettingsRow";
import { PathPickButton } from "../PathPickButton";
export interface HomeDirValue {
value: string;
@@ -122,6 +123,13 @@ export function HomeDirSetting({
className="input flex-1 font-mono"
placeholder={placeholder}
/>
<PathPickButton
start={input}
onPick={(p) => {
setInput(p);
setError(null);
}}
/>
<button
type="button"
onClick={handleSave}
@@ -710,8 +710,13 @@ export function PricingSettings({ vendor, allowReset = false, id = "pricing", he
onContextMenu={(e) => ctx.open(e, rule)}
className="hover:bg-surface-4 transition-colors group"
>
<td className="whitespace-nowrap px-3 py-3 text-sm font-mono text-fg-secondary">
{rule.model_pattern}
{/* The trailing wildcard is noise in a list of prefixes; the
full pattern stays in the tooltip, the editor and Copy. */}
<td
title={rule.model_pattern}
className="whitespace-nowrap px-3 py-3 text-sm font-mono text-fg-secondary"
>
{rule.model_pattern.replace(/%$/, "")}
</td>
<td className="whitespace-nowrap px-3 py-3 text-sm text-fg-secondary">
{rule.display_name}
@@ -78,7 +78,7 @@ beforeEach(() => {
describe("ClaudeAgentHubSettings", () => {
it("renders pricing, hook status and CLAUDE_HOME in their own sections", async () => {
const { container } = render(<ClaudeAgentHubSettings />);
expect(await screen.findByText("claude-opus-5-5%")).toBeInTheDocument();
expect(await screen.findByText("claude-opus-5-5")).toBeInTheDocument();
for (const id of ["pricing", "hooks", "claude-home"]) {
expect(container.querySelector(`section#${id}`)).not.toBeNull();
}
@@ -96,7 +96,7 @@ describe("ClaudeAgentHubSettings", () => {
await user.type(input, "/srv/claude");
const home = container.querySelector("section#claude-home") as HTMLElement;
const off = confirmYes();
await user.click(within(home).getByRole("button"));
await user.click(within(home).getByRole("button", { name: "Save" }));
await waitFor(() => expect(api.settings.claudeHome.set).toHaveBeenCalledWith("/srv/claude"));
off();
@@ -115,7 +115,7 @@ describe("ClaudeAgentHubSettings", () => {
const home = container.querySelector("section#claude-home") as HTMLElement;
const reads = vi.mocked(api.settings.info).mock.calls.length;
const off = confirmYes();
await user.click(within(home).getByRole("button"));
await user.click(within(home).getByRole("button", { name: "Save" }));
await waitFor(() => expect(onHomeChanged).toHaveBeenCalledTimes(1));
off();
expect(vi.mocked(api.settings.info).mock.calls.length).toBeGreaterThan(reads);
@@ -67,11 +67,12 @@ describe("PricingSettings", () => {
it("shows the vendor's rows, and the rest behind Show all models", async () => {
const user = userEvent.setup();
render(<PricingSettings vendor="openai" />);
expect(await screen.findByText("gpt-5.6-sol%")).toBeInTheDocument();
expect(screen.queryByText("claude-opus-5-5%")).toBeNull();
// The trailing wildcard is hidden in the list, kept in the tooltip.
expect(await screen.findByText("gpt-5.6-sol")).toHaveAttribute("title", "gpt-5.6-sol%");
expect(screen.queryByText("claude-opus-5-5")).toBeNull();
expect(screen.queryByRole("button", { name: /Reset Defaults/i })).toBeNull();
await user.click(screen.getByRole("checkbox", { name: /Show all models \(1 more\)/ }));
expect(screen.getByText("claude-opus-5-5%")).toBeInTheDocument();
expect(screen.getByText("claude-opus-5-5")).toBeInTheDocument();
});
it("marks a row the automatic sync keeps up to date", async () => {
@@ -82,7 +83,7 @@ describe("PricingSettings", () => {
],
});
render(<PricingSettings vendor="openai" />);
await screen.findByText("gpt-6-sol%");
await screen.findByText("gpt-6-sol");
expect(screen.getAllByText("Auto")).toHaveLength(1);
});
@@ -95,7 +96,7 @@ describe("PricingSettings", () => {
it("turns Show all on when a row outside the filter is added", async () => {
const user = userEvent.setup();
render(<PricingSettings vendor="openai" />);
await screen.findByText("gpt-5.6-sol%");
await screen.findByText("gpt-5.6-sol");
await user.click(screen.getByRole("button", { name: /Add Model/i }));
const [pattern, name] = screen.getAllByRole("textbox");
await user.type(pattern!, "deepseek-v4%");
@@ -104,7 +105,7 @@ describe("PricingSettings", () => {
pricing: [...ROWS, rule("deepseek-v4%", "DeepSeek V4")],
});
await user.click(screen.getByTitle(/save/i));
expect(await screen.findByText("deepseek-v4%")).toBeInTheDocument();
expect(await screen.findByText("deepseek-v4")).toBeInTheDocument();
expect(screen.getByRole("checkbox", { name: /Show all models/ })).toBeChecked();
});
@@ -112,8 +113,8 @@ describe("PricingSettings", () => {
const user = userEvent.setup();
vi.mocked(api.settings.resetPricing).mockReset().mockResolvedValue({ ok: true, pricing: ROWS });
render(<PricingSettings vendor="claude" allowReset />);
expect(await screen.findByText("claude-opus-5-5%")).toBeInTheDocument();
expect(screen.queryByText("gpt-5.6-sol%")).toBeNull();
expect(await screen.findByText("claude-opus-5-5")).toBeInTheDocument();
expect(screen.queryByText("gpt-5.6-sol")).toBeNull();
await user.click(screen.getByRole("button", { name: /Reset Defaults/i }));
expect(api.settings.resetPricing).not.toHaveBeenCalled();
await user.click(screen.getByRole("button", { name: /confirm/i }));
@@ -128,7 +129,7 @@ describe("PricingSettings", () => {
const confirm = answerConfirms(true);
const user = userEvent.setup();
render(<PricingSettings vendor="openai" />);
const row = (await screen.findByText("gpt-5.6-sol%")).closest("tr")!;
const row = (await screen.findByText("gpt-5.6-sol")).closest("tr")!;
await user.pointer({ keys: "[MouseRight]", target: row });
await user.click(screen.getByRole("menuitem", { name: "Delete" }));
@@ -147,7 +148,7 @@ describe("PricingSettings", () => {
const confirm = answerConfirms(false);
const user = userEvent.setup();
render(<PricingSettings vendor="openai" />);
await screen.findByText("gpt-5.6-sol%");
await screen.findByText("gpt-5.6-sol");
await user.click(screen.getByTitle("Delete"));
await vi.waitFor(() => expect(confirm.asked).toHaveLength(1));
expect(confirm.asked[0]!.message).toMatch(/sync/i);
@@ -167,14 +168,14 @@ describe("PricingSettings", () => {
<PricingSettings vendor="openai" id="b" />
</>
);
await screen.findByText("gpt-5.6-sol%");
await screen.findByText("claude-opus-5-5%");
await screen.findByText("gpt-5.6-sol");
await screen.findByText("claude-opus-5-5");
vi.mocked(api.pricing.list).mockResolvedValue({
pricing: [...ROWS, rule("claude-fable-5%", "Claude Fable 5")],
});
// The second table (openai) deletes its row; the first must reload too.
await user.click(screen.getAllByTitle("Delete")[1]!);
expect(await screen.findByText("claude-fable-5%")).toBeInTheDocument();
expect(await screen.findByText("claude-fable-5")).toBeInTheDocument();
confirm.off();
});
});