diff --git a/desktop/frontend-next/src/design/settings.css b/desktop/frontend-next/src/design/settings.css index a67cfbaece..b290015039 100644 --- a/desktop/frontend-next/src/design/settings.css +++ b/desktop/frontend-next/src/design/settings.css @@ -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; } diff --git a/desktop/frontend-next/src/ui/CapabilityScope.tsx b/desktop/frontend-next/src/ui/CapabilityScope.tsx index 654e2f74ed..e27c43e131 100644 --- a/desktop/frontend-next/src/ui/CapabilityScope.tsx +++ b/desktop/frontend-next/src/ui/CapabilityScope.tsx @@ -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 ( + + + {open && ( + { + setOpen(false); + onPick(root); + }} + /> + )} + + ); +} + // 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 (
@@ -34,21 +62,8 @@ export function ScopeRow({ {t("ext.manage_which_project")} {t("ext.scope_hint")}
-
- - {open && ( - { - setOpen(false); - onPick(root); - }} - /> - )} +
+
diff --git a/desktop/frontend-next/src/ui/Settings.scope.test.tsx b/desktop/frontend-next/src/ui/Settings.scope.test.tsx index 71ae1e820f..b027c2a3f8 100644 --- a/desktop/frontend-next/src/ui/Settings.scope.test.tsx +++ b/desktop/frontend-next/src/ui/Settings.scope.test.tsx @@ -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( {}} 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"); +}); diff --git a/desktop/frontend-next/src/ui/Settings.tsx b/desktop/frontend-next/src/ui/Settings.tsx index cafe1d3264..975bd32160 100644 --- a/desktop/frontend-next/src/ui/Settings.tsx +++ b/desktop/frontend-next/src/ui/Settings.tsx @@ -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, <>
{ setInstalledTarget(null); setExtTab(tab); }} /> + {scope && } (["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); }} />
diff --git a/desktop/frontend-next/src/ui/scope-row.interaction.test.tsx b/desktop/frontend-next/src/ui/scope-row.interaction.test.tsx index f4058902cf..09c636b1e0 100644 --- a/desktop/frontend-next/src/ui/scope-row.interaction.test.tsx +++ b/desktop/frontend-next/src/ui/scope-row.interaction.test.tsx @@ -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(); + await userEvent.click(screen.getByRole("button", { name: /app/ })); + await userEvent.click(screen.getByRole("option", { name: /site/ })); + expect(onPick).toHaveBeenCalledWith("/work/site"); + }); +});