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
340 changes: 340 additions & 0 deletions apps/website/content/docs/chat/api/api-docs.json

Large diffs are not rendered by default.

1,582 changes: 1,582 additions & 0 deletions docs/superpowers/plans/2026-05-12-chat-projects.md

Large diffs are not rendered by default.

612 changes: 612 additions & 0 deletions docs/superpowers/specs/2026-05-12-chat-projects-design.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,19 @@
}

<chat-sidenav
[threads]="threadsSvc.threads()"
[threads]="visibleThreads()"
[archivedThreads]="threadsSvc.archivedThreads()"
[projects]="projectsSvc.projects()"
[selectedProjectId]="selectedProjectId()"
[projectActions]="projectActions"
[activeThreadId]="threadIdSignal() ?? ''"
[mode]="sidenavMode()"
[(open)]="drawerOpen"
[actions]="threadActions"
(newChat)="onNewThread()"
(threadSelected)="onThreadSelected($event)"
(projectSelected)="onProjectSelected($event)"
(newProjectRequested)="onNewProjectClicked()"
(searchOpened)="paletteOpen.set(true)"
(openChange)="onSidenavOpenChange($event)"
(modeChange)="onSidenavModeChange($event)"
Expand Down
48 changes: 47 additions & 1 deletion examples/chat/angular/src/app/shell/demo-shell.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,12 @@ import {
type InterruptAction,
type ThreadMatch,
type ThreadActionAdapter,
type Thread,
type ProjectActionAdapter,
} from '@ngaf/chat';
import { PalettePersistence } from './palette-persistence.service';
import { ThreadsService } from './threads.service';
import { ProjectsService } from './projects.service';
import { DEMO_AGENT } from './shell-tokens';

