From 517fb29a72a67c9490698c6b5eb0a148e58a2c41 Mon Sep 17 00:00:00 2001 From: Joel Teply Date: Wed, 13 May 2026 12:26:15 -0500 Subject: [PATCH] first-run-ux: welcome modal + first-run gate (PR-B of #1101) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stacked on top of PR-A (feat/empty-states-foundation), which adds the ModalWidget primitive and the `hasOnboarded` field. PR-B is the user-facing flow that consumes both. What ships: - `widgets/onboarding/WelcomeModalWidget.ts` (new). Two short panels wrapped in ``: 1. Intro — what Continuum is, in one paragraph 2. Hand-off — "Helper AI is in General. Send a message there to see the system in motion." Skip-keys note: optional cloud providers in Settings. Step indicator + Back/Next/Got-it buttons. Backdrop / Escape / X dismissal is treated as "completed" so the modal doesn't nag the user the next session. - MainWidget gate. In `onFirstRender`, checks `this.currentUser.hasOnboarded` (loaded by ReactiveWidget's connectedCallback before onFirstRender runs). Falsy → open the modal. On `welcome-complete`, persists `hasOnboarded: true` via `data/update` and reflects the value on the in-memory entity so a re-render doesn't re-open the modal. Persist failure is logged but not surfaced — the worst-case is "modal shows again next session." - Seed: `SYSTEM_ROOM_UNIQUE_IDS` extended with `'general'`. Previous set was `['settings', 'help', 'theme', 'canvas']`, so a fresh install put Helper AI into support rooms only and left General with no AI for users running with zero API keys. The welcome modal's hand-off copy now matches what's actually in the room. The constant is referenced from both the fresh-seed and existing-rooms paths in seed-continuum.ts, so the change applies in both flows. Copy choices (per #1101 discussion): - Skip the API-key step — local inference is enough out-of-box after #336's model evaluation work; Settings is a follow-up, not a blocker. - Feature Helper AI specifically (not GeneralAI, which requires ANTHROPIC_API_KEY and won't be seeded for no-key users). - Tone: warm, brief, system-confident — three short paragraphs total across both panels. Easy to edit at the strings in WelcomeModalWidget. `npm run build:ts` is green. Not visually validated locally — flow gate is in the test plan. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/scripts/seed-continuum.ts | 8 +- src/widgets/main/MainWidget.ts | 52 +++++ src/widgets/onboarding/WelcomeModalWidget.ts | 215 +++++++++++++++++++ 3 files changed, 274 insertions(+), 1 deletion(-) create mode 100644 src/widgets/onboarding/WelcomeModalWidget.ts diff --git a/src/scripts/seed-continuum.ts b/src/scripts/seed-continuum.ts index f8054420b..3bd4bdc8e 100644 --- a/src/scripts/seed-continuum.ts +++ b/src/scripts/seed-continuum.ts @@ -394,7 +394,13 @@ const ALL_EXPECTED_ROOMS = [ { uniqueId: 'code', name: 'code', displayName: 'Code', description: 'Collaborative coding — reading, writing, reviewing, and shipping code as a team', topic: 'Software development with real tools and real agent loops', tags: ['coding', 'development', 'engineering'], recipeId: 'coding' }, ] as const; -const SYSTEM_ROOM_UNIQUE_IDS = ['settings', 'help', 'theme', 'canvas'] as const; +// Helper AI is auto-added to these rooms during seed (both fresh and +// existing-rooms paths). 'general' is included so the first-run welcome +// modal (#1101) can honestly point new users at Helper AI as their +// first conversation partner — without this, a fresh install puts Helper +// in support rooms only, leaving General empty of any AI for users with +// no API keys configured. +const SYSTEM_ROOM_UNIQUE_IDS = ['general', 'settings', 'help', 'theme', 'canvas'] as const; // ===== MAIN SEEDING ===== diff --git a/src/widgets/main/MainWidget.ts b/src/widgets/main/MainWidget.ts index 038103ad9..d1709c2ec 100644 --- a/src/widgets/main/MainWidget.ts +++ b/src/widgets/main/MainWidget.ts @@ -22,6 +22,10 @@ import { jtagGlobal } from '../../system/core/types/GlobalAugmentations'; import { UI_EVENTS } from '../../system/core/shared/EventConstants'; import type { UUID } from '../../system/core/types/CrossPlatformUUID'; import type { ContentItem } from '../../system/data/entities/UserStateEntity'; +import { COLLECTIONS } from '../../system/shared/Constants'; +import { DATA_COMMANDS } from '../../commands/data/shared/DataCommandConstants'; +import type { DataUpdateParams, DataUpdateResult } from '../../commands/data/update/shared/DataUpdateTypes'; +import '../onboarding/WelcomeModalWidget'; import { getWidgetForType, buildContentPath, parseContentPath, getRightPanelConfig, initializeRecipeLayouts } from './shared/ContentTypeRegistry'; import { PositronContentStateAdapter } from '../shared/services/state/PositronContentStateAdapter'; import { PositronWidgetState } from '../shared/services/state/PositronWidgetState'; @@ -45,6 +49,11 @@ export class MainWidget extends ReactiveWidget { // antipattern. setupUrlRouting() sets currentPath from the actual URL. @reactive() private currentPath = ''; + // First-run welcome (#1101). True when the current user's + // `UserEntity.hasOnboarded` is falsy. Set in onFirstRender after + // user context loads; cleared when the modal completes. + @reactive() private _showWelcome = false; + // Non-reactive state (internal tracking) private contentManager!: ContentInfoManager; private currentContent: ContentInfo | null = null; @@ -133,9 +142,44 @@ export class MainWidget extends ReactiveWidget { // Track tab visibility for temperature this.setupVisibilityTracking(); + // First-run welcome (#1101). currentUser is populated by + // ReactiveWidget.connectedCallback() before onFirstRender runs. + // Falsy `hasOnboarded` (including undefined on existing rows + // pre-migration) opens the modal. + if (this.currentUser && !this.currentUser.hasOnboarded) { + this._showWelcome = true; + } + this.log('Main panel initialized'); } + /** + * Fired when the user advances past the final welcome panel — or + * dismisses the modal. Either way, mark the user onboarded so the + * modal doesn't re-appear on the next session. Failure to persist + * just means the modal shows again next time; not worth surfacing. + */ + private async onWelcomeComplete(): Promise { + this._showWelcome = false; + const user = this.currentUser; + if (!user?.id) return; + try { + await this.executeCommand(DATA_COMMANDS.UPDATE, { + collection: COLLECTIONS.USERS, + id: user.id, + data: { hasOnboarded: true }, + backend: 'server', + dbHandle: 'default', + }); + // Reflect immediately on the in-memory entity so a hot re-render + // (e.g. theme switch) doesn't re-open the modal before the next + // page load reloads currentUser from the server. + user.hasOnboarded = true; + } catch (err) { + console.warn('MainWidget: failed to persist hasOnboarded — modal will re-show next session', err); + } + } + // === RENDER === protected override renderContent(): TemplateResult { @@ -162,6 +206,14 @@ export class MainWidget extends ReactiveWidget { About + + + this.onWelcomeComplete()} + > `; } diff --git a/src/widgets/onboarding/WelcomeModalWidget.ts b/src/widgets/onboarding/WelcomeModalWidget.ts new file mode 100644 index 000000000..d2a14507f --- /dev/null +++ b/src/widgets/onboarding/WelcomeModalWidget.ts @@ -0,0 +1,215 @@ +/** + * WelcomeModalWidget — first-run introduction shown to a user whose + * `UserEntity.hasOnboarded` is falsy. Two short panels: + * + * 1. Intro — what Continuum is, in one paragraph + * 2. Hand-off — "Helper AI is in General, say hi" + * + * Wraps the generic ModalWidget. Fires `welcome-complete` when the user + * advances past the final panel; the parent persists + * `hasOnboarded=true` via `data/update`. + * + * Copy is intentionally short and revisable — see #1101 for the policy + * (warm, brief, system-confident-not-salesy). Edit the strings below + * directly; no separate i18n table yet. + * + * Introduced under #1101 PR-B. Depends on `widgets/shared/ModalWidget` + * from PR-A. + */ + +import { LitElement, html, css, type TemplateResult } from 'lit'; +import '../shared/ModalWidget'; + +export class WelcomeModalWidget extends LitElement { + static override properties = { + open: { type: Boolean, reflect: true }, + step: { type: Number }, + } as const; + + open = false; + step = 0; + + static override styles = css` + :host { + display: contents; + } + + .panel { + display: flex; + flex-direction: column; + gap: 12px; + } + + .panel-title { + font-size: 1.25em; + font-weight: 600; + margin: 0; + line-height: 1.25; + } + + .panel-body { + font-size: 0.95em; + line-height: 1.5; + margin: 0; + color: var(--text-secondary, rgba(255, 255, 255, 0.78)); + } + + .panel-body strong { + color: var(--text-primary, #e0e0e0); + } + + .step-indicator { + display: flex; + gap: 6px; + margin-top: 8px; + } + + .step-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--border-subtle, rgba(255, 255, 255, 0.18)); + } + + .step-dot.active { + background: var(--accent-color, #4a9eff); + } + + button { + padding: 8px 16px; + border-radius: 6px; + cursor: pointer; + font-size: 0.95em; + font-weight: 500; + border: 0; + } + + .btn-primary { + background: var(--accent-color, #4a9eff); + color: var(--button-text, #fff); + } + + .btn-primary:hover { + filter: brightness(1.08); + } + + .btn-primary:focus-visible { + outline: 2px solid var(--accent-color, #4a9eff); + outline-offset: 2px; + } + + .btn-secondary { + background: transparent; + color: var(--text-secondary, rgba(255, 255, 255, 0.7)); + border: 1px solid var(--border-subtle, rgba(255, 255, 255, 0.18)); + } + + .btn-secondary:hover { + background: rgba(255, 255, 255, 0.05); + } + `; + + private readonly totalSteps = 2; + + private onNext(): void { + if (this.step < this.totalSteps - 1) { + this.step += 1; + } else { + this.complete(); + } + } + + private onBack(): void { + if (this.step > 0) this.step -= 1; + } + + private complete(): void { + this.open = false; + this.dispatchEvent(new CustomEvent('welcome-complete', { bubbles: true, composed: true })); + } + + /** + * Modal-close fires when the user dismisses via Escape, backdrop, or + * the X button. Treat that as "completed" too — the user has seen the + * intro, no reason to nag them again on next session. + */ + private onModalClose(): void { + this.complete(); + } + + private renderStep(): TemplateResult { + if (this.step === 0) { + return html` +
+

Welcome to Continuum

+

+ Continuum is a shared workspace where you collaborate with humans + and AI personas side-by-side — in chat rooms, on calls, on + documents. The AIs here aren't tools you query; they're + citizens of the workspace, with their own + specialities, memory, and presence. +

+

+ Nothing to configure to get started — you already have a model + running locally. +

+
+ `; + } + return html` +
+

Say hi to Helper AI

+

+ Helper AI is already in your General room. + It runs locally on your machine — no API keys, no cloud round-trips. + Send a message there to see the system in motion. +

+

+ When you want richer responses, head into Settings to plug in + cloud providers like Anthropic, OpenAI, or others. Optional, never required. +

+
+ `; + } + + private renderFooter(): TemplateResult { + const isLast = this.step === this.totalSteps - 1; + return html` + + + ${this.step > 0 + ? html`` + : null} + + `; + } + + override render(): TemplateResult { + return html` + this.onModalClose()} + > + ${this.renderStep()} +
+ ${this.renderFooter()} +
+
+ `; + } +} + +customElements.define('welcome-modal', WelcomeModalWidget); + +declare global { + interface HTMLElementTagNameMap { + 'welcome-modal': WelcomeModalWidget; + } +}