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

Large diffs are not rendered by default.

1,499 changes: 1,499 additions & 0 deletions docs/superpowers/plans/2026-05-12-chat-row-actions.md

Large diffs are not rendered by default.

552 changes: 552 additions & 0 deletions docs/superpowers/specs/2026-05-12-chat-row-actions-design.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
[activeThreadId]="threadIdSignal() ?? ''"
[mode]="sidenavMode()"
[(open)]="drawerOpen"
[actions]="threadActions"
(newChat)="onNewThread()"
(threadSelected)="onThreadSelected($event)"
(searchOpened)="paletteOpen.set(true)"
Expand Down
12 changes: 12 additions & 0 deletions examples/chat/angular/src/app/shell/demo-shell.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
type ChatSidenavMode,
type InterruptAction,
type ThreadMatch,
type ThreadActionAdapter,
} from '@ngaf/chat';
import { PalettePersistence } from './palette-persistence.service';
import { ThreadsService } from './threads.service';
Expand Down Expand Up @@ -215,6 +216,17 @@ 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 threadActions: ThreadActionAdapter = {
delete: async (id) => {
await this.threadsSvc.delete(id);
if (this.threadIdSignal() === id) {
this.threadIdSignal.set(null);
this.persistence.write('threadId', null);
}
},
rename: (id, title) => this.threadsSvc.rename(id, title),
};

