Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion src/scripts/seed-continuum.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =====

Expand Down
52 changes: 52 additions & 0 deletions src/widgets/main/MainWidget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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;
Expand Down Expand Up @@ -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<void> {
this._showWelcome = false;
const user = this.currentUser;
if (!user?.id) return;
try {
await this.executeCommand<DataUpdateParams, DataUpdateResult>(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 {
Expand All @@ -162,6 +206,14 @@ export class MainWidget extends ReactiveWidget {
<a href="#about">About</a>
</div>
</div>

<!-- First-run welcome (#1101). Self-positions via fixed/z-index
so its placement in the DOM doesn't matter; lives at the
container's bottom for theme variable inheritance. -->
<welcome-modal
?open=${this._showWelcome}
@welcome-complete=${() => this.onWelcomeComplete()}
></welcome-modal>
</div>
`;
}
Expand Down
215 changes: 215 additions & 0 deletions src/widgets/onboarding/WelcomeModalWidget.ts
Original file line number Diff line number Diff line change
@@ -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`
<div class="panel">
<h3 class="panel-title">Welcome to Continuum</h3>
<p class="panel-body">
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
<strong>citizens</strong> of the workspace, with their own
specialities, memory, and presence.
</p>
<p class="panel-body">
Nothing to configure to get started — you already have a model
running locally.
</p>
</div>
`;
}
return html`
<div class="panel">
<h3 class="panel-title">Say hi to Helper AI</h3>
<p class="panel-body">
<strong>Helper AI</strong> is already in your <strong>General</strong> room.
It runs locally on your machine — no API keys, no cloud round-trips.
Send a message there to see the system in motion.
</p>
<p class="panel-body">
When you want richer responses, head into Settings to plug in
cloud providers like Anthropic, OpenAI, or others. Optional, never required.
</p>
</div>
`;
}

private renderFooter(): TemplateResult {
const isLast = this.step === this.totalSteps - 1;
return html`
<div class="step-indicator" aria-label="Welcome progress" role="presentation">
${Array.from({ length: this.totalSteps }, (_, i) => html`
<span class="step-dot ${i === this.step ? 'active' : ''}"></span>
`)}
</div>
<span style="flex: 1"></span>
${this.step > 0
? html`<button type="button" class="btn-secondary" @click=${() => this.onBack()}>Back</button>`
: null}
<button type="button" class="btn-primary" @click=${() => this.onNext()}>
${isLast ? 'Got it' : 'Next'}
</button>
`;
}

override render(): TemplateResult {
return html`
<modal-widget
?open=${this.open}
modal-title="Get started"
@modal-close=${() => this.onModalClose()}
>
${this.renderStep()}
<div slot="footer" style="display: flex; align-items: center; gap: 8px; width: 100%;">
${this.renderFooter()}
</div>
</modal-widget>
`;
}
}

customElements.define('welcome-modal', WelcomeModalWidget);

declare global {
interface HTMLElementTagNameMap {
'welcome-modal': WelcomeModalWidget;
}
}
Loading