feat(studio): the extension page gets a project pill beside Add, so the project is switched in place; the settings row stays as the same control

This commit is contained in:
yhh committed 2026-11-06 02:50:06 -08:00
1 parent 5e00091026
commit 6ebff84af2
5 files changed
+70 -17

No files matched your search

@@ -459,3 +459,8 @@
.scopeaway > span { flex: 1; min-width: 0; }
.scopeaway .act { height: 24px; padding: 0 10px; border: 1px solid var(--border); border-radius: var(--r-pill); background: transparent; color: var(--text); font: var(--w-reg) var(--fs-meta)/1 var(--ui); cursor: pointer; }
.scopeaway .act:hover { background: var(--overlay); }
.ext-bar .ext-scope { margin-left: auto; }
.ext-bar .ext-scope + .ext-add { margin-inline-start: 0; }
.scopeanchor { position: relative; display: inline-block; }
.scopeanchor .scope-pick { top: calc(100% + 4px); right: 0; left: auto; }
.scoperow .scopeanchor .scope-pick { top: calc(100% + 4px); right: 0; left: auto; }
@@ -14,6 +14,35 @@ export function Exception({ onClear, busy }: { onClear: () => void; busy: boolea
);
}
// The project the extension lists answer for, as one pill that opens the list of
// projects. It sits in settings and on the extension page, so choosing is never
// a trip to another page.
export function ScopePill({
scope, scopes, onPick,
}: {
scope: CapabilityScope; scopes: CapabilityScope[]; onPick: (root: string) => void;
}) {
const [open, setOpen] = useState(false);
return (
<span className="scopeanchor">
<button type="button" className="spill who" data-action="ext.scope" aria-expanded={open} onClick={() => setOpen(!open)}>
{scope.label || scope.name}
<i className="cv" aria-hidden>▾</i>
</button>
{open && (
<ScopePicker
scopes={scopes}
at={scope.root}
onPick={(root) => {
setOpen(false);
onPick(root);
}}
/>
)}
</span>
);
}
// Which folder the extension lists answer for. The shell holds several projects
// at once and the pane follows whichever one is in front, so this is chosen on
// purpose here rather than inferred from the tab in front. Worktrees of one
@@ -25,7 +54,6 @@ export function ScopeRow({
}: {
scope: CapabilityScope; scopes: CapabilityScope[]; onPick: (root: string) => void;
}) {
const [open, setOpen] = useState(false);
const shared = scope.repo && (scope.trees ?? 1) > 1;
return (
<div className="scoperow">
@@ -34,21 +62,8 @@ export function ScopeRow({
<span className="srow-name">{t("ext.manage_which_project")}</span>
<span className="srow-desc">{t("ext.scope_hint")}</span>
</div>
<div className="srow-ctl scopeanchor">
<button type="button" className="spill who" data-action="ext.scope" aria-expanded={open} onClick={() => setOpen(!open)}>
{scope.label || scope.name}
<i className="cv" aria-hidden>▾</i>
</button>
{open && (
<ScopePicker
scopes={scopes}
at={scope.root}
onPick={(root) => {
setOpen(false);
onPick(root);
}}
/>
)}
<div className="srow-ctl">
<ScopePill scope={scope} scopes={scopes} onPick={onPick} />
</div>
</div>
<div className="scopeinfo">
@@ -38,3 +38,24 @@ it("keeps the selected project's skills when the previous read finishes late", a
expect(screen.getByText("only-b")).toBeTruthy();
expect(screen.queryByText("only-a")).toBeNull();
});
it("switches the project from the extension page itself, without a trip to another page", async () => {
const scopes = [
{ root: "/projects/a", name: "Project A", key: "a", repo: true, overrides: 0, current: true },
{ root: "/projects/b", name: "Project B", key: "b", repo: true, overrides: 0 },
];
const port = new MockPort() as unknown as AgentPort;
port.capabilityScopes = vi.fn(async () => scopes) as never;
port.mcp = vi.fn(async (root?: string) => ({ servers: [], scope: root === scopes[1].root ? scopes[1] : scopes[0] })) as never;
port.skills = vi.fn(async (root?: string) => ({ implicit: true, skills: [{ name: root === scopes[1].root ? "only-b" : "only-a", enabled: true }] })) as never;
render(<Settings
hub={new MockHub()} onError={() => {}} port={port} status={null} theme="" reloadThemes={() => {}}
onTheme={() => {}} contrast="" onContrast={() => {}} weight="" onWeight={() => {}}
look={{}} onLook={() => {}} onClose={() => {}} onChanged={() => {}} at="ext" account={null}
accountUnread="" reloadAccount={() => {}}
/>);
await userEvent.click(await screen.findByRole("button", { name: /Project A/ }));
await userEvent.click(screen.getByRole("option", { name: /Project B/ }));
await screen.findByText("only-b");
expect(document.querySelector(".scopeaway")?.textContent).toContain("Project B");
});
+2 -1
View File
@@ -47,7 +47,7 @@ import { planProtocolSwitch } from "./protocolswitch";
import { Boundary } from "./Boundary";
import { DEFAULT_DAYS } from "./Usage";
import { Appearance, SCHEMES } from "./Appearance";
import { ScopeAway, ScopeRow } from "./CapabilityScope";
import { ScopeAway, ScopePill, ScopeRow } from "./CapabilityScope";
import { SectionCard } from "./SectionCard";
import { savedNotApplied, reason } from "../i18n/kernel";
import type { SettingsProps as Props } from "./SettingsProps";
@@ -476,6 +476,7 @@ export function Settings({ hub, onError, port, networkPort, localWorkspacePort,
<>
<div className="ext-bar">
<ExtTabs at={extTab} disabled={updatingPkg.applying} onPick={(tab) => { setInstalledTarget(null); setExtTab(tab); }} />
{scope && <span className="ext-scope"><ScopePill scope={scope} scopes={scopes} onPick={setScopeAt} /></span>}
<ExtAddMenu disabled={updatingPkg.applying} offer={new Set<ExtAddWay>(["market", ...(updatingPkg.name ? [] : ["source", "folder"] as const), ...(live ? ["service"] as const : [])])}
onPick={(way) => { setInstalledTarget(null); setExtTab(way === "market" ? "market" : "installed"); if (way === "service") setAdding(true); else if (way !== "market") setAddingPkg(way); }} />
</div>
@@ -49,3 +49,14 @@ describe("the extension page naming another project", () => {
expect(onBack).toHaveBeenCalledOnce();
});
});
describe("the project pill on the extension page", () => {
it("opens the same list in place and hands back the chosen root", async () => {
const { ScopePill } = await import("./CapabilityScope");
const onPick = vi.fn();
render(<ScopePill scope={scope()} scopes={scopes} onPick={onPick} />);
await userEvent.click(screen.getByRole("button", { name: /app/ }));
await userEvent.click(screen.getByRole("option", { name: /site/ }));
expect(onPick).toHaveBeenCalledWith("/work/site");
});
});