diff --git a/cmd/pad/main.go b/cmd/pad/main.go index 04aa58e5..844bd786 100644 --- a/cmd/pad/main.go +++ b/cmd/pad/main.go @@ -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())) diff --git a/web/src/lib/components/OnboardingIdeaBanner.svelte b/web/src/lib/components/OnboardingIdeaBanner.svelte new file mode 100644 index 00000000..648cfed0 --- /dev/null +++ b/web/src/lib/components/OnboardingIdeaBanner.svelte @@ -0,0 +1,144 @@ + + +
+ +
+

Your workspace has an idea waiting.

+

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

+
+ {TRIGGER_PHRASE} + +
+

+ 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. + Read it first if you'd + like to see what's there. +

+
+
+ + diff --git a/web/src/routes/[username]/[workspace]/+page.svelte b/web/src/routes/[username]/[workspace]/+page.svelte index b7e0f5bc..809f7e21 100644 --- a/web/src/routes/[username]/[workspace]/+page.svelte +++ b/web/src/routes/[username]/[workspace]/+page.svelte @@ -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(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; + 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 @@ + + {#if ideaOneStatus === 'new' && !onboardingDismissed} +
+ +
+ {/if} {#if totalItems === 0 && !onboardingDismissed}