fix(canvas): collapse Org Templates section by default in palette

The TemplatePalette's Org Templates section rendered all cards
inline, each ~120 px tall (name + description + "Import org" button).
With 4 org templates on disk that's ~500 px of drawer height — the
individual workspace templates at the top (AutoGen / LangGraph /
Hermes / …) got pushed off-screen, which is the exact complaint from
the test session ("templates still 90% org, cant even see normal
workspace template").

Collapsed the Org Templates section by default. The header now
toggles with an ▶ caret and shows the count ("Org Templates (4)").
Clicking expands to reveal the full card list; clicking again
collapses. Persists only within a session — fresh mounts start
collapsed so the primary deploy path stays visible.

Individual workspace templates are the usual starting point (pick a
runtime, deploy one agent), while org templates are a heavier
"deploy this whole pre-built team" action. Making the second
expandable matches the relative frequency.

- `TemplatePalette.tsx::OrgTemplatesSection` — added `expanded`
  state (default false), wrapped the cards in `{expanded && …}`,
  turned the header into a toggle button with `aria-expanded` +
  `aria-controls`.
- `__tests__/OrgTemplatesSection.test.tsx` — 3 new rendering tests:
  collapsed-by-default (cards absent), click expands (cards appear),
  click again collapses (cards gone). Mocks /org/templates with a
  2-entry response so the count assertion is stable.

Full canvas vitest: 930/930 pass (up from 927).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hongming Wang 2026-04-23 16:24:49 -07:00
parent b4719ad070
commit 03b56fa5af
2 changed files with 132 additions and 2 deletions

View File

@ -54,6 +54,13 @@ export function OrgTemplatesSection() {
const [loading, setLoading] = useState(false);
const [importing, setImporting] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
// Collapsed by default — org templates are multi-workspace imports
// that most new users don't reach for first. Keeping them
// expand-on-demand frees ~400 px of vertical space for the
// individual workspace templates above, which is the primary
// deploy path. The count in the header still makes discovery
// obvious: "Org Templates (4) ▸".
const [expanded, setExpanded] = useState(false);
const loadOrgs = useCallback(async () => {
setLoading(true);
@ -80,9 +87,26 @@ export function OrgTemplatesSection() {
return (
<div className="space-y-2" data-testid="org-templates-section">
<div className="flex items-center justify-between">
<h3 className="text-[10px] uppercase tracking-wide text-zinc-500 font-semibold">
<button
type="button"
onClick={() => setExpanded((v) => !v)}
aria-expanded={expanded}
aria-controls="org-templates-body"
className="flex items-center gap-1.5 text-[10px] uppercase tracking-wide text-zinc-500 hover:text-zinc-300 font-semibold transition-colors"
>
<span
aria-hidden="true"
className={`inline-block text-[8px] transition-transform duration-150 ${expanded ? "rotate-90" : ""}`}
>
</span>
Org Templates
</h3>
{orgs.length > 0 && (
<span className="text-zinc-600 normal-case tracking-normal">
({orgs.length})
</span>
)}
</button>
<button
onClick={loadOrgs}
aria-label="Refresh org templates"
@ -92,6 +116,8 @@ export function OrgTemplatesSection() {
</button>
</div>
{expanded && (
<div id="org-templates-body" className="space-y-2">
{loading && (
<div role="status" aria-live="polite" className="flex items-center gap-1.5 text-[10px] text-zinc-500">
<Spinner size="sm" />
@ -141,6 +167,8 @@ export function OrgTemplatesSection() {
</div>
);
})}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,102 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, waitFor, fireEvent, cleanup } from "@testing-library/react";
// Tests for the default-collapsed + expand-on-click behavior of the
// org templates drawer. Before this change the section rendered all
// org cards inline, which pushed the individual workspace templates
// off-screen when there were ≥3 orgs on disk. Collapsed-by-default
// keeps the scroll focused on the primary deploy path.
vi.mock("@/lib/api", () => ({
api: {
get: vi.fn().mockResolvedValue([
{ dir: "free-beats-all", name: "Free Beats All", description: "d1", workspaces: 3 },
{ dir: "medo-smoke", name: "MeDo Smoke Test", description: "d2", workspaces: 1 },
]),
post: vi.fn().mockResolvedValue({}),
},
}));
vi.mock("../Spinner", () => ({ Spinner: () => null }));
vi.mock("../MissingKeysModal", () => ({ MissingKeysModal: () => null }));
vi.mock("../ConfirmDialog", () => ({ ConfirmDialog: () => null }));
vi.mock("@/lib/deploy-preflight", () => ({ checkDeploySecrets: vi.fn() }));
import { OrgTemplatesSection } from "../TemplatePalette";
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
cleanup();
});
describe("OrgTemplatesSection — collapse/expand", () => {
it("renders collapsed by default — org cards are NOT in the DOM", async () => {
render(<OrgTemplatesSection />);
// The header toggle is visible immediately…
// Two buttons match "Org Templates" (toggle + refresh) — pick the
// toggle by its aria-controls binding.
const toggle = (await screen.findAllByRole("button")).find((b) =>
b.getAttribute("aria-controls") === "org-templates-body"
)!;
expect(toggle).toBeTruthy();
expect(toggle.getAttribute("aria-expanded")).toBe("false");
// …and the count appears after loadOrgs resolves.
await waitFor(() => {
expect(toggle.textContent).toContain("(2)");
});
// But none of the individual org cards should be rendered yet.
expect(screen.queryByText("Free Beats All")).toBeNull();
expect(screen.queryByText("MeDo Smoke Test")).toBeNull();
});
it("clicking the header reveals the org cards", async () => {
render(<OrgTemplatesSection />);
// Wait for the count so we know loadOrgs finished.
// Two buttons match "Org Templates" (toggle + refresh) — pick the
// toggle by its aria-controls binding.
const toggle = (await screen.findAllByRole("button")).find((b) =>
b.getAttribute("aria-controls") === "org-templates-body"
)!;
await waitFor(() => {
expect(toggle.textContent).toContain("(2)");
});
// Expand.
fireEvent.click(toggle);
await waitFor(() => {
expect(toggle.getAttribute("aria-expanded")).toBe("true");
});
// Org cards now visible.
expect(screen.getByText("Free Beats All")).toBeTruthy();
expect(screen.getByText("MeDo Smoke Test")).toBeTruthy();
});
it("clicking the header again collapses back", async () => {
render(<OrgTemplatesSection />);
// Two buttons match "Org Templates" (toggle + refresh) — pick the
// toggle by its aria-controls binding.
const toggle = (await screen.findAllByRole("button")).find((b) =>
b.getAttribute("aria-controls") === "org-templates-body"
)!;
await waitFor(() => {
expect(toggle.textContent).toContain("(2)");
});
fireEvent.click(toggle); // expand
expect(screen.getByText("Free Beats All")).toBeTruthy();
fireEvent.click(toggle); // collapse
await waitFor(() => {
expect(toggle.getAttribute("aria-expanded")).toBe("false");
});
expect(screen.queryByText("Free Beats All")).toBeNull();
});
});