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
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
128 changes: 128 additions & 0 deletions src/widgets/shared/EmptyStateWidget.ts
Original file line number Diff line number Diff line change
@@ -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`<div class="empty-icon" aria-hidden="true">${this.icon}</div>`
: null}
${this.emptyTitle
? html`<h3 class="empty-title">${this.emptyTitle}</h3>`
: null}
${this.subtitle
? html`<p class="empty-subtitle">${this.subtitle}</p>`
: null}
<slot></slot>
${this.actionLabel
? html`<button
class="empty-action"
type="button"
@click=${() => this.onActionClick()}
>${this.actionLabel}</button>`
: null}
`;
}
}

customElements.define('empty-state', EmptyStateWidget);

declare global {
interface HTMLElementTagNameMap {
'empty-state': EmptyStateWidget;
}
}
Loading
Loading