mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-11 13:28:57 +00:00
feat(onboarding): surface IDEA-1 trigger phrase across CLI and web UI (TASK-1134) (#403)
* feat(onboarding): surface IDEA-1 trigger phrase across CLI and web UI (TASK-1134)
Make the seeded onboarding entry point discoverable without prior
knowledge. CONVE-191 calls for full-stack thinking on user-facing
features — this lands on every surface a fresh user might check.
CLI surfaces:
• `pad auth setup` success message gains a closing hint pointing at
`use pad to get IDEA-1` in a new agent session. New helper
printIdeaOneTriggerHint() so future templates can reuse the shape.
• `printOnboardingHints` (used after `pad init` / workspace creation)
now leads with the trigger phrase before the existing /pad prompt
suggestions. IDEA-1 is named because it's the seeded primary entry
in software-category templates; people-category templates will
seed REQ-1 / APP-1 etc. and need a template-aware version of this
hint — tracked under PLAN-1140.
Web UI surfaces:
• New OnboardingIdeaBanner component renders on the workspace
dashboard whenever IDEA-1 is in status=new. Shows the trigger
phrase verbatim with a copy button and a "Read it first" deep link
into the seeded item itself. Disappears the moment the user (or
agent) flips IDEA-1 out of `new`.
• Dashboard fetches IDEA-1 alongside its existing dashboard +
collections calls (cheap, indexed by ref) and re-checks on every
poll (default 30s) plus every sync signal so the banner is
self-correcting.
• Existing OnboardingChecklist gate (`totalItems === 0`) is left
alone. It still serves empty / non-templated workspaces; the new
banner is the templated-workspace surface.
No tests added — both surfaces are pure copy/render. Existing
dashboard + auth-setup tests still pass.
Parent: PLAN-1131. Origin: IDEA-1128.
* fix(onboarding): pin IDEA-1 lookup to exact prefix+number match per Codex review (round 1)
Server-side ResolveItem (via GetItemByRef) falls back from PREFIX-NUMBER
to a number-only lookup when the prefix doesn't match any collection in
the workspace. That fallback exists so an item moved between collections
is still resolvable by its old ref — but it has a bad interaction with
my new dashboard lookup:
In a non-software-category workspace (hiring, interviewing, …), there
is no Ideas collection. `api.items.get(ws, 'IDEA-1')` would silently
return whatever item has item_number=1 — typically REQ-1 (Requisition)
or APP-1 (Application). If that item happened to have status=new
(which the seeded Requisition / Application entries do), the dashboard
would render the IDEA-1 onboarding banner pointing at a /ideas/... URL
that 404s.
Fix: verify item.collection_prefix === 'IDEA' && item.item_number === 1
before trusting the result. Mismatch (or missing) → ideaOneStatus = null,
banner stays hidden. Software workspaces with a real IDEA-1 still match;
hiring / interviewing / interview-loop-style workspaces stop seeing the
banner entirely.
Caught by Codex on PR #403.
* fix(onboarding): guard IDEA-1 lookup against stale-workspace writes per Codex review (round 2)
Previous round addressed the wrong-collection match. This round fixes a
related race: rapid workspace navigation could let a slow loadIdeaOne()
from workspace A resolve after the user is already on workspace B and
write A's status into B's state, briefly rendering the IDEA-1 banner on
a workspace that doesn't have it.
Two-part fix:
1. The dashboard $effect that triggers load() now resets
ideaOneStatus = null synchronously when wsSlug changes, so any
leftover `new` status from the previous workspace can't briefly
render the banner during the window between navigation and the new
fetch resolving.
2. loadIdeaOne() now compares its captured slug against the current
wsSlug at every assignment point (success and error paths). If
they've diverged, the response is dropped — only the active
workspace's request can write ideaOneStatus.
Standard "was this still the active request" pattern. No behavior
change for the common case (single-workspace dashboard); the guard
only fires when navigation interleaves with an in-flight fetch.
Caught by Codex on PR #403.
This commit is contained in:
+24
-1
@@ -841,11 +841,28 @@ func setupCmd() *cobra.Command {
|
||||
green := color.New(color.FgGreen).SprintFunc()
|
||||
fmt.Printf("%s First admin account created\n", green("✓"))
|
||||
fmt.Printf("%s Logged in as %s (%s)\n", green("✓"), resp.User.Name, resp.User.Email)
|
||||
printIdeaOneTriggerHint()
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// printIdeaOneTriggerHint surfaces the trigger phrase that points a fresh
|
||||
// agent session at the seeded IDEA-1 onboarding item. The phrase is named
|
||||
// (not generic) so a copy-paste lands deterministically on the right ref
|
||||
// in software-category workspaces. People-category and other templates
|
||||
// will need a template-aware version of this hint — tracked under
|
||||
// PLAN-1140 (the people-category onboarding plan).
|
||||
func printIdeaOneTriggerHint() {
|
||||
bold := color.New(color.Bold)
|
||||
cyan := color.New(color.FgCyan)
|
||||
|
||||
fmt.Println()
|
||||
bold.Println("To get started:")
|
||||
fmt.Printf(" Open a fresh agent session (Claude Code, Cursor, etc.) and say:\n")
|
||||
fmt.Printf(" %s\n", cyan.Sprint("use pad to get IDEA-1"))
|
||||
}
|
||||
|
||||
func loginCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "login",
|
||||
@@ -1787,8 +1804,14 @@ func printOnboardingHints(cfg *config.Config) {
|
||||
|
||||
fmt.Println()
|
||||
bold.Println("Get started:")
|
||||
// IDEA-1 is the seeded onboarding entry point in software-category
|
||||
// workspaces (`startup` template); naming it specifically here avoids
|
||||
// a generic "your first item" copy that would force the user to
|
||||
// figure out the ref themselves. Other templates seed different
|
||||
// primary-entry refs (REQ-1 / APP-1 / …); making this template-aware
|
||||
// is tracked under PLAN-1140.
|
||||
fmt.Printf(" %s %s\n", cyan.Sprint("/pad"), "use pad to get IDEA-1")
|
||||
fmt.Printf(" %s %s\n", cyan.Sprint("/pad"), "scan this codebase and set up my workspace")
|
||||
fmt.Printf(" %s %s\n", cyan.Sprint("/pad"), "what conventions should this project follow?")
|
||||
fmt.Printf(" %s %s\n", cyan.Sprint("/pad"), "create a plan for what I'm working on")
|
||||
fmt.Println()
|
||||
fmt.Printf("Or open the web UI at %s\n", bold.Sprint(cfg.BrowserURL()))
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
<script lang="ts">
|
||||
import { copyToClipboard } from '$lib/utils/clipboard';
|
||||
|
||||
interface Props {
|
||||
/** Workspace slug — used to link "View IDEA-1" into the right URL. */
|
||||
wsSlug: string;
|
||||
/** Workspace owner username — used to build the IDEA-1 deep link. */
|
||||
username: string;
|
||||
/** Slug of the seeded IDEA-1 (the workspace bootstrap creates it
|
||||
* with this slug). Defaults to the canonical seed slug; passing
|
||||
* it explicitly keeps the component decoupled from the seeder. */
|
||||
ideaSlug?: string;
|
||||
}
|
||||
|
||||
let { wsSlug, username, ideaSlug = 'welcome-lets-get-this-place-set-up' }: Props = $props();
|
||||
|
||||
const TRIGGER_PHRASE = 'use pad to get IDEA-1';
|
||||
|
||||
let copied = $state(false);
|
||||
|
||||
async function copyTrigger() {
|
||||
const ok = await copyToClipboard(TRIGGER_PHRASE);
|
||||
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 an idea 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">{TRIGGER_PHRASE}</code>
|
||||
<button class="copy-btn" type="button" onclick={copyTrigger} title="Copy to clipboard">
|
||||
{copied ? 'Copied!' : 'Copy'}
|
||||
</button>
|
||||
</div>
|
||||
<p class="idea-banner-footnote">
|
||||
IDEA-1 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 as plans, tasks, and ideas — using your real work, not toy data.
|
||||
<a href="/{username}/{wsSlug}/ideas/{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>
|
||||
@@ -8,6 +8,7 @@
|
||||
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 ConnectWorkspaceModal from '$lib/components/ConnectWorkspaceModal.svelte';
|
||||
import { titleStore } from '$lib/stores/title.svelte';
|
||||
import type { DashboardResponse, Collection } from '$lib/types';
|
||||
@@ -22,6 +23,13 @@
|
||||
let onboardingDismissed = $state(false);
|
||||
let connectOpen = $state(false);
|
||||
|
||||
// Status of the seeded IDEA-1 onboarding entry. Drives the
|
||||
// OnboardingIdeaBanner gate: shown only while the user hasn't yet
|
||||
// engaged with the agent (status === 'new'). null = not yet checked /
|
||||
// no IDEA-1 exists in this workspace (e.g. an empty-template or
|
||||
// non-software-category workspace).
|
||||
let ideaOneStatus = $state<string | null>(null);
|
||||
|
||||
// Sync dismissed state from localStorage when workspace changes
|
||||
$effect(() => {
|
||||
if (browser && wsSlug) {
|
||||
@@ -47,7 +55,13 @@
|
||||
// the dashboard to refetch + re-render — a visible flicker. Wrap in
|
||||
// `untrack` so the only tracked dep is `wsSlug` from the if-check.
|
||||
$effect(() => {
|
||||
if (wsSlug) untrack(() => load(wsSlug));
|
||||
if (wsSlug) {
|
||||
// Reset banner state immediately on workspace change so a stale
|
||||
// `new` status from the previous workspace can't briefly render
|
||||
// the IDEA-1 banner before loadIdeaOne resolves for the new one.
|
||||
ideaOneStatus = null;
|
||||
untrack(() => load(wsSlug));
|
||||
}
|
||||
});
|
||||
|
||||
// Workspace home shows only the workspace-level title — clear section/item.
|
||||
@@ -90,6 +104,62 @@
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
// Refresh the seeded IDEA-1 status alongside each dashboard load.
|
||||
// Cheap (single-row lookup, indexed by ref) and self-correcting:
|
||||
// once the user engages the agent and IDEA-1 leaves status=new,
|
||||
// the next poll silently hides the banner.
|
||||
void loadIdeaOne(slug);
|
||||
}
|
||||
|
||||
// loadIdeaOne fetches the seeded IDEA-1 entry's status. A 404 (or any
|
||||
// error) leaves ideaOneStatus null, which keeps the banner hidden —
|
||||
// workspaces that don't ship an onboarding seed should never see the
|
||||
// banner. Errors are intentionally silent: the dashboard is the
|
||||
// primary surface, and a broken sub-fetch should not throw the
|
||||
// dashboard render off.
|
||||
//
|
||||
// IMPORTANT: server-side ResolveItem (in store.GetItemByRef) falls
|
||||
// back from PREFIX-NUMBER to a number-only lookup when the prefix
|
||||
// doesn't match any collection in the workspace. In a non-software
|
||||
// workspace (hiring, interviewing, …), `IDEA-1` would resolve to
|
||||
// whatever item has item_number=1 (REQ-1 / APP-1 / etc.) — and
|
||||
// rendering the banner against that item would link to a missing
|
||||
// /ideas/... page and confuse the user. We pin to the exact ref
|
||||
// before trusting the result.
|
||||
async function loadIdeaOne(slug: string) {
|
||||
try {
|
||||
const item = await api.items.get(slug, 'IDEA-1');
|
||||
// Drop the response if the user navigated to a different
|
||||
// workspace while we were waiting on the network. Otherwise a
|
||||
// slow request from workspace A (where IDEA-1 is `new`) could
|
||||
// land after the user is on workspace B and incorrectly flip
|
||||
// the banner on. Mirrors the standard "was this still the
|
||||
// active request" pattern.
|
||||
if (slug !== wsSlug) return;
|
||||
// Guard against ResolveItem's prefix→number-only fallback: only
|
||||
// accept the result if it is the actual seeded IDEA-1.
|
||||
if (!item || item.collection_prefix !== 'IDEA' || item.item_number !== 1) {
|
||||
ideaOneStatus = null;
|
||||
return;
|
||||
}
|
||||
// item.fields is a JSON string per the API shape (see Item.fields
|
||||
// in lib/types). Empty / malformed payloads collapse to '' so
|
||||
// the banner stays hidden rather than flashing on bad data.
|
||||
let status = '';
|
||||
if (item.fields) {
|
||||
try {
|
||||
const parsed = JSON.parse(item.fields) as Record<string, unknown>;
|
||||
if (typeof parsed.status === 'string') status = parsed.status;
|
||||
} catch {
|
||||
/* leave status empty */
|
||||
}
|
||||
}
|
||||
ideaOneStatus = status;
|
||||
} catch {
|
||||
// Same race-guard applies on the error path: only clear if this
|
||||
// is still the active workspace.
|
||||
if (slug === wsSlug) ideaOneStatus = null;
|
||||
}
|
||||
}
|
||||
|
||||
let totalItems = $derived(dashboard?.summary.total_items ?? 0);
|
||||
@@ -200,6 +270,17 @@
|
||||
</header>
|
||||
|
||||
<!-- 2. Onboarding -->
|
||||
<!-- IDEA-1 banner: shown only while the seeded onboarding entry is
|
||||
still untouched (status === 'new'). Once the user engages the
|
||||
agent and the status flips, the banner disappears on the next
|
||||
dashboard poll. 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 ideaOneStatus === 'new' && !onboardingDismissed}
|
||||
<div class="onboarding-wrapper">
|
||||
<OnboardingIdeaBanner {wsSlug} {username} />
|
||||
</div>
|
||||
{/if}
|
||||
{#if totalItems === 0 && !onboardingDismissed}
|
||||
<div class="onboarding-wrapper">
|
||||
<OnboardingChecklist {wsSlug} {username} byCollection={dashboard.summary.by_collection} ondismiss={dismissOnboarding} />
|
||||
|
||||
Reference in New Issue
Block a user