Skip to content
Draft
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 @@ -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 =====

Expand Down
11 changes: 9 additions & 2 deletions src/shared/generated/entity_schemas.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -147,6 +147,13 @@
"nullable": true,
"references": "genomes.id"
}
},
{
"fieldName": "hasOnboarded",
"fieldType": "boolean",
"options": {
"nullable": true
}
}
],
"compositeIndexes": [],
Expand Down
7 changes: 7 additions & 0 deletions src/system/data/entities/UserEntity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ import {
EnumField,
JsonField,
ForeignKeyField,
BooleanField,
TEXT_LENGTH
} from '../decorators/FieldDecorators';
import { BaseEntity } from './BaseEntity';
Expand Down Expand Up @@ -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;
Expand Down
29 changes: 29 additions & 0 deletions src/widgets/chat/chat-widget/ChatWidget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -971,6 +972,17 @@ export class ChatWidget extends EntityScrollerWidget<ChatMessageEntity> {
<!-- EntityScroller will populate this container -->
</div>

<!-- Empty state for rooms with no messages (#1101). Hidden until
updateEntityCount() reveals it after the first load completes,
so the user never sees a blank "is this loading?" panel. -->
<empty-state
id="chatEmptyState"
hidden
icon="💬"
empty-title="Send your first message"
subtitle="Try @Helper for a hand, or just say hi — the AIs in this room will respond."
></empty-state>

<div class="typing-indicator-container" id="typingIndicator"></div>

${this.renderFooter()}
Expand All @@ -989,6 +1001,23 @@ export class ChatWidget extends EntityScrollerWidget<ChatMessageEntity> {
`;
}

/**
* 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.
Expand Down
19 changes: 19 additions & 0 deletions src/widgets/chat/room-list/RoomListWidget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -116,6 +117,24 @@ export class RoomListWidget extends ReactiveListWidget<RoomEntity> {
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`
<empty-state
icon=${isDmFilter ? '✉️' : '#'}
empty-title=${isDmFilter ? 'No direct messages yet' : 'No rooms yet'}
subtitle=${isDmFilter
? 'Open a DM with another user or persona to start a private conversation.'
: 'Rooms are shared spaces for conversations with humans and AI personas.'}
></empty-state>
`;
}

// === ITEM ===
renderItem(room: RoomEntity): TemplateResult {
if (this.isDM(room)) {
Expand Down
18 changes: 17 additions & 1 deletion src/widgets/chat/user-list/UserListWidget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -177,12 +178,27 @@ export class UserListWidget extends ReactiveListWidget<UserEntity> {
return html`
<div class="entity-list-container">
${this.renderHeader()}
<div class="${this.containerClass}"></div>
<div class="${this.containerClass}" ?hidden=${this.isEmpty}></div>
${this.isEmpty ? this.renderEmptyState() : nothing}
${this.renderFooter()}
</div>
`;
}

// === EMPTY STATE === (#1101)
protected override renderEmptyState(): TemplateResult {
const filterActive = this.activeFilters.size > 0 && !this.activeFilters.has('all');
return html`
<empty-state
icon=${filterActive ? '🔎' : '👥'}
empty-title=${filterActive ? 'No users match this filter' : 'No users yet'}
subtitle=${filterActive
? 'Try clearing or changing the filter chips above.'
: 'Humans, personas, and agents will appear here once they join the workspace.'}
></empty-state>
`;
}

// === ITEM RENDERING ===
renderItem(user: UserEntity): TemplateResult {
const displayName = user.displayName ?? 'Unknown User';
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 { 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';
Expand All @@ -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;
Expand Down Expand Up @@ -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<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 @@ -157,6 +201,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
Loading
Loading