diff --git a/frontend/src/routes/WorkspaceHome.onboard.test.ts b/frontend/src/routes/WorkspaceHome.onboard.test.ts new file mode 100644 index 00000000..ca84680c --- /dev/null +++ b/frontend/src/routes/WorkspaceHome.onboard.test.ts @@ -0,0 +1,39 @@ +/** + * Tests for the WorkspaceHome first-run onboarding gate. + * + * WorkspaceHome swaps its normal activity/projects grid for a guided + * onboarding panel when the workspace has resolved with zero + * repositories. The predicate is mirrored here (matching the + * RepoTabs / AccountNav test convention) so it stays fast and + * dependency-free; any change to `isFreshWorkspace` in the SFC must + * be reflected here. + */ + +import { describe, expect, test } from "bun:test"; + +type LoadState = "loading" | "ready" | "error"; + +// Mirrors `isFreshWorkspace` in WorkspaceHome.vue. +function isFreshWorkspace(loadState: LoadState, repoCount: number): boolean { + return loadState === "ready" && repoCount === 0; +} + +describe("WorkspaceHome onboarding gate", () => { + test("shows onboarding once a resolved workspace has no repos", () => { + expect(isFreshWorkspace("ready", 0)).toBe(true); + }); + + test("hides onboarding while the workspace is still loading", () => { + // Avoid flashing the first-run panel during a slow load. + expect(isFreshWorkspace("loading", 0)).toBe(false); + }); + + test("hides onboarding on a load error", () => { + expect(isFreshWorkspace("error", 0)).toBe(false); + }); + + test("hides onboarding as soon as any repository exists", () => { + expect(isFreshWorkspace("ready", 1)).toBe(false); + expect(isFreshWorkspace("ready", 12)).toBe(false); + }); +}); diff --git a/frontend/src/routes/WorkspaceHome.vue b/frontend/src/routes/WorkspaceHome.vue index 317d012f..72a35aad 100644 --- a/frontend/src/routes/WorkspaceHome.vue +++ b/frontend/src/routes/WorkspaceHome.vue @@ -63,6 +63,26 @@ const workspace = computed(() => payload.value?.workspace ?? { }); const repositories = computed(() => workspace.value.repositories); const extensionCount = computed(() => payload.value?.extensionInstallations?.length ?? 0); + +/** + * A freshly-provisioned single-user forge has no repositories yet. + * Rather than render an empty header strip over an empty activity + * stream — which reads as "broken" on first run — the home swaps to + * a guided first-run panel: the two ways to get a repo into the forge + * (create empty / import by URL, both served by `/new`) plus the two + * navigation affordances a newcomer won't discover on their own + * (⌘K palette, the `comtrya.cue` config model). Only shown once the + * workspace summary has resolved so a slow load doesn't flash it. + */ +const isFreshWorkspace = computed( + () => loadState.value === "ready" && repositories.value.length === 0, +); + +const isMac = + typeof navigator !== "undefined" + ? /mac|iphone|ipad/i.test(navigator.platform || navigator.userAgent || "") + : false; +const cmdLabel = computed(() => (isMac ? "⌘" : "Ctrl")); const extensionRuntime = computed( () => payload.value?.instance?.capabilities?.extensionRuntime ? "enabled" : "disabled", ); @@ -388,7 +408,52 @@ async function fetchWorkspaceHome(signal: AbortSignal): Promise{{ loadError }}

-
+
+
+ First run +

Bring your first repository in

+

+ This workspace is empty. Create a fresh repository or import one you + already have — both land here with their comtrya.cue + config evaluated and ready to browse. +

+
+ +
+ + + + Create a repository + Start empty and push your first commit over git. + + + + + + Import from a URL + Clone an existing repo into the forge by its remote URL. + + +
+ +
    +
  • + {{ cmdLabel }}K + opens the command palette — jump to any repo, issue, or pull. +
  • +
  • + A comtrya.cue at a repo root declares its visibility, + default branch, enabled extensions, and projects. +
  • +
+
+ +