export type DemoMode = 'embed' | 'popup' | 'sidebar';
Expand Down Expand Up @@ -70,6 +73,7 @@ export class DemoShell {
private readonly persistence = inject(PalettePersistence);
private readonly document = inject(DOCUMENT);
protected readonly threadsSvc = inject(ThreadsService);
protected readonly projectsSvc = inject(ProjectsService);

constructor() {
// Reflect the chosen theme onto <html data-theme="..."> so the
Expand Down Expand Up @@ -180,6 +184,13 @@ export class DemoShell {
this.viewportWidth() >= 1024 ? this.storedDesktopMode() : 'drawer',
);

/** Active threads filtered by the selected project (or all when none selected). */
protected readonly visibleThreads = computed<Thread[]>(() => {
const sel = this.selectedProjectId();
const all = this.threadsSvc.threads();
return sel === null ? all : all.filter((t) => t.projectId === sel);
});

/** Client-side title filter over the loaded threads. */
protected readonly searchResults = computed<ThreadMatch[]>(() => {
const q = this.searchQueryDebounced().toLowerCase().trim();
Expand Down Expand Up @@ -227,6 +238,27 @@ export class DemoShell {
/** Persisted thread id (null on first run). Reactive so reload reconnects to the same thread. */
protected readonly threadIdSignal = signal<string | null>(this.persistence.read('threadId') ?? null);

protected readonly selectedProjectId = signal<string | null>(
this.persistence.read('selectedProjectId') ?? null,
);

protected readonly projectActions: ProjectActionAdapter = {
create: async (name) => {
const r = await this.projectsSvc.create(name);
this.selectedProjectId.set(r.id);
this.persistence.write('selectedProjectId', r.id);
return r;
},
rename: (id, name) => this.projectsSvc.rename(id, name),
delete: async (id) => {
await this.projectsSvc.delete(id);
if (this.selectedProjectId() === id) {
this.selectedProjectId.set(null);
this.persistence.write('selectedProjectId', null);
}
},
};

protected readonly threadActions: ThreadActionAdapter = {
delete: async (id) => {
await this.threadsSvc.delete(id);
Expand All @@ -246,6 +278,9 @@ export class DemoShell {
unarchive: (id) => this.threadsSvc.unarchive(id),
pin: (id) => this.threadsSvc.pin(id),
unpin: (id) => this.threadsSvc.unpin(id),
moveToProject: async (id, projectId) => {
await this.threadsSvc.moveToProject(id, projectId);
},
};

/**
Expand Down Expand Up @@ -345,6 +380,16 @@ export class DemoShell {
this.persistence.write('threadId', threadId);
}

protected onProjectSelected(projectId: string): void {
this.selectedProjectId.set(projectId);
this.persistence.write('selectedProjectId', projectId);
}

protected onNewProjectClicked(): void {
// Framework's chat-project-list owns the inline-create flow; this is an
// informational event for the consumer.
}

protected onSearchSelect(threadId: string): void {
this.onThreadSelected(threadId);
this.paletteOpen.set(false);
Expand All @@ -353,7 +398,8 @@ export class DemoShell {

/** Create a new thread via the backend and switch to it. */
protected async onNewThread(): Promise<void> {
const id = await this.threadsSvc.create();
const sel = this.selectedProjectId();
const id = await this.threadsSvc.create(sel ?? undefined);
if (id) {
this.threadIdSignal.set(id);
this.persistence.write('threadId', id);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ interface PaletteState {
threadId?: string | null;
drawerOpen?: boolean | null;
sidenavMode?: 'expanded' | 'collapsed' | null;
selectedProjectId?: string | null;
}

type PaletteKey = keyof PaletteState;
Expand Down
39 changes: 39 additions & 0 deletions examples/chat/angular/src/app/shell/projects.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// SPDX-License-Identifier: MIT
import { Injectable, signal } from '@angular/core';
import type { Project } from '@ngaf/chat';

const STORAGE_KEY = 'ngaf-example-projects-v1';

@Injectable({ providedIn: 'root' })
export class ProjectsService {
readonly projects = signal<Project[]>(this.load());

async create(name: string): Promise<{ id: string }> {
const id = (typeof crypto !== 'undefined' && 'randomUUID' in crypto)
? crypto.randomUUID()
: `proj-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
this.projects.update((p) => [{ id, name }, ...p]);
this.save(this.projects());
return { id };
}

async rename(id: string, name: string): Promise<void> {
this.projects.update((p) => p.map((x) => x.id === id ? { ...x, name } : x));
this.save(this.projects());
}

async delete(id: string): Promise<void> {
this.projects.update((p) => p.filter((x) => x.id !== id));
this.save(this.projects());
}

private load(): Project[] {
if (typeof localStorage === 'undefined') return [];
try { return JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '[]'); } catch { return []; }
}

private save(p: Project[]): void {
if (typeof localStorage === 'undefined') return;
localStorage.setItem(STORAGE_KEY, JSON.stringify(p));
}
}
17 changes: 14 additions & 3 deletions examples/chat/angular/src/app/shell/threads.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,11 @@ export class ThreadsService {
}
}

async create(): Promise<string | null> {
async create(projectId?: string): Promise<string | null> {
try {
const t = await this.client.threads.create({ metadata: {} });
const t = await this.client.threads.create({
metadata: projectId !== undefined ? { projectId } : {},
});
await this.refresh();
return t.thread_id;
} catch {
Expand Down Expand Up @@ -57,6 +59,11 @@ export class ThreadsService {
await this.refresh();
}

async moveToProject(threadId: string, projectId: string | null): Promise<void> {
await this.client.threads.update(threadId, { metadata: { projectId } });
await this.refresh();
}

async pin(threadId: string): Promise<void> {
await this.client.threads.update(threadId, { metadata: { pinned: true } });
await this.refresh();
Expand All @@ -69,17 +76,21 @@ export class ThreadsService {

/** Best-effort title from thread metadata; falls back to a truncated id. */
private toThread(t: SdkThread): Thread {
const meta = (t.metadata ?? {}) as { title?: unknown; archived?: unknown; pinned?: unknown };
const meta = (t.metadata ?? {}) as { title?: unknown; archived?: unknown; pinned?: unknown; projectId?: unknown };
const customTitle = meta.title;
const archived = meta.archived === true;
const pinned = meta.pinned === true;
const projectId = typeof meta.projectId === 'string' && meta.projectId.length > 0
? meta.projectId
: null;
return {
id: t.thread_id,
title: typeof customTitle === 'string' && customTitle.length > 0
? customTitle
: `Thread ${t.thread_id.slice(0, 8)}`,
status: archived ? 'archived' : 'active',
pinned,
projectId,
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -194,4 +194,42 @@ describe('ChatSidenavComponent', () => {
fixture.detectChanges();
expect(heading.getAttribute('aria-expanded')).toBe('false');
});

it('projects=null renders no Projects section', () => {
const fixture = render({ threads: [{ id: 't1' }] });
expect(fixture.nativeElement.querySelector('.chat-sidenav__projects')).toBeNull();
});

it('projects=[p1,p2] renders the Projects section with two rows', () => {
const fixture = render({ threads: [{ id: 't1' }] });
fixture.componentRef.setInput('projects', [{ id: 'p1', name: 'Work' }, { id: 'p2', name: 'Personal' }]);
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('.chat-sidenav__projects')).not.toBeNull();
const rows = fixture.nativeElement.querySelectorAll('.chat-project-list__item');
expect(rows.length).toBe(2);
});

it('selectedProjectId highlights the matching project row', () => {
const fixture = render({ threads: [{ id: 't1' }] });
fixture.componentRef.setInput('projects', [{ id: 'p1', name: 'Work' }, { id: 'p2', name: 'Personal' }]);
fixture.componentRef.setInput('selectedProjectId', 'p2');
fixture.detectChanges();
const rows = fixture.nativeElement.querySelectorAll('.chat-project-list__item');
expect(rows[0].getAttribute('data-active')).toBeNull();
expect(rows[1].getAttribute('data-active')).toBe('true');
});

it('projectActions.create shows "+ New project" and emits newProjectRequested on click', () => {
const fixture = render({ threads: [{ id: 't1' }] });
fixture.componentRef.setInput('projects', []);
fixture.componentRef.setInput('projectActions', { create: async () => ({ id: 'x' }) });
fixture.detectChanges();
let emits = 0;
fixture.componentInstance.newProjectRequested.subscribe(() => { emits++; });
const btn = fixture.nativeElement.querySelector('.chat-project-list__new') as HTMLButtonElement;
expect(btn).not.toBeNull();
btn.click();
fixture.detectChanges();
expect(emits).toBe(1);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,18 @@ import {
type Thread,
type ThreadActionAdapter,
} from '../../primitives/chat-thread-list/chat-thread-list.component';
import {
ChatProjectListComponent,
type Project,
type ProjectActionAdapter,
} from '../../primitives/chat-project-list/chat-project-list.component';

export type ChatSidenavMode = 'expanded' | 'collapsed' | 'drawer';

@Component({
selector: 'chat-sidenav',
standalone: true,
imports: [ChatThreadListComponent],
imports: [ChatThreadListComponent, ChatProjectListComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
'[attr.data-mode]': 'mode()',
Expand Down Expand Up @@ -103,13 +108,28 @@ export type ChatSidenavMode = 'expanded' | 'collapsed' | 'drawer';
<ng-content select="[sidenavPrimary]" />
</div>

@if (projects() !== null) {
<div class="chat-sidenav__projects">
<div class="chat-sidenav__threads-heading">Projects</div>
<chat-project-list
[projects]="projects()!"
[activeProjectId]="selectedProjectId()"
[showNewProjectButton]="!!projectActions()?.create"
[actions]="projectActions()"
(projectSelected)="projectSelected.emit($event)"
(newProjectRequested)="newProjectRequested.emit()"
/>
</div>
}

@if (threads() !== null) {
<div class="chat-sidenav__threads">
<div class="chat-sidenav__threads-heading">Recent</div>
<chat-thread-list
[threads]="threads()!"
[activeThreadId]="activeThreadId() ?? ''"
[actions]="actions()"
[projects]="projects()"
(threadSelected)="threadSelected.emit($event)"
/>
</div>
Expand Down Expand Up @@ -142,6 +162,7 @@ export type ChatSidenavMode = 'expanded' | 'collapsed' | 'drawer';
[threads]="archivedThreads()!"
[activeThreadId]="activeThreadId() ?? ''"
[actions]="actions()"
[projects]="projects()"
(threadSelected)="threadSelected.emit($event)"
/>
}
Expand All @@ -167,12 +188,17 @@ export class ChatSidenavComponent {
readonly activeThreadId = input<string | null>(null);
readonly actions = input<ThreadActionAdapter | null>(null);
readonly archivedThreads = input<Thread[] | null>(null);
readonly projects = input<Project[] | null>(null);
readonly selectedProjectId = input<string | null>(null);
readonly projectActions = input<ProjectActionAdapter | null>(null);

readonly newChat = output<void>();
readonly threadSelected = output<string>();
readonly searchOpened = output<void>();
readonly openChange = output<boolean>();
readonly modeChange = output<ChatSidenavMode>();
readonly projectSelected = output<string>();
readonly newProjectRequested = output<void>();

protected readonly archivedOpen = signal<boolean>(false);

Expand Down
Loading
Loading