From db0f1df8baf3230091a100398dd5189e68221caa Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Thu, 20 Aug 2026 22:12:59 -0700 Subject: [PATCH 1/4] Add tests for Files page actions in the top bar --- apps/web/test/library-page-selection.test.tsx | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/apps/web/test/library-page-selection.test.tsx b/apps/web/test/library-page-selection.test.tsx index 8b47fe8ee..e7b36f0fe 100644 --- a/apps/web/test/library-page-selection.test.tsx +++ b/apps/web/test/library-page-selection.test.tsx @@ -262,4 +262,48 @@ describe("LibraryPage top-nav action placement", () => { clone.querySelector('[data-testid="stage-top-bar"]')?.remove(); expect(clone.textContent).not.toContain("Upload"); }); + + test("the filter, sort and view controls sit in the top bar, not a body toolbar", () => { + act(() => { + root.render( + undefined} + workbenchScope={{ title: "Launch plan" }} + scope="all" + onScopeChange={() => undefined} + />, + ); + }); + const topBarActions = container.querySelector( + '[data-testid="stage-top-bar-actions"]', + ); + expect(topBarActions).not.toBeNull(); + expect( + topBarActions?.querySelector('[aria-label="Filter files"]'), + ).not.toBeNull(); + expect( + topBarActions?.querySelector('[aria-label="Files scope"]'), + ).not.toBeNull(); + expect( + topBarActions?.querySelector('[aria-label="Newest first"]'), + ).not.toBeNull(); + expect(topBarActions?.querySelector('[aria-label="View"]')).not.toBeNull(); + expect(container.querySelector(".page-toolbar")).toBeNull(); + }); + + test("scoped filtering is labelled as filtering, never as a second search", () => { + act(() => { + root.render(); + }); + expect(container.querySelector('[aria-label="Search files"]')).toBeNull(); + expect(container.textContent).not.toContain("Search files"); + }); + + test("files open as rows before grids", () => { + act(() => { + root.render(); + }); + expect(container.querySelector('[data-slot="table"]')).not.toBeNull(); + }); }); From 93c2e6676df25f7b947517605327bd6288ded0b5 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Thu, 20 Aug 2026 22:12:59 -0700 Subject: [PATCH 2/4] Files: page actions move to the top nav, scoped search becomes a filter The workbench lens, the name filter, sort, and the rows/grid toggle floated in an unstyled page-body toolbar (`page-toolbar` was never defined in any stylesheet) while only Upload sat in the top bar. All of them now live in `StageTopBar`'s action slot, which DESIGN.md names as the single home for a page's own controls. The scoped input is labelled as filtering rather than as a second "Search files" box: the product has exactly one search surface and the top bar already carries it. --- apps/web/src/pages/library-page.tsx | 118 +++++++++++++++------------- 1 file changed, 62 insertions(+), 56 deletions(-) diff --git a/apps/web/src/pages/library-page.tsx b/apps/web/src/pages/library-page.tsx index a12450e91..14e26c6b5 100644 --- a/apps/web/src/pages/library-page.tsx +++ b/apps/web/src/pages/library-page.tsx @@ -310,8 +310,15 @@ function PreviewPane({ } /** - * Artifact gallery with dense cards (kind badge, title, owner · updated) - * and an in-stage preview when a row is selected. Real data only. + * The Files stage: a row list of everything this workbench owns, with an + * in-stage preview when a row is selected. Real data only. + * + * Every control the page owns — the workbench lens, the name filter, sort, + * the rows/grid toggle, Upload — lives in `StageTopBar`'s action slot + * (DESIGN.md → Pages & Routing: the top nav owns the page's actions, and a + * page body never floats its own). The name filter is a filter control, not + * a second search: the product has exactly one search surface and it is the + * palette the top bar already carries (DESIGN.md → Search). */ export function LibraryPage({ artifacts, @@ -440,6 +447,59 @@ export function LibraryPage({ All ) : null} + {workbenchScope !== null && onScopeChange !== undefined ? ( +
+ + +
+ ) : null} + + + + + + + {(Object.keys(SORT_LABEL) as ArtifactSort[]).map((option) => ( + setSort(option)}> + {SORT_LABEL[option]} + + ))} + + + {onUpload !== undefined ? ( - - - ) : null} - - - - - - - {(Object.keys(SORT_LABEL) as ArtifactSort[]).map((option) => ( - setSort(option)}> - {SORT_LABEL[option]} - - ))} - - - - {uploadError !== undefined && uploadError !== null ? (

{uploadError} From cd0ac1efa673a13990ad5a296cd4c2ee85b8e460 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Thu, 20 Aug 2026 22:15:24 -0700 Subject: [PATCH 3/4] Add tests for skill visibility copy and version-history layout --- apps/web/src/skill-version-author.test.ts | 17 ++++++++++ apps/web/test/skill-detail-page.test.tsx | 39 ++++++++++++++++++++++- 2 files changed, 55 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/skill-version-author.test.ts diff --git a/apps/web/src/skill-version-author.test.ts b/apps/web/src/skill-version-author.test.ts new file mode 100644 index 000000000..32e3d5971 --- /dev/null +++ b/apps/web/src/skill-version-author.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, test } from "bun:test"; + +import { skillVersionSavedBy } from "./skill-version-author"; + +describe("skillVersionSavedBy", () => { + test("a save made through the product reads as the product, not its git identity", () => { + expect(skillVersionSavedBy("interchange-hub")).toBe("Workbench"); + }); + + test("a real person's commit keeps their name", () => { + expect(skillVersionSavedBy("Grace Hopper")).toBe("Grace Hopper"); + }); + + test("surrounding whitespace does not smuggle the internal name through", () => { + expect(skillVersionSavedBy(" interchange-hub ")).toBe("Workbench"); + }); +}); diff --git a/apps/web/test/skill-detail-page.test.tsx b/apps/web/test/skill-detail-page.test.tsx index b9deb801c..c0be8f645 100644 --- a/apps/web/test/skill-detail-page.test.tsx +++ b/apps/web/test/skill-detail-page.test.tsx @@ -221,6 +221,43 @@ describe("SkillDetailPage", () => { expect(table.textContent).toContain("current"); }); + test("a save made through the product is attributed to it, never to an internal name", async () => { + stubs[`GET ${BASE}/versions`] = ok({ + versions: [{ ...VERSIONS[0], author: "interchange-hub" }], + }); + const el = await mount(); + const table = versionTable(el); + expect(table.textContent).toContain("Workbench"); + expect(table.textContent).not.toContain("interchange-hub"); + expect(table.textContent).not.toContain("interch…"); + }); + + test("version history is a full-width surface, not a crowded side column", async () => { + const el = await mount(); + const aside = el.querySelector("aside"); + expect(aside).toBeNull(); + expect(versionTable(el).closest("aside")).toBeNull(); + }); + + test("visibility says who can see the skill and what the action will do", async () => { + const el = await mount(); + expect(el.textContent).toContain("Only you can use this skill."); + expect(buttonNamed(el, "Share with everyone here")).toBeDefined(); + expect(el.textContent).not.toContain("Make private"); + }); + + test("a shared skill says so in the same plain words", async () => { + stubs[`GET ${BASE}`] = ok({ + skill: { ...SKILL, scope: "tenant" }, + pinnedBy: [], + }); + const el = await mount(); + expect(el.textContent).toContain( + "Everyone in this workbench can use this skill.", + ); + expect(buttonNamed(el, "Make it private to me")).toBeDefined(); + }); + test("Save… is offered only once the editor differs from the published version", async () => { const el = await mount(); const bar = el.querySelector('[data-testid="stage-top-bar-actions"]'); @@ -419,7 +456,7 @@ describe("SkillDetailPage", () => { await act(async () => { typeInto("skill-body", "Read the report.\nMy unsaved edit."); }); - await click(buttonNamed(el, "Share with workbench")); + await click(buttonNamed(el, "Share with everyone here")); expect(el.textContent).not.toContain("Couldn't load this skill"); expect(el.textContent).not.toContain("hub is down"); From 8abc7f977b779b6b3674d6fae45af180bcf73440 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Thu, 20 Aug 2026 22:15:24 -0700 Subject: [PATCH 4/4] Skills detail: say who can see a skill, and give version history room MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "SHARED" beside "Make private" was system vocabulary — a person could read both and still not know what the button would do. The page now says plainly who can use the skill today, and the button says what will change ("Share with everyone here" / "Make it private to me"). The quick-peek panel on Plugins says the same words, so the same action never reads two ways. Version history moves out of the narrow side column that squeezed every heading down to a stub ("Versio…", "Updat…", "interch…") and onto the full width of the page. "Who" becomes "Saved by", and a save made through the product is attributed to the product rather than to the git identity the hub commits under — an internal name a reader would otherwise take for a teammate. --- .../src/pages/plugin-skill-detail-panel.tsx | 14 +- apps/web/src/pages/skill-detail-page.tsx | 344 +++++++++--------- apps/web/src/skill-version-author.ts | 19 + 3 files changed, 201 insertions(+), 176 deletions(-) create mode 100644 apps/web/src/skill-version-author.ts diff --git a/apps/web/src/pages/plugin-skill-detail-panel.tsx b/apps/web/src/pages/plugin-skill-detail-panel.tsx index ae494dd23..3184724f7 100644 --- a/apps/web/src/pages/plugin-skill-detail-panel.tsx +++ b/apps/web/src/pages/plugin-skill-detail-panel.tsx @@ -131,12 +131,12 @@ export function PluginSkillDetailPanel({ ) : ( -

- +
+

{state.skill.scope === "tenant" - ? "Shared with everyone" - : "Just you"} - + ? "Everyone in this workbench can use this skill." + : "Only you can use this skill."} +

diff --git a/apps/web/src/pages/skill-detail-page.tsx b/apps/web/src/pages/skill-detail-page.tsx
index 8690185ab..e909c1f38 100644
--- a/apps/web/src/pages/skill-detail-page.tsx
+++ b/apps/web/src/pages/skill-detail-page.tsx
@@ -20,6 +20,12 @@
 //
 // The editor buffer is newline-normalized, so the bytes reviewed in the
 // diff are exactly the bytes the confirm writes.
+//
+// The page reads top to bottom in one column: who can see the skill, the
+// description agents match on, the body, then the full version history.
+// History was a narrow side column, which left every one of its columns
+// truncated to a stub — a table nobody can read teaches nothing, so it now
+// gets the full width it needs.
 
 import {
   Badge,
@@ -51,6 +57,7 @@ import { useCallback, useEffect, useState, type ReactNode } from "react";
 
 import { useBench } from "../bench-context";
 import { SKILLS_PATH_PREFIX, skillIdFromPath } from "../path-ids";
+import { skillVersionSavedBy } from "../skill-version-author";
 import { StageTopBar } from "../shell/stage-top-bar";
 import {
   listSkillVersions,
@@ -323,10 +330,12 @@ export function SkillDetailPage({
             Updated {formatRelativeTime(skill.updatedAtIso, now)}
           

-
- - {shared ? "Shared" : "Private"} - +
+

+ {shared + ? "Everyone in this workbench can use this skill." + : "Only you can use this skill."} +

@@ -353,175 +362,172 @@ export function SkillDetailPage({

)} -
-
-
-