diff --git a/apps/website/content/docs/chat/api/api-docs.json b/apps/website/content/docs/chat/api/api-docs.json index d6d8b786..0cfc4029 100644 --- a/apps/website/content/docs/chat/api/api-docs.json +++ b/apps/website/content/docs/chat/api/api-docs.json @@ -3758,6 +3758,19 @@ "description": "", "params": [] }, + { + "name": "performPin", + "signature": "performPin(threadId: string)", + "description": "", + "params": [ + { + "name": "threadId", + "type": "string", + "description": "", + "optional": false + } + ] + }, { "name": "performUnarchive", "signature": "performUnarchive(threadId: string)", @@ -3771,6 +3784,19 @@ } ] }, + { + "name": "performUnpin", + "signature": "performUnpin(threadId: string)", + "description": "", + "params": [ + { + "name": "threadId", + "type": "string", + "description": "", + "optional": false + } + ] + }, { "name": "relativeTime", "signature": "relativeTime(epochMs: number)", @@ -5939,6 +5965,12 @@ "description": "", "optional": true }, + { + "name": "pin", + "type": "unknown", + "description": "", + "optional": true + }, { "name": "rename", "type": "unknown", @@ -5950,6 +5982,12 @@ "type": "unknown", "description": "", "optional": true + }, + { + "name": "unpin", + "type": "unknown", + "description": "", + "optional": true } ], "examples": [] diff --git a/docs/superpowers/plans/2026-05-12-chat-pin.md b/docs/superpowers/plans/2026-05-12-chat-pin.md new file mode 100644 index 00000000..65042632 --- /dev/null +++ b/docs/superpowers/plans/2026-05-12-chat-pin.md @@ -0,0 +1,56 @@ +# Chat per-row pin (Phase 3d) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add per-thread pin/unpin to `@ngaf/chat` plus an archived-search freebie in the example. + +**Architecture:** Framework adds typed `Thread.pinned` + adapter `pin/unpin` methods + pin-aware menu + pin icon. No ordering logic — consumer pre-sorts. Example wires LangGraph SDK metadata. + +**Tech Stack:** Angular 20 signals, Nx, Vitest, LangGraph SDK. + +See: `docs/superpowers/specs/2026-05-12-chat-pin-design.md`. + +--- + +### Task 1: Framework type + menu + icon + +**Files:** +- Modify: `libs/chat/src/lib/primitives/chat-thread-list/chat-thread-list.component.ts` +- Modify: `libs/chat/src/lib/styles/chat-thread-list.styles.ts` +- Modify: `libs/chat/src/lib/primitives/chat-thread-list/chat-thread-list.component.spec.ts` + +- [ ] Extend `Thread` with optional `pinned?: boolean` (before the `[key: string]: unknown` index signature). +- [ ] Extend `ThreadActionAdapter` with optional `pin?(threadId): Promise` and `unpin?(threadId): Promise`. +- [ ] Update `currentMenuItems` so active mode includes Pin/Unpin (look up thread on `this.threads()`, not `visibleThreads()`). +- [ ] Update `showKebab` to include pin/unpin in the active branch. +- [ ] Route `pin` and `unpin` ids in `onMenuAction` to new `performPin/performUnpin` methods. +- [ ] Add `performPin` and `performUnpin` (no optimistic hide). +- [ ] Update template: prepend a small SVG pin inside `chat-thread-list__item-title` when `thread.pinned`. +- [ ] Append `.chat-thread-list__item-pin` CSS rule. +- [ ] Add 6 spec cases inside `describe('with adapter', ...)`: pin shown when not pinned; pin hidden when pinned; unpin shown when pinned; both provided & not pinned → Pin; both provided & pinned → Unpin; SVG renders only when `thread.pinned === true`. +- [ ] Run `nx run chat:test`; all green. +- [ ] Run `nx run chat:build && nx lint chat`; clean. + +--- + +### Task 2: Example wiring (ThreadsService + demo-shell) + +**Files:** +- Modify: `examples/chat/angular/src/app/shell/threads.service.ts` +- Modify: `examples/chat/angular/src/app/shell/demo-shell.component.ts` + +- [ ] Add `pin(threadId)` and `unpin(threadId)` to `ThreadsService` (PATCH `metadata.pinned`, refresh). +- [ ] Update `toThread` to read `meta.pinned`. +- [ ] Update `refresh()` to sort active threads pinned-first (stable for archived). +- [ ] Extend `demo-shell.threadActions` with `pin`/`unpin`. +- [ ] Replace `searchResults` computed to concat active + archived (with `subtitle: 'Archived'`), capped at 50. +- [ ] Run `nx run examples-chat-angular:build`; clean. + +--- + +### Task 3: Docs + verification + +- [ ] Run `npx tsx apps/website/scripts/generate-api-docs.ts`. +- [ ] Browser-verify pin/unpin path via preview. +- [ ] Commit, open PR `feat(chat): per-row pin (Phase 3d) + archived-search`. +- [ ] Squash-merge on green. diff --git a/docs/superpowers/specs/2026-05-12-chat-pin-design.md b/docs/superpowers/specs/2026-05-12-chat-pin-design.md new file mode 100644 index 00000000..354d9833 --- /dev/null +++ b/docs/superpowers/specs/2026-05-12-chat-pin-design.md @@ -0,0 +1,47 @@ +# Chat per-row pin (Phase 3d) — design + +**Goal:** Add per-thread pin/unpin to `@ngaf/chat` with a small archived-search example tweak. + +## Design principles + +- Framework stays dumb about ordering. Consumers pre-sort threads pinned-first. +- No optimistic icon flip — pin icon updates after the consumer refreshes. +- Active mode only — archived threads aren't pinnable. + +## Surface additions to `@ngaf/chat` + +### `Thread.pinned?: boolean` +Typed documentation field. Framework renders a pin icon when true. + +### `ThreadActionAdapter.pin?` / `.unpin?` +```ts +pin?(threadId: string): Promise; +unpin?(threadId: string): Promise; +``` + +### Menu behavior +- Active mode only. +- Pin shown when adapter has `pin` AND row is not pinned. +- Unpin shown when adapter has `unpin` AND row IS pinned. +- Pinned-state lookup goes against original `threads()` (NOT `visibleThreads()`). + +### Pin icon +Small SVG prepended inside `chat-thread-list__item-title` when `thread.pinned === true`. + +## Example consumer wiring + +- `ThreadsService.pin/unpin`: PATCH `metadata.pinned`, then refresh. +- `toThread`: read `meta.pinned`. +- `refresh()` sorts active threads pinned-first. +- `demo-shell.threadActions`: add `pin` and `unpin`. + +## Archived-search freebie (bundled) + +`demo-shell.searchResults` extends to include archived matches with `subtitle: 'Archived'`. + +## Out of scope + +- Drag-to-reorder pinned threads. +- Pin from archived view. +- Per-pin timestamp / pin-order. +- Optimistic pin icon flip. diff --git a/examples/chat/angular/src/app/shell/demo-shell.component.ts b/examples/chat/angular/src/app/shell/demo-shell.component.ts index ee8733ee..6dee7c54 100644 --- a/examples/chat/angular/src/app/shell/demo-shell.component.ts +++ b/examples/chat/angular/src/app/shell/demo-shell.component.ts @@ -176,10 +176,13 @@ export class DemoShell { protected readonly searchResults = computed(() => { const q = this.searchQueryDebounced().toLowerCase().trim(); if (!q) return []; - return this.threadsSvc.threads() + const active = this.threadsSvc.threads() .filter((t) => (t.title ?? '').toLowerCase().includes(q)) - .slice(0, 50) .map((t) => ({ id: t.id, title: t.title ?? t.id })); + const archived = this.threadsSvc.archivedThreads() + .filter((t) => (t.title ?? '').toLowerCase().includes(q)) + .map((t) => ({ id: t.id, title: t.title ?? t.id, subtitle: 'Archived' })); + return [...active, ...archived].slice(0, 50); }); protected readonly modeOptions = [ @@ -233,6 +236,8 @@ export class DemoShell { } }, unarchive: (id) => this.threadsSvc.unarchive(id), + pin: (id) => this.threadsSvc.pin(id), + unpin: (id) => this.threadsSvc.unpin(id), }; /** diff --git a/examples/chat/angular/src/app/shell/threads.service.ts b/examples/chat/angular/src/app/shell/threads.service.ts index df33c9c6..2d148615 100644 --- a/examples/chat/angular/src/app/shell/threads.service.ts +++ b/examples/chat/angular/src/app/shell/threads.service.ts @@ -16,7 +16,11 @@ export class ThreadsService { try { const list = await this.client.threads.search({ limit: 50 }); const mapped = list.map((t) => this.toThread(t)); - this.threads.set(mapped.filter((t) => t.status !== 'archived')); + this.threads.set( + mapped + .filter((t) => t.status !== 'archived') + .sort((a, b) => Number(b.pinned ?? false) - Number(a.pinned ?? false)), + ); this.archivedThreads.set(mapped.filter((t) => t.status === 'archived')); } catch { // Backend may be down; leave signals as-is. @@ -53,17 +57,29 @@ export class ThreadsService { await this.refresh(); } + async pin(threadId: string): Promise { + await this.client.threads.update(threadId, { metadata: { pinned: true } }); + await this.refresh(); + } + + async unpin(threadId: string): Promise { + await this.client.threads.update(threadId, { metadata: { pinned: false } }); + await this.refresh(); + } + /** 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 }; + const meta = (t.metadata ?? {}) as { title?: unknown; archived?: unknown; pinned?: unknown }; const customTitle = meta.title; const archived = meta.archived === true; + const pinned = meta.pinned === true; 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, }; } } diff --git a/libs/chat/src/lib/primitives/chat-thread-list/chat-thread-list.component.spec.ts b/libs/chat/src/lib/primitives/chat-thread-list/chat-thread-list.component.spec.ts index 8a97d3c9..b250bab7 100644 --- a/libs/chat/src/lib/primitives/chat-thread-list/chat-thread-list.component.spec.ts +++ b/libs/chat/src/lib/primitives/chat-thread-list/chat-thread-list.component.spec.ts @@ -318,6 +318,77 @@ describe('ChatThreadListComponent', () => { expect(remaining.length).toBe(2); }); + it('Pin: action provided + row not pinned → menu includes "Pin"', () => { + const fixture = render({ actions: { pin: noop } }); + (fixture.nativeElement.querySelector('.chat-thread-list__kebab') as HTMLElement).click(); + fixture.detectChanges(); + const labels = Array.from(document.querySelectorAll('.chat-overflow-menu__item')) + .map((el) => (el as HTMLElement).textContent?.trim()); + expect(labels).toContain('Pin'); + expect(labels).not.toContain('Unpin'); + }); + + it('Pin: action provided + row pinned → menu does NOT include "Pin" (and no Unpin since unpin not provided)', () => { + const fixture = render({ + threads: [{ id: 't1', title: 'First', pinned: true }, { id: 't2', title: 'Second' }], + actions: { pin: noop }, + }); + (fixture.nativeElement.querySelector('.chat-thread-list__kebab') as HTMLElement).click(); + fixture.detectChanges(); + const labels = Array.from(document.querySelectorAll('.chat-overflow-menu__item')) + .map((el) => (el as HTMLElement).textContent?.trim()); + expect(labels).not.toContain('Pin'); + expect(labels).not.toContain('Unpin'); + }); + + it('Unpin: action provided + row pinned → menu includes "Unpin"', () => { + const fixture = render({ + threads: [{ id: 't1', title: 'First', pinned: true }, { id: 't2', title: 'Second' }], + actions: { unpin: noop }, + }); + (fixture.nativeElement.querySelector('.chat-thread-list__kebab') as HTMLElement).click(); + fixture.detectChanges(); + const labels = Array.from(document.querySelectorAll('.chat-overflow-menu__item')) + .map((el) => (el as HTMLElement).textContent?.trim()); + expect(labels).toContain('Unpin'); + expect(labels).not.toContain('Pin'); + }); + + it('Pin+Unpin both provided, row not pinned → menu has "Pin" not "Unpin"', () => { + const fixture = render({ actions: { pin: noop, unpin: noop } }); + (fixture.nativeElement.querySelector('.chat-thread-list__kebab') as HTMLElement).click(); + fixture.detectChanges(); + const labels = Array.from(document.querySelectorAll('.chat-overflow-menu__item')) + .map((el) => (el as HTMLElement).textContent?.trim()); + expect(labels).toContain('Pin'); + expect(labels).not.toContain('Unpin'); + }); + + it('Pin+Unpin both provided, row pinned → menu has "Unpin" not "Pin"', () => { + const fixture = render({ + threads: [{ id: 't1', title: 'First', pinned: true }, { id: 't2', title: 'Second' }], + actions: { pin: noop, unpin: noop }, + }); + (fixture.nativeElement.querySelector('.chat-thread-list__kebab') as HTMLElement).click(); + fixture.detectChanges(); + const labels = Array.from(document.querySelectorAll('.chat-overflow-menu__item')) + .map((el) => (el as HTMLElement).textContent?.trim()); + expect(labels).toContain('Unpin'); + expect(labels).not.toContain('Pin'); + }); + + it('Pin icon SVG renders only when thread.pinned === true', () => { + const fixture = render({ + threads: [{ id: 't1', title: 'First', pinned: true }, { id: 't2', title: 'Second' }], + actions: { pin: noop, unpin: noop }, + }); + const pins = fixture.nativeElement.querySelectorAll('.chat-thread-list__item-pin'); + expect(pins.length).toBe(1); + const titles = fixture.nativeElement.querySelectorAll('.chat-thread-list__item-title'); + expect(titles[0].querySelector('.chat-thread-list__item-pin')).not.toBeNull(); + expect(titles[1].querySelector('.chat-thread-list__item-pin')).toBeNull(); + }); + it('mode="archived" with only rename+archive (no unarchive/delete) hides the kebab', () => { const fixture = TestBed.createComponent(ChatThreadListComponent); fixture.componentRef.setInput('threads', [{ id: 't1', title: 'A', status: 'archived' as const }]); diff --git a/libs/chat/src/lib/primitives/chat-thread-list/chat-thread-list.component.ts b/libs/chat/src/lib/primitives/chat-thread-list/chat-thread-list.component.ts index add134ae..77d10ff5 100644 --- a/libs/chat/src/lib/primitives/chat-thread-list/chat-thread-list.component.ts +++ b/libs/chat/src/lib/primitives/chat-thread-list/chat-thread-list.component.ts @@ -34,6 +34,10 @@ export type Thread = { * `threads` and `archivedThreads` inputs on chat-sidenav. The field is * typed documentation of intent. */ status?: 'active' | 'archived'; + /** Optional flag indicating the thread is pinned (sticky-top). The framework + * renders a pin icon when true but does NOT sort — the consumer pre-sorts + * pinned threads to the top of the `threads` input. */ + pinned?: boolean; [key: string]: unknown; }; @@ -54,6 +58,10 @@ export interface ThreadActionAdapter { archive?(threadId: string): Promise; /** Restore an archived thread to the active list. */ unarchive?(threadId: string): Promise; + /** Mark the thread as pinned. */ + pin?(threadId: string): Promise; + /** Unpin a previously pinned thread. */ + unpin?(threadId: string): Promise; } @Component({ @@ -94,7 +102,14 @@ export interface ThreadActionAdapter { [attr.aria-current]="thread.id === activeThreadId() ? 'true' : null" (click)="selectThread(thread.id)" > - {{ threadLabel(thread) }} + + @if (thread.pinned) { + + } + {{ threadLabel(thread) }} + @if (thread.updatedAt !== undefined) { {{ relativeTime(thread.updatedAt) }} } @@ -174,7 +189,11 @@ export class ChatThreadListComponent { if (!a) return []; const items: OverflowMenuItem[] = []; if (this.mode() === 'active') { + const thread = this.threads().find((t) => t.id === id); + const isPinned = thread?.pinned === true; if (a.rename) items.push({ id: 'rename', label: 'Rename' }); + if (a.pin && !isPinned) items.push({ id: 'pin', label: 'Pin' }); + if (a.unpin && isPinned) items.push({ id: 'unpin', label: 'Unpin' }); if (a.archive) items.push({ id: 'archive', label: 'Archive' }); if (a.delete) items.push({ id: 'delete', label: 'Delete', tone: 'destructive' }); } else { @@ -207,7 +226,7 @@ export class ChatThreadListComponent { protected showKebab(): boolean { const a = this.actions(); if (!a) return false; - if (this.mode() === 'active') return Boolean(a.rename || a.archive || a.delete); + if (this.mode() === 'active') return Boolean(a.rename || a.pin || a.unpin || a.archive || a.delete); return Boolean(a.unarchive || a.delete); } @@ -232,9 +251,25 @@ export class ChatThreadListComponent { void this.performArchive(threadId); } else if (id === 'unarchive') { void this.performUnarchive(threadId); + } else if (id === 'pin') { + void this.performPin(threadId); + } else if (id === 'unpin') { + void this.performUnpin(threadId); } } + protected async performPin(threadId: string): Promise { + const a = this.actions(); + if (!a?.pin) return; + try { await a.pin(threadId); } catch { /* visual state remains until next successful refresh */ } + } + + protected async performUnpin(threadId: string): Promise { + const a = this.actions(); + if (!a?.unpin) return; + try { await a.unpin(threadId); } catch { /* visual state remains until next successful refresh */ } + } + protected onEditInput(e: Event): void { this.editingValue.set((e.target as HTMLInputElement).value); } diff --git a/libs/chat/src/lib/styles/chat-thread-list.styles.ts b/libs/chat/src/lib/styles/chat-thread-list.styles.ts index 474ef658..bc6dccb7 100644 --- a/libs/chat/src/lib/styles/chat-thread-list.styles.ts +++ b/libs/chat/src/lib/styles/chat-thread-list.styles.ts @@ -89,6 +89,14 @@ export const CHAT_THREAD_LIST_STYLES = ` outline: 2px solid var(--ngaf-chat-primary); outline-offset: 2px; } + .chat-thread-list__item-pin { + width: 11px; + height: 11px; + margin-right: 4px; + color: var(--ngaf-chat-text-muted); + vertical-align: -1px; + display: inline-block; + } .chat-thread-list__edit { flex: 1 1 auto; border: 1px solid var(--ngaf-chat-primary);