From 45aa2b3d9ef14ac1a9d1e6c2a6aa99dacca30770 Mon Sep 17 00:00:00 2001 From: Joel Teply Date: Wed, 13 May 2026 12:02:42 -0500 Subject: [PATCH 1/2] first-run-ux: foundations + empty states (PR-A of #1101) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three additive pieces that PR-B (welcome modal) will sit on top of, and that already improve UX in their own right by replacing blank list panels with explanatory empty states. 1. UserEntity gains `hasOnboarded?: boolean` (BooleanField, nullable). Per-user, cross-device — falsy on existing rows so the welcome modal (PR-B) defaults to "show." entity_schemas.json regenerated. 2. New shared widget `widgets/shared/ModalWidget.ts` — generic Lit dialog. Reactive `open` / `modalTitle` / `closable` properties; focus trap, Escape + backdrop dismiss, focus restoration on close; role=dialog + aria-modal=true + aria-labelledby out of the box. Slots for default body + footer. Reusable for any future modal need beyond onboarding (settings dialogs, confirms, etc.). 3. New shared widget `widgets/shared/EmptyStateWidget.ts` — `` custom element with icon / title / subtitle / optional action button. Fires `empty-state-action` event when the action is clicked. Drops into any list or content area that can be legitimately empty. Wiring (3 surfaces, all behind a load-completed gate so the empty state never flashes during initial scroller hydration): ReactiveEntityScrollerWidget — new `protected get isEmpty()` returns true only after the scroller's first load has completed AND the entity count is zero. Subclasses use this to decide whether to render an empty UI. ReactiveListWidget — new virtual `renderEmptyState()` returning `nothing` by default; main render hides the container and shows the empty state when `isEmpty` is true. RoomListWidget — overrides `renderEmptyState()`. Copy depends on the active filter (DM filter → "No direct messages yet" / "Open a DM..."; rooms filter → "No rooms yet" / "Rooms are shared spaces..."). No "create your first room" CTA wired yet; left for a follow-up once room-creation UX lands. UserListWidget — overrides `renderEmptyState()`. Copy depends on whether any type/status filter is active. The widget overrides `render()` directly (bypasses base render) so we mirror the same ?hidden + empty-state conditional locally. ChatWidget — uses string-based templates (not Lit), so wired differently. `` element added to renderTemplate with `hidden` set; `updateEntityCount()` is overridden to toggle the attribute based on `getEntityCount() === 0` after every CRUD event + the initial post-load count update. Initial `hidden` prevents flash during room-switch loading. Out of scope (PR-B): welcome modal, first-run gate in MainWidget, write-back of `hasOnboarded=true` on modal completion, tutorial- persona seeding verification. `npm run build:ts` is green. Not visually validated locally — deploy + screenshot is the gate before un-drafting. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/shared/generated/entity_schemas.json | 11 +- src/system/data/entities/UserEntity.ts | 7 + src/widgets/chat/chat-widget/ChatWidget.ts | 29 ++ src/widgets/chat/room-list/RoomListWidget.ts | 19 ++ src/widgets/chat/user-list/UserListWidget.ts | 18 +- src/widgets/shared/EmptyStateWidget.ts | 128 +++++++++ src/widgets/shared/ModalWidget.ts | 271 ++++++++++++++++++ .../shared/ReactiveEntityScrollerWidget.ts | 10 + src/widgets/shared/ReactiveListWidget.ts | 14 +- 9 files changed, 503 insertions(+), 4 deletions(-) create mode 100644 src/widgets/shared/EmptyStateWidget.ts create mode 100644 src/widgets/shared/ModalWidget.ts diff --git a/src/shared/generated/entity_schemas.json b/src/shared/generated/entity_schemas.json index 3ef7d8b32..585466382 100644 --- a/src/shared/generated/entity_schemas.json +++ b/src/shared/generated/entity_schemas.json @@ -1,7 +1,7 @@ { "$schemaVersion": 1, - "$generatedAt": "2026-04-16T16:01:24.629Z", - "$sha256": "8cf44380640f9ba2f2e56548259b69d71c31b22c4a9553a74e92d23a82033f20", + "$generatedAt": "2026-05-13T17:01:40.910Z", + "$sha256": "27d02233ae3839f7fad6affbd9b4e308a7a08c3bb72329aafa2cb39fcbcd3217", "entities": { "users": { "collection": "users", @@ -147,6 +147,13 @@ "nullable": true, "references": "genomes.id" } + }, + { + "fieldName": "hasOnboarded", + "fieldType": "boolean", + "options": { + "nullable": true + } } ], "compositeIndexes": [], diff --git a/src/system/data/entities/UserEntity.ts b/src/system/data/entities/UserEntity.ts index 670260918..589f7b4e7 100644 --- a/src/system/data/entities/UserEntity.ts +++ b/src/system/data/entities/UserEntity.ts @@ -96,6 +96,7 @@ import { EnumField, JsonField, ForeignKeyField, + BooleanField, TEXT_LENGTH } from '../decorators/FieldDecorators'; import { BaseEntity } from './BaseEntity'; @@ -174,6 +175,12 @@ export class UserEntity extends BaseEntity { @ForeignKeyField({ references: 'genomes.id', nullable: true }) genomeId?: UUID; + // First-run onboarding state. Per-user, cross-device — the welcome + // modal is shown when this is falsy and set to true when the user + // completes (or dismisses) the introduction. Tracked under #1101. + @BooleanField({ nullable: true }) + hasOnboarded?: boolean; + // ✨ DECORATOR-DRIVEN AUTO-JOIN: Profile always included (future: @JoinField decorator) // For now, manually joined - decorator system will automate this profile?: UserProfileEntity; diff --git a/src/widgets/chat/chat-widget/ChatWidget.ts b/src/widgets/chat/chat-widget/ChatWidget.ts index 58c591d46..4b011cf2e 100644 --- a/src/widgets/chat/chat-widget/ChatWidget.ts +++ b/src/widgets/chat/chat-widget/ChatWidget.ts @@ -29,6 +29,7 @@ import { ImageMessageAdapter } from '../adapters/ImageMessageAdapter'; import { URLCardAdapter } from '../adapters/URLCardAdapter'; import { ToolOutputAdapter } from '../adapters/ToolOutputAdapter'; import { TextMessageAdapter } from '../adapters/TextMessageAdapter'; +import '../../shared/EmptyStateWidget'; import { MessageInputEnhancer } from '../message-input/MessageInputEnhancer'; import { MentionAutocomplete } from '../message-input/MentionAutocomplete'; import { AIStatusIndicator } from './AIStatusIndicator'; @@ -971,6 +972,17 @@ export class ChatWidget extends EntityScrollerWidget { + + +
${this.renderFooter()} @@ -989,6 +1001,23 @@ export class ChatWidget extends EntityScrollerWidget { `; } + /** + * Toggle the empty-state panel on top of the standard count-badge + * update. The base implementation only updates the .list-count text; + * we also reveal the "Send your first message" panel when the room + * has zero messages so a new user isn't staring at a blank surface. + * Called after the initial scroller load and after every CRUD event + * — the messages-container is hidden via CSS sibling rules during + * the empty state to avoid a stacked-empty-box look. + */ + protected override updateEntityCount(): void { + super.updateEntityCount(); + const emptyState = this.shadowRoot?.getElementById('chatEmptyState') as HTMLElement | null; + if (!emptyState) return; + const isEmpty = this.getEntityCount() === 0; + emptyState.toggleAttribute('hidden', !isEmpty); + } + /** * Render thumbnail chips for pendingAttachments above the textarea. * Image attachments get a thumbnail; non-image attachments get a filename chip. diff --git a/src/widgets/chat/room-list/RoomListWidget.ts b/src/widgets/chat/room-list/RoomListWidget.ts index f5dfb0368..ffe9a1d25 100644 --- a/src/widgets/chat/room-list/RoomListWidget.ts +++ b/src/widgets/chat/room-list/RoomListWidget.ts @@ -16,6 +16,7 @@ import { type TemplateResult, type CSSResultGroup } from '../../shared/ReactiveListWidget'; +import '../../shared/EmptyStateWidget'; import { RoomEntity } from '../../../system/data/entities/RoomEntity'; import { UserEntity } from '../../../system/data/entities/UserEntity'; import { CONTENT_TYPE_CONFIGS, type ContentType } from '../../../shared/generated/ContentTypes'; @@ -116,6 +117,24 @@ export class RoomListWidget extends ReactiveListWidget { this.scroller?.load(); } + // === EMPTY STATE === (#1101) + protected override renderEmptyState(): TemplateResult { + // Copy depends on which filter is active so the message matches what + // the user is looking at. The "create your first room" CTA is left + // unwired for now — emits an event the parent can listen for once + // room-creation UX lands. + const isDmFilter = this.activeFilter === 'dms'; + return html` + + `; + } + // === ITEM === renderItem(room: RoomEntity): TemplateResult { if (this.isDM(room)) { diff --git a/src/widgets/chat/user-list/UserListWidget.ts b/src/widgets/chat/user-list/UserListWidget.ts index e943c42f5..95734e938 100644 --- a/src/widgets/chat/user-list/UserListWidget.ts +++ b/src/widgets/chat/user-list/UserListWidget.ts @@ -16,6 +16,7 @@ import { type TemplateResult, type CSSResultGroup } from '../../shared/ReactiveListWidget'; +import '../../shared/EmptyStateWidget'; import { render } from 'lit'; import type { RenderFn, RenderContext } from '../../shared/EntityScroller'; import { UserEntity } from '../../../system/data/entities/UserEntity'; @@ -177,12 +178,27 @@ export class UserListWidget extends ReactiveListWidget { return html`
${this.renderHeader()} -
+
+ ${this.isEmpty ? this.renderEmptyState() : nothing} ${this.renderFooter()}
`; } + // === EMPTY STATE === (#1101) + protected override renderEmptyState(): TemplateResult { + const filterActive = this.activeFilters.size > 0 && !this.activeFilters.has('all'); + return html` + + `; + } + // === ITEM RENDERING === renderItem(user: UserEntity): TemplateResult { const displayName = user.displayName ?? 'Unknown User'; diff --git a/src/widgets/shared/EmptyStateWidget.ts b/src/widgets/shared/EmptyStateWidget.ts new file mode 100644 index 000000000..ecea71a9b --- /dev/null +++ b/src/widgets/shared/EmptyStateWidget.ts @@ -0,0 +1,128 @@ +/** + * EmptyStateWidget — generic "no items yet" panel. + * + * Drop into any list or content area that can be empty (no messages, + * no rooms, no personas). The user sees an icon, a title, an optional + * subtitle, and an optional action button instead of an unexplained + * blank surface. + * + * Properties: + * - icon: string — emoji or single character (decorative, aria-hidden) + * - emptyTitle: string — heading text + * - subtitle: string — explanatory text under the heading (optional) + * - actionLabel: string — text on the call-to-action button. If empty, + * no button is rendered. + * + * Events: + * - empty-state-action: fired when the action button is clicked + * + * Slots: + * - default: extra content rendered below the subtitle + * + * Introduced under #1101 (first-run UX) as part of PR-A. + */ + +import { LitElement, html, css, type TemplateResult } from 'lit'; + +export class EmptyStateWidget extends LitElement { + static override properties = { + icon: { type: String }, + emptyTitle: { type: String, attribute: 'empty-title' }, + subtitle: { type: String }, + actionLabel: { type: String, attribute: 'action-label' }, + } as const; + + icon = ''; + emptyTitle = ''; + subtitle = ''; + actionLabel = ''; + + static override styles = css` + :host { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; + padding: 32px 24px; + text-align: center; + color: var(--text-muted, rgba(255, 255, 255, 0.55)); + min-height: 200px; + } + + .empty-icon { + font-size: 2.5em; + line-height: 1; + opacity: 0.7; + } + + .empty-title { + font-size: 1.1em; + font-weight: 600; + margin: 0; + color: var(--text-primary, #e0e0e0); + } + + .empty-subtitle { + font-size: 0.92em; + max-width: 42ch; + margin: 0; + line-height: 1.45; + } + + .empty-action { + margin-top: 8px; + padding: 8px 16px; + background: var(--accent-color, #4a9eff); + color: var(--button-text, #fff); + border: 0; + border-radius: 6px; + cursor: pointer; + font-size: 0.95em; + font-weight: 500; + } + + .empty-action:hover { + filter: brightness(1.08); + } + + .empty-action:focus-visible { + outline: 2px solid var(--accent-color, #4a9eff); + outline-offset: 2px; + } + `; + + private onActionClick(): void { + this.dispatchEvent(new CustomEvent('empty-state-action', { bubbles: true, composed: true })); + } + + override render(): TemplateResult { + return html` + ${this.icon + ? html`` + : null} + ${this.emptyTitle + ? html`

${this.emptyTitle}

` + : null} + ${this.subtitle + ? html`

${this.subtitle}

` + : null} + + ${this.actionLabel + ? html`` + : null} + `; + } +} + +customElements.define('empty-state', EmptyStateWidget); + +declare global { + interface HTMLElementTagNameMap { + 'empty-state': EmptyStateWidget; + } +} diff --git a/src/widgets/shared/ModalWidget.ts b/src/widgets/shared/ModalWidget.ts new file mode 100644 index 000000000..cd3292d6b --- /dev/null +++ b/src/widgets/shared/ModalWidget.ts @@ -0,0 +1,271 @@ +/** + * ModalWidget — generic Lit modal dialog. + * + * Reactive `open` property. When opened, traps focus inside, restores + * focus on close, listens for Escape and backdrop clicks. Accessible + * by default: role="dialog", aria-modal="true", aria-labelledby on the + * title. + * + * Slots: + * - default: modal body content + * - footer: action buttons (optional) + * + * Properties: + * - open: boolean — whether the modal is visible + * - modalTitle: string — title text (drives aria-labelledby) + * - closable: boolean — whether the user can dismiss via X / Escape / + * backdrop. Set false for required flows. Defaults true. + * + * Events: + * - modal-close: fired when the user dismisses the modal + * + * Introduced under #1101 (first-run UX) as part of PR-A. Designed to + * be reusable for any future modal need — settings dialogs, confirms, + * onboarding flows. + */ + +import { LitElement, html, css, type TemplateResult } from 'lit'; + +const FOCUSABLE_SELECTOR = [ + 'a[href]', + 'button:not([disabled])', + 'input:not([disabled])', + 'textarea:not([disabled])', + 'select:not([disabled])', + '[tabindex]:not([tabindex="-1"])', +].join(','); + +export class ModalWidget extends LitElement { + static override properties = { + open: { type: Boolean, reflect: true }, + modalTitle: { type: String, attribute: 'modal-title' }, + closable: { type: Boolean }, + } as const; + + open = false; + modalTitle = ''; + closable = true; + + private _previouslyFocused: HTMLElement | null = null; + private _onKeyDown = (e: KeyboardEvent) => this.handleKeyDown(e); + + static override styles = css` + :host { + display: contents; + } + + .modal-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.55); + display: flex; + align-items: center; + justify-content: center; + z-index: 9999; + animation: fade-in 120ms ease-out; + } + + .modal-dialog { + background: var(--surface-primary, #1e1e1e); + color: var(--text-primary, #e0e0e0); + border: 1px solid var(--border-subtle, rgba(255, 255, 255, 0.1)); + border-radius: 10px; + min-width: 320px; + max-width: min(560px, 90vw); + max-height: 90vh; + display: flex; + flex-direction: column; + box-shadow: 0 12px 48px rgba(0, 0, 0, 0.45); + animation: zoom-in 150ms cubic-bezier(0.2, 0.9, 0.2, 1.1); + } + + .modal-header { + display: flex; + align-items: center; + gap: 8px; + padding: 14px 16px; + border-bottom: 1px solid var(--border-subtle, rgba(255, 255, 255, 0.1)); + } + + .modal-title { + flex: 1; + font-size: 1.1em; + font-weight: 600; + margin: 0; + } + + .modal-close { + background: transparent; + border: 0; + color: inherit; + cursor: pointer; + font-size: 1.2em; + padding: 4px 8px; + border-radius: 4px; + line-height: 1; + } + + .modal-close:hover { + background: rgba(255, 255, 255, 0.08); + } + + .modal-body { + padding: 16px; + overflow-y: auto; + flex: 1; + } + + .modal-footer { + display: flex; + justify-content: flex-end; + gap: 8px; + padding: 12px 16px; + border-top: 1px solid var(--border-subtle, rgba(255, 255, 255, 0.1)); + } + + .modal-footer:empty { + display: none; + } + + @keyframes fade-in { + from { opacity: 0; } + to { opacity: 1; } + } + + @keyframes zoom-in { + from { transform: scale(0.96); opacity: 0; } + to { transform: scale(1); opacity: 1; } + } + `; + + override connectedCallback(): void { + super.connectedCallback(); + document.addEventListener('keydown', this._onKeyDown); + } + + override disconnectedCallback(): void { + document.removeEventListener('keydown', this._onKeyDown); + super.disconnectedCallback(); + } + + override updated(changed: Map): void { + if (changed.has('open')) { + if (this.open) { + this._previouslyFocused = (this.getRootNode() as Document).activeElement as HTMLElement | null; + // Defer focusing to next paint so the dialog is in the DOM. + requestAnimationFrame(() => this.focusFirstElement()); + } else if (this._previouslyFocused) { + this._previouslyFocused.focus?.(); + this._previouslyFocused = null; + } + } + } + + private handleKeyDown(e: KeyboardEvent): void { + if (!this.open) return; + if (e.key === 'Escape' && this.closable) { + e.stopPropagation(); + this.requestClose(); + return; + } + if (e.key === 'Tab') { + this.trapFocus(e); + } + } + + private trapFocus(e: KeyboardEvent): void { + const focusable = this.getFocusableElements(); + if (focusable.length === 0) return; + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + const active = this.shadowRoot?.activeElement as HTMLElement | null; + if (e.shiftKey && active === first) { + e.preventDefault(); + last.focus(); + } else if (!e.shiftKey && active === last) { + e.preventDefault(); + first.focus(); + } + } + + private getFocusableElements(): HTMLElement[] { + const dialog = this.shadowRoot?.querySelector('.modal-dialog'); + if (!dialog) return []; + return Array.from(dialog.querySelectorAll(FOCUSABLE_SELECTOR)); + } + + private focusFirstElement(): void { + const focusable = this.getFocusableElements(); + if (focusable.length > 0) { + focusable[0].focus(); + } else { + // Fallback: focus the dialog itself so Escape still works + (this.shadowRoot?.querySelector('.modal-dialog') as HTMLElement | null)?.focus(); + } + } + + /** + * Programmatic close — also fires the modal-close event so parents + * can react (e.g., persist `hasOnboarded=true`). + */ + requestClose(): void { + if (!this.closable) return; + this.open = false; + this.dispatchEvent(new CustomEvent('modal-close', { bubbles: true, composed: true })); + } + + private onBackdropClick(e: MouseEvent): void { + if (e.target === e.currentTarget) { + this.requestClose(); + } + } + + override render(): TemplateResult | null { + if (!this.open) return null; + const titleId = `modal-title-${this.uniqueId}`; + return html` + + `; + } + + // Stable id per instance — used for aria-labelledby. Random suffix + // so two modals on the same page don't collide. + private readonly uniqueId = Math.random().toString(36).slice(2, 10); +} + +customElements.define('modal-widget', ModalWidget); + +declare global { + interface HTMLElementTagNameMap { + 'modal-widget': ModalWidget; + } +} diff --git a/src/widgets/shared/ReactiveEntityScrollerWidget.ts b/src/widgets/shared/ReactiveEntityScrollerWidget.ts index 9671e255e..8a940d53f 100644 --- a/src/widgets/shared/ReactiveEntityScrollerWidget.ts +++ b/src/widgets/shared/ReactiveEntityScrollerWidget.ts @@ -187,6 +187,16 @@ export abstract class ReactiveEntityScrollerWidget extends // === Convenience methods === /** Get current entity count (reactive — triggers re-render when changed) */ + /** + * True when the scroller has finished its first load AND has zero + * entities. Subclasses use this to decide whether to render an + * empty-state UI. Distinct from `entityCount === 0` alone, which + * is also true during the brief pre-load window. + */ + protected get isEmpty(): boolean { + return this._scrollerInitialized && this._entityCount === 0; + } + protected get entityCount(): number { return this._entityCount; } diff --git a/src/widgets/shared/ReactiveListWidget.ts b/src/widgets/shared/ReactiveListWidget.ts index 75d47677d..efa38cc7a 100644 --- a/src/widgets/shared/ReactiveListWidget.ts +++ b/src/widgets/shared/ReactiveListWidget.ts @@ -108,15 +108,27 @@ export abstract class ReactiveListWidget extends ReactiveE return nothing; } + /** + * Render the empty-state shown when the scroller has loaded zero + * items. Empty by default — `nothing` means "do not render an empty + * state, leave the container blank." Subclasses override to surface + * a guided empty state (icon + title + subtitle + optional action). + * Introduced under #1101 — see `widgets/shared/EmptyStateWidget.ts`. + */ + protected renderEmptyState(): TemplateResult | typeof nothing { + return nothing; + } + // === MAIN RENDER - Composes header/body/footer === override render(): TemplateResult { return html`
${this.renderHeader()} -
+
+ ${this.isEmpty ? this.renderEmptyState() : nothing} ${this.renderFooter()}
`; From 3d235f963d1839f6398a503973246a17ec431233 Mon Sep 17 00:00:00 2001 From: Joel Teply Date: Wed, 13 May 2026 12:26:15 -0500 Subject: [PATCH 2/2] 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 9b41b4f09..05d6dc248 100644 --- a/src/scripts/seed-continuum.ts +++ b/src/scripts/seed-continuum.ts @@ -332,7 +332,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 de93e6432..f65d5cdda 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 { ROOM_UNIQUE_IDS } from '../../system/data/constants/RoomConstants'; +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'; @@ -43,6 +47,11 @@ export class MainWidget extends ReactiveWidget { // Reactive state @reactive() private currentPath = `/chat/${ROOM_UNIQUE_IDS.GENERAL}`; + // 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; @@ -128,9 +137,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 { @@ -157,6 +201,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; + } +}