mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-11 13:28:57 +00:00
feat(web): unify dashboard onboarding banners around needs_onboarding signal (TASK-1530) (#594)
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.
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -1,368 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { copyToClipboard } from '$lib/utils/clipboard';
|
||||
|
||||
interface Props {
|
||||
wsSlug: string;
|
||||
username?: string;
|
||||
byCollection: Record<string, Record<string, number>>;
|
||||
// Slugs of collections that actually exist in this workspace. Steps
|
||||
// targeting missing collections (e.g. blank-template workspaces with
|
||||
// no plans/tasks/docs) are filtered out so users don't get
|
||||
// "Collection not found" links.
|
||||
collectionSlugs?: string[];
|
||||
ondismiss?: () => void;
|
||||
}
|
||||
|
||||
let { wsSlug, username = '', byCollection, collectionSlugs, ondismiss }: Props = $props();
|
||||
|
||||
let collectionSet = $derived(new Set(collectionSlugs ?? []));
|
||||
function collectionExists(slug: string): boolean {
|
||||
// If collectionSlugs wasn't provided, fall back to "assume exists"
|
||||
// for backward compat with callers that haven't been updated.
|
||||
return collectionSlugs === undefined ? true : collectionSet.has(slug);
|
||||
}
|
||||
|
||||
let copiedHint = $state<string | null>(null);
|
||||
|
||||
function collectionHasItems(slug: string): boolean {
|
||||
const breakdown = byCollection[slug];
|
||||
if (!breakdown) return false;
|
||||
return Object.values(breakdown).reduce((sum, n) => sum + n, 0) > 0;
|
||||
}
|
||||
|
||||
function collectionItemCount(slug: string): number {
|
||||
const breakdown = byCollection[slug];
|
||||
if (!breakdown) return 0;
|
||||
return Object.values(breakdown).reduce((sum, n) => sum + n, 0);
|
||||
}
|
||||
|
||||
interface Step {
|
||||
title: string;
|
||||
href: string;
|
||||
done: boolean;
|
||||
hint: string;
|
||||
// Optional gate: if set, this step is only shown when the target
|
||||
// collection exists in the workspace. The conventions step has no
|
||||
// gate because the conventions collection is system-level and
|
||||
// shipped by every template (including blank).
|
||||
requiresCollection?: string;
|
||||
}
|
||||
|
||||
let allSteps = $derived<Step[]>([
|
||||
{
|
||||
title: 'Add project conventions',
|
||||
href: `/${username}/${wsSlug}/library`,
|
||||
done: collectionHasItems('conventions'),
|
||||
hint: '/pad what conventions should this project follow?'
|
||||
},
|
||||
{
|
||||
title: 'Create your first plan',
|
||||
href: `/${username}/${wsSlug}/plans`,
|
||||
done: collectionHasItems('plans'),
|
||||
hint: '/pad create a plan for what I\'m working on',
|
||||
requiresCollection: 'plans'
|
||||
},
|
||||
{
|
||||
title: 'Add a few tasks',
|
||||
href: `/${username}/${wsSlug}/tasks`,
|
||||
done: collectionItemCount('tasks') >= 3,
|
||||
hint: '/pad break down my current work into tasks',
|
||||
requiresCollection: 'tasks'
|
||||
},
|
||||
{
|
||||
title: 'Write an architecture doc',
|
||||
href: `/${username}/${wsSlug}/docs`,
|
||||
done: collectionHasItems('docs'),
|
||||
hint: '/pad document the architecture of this project',
|
||||
requiresCollection: 'docs'
|
||||
}
|
||||
]);
|
||||
|
||||
let steps = $derived(allSteps.filter(s => !s.requiresCollection || collectionExists(s.requiresCollection)));
|
||||
|
||||
let completedCount = $derived(steps.filter((s) => s.done).length);
|
||||
let progressPct = $derived(steps.length === 0 ? 0 : Math.round((completedCount / steps.length) * 100));
|
||||
|
||||
async function copyHint(text: string) {
|
||||
const ok = await copyToClipboard(text);
|
||||
if (ok) {
|
||||
copiedHint = text;
|
||||
setTimeout(() => { copiedHint = null; }, 1500);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="onboarding">
|
||||
<div class="onboarding-header">
|
||||
<div class="header-text">
|
||||
<h2>Set up your workspace</h2>
|
||||
<p class="subtitle">Complete these steps to get the most out of Pad.</p>
|
||||
</div>
|
||||
{#if ondismiss}
|
||||
<button class="dismiss-btn" onclick={ondismiss} title="Dismiss setup guide" aria-label="Dismiss setup guide">×</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="progress-section">
|
||||
<span class="progress-label">{completedCount} of {steps.length} complete</span>
|
||||
<div class="progress-track">
|
||||
<div class="progress-fill" style:width="{progressPct}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ol class="step-list">
|
||||
{#each steps as step (step.title)}
|
||||
<li class="step" class:done={step.done}>
|
||||
<div class="step-icon">
|
||||
{#if step.done}
|
||||
<svg class="check-icon" viewBox="0 0 20 20" fill="currentColor" width="20" height="20">
|
||||
<circle cx="10" cy="10" r="10" />
|
||||
<path d="M6 10.5l2.5 2.5 5.5-5.5" stroke="#fff" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
{:else}
|
||||
<svg class="empty-icon" viewBox="0 0 20 20" width="20" height="20">
|
||||
<circle cx="10" cy="10" r="9" stroke="currentColor" stroke-width="1.5" fill="none" />
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="step-body">
|
||||
<a href={step.href} class="step-title">{step.title}</a>
|
||||
{#if !step.done}
|
||||
<span class="step-hint">
|
||||
Try: <code>{step.hint}</code>
|
||||
<button
|
||||
class="copy-btn"
|
||||
onclick={() => copyHint(step.hint)}
|
||||
title="Copy to clipboard"
|
||||
>
|
||||
{#if copiedHint === step.hint}
|
||||
Copied!
|
||||
{:else}
|
||||
Copy
|
||||
{/if}
|
||||
</button>
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
|
||||
<div class="onboarding-footer">
|
||||
<p class="footer-instructions">
|
||||
Install the Pad skill in your project with <code>pad agent install</code>, then paste a prompt above into Claude Code or your favorite AI agent.
|
||||
</p>
|
||||
<a href="/{username}/{wsSlug}/library" class="footer-link">Or browse the library for conventions and playbooks</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.onboarding {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: var(--space-6);
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.onboarding-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
margin-bottom: var(--space-5);
|
||||
}
|
||||
|
||||
.header-text {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.onboarding-header h2 {
|
||||
font-size: 1.2em;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 var(--space-1) 0;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 0.88em;
|
||||
color: var(--text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.dismiss-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
font-size: 1.3em;
|
||||
cursor: pointer;
|
||||
padding: 0 var(--space-1);
|
||||
line-height: 1;
|
||||
border-radius: var(--radius-sm);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dismiss-btn:hover {
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
/* Progress */
|
||||
.progress-section {
|
||||
margin-bottom: var(--space-5);
|
||||
}
|
||||
|
||||
.progress-label {
|
||||
display: block;
|
||||
font-size: 0.82em;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.progress-track {
|
||||
height: 6px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: var(--accent-green);
|
||||
border-radius: 3px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
/* Steps */
|
||||
.step-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.step {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3) var(--space-3);
|
||||
border-radius: var(--radius);
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.step:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.step-icon {
|
||||
flex-shrink: 0;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.check-icon {
|
||||
color: var(--accent-green);
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.step-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.step-title {
|
||||
font-size: 0.92em;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.step-title:hover {
|
||||
color: var(--accent-blue);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.done .step-title {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.step-hint {
|
||||
font-size: 0.8em;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.5;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.step-hint code {
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 3px;
|
||||
padding: 1px 5px;
|
||||
font-size: 0.92em;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.copy-btn {
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.88em;
|
||||
cursor: pointer;
|
||||
padding: 0 6px;
|
||||
border-radius: 3px;
|
||||
white-space: nowrap;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.copy-btn:hover {
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.onboarding-footer {
|
||||
margin-top: var(--space-5);
|
||||
padding-top: var(--space-4);
|
||||
border-top: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.footer-link {
|
||||
font-size: 0.85em;
|
||||
color: var(--accent-blue);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.footer-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.footer-instructions {
|
||||
font-size: 0.85em;
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.footer-instructions code {
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 3px;
|
||||
padding: 1px 5px;
|
||||
font-size: 0.92em;
|
||||
}
|
||||
</style>
|
||||
@@ -1,150 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { copyToClipboard } from '$lib/utils/clipboard';
|
||||
|
||||
interface Props {
|
||||
/** Workspace slug — used to link the seeded item into the right URL. */
|
||||
wsSlug: string;
|
||||
/** Workspace owner username — used to build the deep link. */
|
||||
username: string;
|
||||
/** The seeded primary-entry ref (e.g. "IDEA-1", "BACK-1", "FEAT-1").
|
||||
* Drives the trigger phrase the user copies. The dashboard handler
|
||||
* computes this per workspace based on the seeded item; the
|
||||
* component just renders it. */
|
||||
primaryRef: string;
|
||||
/** Slug of the seeded item — used for the "Read it first" link. */
|
||||
ideaSlug: string;
|
||||
/** Collection slug the seeded item lives in (ideas / backlog /
|
||||
* features). Used to build the deep-link path. */
|
||||
collectionSlug: string;
|
||||
}
|
||||
|
||||
let { wsSlug, username, primaryRef, ideaSlug, collectionSlug }: Props = $props();
|
||||
|
||||
let triggerPhrase = $derived(`use pad to get ${primaryRef}`);
|
||||
|
||||
let copied = $state(false);
|
||||
|
||||
async function copyTrigger() {
|
||||
const ok = await copyToClipboard(triggerPhrase);
|
||||
if (ok) {
|
||||
copied = true;
|
||||
setTimeout(() => {
|
||||
copied = false;
|
||||
}, 1500);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="idea-banner">
|
||||
<div class="idea-banner-icon" aria-hidden="true">💡</div>
|
||||
<div class="idea-banner-body">
|
||||
<h2>Your workspace has a starting point waiting.</h2>
|
||||
<p>
|
||||
Open a fresh agent session — Claude Code, Cursor, Codex, whatever you have —
|
||||
and say:
|
||||
</p>
|
||||
<div class="trigger-row">
|
||||
<code class="trigger-phrase">{triggerPhrase}</code>
|
||||
<button class="copy-btn" type="button" onclick={copyTrigger} title="Copy to clipboard">
|
||||
{copied ? 'Copied!' : 'Copy'}
|
||||
</button>
|
||||
</div>
|
||||
<p class="idea-banner-footnote">
|
||||
{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.
|
||||
<a href="/{username}/{wsSlug}/{collectionSlug}/{ideaSlug}">Read it first</a>
|
||||
if you'd like to see what's there.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.idea-banner {
|
||||
display: flex;
|
||||
gap: var(--space-4);
|
||||
align-items: flex-start;
|
||||
background: color-mix(in srgb, var(--accent-blue) 8%, var(--bg-secondary));
|
||||
border: 1px solid color-mix(in srgb, var(--accent-blue) 30%, var(--border));
|
||||
border-radius: var(--radius);
|
||||
padding: var(--space-5);
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.idea-banner-icon {
|
||||
font-size: 1.6em;
|
||||
line-height: 1;
|
||||
margin-top: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.idea-banner-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.idea-banner-body h2 {
|
||||
font-size: 1.05em;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 var(--space-2) 0;
|
||||
}
|
||||
|
||||
.idea-banner-body p {
|
||||
font-size: 0.92em;
|
||||
color: var(--text-secondary);
|
||||
margin: 0 0 var(--space-3) 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.trigger-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-3);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.trigger-phrase {
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
font-size: 0.95em;
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-mono, monospace);
|
||||
user-select: all;
|
||||
}
|
||||
|
||||
.copy-btn {
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.85em;
|
||||
cursor: pointer;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-radius: var(--radius-sm);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.copy-btn:hover {
|
||||
background: var(--bg-primary);
|
||||
border-color: var(--accent-blue);
|
||||
}
|
||||
|
||||
.idea-banner-footnote {
|
||||
font-size: 0.85em !important;
|
||||
color: var(--text-muted) !important;
|
||||
margin: 0 !important;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.idea-banner-footnote a {
|
||||
color: var(--accent-blue);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.idea-banner-footnote a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,183 @@
|
||||
<script lang="ts">
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
interface Props {
|
||||
wsSlug: string;
|
||||
/**
|
||||
* Click handler that opens the workspace's ConnectWorkspaceModal.
|
||||
* Wired by the parent so this banner stays unaware of how the
|
||||
* modal is mounted (the workspace page already mounts a single
|
||||
* ConnectWorkspaceModal instance — passing a handler avoids
|
||||
* mounting a second one here).
|
||||
*/
|
||||
onconnect: () => void;
|
||||
ondismiss?: () => void;
|
||||
}
|
||||
|
||||
let { wsSlug, onconnect, ondismiss }: Props = $props();
|
||||
|
||||
// Dismissal persists in the existing localStorage key shape
|
||||
// (`pad-onboarding-dismissed-{wsSlug}`) per IDEA-1516 §3 — users who
|
||||
// dismissed the old OnboardingChecklist don't get re-prompted by the
|
||||
// new banner. The parent (+page.svelte) owns the canonical dismiss
|
||||
// state; this component just signals up via ondismiss.
|
||||
|
||||
function handleDismiss(event: MouseEvent) {
|
||||
event.stopPropagation();
|
||||
ondismiss?.();
|
||||
if (browser) {
|
||||
localStorage.setItem(`pad-onboarding-dismissed-${wsSlug}`, 'true');
|
||||
}
|
||||
}
|
||||
|
||||
function handleConnect() {
|
||||
onconnect();
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
// Banner-level Enter/Space activates the Connect CTA — but only
|
||||
// when the keydown originates from the banner itself, not from a
|
||||
// nested button (the dismiss X). Mirrors ConnectBanner.svelte's
|
||||
// guard so dismissing via keyboard doesn't also fire connect.
|
||||
if (e.target !== e.currentTarget) return;
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onconnect();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="nudge-banner"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={handleConnect}
|
||||
onkeydown={handleKeydown}
|
||||
>
|
||||
<span class="nudge-icon" aria-hidden="true">✨</span>
|
||||
<div class="nudge-body">
|
||||
<span class="nudge-title">Set up your workspace</span>
|
||||
<p class="nudge-text">
|
||||
Your workspace is ready. Connect your agent and type
|
||||
<code>/pad onboard</code> to walk through setting it up.
|
||||
</p>
|
||||
</div>
|
||||
<span class="nudge-actions">
|
||||
<span class="nudge-cta">Connect agent →</span>
|
||||
<button
|
||||
class="dismiss-btn"
|
||||
type="button"
|
||||
aria-label="Dismiss banner"
|
||||
onclick={handleDismiss}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Mirrors ConnectBanner.svelte's visual language — same border, hover,
|
||||
and focus treatment so the two banners feel like a single system. The
|
||||
nudge-banner is taller because it carries a heading + body, whereas
|
||||
ConnectBanner is a single-line nudge. */
|
||||
.nudge-banner {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
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);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
box-sizing: border-box;
|
||||
text-align: left;
|
||||
}
|
||||
.nudge-banner:hover {
|
||||
border-color: var(--accent-blue);
|
||||
background: color-mix(in srgb, var(--accent-blue) 4%, var(--bg-secondary));
|
||||
}
|
||||
.nudge-banner:focus-visible {
|
||||
outline: 2px solid var(--accent-blue);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.nudge-icon {
|
||||
font-size: 1.2em;
|
||||
line-height: 1.3;
|
||||
flex-shrink: 0;
|
||||
color: var(--accent-blue);
|
||||
}
|
||||
|
||||
.nudge-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.nudge-title {
|
||||
font-size: 0.92em;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.nudge-text {
|
||||
margin: 0;
|
||||
font-size: 0.85em;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.45;
|
||||
}
|
||||
.nudge-text code {
|
||||
background: var(--bg-tertiary);
|
||||
padding: 1px 5px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.95em;
|
||||
}
|
||||
|
||||
.nudge-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
flex-shrink: 0;
|
||||
padding-top: 2px;
|
||||
}
|
||||
.nudge-cta {
|
||||
color: var(--accent-blue);
|
||||
font-weight: 600;
|
||||
font-size: 0.88em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.dismiss-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
font-size: 0.85em;
|
||||
line-height: 1;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.dismiss-btn:hover {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.nudge-banner {
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.nudge-actions {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -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
|
||||
|
||||
@@ -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 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 2. Onboarding -->
|
||||
<!-- Onboarding seed banner: shown only while the seeded primary entry
|
||||
is still untouched (server returns active=true when its status
|
||||
equals the schema initial value). Once the user engages the
|
||||
agent and the status flips, the next dashboard poll returns
|
||||
active=false and the banner disappears. The OnboardingChecklist
|
||||
below remains gated on totalItems === 0 — its surface is empty
|
||||
/ non-templated workspaces; this banner is the surface for
|
||||
templated (seeded) workspaces. -->
|
||||
{#if onboardingSeed?.active && !onboardingDismissed}
|
||||
<!-- 2. Onboarding nudge (IDEA-1516 §3 / TASK-1530) -->
|
||||
<!--
|
||||
Single banner triggered by `dashboard.needs_onboarding`. The
|
||||
pre-IDEA-1516 design split this into two banners — an
|
||||
OnboardingIdeaBanner gated on the seeded primary entry
|
||||
(IDEA-1 / BACK-1 / FEAT-1) plus an OnboardingChecklist gated
|
||||
on totalItems === 0. Both were wired to retired signals
|
||||
(seed-item pattern from PLAN-1496, item-count heuristic
|
||||
predating needs_onboarding) and produced two competing CTAs
|
||||
in the same screen. The new banner reads the canonical
|
||||
AgentBootstrap.NeedsOnboarding signal (mirrored onto the
|
||||
dashboard response by TASK-1530's backend change) and
|
||||
delegates "Connect agent →" to the workspace's already-mounted
|
||||
ConnectWorkspaceModal — same modal the Phase F auto-open hook
|
||||
and ConnectBanner use.
|
||||
-->
|
||||
{#if needsOnboarding && !onboardingDismissed}
|
||||
<div class="onboarding-wrapper">
|
||||
<OnboardingIdeaBanner
|
||||
<OnboardingNudgeBanner
|
||||
{wsSlug}
|
||||
{username}
|
||||
primaryRef={onboardingSeed.ref}
|
||||
ideaSlug={onboardingSeed.slug}
|
||||
collectionSlug={onboardingSeed.collection_slug}
|
||||
onconnect={() => (connectOpen = true)}
|
||||
ondismiss={dismissOnboarding}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{#if totalItems === 0 && !onboardingDismissed}
|
||||
<div class="onboarding-wrapper">
|
||||
<OnboardingChecklist {wsSlug} {username} byCollection={dashboard.summary.by_collection} collectionSlugs={collections.map(c => c.slug)} ondismiss={dismissOnboarding} />
|
||||
<button class="connect-card" type="button" onclick={() => (connectOpen = true)}>
|
||||
<span class="connect-card-icon" aria-hidden="true">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="4 17 10 11 4 5" />
|
||||
<line x1="12" y1="19" x2="20" y2="19" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="connect-card-body">
|
||||
<span class="connect-card-title">Connect your local project</span>
|
||||
<span class="connect-card-subtitle">Manage this workspace from your terminal with the pad CLI.</span>
|
||||
</span>
|
||||
<span class="connect-card-cta" aria-hidden="true">→</span>
|
||||
</button>
|
||||
</div>
|
||||
{:else if totalItems === 0 && onboardingDismissed}
|
||||
{:else if needsOnboarding && onboardingDismissed}
|
||||
<div class="onboarding-reshow">
|
||||
<button class="reshow-btn" onclick={showOnboarding}>Show setup guide</button>
|
||||
</div>
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user