/**
* Shared agent instance. Patched submit injects state.model on every
* submission so the graph picks up the latest model selection without
Expand Down
16 changes: 16 additions & 0 deletions examples/chat/angular/src/app/shell/threads.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,22 @@ export class ThreadsService {
}
}

async delete(threadId: string): Promise<void> {
const res = await fetch(`${API_URL}/threads/${threadId}`, { method: 'DELETE' });
if (!res.ok) throw new Error(`delete ${threadId} failed: ${res.status}`);
await this.refresh();
}

async rename(threadId: string, newTitle: string): Promise<void> {
const res = await fetch(`${API_URL}/threads/${threadId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ metadata: { title: newTitle } }),
});
if (!res.ok) throw new Error(`rename ${threadId} failed: ${res.status}`);
await this.refresh();
}

/** Best-effort title: first user message from the thread's checkpoint
* if present in metadata, else a truncated thread id. */
private titleFor(t: { thread_id: string; metadata?: Record<string, unknown> }): string {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,11 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { fromEvent } from 'rxjs';
import { CHAT_HOST_TOKENS } from '../../styles/chat-tokens';
import { CHAT_SIDENAV_STYLES } from '../../styles/chat-sidenav.styles';
import { ChatThreadListComponent, type Thread } from '../../primitives/chat-thread-list/chat-thread-list.component';
import {
ChatThreadListComponent,
type Thread,
type ThreadActionAdapter,
} from '../../primitives/chat-thread-list/chat-thread-list.component';

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

Expand Down Expand Up @@ -84,6 +88,7 @@ export type ChatSidenavMode = 'expanded' | 'collapsed' | 'drawer';
<chat-thread-list
[threads]="threads()!"
[activeThreadId]="activeThreadId() ?? ''"
[actions]="actions()"
(threadSelected)="threadSelected.emit($event)"
/>
</div>
Expand All @@ -104,6 +109,7 @@ export class ChatSidenavComponent {
readonly open = input<boolean>(false);
readonly threads = input<Thread[] | null>(null);
readonly activeThreadId = input<string | null>(null);
readonly actions = input<ThreadActionAdapter | null>(null);

readonly newChat = output<void>();
readonly threadSelected = output<string>();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
// libs/chat/src/lib/primitives/chat-confirm-dialog/chat-confirm-dialog.component.spec.ts
// SPDX-License-Identifier: MIT
import { TestBed } from '@angular/core/testing';
import { describe, expect, it } from 'vitest';
import { ChatConfirmDialogComponent } from './chat-confirm-dialog.component';

function render(opts: {
open?: boolean;
title?: string;
body?: string;
confirmLabel?: string;
cancelLabel?: string;
tone?: 'destructive' | 'normal';
} = {}) {
const fixture = TestBed.createComponent(ChatConfirmDialogComponent);
fixture.componentRef.setInput('open', opts.open ?? true);
if (opts.title !== undefined) fixture.componentRef.setInput('title', opts.title);
if (opts.body !== undefined) fixture.componentRef.setInput('body', opts.body);
if (opts.confirmLabel !== undefined) fixture.componentRef.setInput('confirmLabel', opts.confirmLabel);
if (opts.cancelLabel !== undefined) fixture.componentRef.setInput('cancelLabel', opts.cancelLabel);
if (opts.tone !== undefined) fixture.componentRef.setInput('tone', opts.tone);
fixture.detectChanges();
return fixture;
}

describe('ChatConfirmDialogComponent', () => {
it('renders nothing when open is false', () => {
const fixture = render({ open: false });
expect(fixture.nativeElement.querySelector('.chat-confirm-dialog')).toBeNull();
});

it('renders title and body when provided', () => {
const fixture = render({ title: 'Delete?', body: 'This cannot be undone.' });
expect(fixture.nativeElement.querySelector('.chat-confirm-dialog__title').textContent.trim()).toBe('Delete?');
expect(fixture.nativeElement.querySelector('.chat-confirm-dialog__body').textContent.trim()).toBe('This cannot be undone.');
});

it('omits body element when body is empty', () => {
const fixture = render({ title: 'T', body: '' });
expect(fixture.nativeElement.querySelector('.chat-confirm-dialog__body')).toBeNull();
const dialog = fixture.nativeElement.querySelector('.chat-confirm-dialog');
expect(dialog.getAttribute('aria-describedby')).toBeNull();
});

it('aria-labelledby points at the title element id', () => {
const fixture = render({ title: 'T' });
const dialog = fixture.nativeElement.querySelector('.chat-confirm-dialog');
const labelId = dialog.getAttribute('aria-labelledby');
expect(labelId).toBeTruthy();
expect(fixture.nativeElement.querySelector(`#${labelId}`).textContent.trim()).toBe('T');
});

it('confirm button click emits confirmed', () => {
const fixture = render();
let emits = 0;
fixture.componentInstance.confirmed.subscribe(() => { emits++; });
(fixture.nativeElement.querySelector('.chat-confirm-dialog__confirm') as HTMLElement).click();
expect(emits).toBe(1);
});

it('cancel button click emits cancelled', () => {
const fixture = render();
let emits = 0;
fixture.componentInstance.cancelled.subscribe(() => { emits++; });
(fixture.nativeElement.querySelector('.chat-confirm-dialog__cancel') as HTMLElement).click();
expect(emits).toBe(1);
});

it('scrim click emits cancelled', () => {
const fixture = render();
let emits = 0;
fixture.componentInstance.cancelled.subscribe(() => { emits++; });
(fixture.nativeElement.querySelector('.chat-confirm-dialog__scrim') as HTMLElement).click();
expect(emits).toBe(1);
});

it('Esc on the dialog emits cancelled', () => {
const fixture = render();
let emits = 0;
fixture.componentInstance.cancelled.subscribe(() => { emits++; });
const dialog = fixture.nativeElement.querySelector('.chat-confirm-dialog');
dialog.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
expect(emits).toBe(1);
});

it('destructive tone applies destructive class to confirm button', () => {
const fixture = render({ tone: 'destructive' });
const confirm = fixture.nativeElement.querySelector('.chat-confirm-dialog__confirm');
expect(confirm.classList.contains('chat-confirm-dialog__confirm--destructive')).toBe(true);
});

it('confirm button has labelled text from confirmLabel input', () => {
const fixture = render({ confirmLabel: 'Yes do it' });
const confirm = fixture.nativeElement.querySelector('.chat-confirm-dialog__confirm');
expect(confirm.textContent.trim()).toBe('Yes do it');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
// libs/chat/src/lib/primitives/chat-confirm-dialog/chat-confirm-dialog.component.ts
// SPDX-License-Identifier: MIT
import {
Component,
ChangeDetectionStrategy,
ElementRef,
effect,
input,
output,
viewChild,
} from '@angular/core';
import { CHAT_HOST_TOKENS } from '../../styles/chat-tokens';
import { CHAT_CONFIRM_DIALOG_STYLES } from '../../styles/chat-confirm-dialog.styles';

let confirmDialogInstanceCounter = 0;

@Component({
selector: 'chat-confirm-dialog',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
styles: [CHAT_HOST_TOKENS, CHAT_CONFIRM_DIALOG_STYLES],
template: `
@if (open()) {
<button
type="button"
class="chat-confirm-dialog__scrim"
aria-label="Cancel"
(click)="cancelled.emit()"
></button>
<div
class="chat-confirm-dialog"
role="dialog"
aria-modal="true"
tabindex="-1"
[attr.aria-labelledby]="titleId"
[attr.aria-describedby]="body() ? bodyId : null"
(keydown)="onDialogKeydown($event)"
>
<h2 [id]="titleId" class="chat-confirm-dialog__title">{{ title() }}</h2>
@if (body()) {
<p [id]="bodyId" class="chat-confirm-dialog__body">{{ body() }}</p>
}
<div class="chat-confirm-dialog__actions">
<button
#cancelBtn
type="button"
class="chat-confirm-dialog__cancel"
(click)="cancelled.emit()"
>{{ cancelLabel() }}</button>
<button
type="button"
class="chat-confirm-dialog__confirm"
[class.chat-confirm-dialog__confirm--destructive]="tone() === 'destructive'"
(click)="confirmed.emit()"
>{{ confirmLabel() }}</button>
</div>
</div>
}
`,
})
export class ChatConfirmDialogComponent {
readonly open = input<boolean>(false);
readonly title = input<string>('Are you sure?');
readonly body = input<string>('');
readonly confirmLabel = input<string>('Confirm');
readonly cancelLabel = input<string>('Cancel');
readonly tone = input<'destructive' | 'normal'>('normal');

readonly confirmed = output<void>();
readonly cancelled = output<void>();

private readonly instanceId = ++confirmDialogInstanceCounter;
protected readonly titleId = `chat-confirm-dialog__title-${this.instanceId}`;
protected readonly bodyId = `chat-confirm-dialog__body-${this.instanceId}`;

private readonly cancelBtn = viewChild<ElementRef<HTMLButtonElement>>('cancelBtn');

constructor() {
effect(() => {
if (!this.open()) return;
queueMicrotask(() => this.cancelBtn()?.nativeElement.focus());
});
}

protected onDialogKeydown(e: KeyboardEvent): void {
if (e.key === 'Escape') {
e.preventDefault();
this.cancelled.emit();
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
// libs/chat/src/lib/primitives/chat-overflow-menu/chat-overflow-menu.component.spec.ts
// SPDX-License-Identifier: MIT
import { TestBed } from '@angular/core/testing';
import { describe, expect, it } from 'vitest';
import { ChatOverflowMenuComponent, type OverflowMenuItem } from './chat-overflow-menu.component';

function render(opts: { open?: boolean; items?: OverflowMenuItem[] } = {}) {
const fixture = TestBed.createComponent(ChatOverflowMenuComponent);
fixture.componentRef.setInput('open', opts.open ?? true);
if (opts.items !== undefined) fixture.componentRef.setInput('items', opts.items);
fixture.detectChanges();
return fixture;
}

describe('ChatOverflowMenuComponent', () => {
it('renders nothing when open is false', () => {
const fixture = render({ open: false, items: [{ id: 'a', label: 'A' }] });
expect(fixture.nativeElement.querySelector('.chat-overflow-menu')).toBeNull();
});

it('renders items list when open is true', () => {
const fixture = render({
items: [
{ id: 'rename', label: 'Rename' },
{ id: 'delete', label: 'Delete', tone: 'destructive' },
],
});
const items = fixture.nativeElement.querySelectorAll('.chat-overflow-menu__item');
expect(items.length).toBe(2);
expect(items[0].textContent.trim()).toBe('Rename');
expect(items[1].textContent.trim()).toBe('Delete');
});

it('applies destructive class to destructive-tone items', () => {
const fixture = render({ items: [{ id: 'd', label: 'D', tone: 'destructive' }] });
const item = fixture.nativeElement.querySelector('.chat-overflow-menu__item');
expect(item.classList.contains('chat-overflow-menu__item--destructive')).toBe(true);
});

it('applies disabled class and aria-disabled to disabled items', () => {
const fixture = render({ items: [{ id: 'x', label: 'X', disabled: true }] });
const item = fixture.nativeElement.querySelector('.chat-overflow-menu__item');
expect(item.classList.contains('chat-overflow-menu__item--disabled')).toBe(true);
expect(item.getAttribute('aria-disabled')).toBe('true');
});

it('item click emits itemSelected and closed', () => {
const fixture = render({ items: [{ id: 'a', label: 'A' }, { id: 'b', label: 'B' }] });
let selected: string | undefined;
let closes = 0;
fixture.componentInstance.itemSelected.subscribe((id: string) => { selected = id; });
fixture.componentInstance.closed.subscribe(() => { closes++; });
const items = fixture.nativeElement.querySelectorAll('.chat-overflow-menu__item');
(items[1] as HTMLElement).click();
expect(selected).toBe('b');
expect(closes).toBe(1);
});

it('disabled item click is a no-op', () => {
const fixture = render({ items: [{ id: 'x', label: 'X', disabled: true }] });
let emits = 0;
fixture.componentInstance.itemSelected.subscribe(() => { emits++; });
fixture.componentInstance.closed.subscribe(() => { emits++; });
(fixture.nativeElement.querySelector('.chat-overflow-menu__item') as HTMLElement).click();
expect(emits).toBe(0);
});

it('scrim click emits closed', () => {
const fixture = render({ items: [{ id: 'a', label: 'A' }] });
let closes = 0;
fixture.componentInstance.closed.subscribe(() => { closes++; });
(fixture.nativeElement.querySelector('.chat-overflow-menu__scrim') as HTMLElement).click();
expect(closes).toBe(1);
});

it('Esc on the menu emits closed', () => {
const fixture = render({ items: [{ id: 'a', label: 'A' }] });
let closes = 0;
fixture.componentInstance.closed.subscribe(() => { closes++; });
const menu = fixture.nativeElement.querySelector('.chat-overflow-menu');
menu.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
expect(closes).toBe(1);
});
});
Loading
Loading