From e27f805ffc8fb42dc7f89a3b52a74dbe117b8266 Mon Sep 17 00:00:00 2001 From: xarmian Date: Tue, 19 May 2026 13:17:57 -0400 Subject: [PATCH] feat(web): unify dashboard onboarding banners around needs_onboarding signal (TASK-1530) (#594) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit IDEA-1516 Phase 3. The pre-IDEA-1516 design split workspace onboarding guidance across two banners — OnboardingIdeaBanner (gated on the retired IDEA-1 / BACK-1 / FEAT-1 seed-item pattern from PLAN-1496) and OnboardingChecklist (gated on a totalItems === 0 heuristic that predates the canonical needs_onboarding flag from TASK-1504). Both fired competing CTAs on the same screen; neither read the canonical signal. Backend (internal/server/handlers_dashboard.go): - Add `NeedsOnboarding bool json:"needs_onboarding"` to DashboardResponse, populated via the existing Store.WorkspaceHasUserCreatedItems EXISTS query (same predicate AgentBootstrap.NeedsOnboarding uses). Web reads it from the dashboard fetch the page already does — no second round-trip against the heavier bootstrap endpoint. Frontend: - Add `needs_onboarding: boolean` to TS DashboardResponse type - Delete OnboardingIdeaBanner.svelte entirely (signal retired, no remaining consumers); the back-end onboarding_seed field stays for now per spec — separate cleanup - Delete OnboardingChecklist.svelte; replace with OnboardingNudgeBanner.svelte — single message + "Connect agent →" CTA that opens the workspace's already-mounted ConnectWorkspaceModal. Dismissible, preserves the existing `pad-onboarding-dismissed-{wsSlug}` localStorage key so users who dismissed the old checklist don't get re-prompted - Workspace +page.svelte: collapse the two banner blocks into one gated on `needsOnboarding && !onboardingDismissed`; reshow button follows the same signal. Drop the now-orphaned `.connect-card` CSS — its function is subsumed by the banner's CTA. Drop the unused OnboardingIdeaBanner / OnboardingChecklist imports and the `onboardingSeed` derived state Smart-suppression deferred to a follow-up. The existing api.workspaces.claimCode endpoint returns suppression info but generates a real claim code as a side effect in the not-suppressed case — calling it on every workspace page-load with needs_onboarding=true is awkward. The CTA still opens the modal, which renders its own suppression state correctly; users get the right experience with one extra click on the rare suppressed case. A dedicated read-only GET /workspaces/{ws}/connect-status endpoint is a separate piece of work. --- internal/server/handlers_dashboard.go | 21 + .../lib/components/OnboardingChecklist.svelte | 368 ------------------ .../components/OnboardingIdeaBanner.svelte | 150 ------- .../components/OnboardingNudgeBanner.svelte | 183 +++++++++ web/src/lib/types/index.ts | 5 + .../[username]/[workspace]/+page.svelte | 129 ++---- 6 files changed, 238 insertions(+), 618 deletions(-) delete mode 100644 web/src/lib/components/OnboardingChecklist.svelte delete mode 100644 web/src/lib/components/OnboardingIdeaBanner.svelte create mode 100644 web/src/lib/components/OnboardingNudgeBanner.svelte diff --git a/internal/server/handlers_dashboard.go b/internal/server/handlers_dashboard.go index 07e3fdf8..60bb41ab 100644 --- a/internal/server/handlers_dashboard.go +++ b/internal/server/handlers_dashboard.go @@ -31,6 +31,15 @@ type DashboardResponse struct { // workspace's agent loop is wired up and the banner stops nagging // the user on this workspace. HasAgentActivity bool `json:"has_agent_activity"` + // NeedsOnboarding is true when the workspace has zero items with + // source != 'template' — i.e. nothing beyond what the template + // seeded. Mirrors the canonical AgentBootstrap.NeedsOnboarding + // flag (PLAN-1496 / TASK-1504) so the web UI can render its + // onboarding nudge without making a second bootstrap call. Flips + // false the moment any user/agent-sourced item exists; the + // dashboard's onboarding banner uses this as its sole gating + // signal post IDEA-1516 / TASK-1530. + NeedsOnboarding bool `json:"needs_onboarding"` // OnboardingSeed identifies the seeded onboarding entry point for // the workspace (e.g. IDEA-1 for `startup`, BACK-1 for `scrum`, // FEAT-1 for `product`) when present and untouched. The web UI's @@ -366,6 +375,18 @@ func (s *Server) buildDashboardResponse(workspaceID string, r *http.Request) (*D } resp.HasAgentActivity = hasAgent + // needs_onboarding mirrors AgentBootstrap.NeedsOnboarding (TASK-1504): + // true when the workspace has zero items with source != 'template'. + // Web UI's onboarding nudge banner (TASK-1530) reads this from the + // dashboard fetch the page already does, so no second round-trip + // against the heavier bootstrap endpoint is needed. Predicate is the + // same EXISTS-backed store helper bootstrap uses. + hasUserItems, err := s.store.WorkspaceHasUserCreatedItems(workspaceID) + if err != nil { + return nil, err + } + resp.NeedsOnboarding = !hasUserItems + // Summary: items grouped by collection slug and status field allItems, err := s.store.ListItems(workspaceID, models.ItemListParams{CollectionIDs: dashCollIDs, ItemIDs: dashItemIDs}) if err != nil { diff --git a/web/src/lib/components/OnboardingChecklist.svelte b/web/src/lib/components/OnboardingChecklist.svelte deleted file mode 100644 index 2d47c6dc..00000000 --- a/web/src/lib/components/OnboardingChecklist.svelte +++ /dev/null @@ -1,368 +0,0 @@ - - -
-
-
-

Set up your workspace

-

Complete these steps to get the most out of Pad.

-
- {#if ondismiss} - - {/if} -
- -
- {completedCount} of {steps.length} complete -
-
-
-
- -
    - {#each steps as step (step.title)} -
  1. -
    - {#if step.done} - - - - - {:else} - - - - {/if} -
    -
    - {step.title} - {#if !step.done} - - Try: {step.hint} - - - {/if} -
    -
  2. - {/each} -
- - -
- - diff --git a/web/src/lib/components/OnboardingIdeaBanner.svelte b/web/src/lib/components/OnboardingIdeaBanner.svelte deleted file mode 100644 index 79f2a1ce..00000000 --- a/web/src/lib/components/OnboardingIdeaBanner.svelte +++ /dev/null @@ -1,150 +0,0 @@ - - -
- -
-

Your workspace has a starting point waiting.

-

- Open a fresh agent session — Claude Code, Cursor, Codex, whatever you have — - and say: -

-
- {triggerPhrase} - -
-

- {primaryRef} is a note from your future self to whoever's helping you set up. - The agent will read it and walk through your project with you, capturing - what you tell it — using your real work, not toy data. - Read it first - if you'd like to see what's there. -

-
-
- - diff --git a/web/src/lib/components/OnboardingNudgeBanner.svelte b/web/src/lib/components/OnboardingNudgeBanner.svelte new file mode 100644 index 00000000..6a89a606 --- /dev/null +++ b/web/src/lib/components/OnboardingNudgeBanner.svelte @@ -0,0 +1,183 @@ + + +
+ +
+ Set up your workspace +

+ Your workspace is ready. Connect your agent and type + /pad onboard to walk through setting it up. +

+
+ + Connect agent → + + +
+ + diff --git a/web/src/lib/types/index.ts b/web/src/lib/types/index.ts index 8c9b1dd9..7d6f7bda 100644 --- a/web/src/lib/types/index.ts +++ b/web/src/lib/types/index.ts @@ -751,6 +751,11 @@ export interface DashboardResponse { // the underlying store query also matches). Drives the connect-agent // banner's auto-hide. has_agent_activity: boolean; + // needs_onboarding mirrors AgentBootstrap.NeedsOnboarding (PLAN-1496 / + // TASK-1504): true when the workspace has zero items with + // source != 'template'. Drives the post-IDEA-1516 onboarding nudge + // banner. Flips false the moment any user/agent-sourced item exists. + needs_onboarding: boolean; // onboarding_seed identifies the seeded onboarding entry for the // workspace (e.g. IDEA-1 for `startup`, BACK-1 for `scrum`, // FEAT-1 for `product`). Present + active drives the diff --git a/web/src/routes/[username]/[workspace]/+page.svelte b/web/src/routes/[username]/[workspace]/+page.svelte index 5870da21..bfc7b141 100644 --- a/web/src/routes/[username]/[workspace]/+page.svelte +++ b/web/src/routes/[username]/[workspace]/+page.svelte @@ -8,8 +8,7 @@ import { uiStore } from '$lib/stores/ui.svelte'; import { syncService } from '$lib/services/sync.svelte'; import { relativeTime } from '$lib/utils/markdown'; - import OnboardingChecklist from '$lib/components/OnboardingChecklist.svelte'; - import OnboardingIdeaBanner from '$lib/components/OnboardingIdeaBanner.svelte'; + import OnboardingNudgeBanner from '$lib/components/OnboardingNudgeBanner.svelte'; import ConnectWorkspaceModal from '$lib/components/ConnectWorkspaceModal.svelte'; import CreateCollectionModal from '$lib/components/collections/CreateCollectionModal.svelte'; import { collectionStore } from '$lib/stores/collections.svelte'; @@ -67,13 +66,13 @@ if (mem !== null) isOwner = mem.role === 'owner'; }); - // The dashboard response carries an `onboarding_seed` field when the - // workspace has a seeded onboarding primary (IDEA-1 / BACK-1 / FEAT-1 - // per template). The OnboardingIdeaBanner shows only when that seed - // is still active (status equals its initial value — agent has not - // yet engaged). The server computes `active` so the frontend doesn't - // need a per-collection "what's the initial status" map. - let onboardingSeed = $derived(dashboard?.onboarding_seed); + // Post IDEA-1516 / TASK-1530: the canonical onboarding signal is + // `dashboard.needs_onboarding` (mirrors AgentBootstrap.NeedsOnboarding + // from PLAN-1496 / TASK-1504). The old `onboarding_seed` field still + // rides on the dashboard response (its backend cleanup is out of + // scope) but no longer has a consumer in this page — the + // OnboardingIdeaBanner that read it was retired with this task. + let needsOnboarding = $derived(dashboard?.needs_onboarding ?? false); // Sync dismissed state from localStorage when workspace changes $effect(() => { @@ -271,44 +270,31 @@ - - - {#if onboardingSeed?.active && !onboardingDismissed} + + + {#if needsOnboarding && !onboardingDismissed}
- (connectOpen = true)} + ondismiss={dismissOnboarding} />
- {/if} - {#if totalItems === 0 && !onboardingDismissed} -
- c.slug)} ondismiss={dismissOnboarding} /> - -
- {:else if totalItems === 0 && onboardingDismissed} + {:else if needsOnboarding && onboardingDismissed}
@@ -649,63 +635,6 @@ flex-direction: column; gap: var(--space-3); } - /* Connect-your-local-project card — sibling under OnboardingChecklist. */ - .connect-card { - display: flex; - align-items: center; - gap: var(--space-3); - width: 100%; - padding: var(--space-3) var(--space-4); - background: var(--bg-secondary); - border: 1px solid var(--border); - border-radius: var(--radius-lg); - text-align: left; - cursor: pointer; - color: inherit; - transition: border-color 0.15s, background 0.15s, transform 0.05s; - } - .connect-card:hover { - border-color: var(--accent-blue); - background: color-mix(in srgb, var(--accent-blue) 4%, var(--bg-secondary)); - } - .connect-card:active { - transform: translateY(1px); - } - .connect-card-icon { - display: flex; - align-items: center; - justify-content: center; - width: 36px; - height: 36px; - border-radius: var(--radius); - background: var(--bg-tertiary); - color: var(--accent-blue); - flex-shrink: 0; - } - .connect-card-body { - display: flex; - flex-direction: column; - gap: 2px; - flex: 1; - min-width: 0; - } - .connect-card-title { - font-size: 0.95em; - font-weight: 600; - color: var(--text-primary); - } - .connect-card-subtitle { - font-size: 0.82em; - color: var(--text-muted); - } - .connect-card-cta { - font-size: 1.1em; - color: var(--text-muted); - flex-shrink: 0; - } - .connect-card:hover .connect-card-cta { - color: var(--accent-blue); - } .onboarding-reshow { margin-bottom: var(--space-4); }