diff --git a/apps/website/content/docs/chat/api/api-docs.json b/apps/website/content/docs/chat/api/api-docs.json index c07987a3..6a9415bd 100644 --- a/apps/website/content/docs/chat/api/api-docs.json +++ b/apps/website/content/docs/chat/api/api-docs.json @@ -3388,6 +3388,18 @@ "description": "", "optional": false }, + { + "name": "archivedOpen", + "type": "WritableSignal", + "description": "", + "optional": false + }, + { + "name": "archivedThreads", + "type": "InputSignal", + "description": "", + "optional": false + }, { "name": "mode", "type": "InputSignal", @@ -3613,6 +3625,12 @@ "description": "", "optional": false }, + { + "name": "mode", + "type": "InputSignal<\"active\" | \"archived\">", + "description": "", + "optional": false + }, { "name": "newThreadRequested", "type": "OutputEmitterRef", @@ -3715,12 +3733,38 @@ } ] }, + { + "name": "performArchive", + "signature": "performArchive(threadId: string)", + "description": "", + "params": [ + { + "name": "threadId", + "type": "string", + "description": "", + "optional": false + } + ] + }, { "name": "performDelete", "signature": "performDelete()", "description": "", "params": [] }, + { + "name": "performUnarchive", + "signature": "performUnarchive(threadId: string)", + "description": "", + "params": [ + { + "name": "threadId", + "type": "string", + "description": "", + "optional": false + } + ] + }, { "name": "relativeTime", "signature": "relativeTime(epochMs: number)", @@ -5845,6 +5889,12 @@ "kind": "interface", "description": "Per-thread row-action adapter. Consumer-provided. The framework calls\nthese methods after user confirmation (delete) or commit (rename) and\nmanages optimistic UI + rollback on rejection.\n\nConsumers MUST refresh their `threads` signal on success — the framework\nclears optimistic overrides in a `finally` block, so a successful adapter\ncall that leaves the input list unchanged would re-render the row.", "properties": [ + { + "name": "archive", + "type": "unknown", + "description": "", + "optional": true + }, { "name": "delete", "type": "unknown", @@ -5856,6 +5906,12 @@ "type": "unknown", "description": "", "optional": true + }, + { + "name": "unarchive", + "type": "unknown", + "description": "", + "optional": true } ], "examples": [] diff --git a/docs/superpowers/plans/2026-05-12-chat-archive.md b/docs/superpowers/plans/2026-05-12-chat-archive.md new file mode 100644 index 00000000..7cb7a7de --- /dev/null +++ b/docs/superpowers/plans/2026-05-12-chat-archive.md @@ -0,0 +1,917 @@ +# Chat archive — Phase 3b 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 archive/unarchive support to the sidenav: a collapsible "Archived" section, two new optional adapter methods (`archive` + `unarchive`), and a mode-aware menu on `chat-thread-list` (Rename/Archive/Delete in active mode; Unarchive/Delete in archived). Bundles a `ThreadsService` migration from raw `fetch` to `@langchain/langgraph-sdk` for all thread CRUD. + +**Architecture:** Adapter-driven, like Phase 3a. The framework stays dumb about lifecycle: consumers pre-filter into `threads` (active) and `archivedThreads` (archived) inputs on `chat-sidenav`. A new `mode` input on `chat-thread-list` drives per-section menu items. Archive/unarchive reuse the existing optimistic-hide mechanism (renamed `pendingDeletes` → `pendingHidden`). No confirmation dialog — archive is reversible. + +**Tech Stack:** Angular 21 standalone components, signal inputs/outputs, plain CSS strings, Vitest + Angular TestBed, `@langchain/langgraph-sdk` (already a transitive dep). + +**Spec:** [docs/superpowers/specs/2026-05-12-chat-archive-design.md](../specs/2026-05-12-chat-archive-design.md) + +--- + +## File map + +**Modify:** +- `libs/chat/src/lib/primitives/chat-thread-list/chat-thread-list.component.ts` — type extensions + `mode` input + `pendingHidden` rename + menu-items rework + `performArchive`/`performUnarchive` + `showKebab` update. +- `libs/chat/src/lib/primitives/chat-thread-list/chat-thread-list.component.spec.ts` — new mode/archive/unarchive tests. +- `libs/chat/src/lib/compositions/chat-sidenav/chat-sidenav.component.ts` — `archivedThreads` input + collapsible section. +- `libs/chat/src/lib/compositions/chat-sidenav/chat-sidenav.component.spec.ts` — new archived-section tests. +- `libs/chat/src/lib/styles/chat-sidenav.styles.ts` — collapsible heading + chevron + empty-state styles. +- `examples/chat/angular/src/app/shell/threads.service.ts` — full rewrite using `@langchain/langgraph-sdk`'s `Client`, plus new methods. +- `examples/chat/angular/src/app/shell/demo-shell.component.ts` — extend `threadActions` with archive/unarchive. +- `examples/chat/angular/src/app/shell/demo-shell.component.html` — bind `[archivedThreads]`. +- `apps/website/content/docs/chat/api/api-docs.json` — regenerated. + +**No new files.** Phase 3b extends what Phase 3a delivered. + +--- + +## Task 1: Type extensions + +**Files:** +- Modify: `libs/chat/src/lib/primitives/chat-thread-list/chat-thread-list.component.ts` + +- [ ] **Step 1: Read the current file** + +Read `libs/chat/src/lib/primitives/chat-thread-list/chat-thread-list.component.ts` to confirm where `Thread` and `ThreadActionAdapter` are declared. + +- [ ] **Step 2: Extend `Thread`** + +Find the `Thread` type definition: + +```typescript +export type Thread = { + id: string; + /** Optional human-friendly label. Falls back to a slice of the id. */ + title?: string; + /** Optional epoch-ms timestamp ... */ + updatedAt?: number; + [key: string]: unknown; +}; +``` + +Insert a new `status` field BEFORE the `[key: string]: unknown` line: + +```typescript +export type Thread = { + id: string; + /** Optional human-friendly label. Falls back to a slice of the id. */ + title?: string; + /** Optional epoch-ms timestamp ... */ + updatedAt?: number; + /** Optional lifecycle status. Undefined treated as 'active'. The framework + * does NOT auto-filter by this field — consumers pre-filter into separate + * `threads` and `archivedThreads` inputs on chat-sidenav. The field is + * typed documentation of intent. */ + status?: 'active' | 'archived'; + [key: string]: unknown; +}; +``` + +- [ ] **Step 3: Extend `ThreadActionAdapter`** + +Find the `ThreadActionAdapter` interface: + +```typescript +export interface ThreadActionAdapter { + delete?(threadId: string): Promise; + rename?(threadId: string, newTitle: string): Promise; +} +``` + +Add `archive?` and `unarchive?` methods at the end: + +```typescript +export interface ThreadActionAdapter { + delete?(threadId: string): Promise; + rename?(threadId: string, newTitle: string): Promise; + /** Archive a thread (reversible). No confirmation dialog — framework calls + * this immediately on click. */ + archive?(threadId: string): Promise; + /** Restore an archived thread to the active list. */ + unarchive?(threadId: string): Promise; +} +``` + +- [ ] **Step 4: Build** + +Run: `npx nx run chat:build` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add libs/chat/src/lib/primitives/chat-thread-list/chat-thread-list.component.ts +git commit -m "feat(chat): extend Thread + ThreadActionAdapter with archive lifecycle" +``` + +--- + +## Task 2: `chat-thread-list` mode + archive/unarchive handlers + +**Files:** +- Modify: `libs/chat/src/lib/primitives/chat-thread-list/chat-thread-list.component.ts` + +This is the biggest task. Component is fully replaced. + +- [ ] **Step 1: Add the `mode` input** + +In the class body, near the other inputs (`threads`, `activeThreadId`, `showNewThreadButton`, `actions`), add: + +```typescript +readonly mode = input<'active' | 'archived'>('active'); +``` + +- [ ] **Step 2: Rename `pendingDeletes` to `pendingHidden`** + +Find: + +```typescript +private readonly pendingDeletes = signal>(new Set()); +``` + +Replace with: + +```typescript +/** Ids hidden from the rendered list during pending delete, archive, or + * unarchive. The framework doesn't distinguish — all three actions hide + * the row from the current list until the adapter promise settles. */ +private readonly pendingHidden = signal>(new Set()); +``` + +In `visibleThreads`, replace the reference to `pendingDeletes()` with `pendingHidden()`: + +```typescript +protected readonly visibleThreads = computed(() => { + const hidden = this.pendingHidden(); + const renames = this.pendingRenames(); + return this.threads() + .filter((t) => !hidden.has(t.id)) + .map((t) => (renames.has(t.id) ? ({ ...t, title: renames.get(t.id) }) : t)); +}); +``` + +In `performDelete`, replace both `pendingDeletes.update(...)` calls with `pendingHidden.update(...)` — same set semantics, just renamed. + +- [ ] **Step 3: Update `currentMenuItems` to be mode-aware** + +Replace the existing `currentMenuItems` computed: + +```typescript +protected readonly currentMenuItems = computed(() => { + const id = this.menuOpenForId(); + if (!id) return []; + const a = this.actions(); + if (!a) return []; + const items: OverflowMenuItem[] = []; + if (this.mode() === 'active') { + if (a.rename) items.push({ id: 'rename', label: 'Rename' }); + if (a.archive) items.push({ id: 'archive', label: 'Archive' }); + if (a.delete) items.push({ id: 'delete', label: 'Delete', tone: 'destructive' }); + } else { + if (a.unarchive) items.push({ id: 'unarchive', label: 'Unarchive' }); + if (a.delete) items.push({ id: 'delete', label: 'Delete', tone: 'destructive' }); + } + return items; +}); +``` + +- [ ] **Step 4: Update `showKebab` to be mode-aware** + +Replace: + +```typescript +protected showKebab(): boolean { + const a = this.actions(); + if (!a) return false; + return Boolean(a.rename || a.delete); +} +``` + +With: + +```typescript +protected showKebab(): boolean { + const a = this.actions(); + if (!a) return false; + if (this.mode() === 'active') return Boolean(a.rename || a.archive || a.delete); + return Boolean(a.unarchive || a.delete); +} +``` + +- [ ] **Step 5: Add archive/unarchive routing in `onMenuAction`** + +Find the existing `onMenuAction`. Add two new branches: + +```typescript +protected onMenuAction(id: string): void { + const threadId = this.menuOpenForId(); + this.menuOpenForId.set(null); + if (!threadId) return; + + if (id === 'rename') { + const t = this.threads().find((x) => x.id === threadId); + this.editingValue.set(typeof t?.title === 'string' ? t.title : ''); + this.editingThreadId.set(threadId); + queueMicrotask(() => this.editInput()?.nativeElement.focus()); + } else if (id === 'delete') { + this.confirmDeleteId.set(threadId); + } else if (id === 'archive') { + void this.performArchive(threadId); + } else if (id === 'unarchive') { + void this.performUnarchive(threadId); + } +} +``` + +- [ ] **Step 6: Add `performArchive` and `performUnarchive` methods** + +Below the existing `performDelete` method, add: + +```typescript +protected async performArchive(threadId: string): Promise { + const a = this.actions(); + if (!a?.archive) return; + this.pendingHidden.update((s) => new Set([...s, threadId])); + try { + await a.archive(threadId); + } catch { + // Rollback: clear override below so the row reappears. + } finally { + this.pendingHidden.update((s) => { + const n = new Set(s); + n.delete(threadId); + return n; + }); + } +} + +protected async performUnarchive(threadId: string): Promise { + const a = this.actions(); + if (!a?.unarchive) return; + this.pendingHidden.update((s) => new Set([...s, threadId])); + try { + await a.unarchive(threadId); + } catch { + // Rollback: clear override below so the row reappears. + } finally { + this.pendingHidden.update((s) => { + const n = new Set(s); + n.delete(threadId); + return n; + }); + } +} +``` + +- [ ] **Step 7: Verify** + +Run: +```bash +npx nx run chat:test 2>&1 | tail -5 +npx nx run chat:build 2>&1 | tail -3 +npx nx lint chat 2>&1 | tail -3 +``` + +All three must pass. Existing 14 tests in the chat-thread-list spec should still pass (no behavioral change to delete/rename flows; only the underlying set name changed and a few new code paths were added). + +- [ ] **Step 8: Commit** + +```bash +git add libs/chat/src/lib/primitives/chat-thread-list/chat-thread-list.component.ts +git commit -m "feat(chat): chat-thread-list mode-aware menu + archive/unarchive handlers" +``` + +--- + +## Task 3: `chat-thread-list` archive tests + +**Files:** +- Modify: `libs/chat/src/lib/primitives/chat-thread-list/chat-thread-list.component.spec.ts` + +Add new test cases. Existing tests remain unchanged. + +- [ ] **Step 1: Add 8 new test cases at the end of the `describe('with adapter', ...)` block** + +Inside the `describe('with adapter', ...)` block, BEFORE its closing brace, add: + +```typescript + it('mode="active" with archive action shows Archive in menu', () => { + const fixture = render({ actions: { archive: vi.fn().mockResolvedValue(undefined) } }); + (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('Archive'); + expect(labels).not.toContain('Unarchive'); + }); + + it('mode="archived" with unarchive action shows Unarchive (and not Rename or Archive)', () => { + const fixture = TestBed.createComponent(ChatThreadListComponent); + fixture.componentRef.setInput('threads', [{ id: 't1', title: 'First', status: 'archived' as const }]); + fixture.componentRef.setInput('actions', { unarchive: vi.fn().mockResolvedValue(undefined), rename: vi.fn().mockResolvedValue(undefined), archive: vi.fn().mockResolvedValue(undefined) }); + fixture.componentRef.setInput('mode', 'archived'); + fixture.detectChanges(); + (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).toEqual(['Unarchive']); + }); + + it('mode="archived" with unarchive + delete shows Unarchive, Delete in that order', () => { + const fixture = TestBed.createComponent(ChatThreadListComponent); + fixture.componentRef.setInput('threads', [{ id: 't1', title: 'First', status: 'archived' as const }]); + fixture.componentRef.setInput('actions', { unarchive: vi.fn().mockResolvedValue(undefined), delete: vi.fn().mockResolvedValue(undefined) }); + fixture.componentRef.setInput('mode', 'archived'); + fixture.detectChanges(); + (fixture.nativeElement.querySelector('.chat-thread-list__kebab') as HTMLElement).click(); + fixture.detectChanges(); + const items = document.querySelectorAll('.chat-overflow-menu__item'); + expect(items.length).toBe(2); + expect((items[0] as HTMLElement).textContent?.trim()).toBe('Unarchive'); + expect((items[1] as HTMLElement).textContent?.trim()).toBe('Delete'); + expect(items[1].classList.contains('chat-overflow-menu__item--destructive')).toBe(true); + }); + + it('Click Archive calls adapter.archive, hides row optimistically, no confirm dialog opens', async () => { + let resolveArchive!: () => void; + const archiveSpy = vi.fn(() => new Promise((r) => { resolveArchive = r; })); + const fixture = render({ actions: { archive: archiveSpy } }); + (fixture.nativeElement.querySelector('.chat-thread-list__kebab') as HTMLElement).click(); + fixture.detectChanges(); + const item = Array.from(document.querySelectorAll('.chat-overflow-menu__item')) + .find((el) => (el as HTMLElement).textContent?.trim() === 'Archive') as HTMLElement; + item.click(); + fixture.detectChanges(); + expect(archiveSpy).toHaveBeenCalledWith('t1'); + // Confirm dialog must NOT appear for archive. + expect(document.querySelector('.chat-confirm-dialog')).toBeNull(); + // Row hidden optimistically (only 't2' remains). + const remaining = fixture.nativeElement.querySelectorAll('.chat-thread-list__item'); + expect(remaining.length).toBe(1); + resolveArchive(); + await new Promise((r) => setTimeout(r, 0)); + }); + + it('Click Unarchive calls adapter.unarchive and hides row optimistically', async () => { + let resolveUnarchive!: () => void; + const unarchiveSpy = vi.fn(() => new Promise((r) => { resolveUnarchive = r; })); + const fixture = TestBed.createComponent(ChatThreadListComponent); + fixture.componentRef.setInput('threads', [ + { id: 't1', title: 'First', status: 'archived' as const }, + { id: 't2', title: 'Second', status: 'archived' as const }, + ]); + fixture.componentRef.setInput('actions', { unarchive: unarchiveSpy }); + fixture.componentRef.setInput('mode', 'archived'); + fixture.detectChanges(); + (fixture.nativeElement.querySelector('.chat-thread-list__kebab') as HTMLElement).click(); + fixture.detectChanges(); + const item = Array.from(document.querySelectorAll('.chat-overflow-menu__item')) + .find((el) => (el as HTMLElement).textContent?.trim() === 'Unarchive') as HTMLElement; + item.click(); + fixture.detectChanges(); + expect(unarchiveSpy).toHaveBeenCalledWith('t1'); + const remaining = fixture.nativeElement.querySelectorAll('.chat-thread-list__item'); + expect(remaining.length).toBe(1); + resolveUnarchive(); + await new Promise((r) => setTimeout(r, 0)); + }); + + it('Archive: when adapter rejects, the hidden row reappears', async () => { + const archiveSpy = vi.fn(async () => { throw new Error('boom'); }); + const fixture = render({ actions: { archive: archiveSpy } }); + (fixture.nativeElement.querySelector('.chat-thread-list__kebab') as HTMLElement).click(); + fixture.detectChanges(); + const item = Array.from(document.querySelectorAll('.chat-overflow-menu__item')) + .find((el) => (el as HTMLElement).textContent?.trim() === 'Archive') as HTMLElement; + item.click(); + await new Promise((r) => setTimeout(r, 0)); + await new Promise((r) => setTimeout(r, 0)); + fixture.detectChanges(); + const remaining = fixture.nativeElement.querySelectorAll('.chat-thread-list__item'); + expect(remaining.length).toBe(2); + }); + + it('Unarchive: when adapter rejects, the hidden row reappears', async () => { + const unarchiveSpy = vi.fn(async () => { throw new Error('boom'); }); + const fixture = TestBed.createComponent(ChatThreadListComponent); + fixture.componentRef.setInput('threads', [ + { id: 't1', title: 'First', status: 'archived' as const }, + { id: 't2', title: 'Second', status: 'archived' as const }, + ]); + fixture.componentRef.setInput('actions', { unarchive: unarchiveSpy }); + fixture.componentRef.setInput('mode', 'archived'); + fixture.detectChanges(); + (fixture.nativeElement.querySelector('.chat-thread-list__kebab') as HTMLElement).click(); + fixture.detectChanges(); + const item = Array.from(document.querySelectorAll('.chat-overflow-menu__item')) + .find((el) => (el as HTMLElement).textContent?.trim() === 'Unarchive') as HTMLElement; + item.click(); + await new Promise((r) => setTimeout(r, 0)); + await new Promise((r) => setTimeout(r, 0)); + fixture.detectChanges(); + const remaining = fixture.nativeElement.querySelectorAll('.chat-thread-list__item'); + expect(remaining.length).toBe(2); + }); + + 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 }]); + fixture.componentRef.setInput('actions', { rename: vi.fn().mockResolvedValue(undefined), archive: vi.fn().mockResolvedValue(undefined) }); + fixture.componentRef.setInput('mode', 'archived'); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('.chat-thread-list__kebab')).toBeNull(); + }); +``` + +- [ ] **Step 2: Run tests** + +Run: `npx nx run chat:test 2>&1 | tail -5` +Expected: PASS (existing + 8 new). + +- [ ] **Step 3: Commit** + +```bash +git add libs/chat/src/lib/primitives/chat-thread-list/chat-thread-list.component.spec.ts +git commit -m "test(chat): chat-thread-list mode + archive/unarchive coverage" +``` + +--- + +## Task 4: `chat-sidenav` archived section + +**Files:** +- Modify: `libs/chat/src/lib/compositions/chat-sidenav/chat-sidenav.component.ts` +- Modify: `libs/chat/src/lib/styles/chat-sidenav.styles.ts` + +- [ ] **Step 1: Update the styles file** + +Open `libs/chat/src/lib/styles/chat-sidenav.styles.ts`. Append these rules INSIDE the template literal (before the closing backtick): + +```css + .chat-sidenav__archived { flex-shrink: 0; } + .chat-sidenav__archived-heading { + display: flex; + align-items: center; + gap: 6px; + width: 100%; + padding: 8px 12px 4px; + border: 0; + background: transparent; + color: var(--ngaf-chat-text-muted); + font: inherit; + font-size: var(--ngaf-chat-font-size-xs); + text-transform: uppercase; + letter-spacing: 0.05em; + text-align: left; + cursor: pointer; + } + .chat-sidenav__archived-heading:hover { color: var(--ngaf-chat-text); } + .chat-sidenav__archived-heading:focus-visible { + outline: 2px solid var(--ngaf-chat-primary); + outline-offset: 2px; + } + .chat-sidenav__archived-chevron { + width: 12px; + height: 12px; + transition: transform 150ms ease; + flex-shrink: 0; + } + .chat-sidenav__archived[data-open="true"] .chat-sidenav__archived-chevron { + transform: rotate(90deg); + } + .chat-sidenav__archived-empty { + padding: 8px 12px; + color: var(--ngaf-chat-text-muted); + font-size: var(--ngaf-chat-font-size-sm); + } + :host([data-mode="collapsed"]) .chat-sidenav__archived { display: none; } +``` + +- [ ] **Step 2: Add `archivedThreads` input + `archivedOpen` signal** + +Open `libs/chat/src/lib/compositions/chat-sidenav/chat-sidenav.component.ts`. + +Near the top, expand the existing Angular imports to include `signal`: + +```typescript +import { + Component, + ChangeDetectionStrategy, + DestroyRef, + inject, + input, + output, + signal, +} from '@angular/core'; +``` + +(If `signal` is already imported, this is a no-op.) + +In the class body, after the existing `activeThreadId` input, add: + +```typescript +readonly archivedThreads = input(null); +``` + +Then add the protected state signal near where other protected fields live (or just after the inputs): + +```typescript +protected readonly archivedOpen = signal(false); +``` + +- [ ] **Step 3: Add the archived section to the template** + +Find the existing `@if (threads() !== null)` block in the template — the one wrapping the Recent ``. AFTER that block's closing brace, insert the archived section: + +```html +@if (archivedThreads() !== null) { +
+ + @if (archivedOpen()) { +
+ @if (archivedThreads()!.length === 0) { +
No archived conversations.
+ } @else { + + } +
+ } +
+} +``` + +Place this BEFORE the existing `
` (the `[sidenavSections]` slot wrapper). + +- [ ] **Step 4: Verify** + +Run: +```bash +npx nx run chat:test 2>&1 | tail -5 +npx nx run chat:build 2>&1 | tail -3 +npx nx lint chat 2>&1 | tail -3 +``` + +If lint flags the new ` + @if (archivedOpen()) { +
+ @if (archivedThreads()!.length === 0) { +
No archived conversations.
+ } @else { + + } +
+ } +
+} +``` + +### Styles + +Added to `chat-sidenav.styles.ts`: + +```css +.chat-sidenav__archived { flex-shrink: 0; } +.chat-sidenav__archived-heading { + display: flex; + align-items: center; + gap: 6px; + width: 100%; + padding: 8px 12px 4px; + border: 0; + background: transparent; + color: var(--ngaf-chat-text-muted); + font: inherit; + font-size: var(--ngaf-chat-font-size-xs); + text-transform: uppercase; + letter-spacing: 0.05em; + text-align: left; + cursor: pointer; +} +.chat-sidenav__archived-heading:hover { color: var(--ngaf-chat-text); } +.chat-sidenav__archived-heading:focus-visible { + outline: 2px solid var(--ngaf-chat-primary); + outline-offset: 2px; +} +.chat-sidenav__archived-chevron { + width: 12px; + height: 12px; + transition: transform 150ms ease; +} +:host .chat-sidenav__archived[data-open="true"] .chat-sidenav__archived-chevron { + transform: rotate(90deg); +} +.chat-sidenav__archived-empty { + padding: 8px 12px; + color: var(--ngaf-chat-text-muted); + font-size: var(--ngaf-chat-font-size-sm); +} +:host([data-mode="collapsed"]) .chat-sidenav__archived { display: none; } +``` + +The collapsed-rail mode hides the section entirely — there's no useful affordance for archive management in an icon rail. + +## Example wiring (`examples-chat-angular`) + +### `ThreadsService` — full rewrite using `@langchain/langgraph-sdk` + +Replaces the existing raw-fetch implementation AND adds the new methods. The existing public signal `threads` and methods `refresh`, `create` keep the same signatures; only their implementations switch to SDK. + +```ts +// examples/chat/angular/src/app/shell/threads.service.ts +import { Injectable, signal } from '@angular/core'; +import { Client, type Thread as SdkThread } from '@langchain/langgraph-sdk'; +import type { Thread } from '@ngaf/chat'; + +const API_URL = 'http://localhost:2024'; + +@Injectable({ providedIn: 'root' }) +export class ThreadsService { + private readonly client = new Client({ apiUrl: API_URL }); + + readonly threads = signal([]); + readonly archivedThreads = signal([]); + + async refresh(): Promise { + 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.archivedThreads.set(mapped.filter((t) => t.status === 'archived')); + } catch { + // Backend may be down; leave signals as-is. + } + } + + async create(): Promise { + try { + const t = await this.client.threads.create({ metadata: {} }); + await this.refresh(); + return t.thread_id; + } catch { + return null; + } + } + + async delete(threadId: string): Promise { + await this.client.threads.delete(threadId); + await this.refresh(); + } + + async rename(threadId: string, newTitle: string): Promise { + await this.client.threads.update(threadId, { metadata: { title: newTitle } }); + await this.refresh(); + } + + async archive(threadId: string): Promise { + await this.client.threads.update(threadId, { metadata: { archived: true } }); + await this.refresh(); + } + + async unarchive(threadId: string): Promise { + await this.client.threads.update(threadId, { metadata: { archived: false } }); + await this.refresh(); + } + + private toThread(t: SdkThread): Thread { + const meta = t.metadata ?? {}; + const customTitle = (meta as { title?: string }).title; + const archived = (meta as { archived?: boolean }).archived === 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', + }; + } +} +``` + +**Note on `toThread`:** the spread-style status assignment is awkward — the implementation can simplify to a single ternary on the `status` field. The shape that matters is that `status: 'archived'` is set when metadata says so, otherwise `'active'`. + +If the existing `titleFor()` helper has additional logic (e.g., reading from `state.values.messages[0]`), preserve it; only the network/SDK layer changes. + +### `demo-shell.component.ts` — extend `threadActions` + +```ts +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), + archive: async (id) => { + await this.threadsSvc.archive(id); + if (this.threadIdSignal() === id) { + this.threadIdSignal.set(null); + this.persistence.write('threadId', null); + } + }, + unarchive: (id) => this.threadsSvc.unarchive(id), +}; +``` + +Active-thread cleanup mirrors the delete pattern — if you archive the currently-open thread, the shell drops the active id so the chat returns to the welcome state. + +### `demo-shell.component.html` — bind the new input + +Add `[archivedThreads]="threadsSvc.archivedThreads()"` to the existing `` element. No other changes. + +## Edge cases + +1. **Adapter has `archive` but `mode='active'` has no other methods.** Menu renders just "Archive". Single-item menu is fine. +2. **Active thread is archived.** Shell-level concern, mirrors the delete pattern (clear `threadIdSignal`). +3. **Empty archived list.** Heading still rendered (consumer passed `archivedThreads=[]` indicating archive feature is enabled); expanded shows "No archived conversations." empty state. +4. **`archivedThreads=null` AND `actions.archive` defined.** Active rows still get an "Archive" menu item; the framework allows archiving but the consumer hasn't enabled the archived view. Acceptable — archived threads aren't visible but are reachable through the LangGraph thread metadata directly. Consumers wanting full UX pass both. +5. **`archivedThreads` provided but `actions.unarchive` not.** Archived rows have a kebab only if `delete` is provided; otherwise no kebab. The user can see archived threads but can't restore them through the UI. Acceptable — explicit consumer choice, framework respects what's wired. +6. **Optimistic archive race with optimistic delete.** Both add to the same `pendingHidden` set; both clear in `finally`. If a row is in two pending ops simultaneously (impossible through UI, but theoretically through programmatic clicks), the set is idempotent. Safe. + +## Testing + +### `chat-thread-list.component.spec.ts` additions + +1. `mode='active' + actions={archive}` → menu includes "Archive". +2. `mode='archived' + actions={unarchive}` → menu includes "Unarchive" but NOT "Archive" or "Rename". +3. `mode='archived' + actions={unarchive, delete}` → menu items in order: Unarchive, Delete (destructive). +4. Click Archive → calls `actions.archive(threadId)`, row hidden via `pendingHidden`, no confirm dialog opens. +5. Click Unarchive → calls `actions.unarchive(threadId)`, row hidden from list. +6. Archive adapter rejects → row reappears. +7. Unarchive adapter rejects → row reappears. +8. `showKebab` returns false in `mode='archived'` when only `rename` and `archive` are provided (neither `unarchive` nor `delete`). + +### `chat-sidenav.component.spec.ts` additions + +1. `archivedThreads=null` → no archived heading rendered. +2. `archivedThreads=[]` → heading renders; click expands; "No archived conversations." shown. +3. `archivedThreads=[t1, t2]` → heading expands to render a `` with `mode="archived"`. +4. Heading click toggles `aria-expanded` between "true" and "false". + +### Build / lint + +- `nx run chat:test` — passes (new + existing). +- `nx run chat:build` — clean. +- `nx lint chat` — clean. +- `nx run examples-chat-angular:build` — clean. + +### Manual (Chrome MCP) verification + +1. Initial: sidenav renders Recent + collapsed "Archived" heading. +2. Hover active row → kebab fades in. Click → menu: Rename, Archive, Delete. +3. Click Archive → row vanishes from Recent; no dialog; LangGraph `threads.update({metadata.archived:true})` succeeds. +4. Click "Archived" heading → expands; the archived thread appears. +5. Hover archived row → kebab fades in. Click → menu: Unarchive, Delete. +6. Click Unarchive → row vanishes from Archived; reappears in Recent after refresh. +7. Archive the *current* thread → row vanishes AND chat returns to welcome state. +8. Reload page → archived state persists (on LangGraph thread metadata). +9. Sidenav `mode='collapsed'` → archived section hidden entirely. + +## Accessibility + +- Archived heading is a ` + @if (archivedOpen()) { +
+ @if (archivedThreads()!.length === 0) { +
No archived conversations.
+ } @else { + + } +
+ } + + } +
@@ -110,12 +146,15 @@ export class ChatSidenavComponent { readonly threads = input(null); readonly activeThreadId = input(null); readonly actions = input(null); + readonly archivedThreads = input(null); readonly newChat = output(); readonly threadSelected = output(); readonly searchOpened = output(); readonly openChange = output(); + protected readonly archivedOpen = signal(false); + private readonly destroyRef = inject(DestroyRef); constructor() { 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 89b79fad..8a97d3c9 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 @@ -200,5 +200,131 @@ describe('ChatThreadListComponent', () => { expect(remaining.length).toBe(2); expect(deleteSpy).toHaveBeenCalledWith('t1'); }); + + it('mode="active" with archive action shows Archive in menu', () => { + const fixture = render({ actions: { archive: vi.fn().mockResolvedValue(undefined) } }); + (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('Archive'); + expect(labels).not.toContain('Unarchive'); + }); + + it('mode="archived" with unarchive action shows Unarchive (and not Rename or Archive)', () => { + const fixture = TestBed.createComponent(ChatThreadListComponent); + fixture.componentRef.setInput('threads', [{ id: 't1', title: 'First', status: 'archived' as const }]); + fixture.componentRef.setInput('actions', { unarchive: vi.fn().mockResolvedValue(undefined), rename: vi.fn().mockResolvedValue(undefined), archive: vi.fn().mockResolvedValue(undefined) }); + fixture.componentRef.setInput('mode', 'archived'); + fixture.detectChanges(); + (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).toEqual(['Unarchive']); + }); + + it('mode="archived" with unarchive + delete shows Unarchive, Delete in that order', () => { + const fixture = TestBed.createComponent(ChatThreadListComponent); + fixture.componentRef.setInput('threads', [{ id: 't1', title: 'First', status: 'archived' as const }]); + fixture.componentRef.setInput('actions', { unarchive: vi.fn().mockResolvedValue(undefined), delete: vi.fn().mockResolvedValue(undefined) }); + fixture.componentRef.setInput('mode', 'archived'); + fixture.detectChanges(); + (fixture.nativeElement.querySelector('.chat-thread-list__kebab') as HTMLElement).click(); + fixture.detectChanges(); + const items = document.querySelectorAll('.chat-overflow-menu__item'); + expect(items.length).toBe(2); + expect((items[0] as HTMLElement).textContent?.trim()).toBe('Unarchive'); + expect((items[1] as HTMLElement).textContent?.trim()).toBe('Delete'); + expect(items[1].classList.contains('chat-overflow-menu__item--destructive')).toBe(true); + }); + + it('Click Archive calls adapter.archive, hides row optimistically, no confirm dialog opens', async () => { + let resolveArchive!: () => void; + const archiveSpy = vi.fn(() => new Promise((r) => { resolveArchive = r; })); + const fixture = render({ actions: { archive: archiveSpy } }); + (fixture.nativeElement.querySelector('.chat-thread-list__kebab') as HTMLElement).click(); + fixture.detectChanges(); + const item = Array.from(document.querySelectorAll('.chat-overflow-menu__item')) + .find((el) => (el as HTMLElement).textContent?.trim() === 'Archive') as HTMLElement; + item.click(); + fixture.detectChanges(); + expect(archiveSpy).toHaveBeenCalledWith('t1'); + expect(document.querySelector('.chat-confirm-dialog')).toBeNull(); + const remaining = fixture.nativeElement.querySelectorAll('.chat-thread-list__item'); + expect(remaining.length).toBe(1); + resolveArchive(); + await new Promise((r) => setTimeout(r, 0)); + }); + + it('Click Unarchive calls adapter.unarchive and hides row optimistically', async () => { + let resolveUnarchive!: () => void; + const unarchiveSpy = vi.fn(() => new Promise((r) => { resolveUnarchive = r; })); + const fixture = TestBed.createComponent(ChatThreadListComponent); + fixture.componentRef.setInput('threads', [ + { id: 't1', title: 'First', status: 'archived' as const }, + { id: 't2', title: 'Second', status: 'archived' as const }, + ]); + fixture.componentRef.setInput('actions', { unarchive: unarchiveSpy }); + fixture.componentRef.setInput('mode', 'archived'); + fixture.detectChanges(); + (fixture.nativeElement.querySelector('.chat-thread-list__kebab') as HTMLElement).click(); + fixture.detectChanges(); + const item = Array.from(document.querySelectorAll('.chat-overflow-menu__item')) + .find((el) => (el as HTMLElement).textContent?.trim() === 'Unarchive') as HTMLElement; + item.click(); + fixture.detectChanges(); + expect(unarchiveSpy).toHaveBeenCalledWith('t1'); + const remaining = fixture.nativeElement.querySelectorAll('.chat-thread-list__item'); + expect(remaining.length).toBe(1); + resolveUnarchive(); + await new Promise((r) => setTimeout(r, 0)); + }); + + it('Archive: when adapter rejects, the hidden row reappears', async () => { + const archiveSpy = vi.fn(async () => { throw new Error('boom'); }); + const fixture = render({ actions: { archive: archiveSpy } }); + (fixture.nativeElement.querySelector('.chat-thread-list__kebab') as HTMLElement).click(); + fixture.detectChanges(); + const item = Array.from(document.querySelectorAll('.chat-overflow-menu__item')) + .find((el) => (el as HTMLElement).textContent?.trim() === 'Archive') as HTMLElement; + item.click(); + await new Promise((r) => setTimeout(r, 0)); + await new Promise((r) => setTimeout(r, 0)); + fixture.detectChanges(); + const remaining = fixture.nativeElement.querySelectorAll('.chat-thread-list__item'); + expect(remaining.length).toBe(2); + }); + + it('Unarchive: when adapter rejects, the hidden row reappears', async () => { + const unarchiveSpy = vi.fn(async () => { throw new Error('boom'); }); + const fixture = TestBed.createComponent(ChatThreadListComponent); + fixture.componentRef.setInput('threads', [ + { id: 't1', title: 'First', status: 'archived' as const }, + { id: 't2', title: 'Second', status: 'archived' as const }, + ]); + fixture.componentRef.setInput('actions', { unarchive: unarchiveSpy }); + fixture.componentRef.setInput('mode', 'archived'); + fixture.detectChanges(); + (fixture.nativeElement.querySelector('.chat-thread-list__kebab') as HTMLElement).click(); + fixture.detectChanges(); + const item = Array.from(document.querySelectorAll('.chat-overflow-menu__item')) + .find((el) => (el as HTMLElement).textContent?.trim() === 'Unarchive') as HTMLElement; + item.click(); + await new Promise((r) => setTimeout(r, 0)); + await new Promise((r) => setTimeout(r, 0)); + fixture.detectChanges(); + const remaining = fixture.nativeElement.querySelectorAll('.chat-thread-list__item'); + expect(remaining.length).toBe(2); + }); + + 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 }]); + fixture.componentRef.setInput('actions', { rename: vi.fn().mockResolvedValue(undefined), archive: vi.fn().mockResolvedValue(undefined) }); + fixture.componentRef.setInput('mode', 'archived'); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('.chat-thread-list__kebab')).toBeNull(); + }); }); }); 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 b48f0563..add134ae 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 @@ -29,6 +29,11 @@ export type Thread = { * render a relative-time line ("just now" / "5 min ago"). When absent * the default template omits the second line. */ updatedAt?: number; + /** Optional lifecycle status. Undefined treated as 'active'. The framework + * does NOT auto-filter by this field — consumers pre-filter into separate + * `threads` and `archivedThreads` inputs on chat-sidenav. The field is + * typed documentation of intent. */ + status?: 'active' | 'archived'; [key: string]: unknown; }; @@ -44,6 +49,11 @@ export type Thread = { export interface ThreadActionAdapter { delete?(threadId: string): Promise; rename?(threadId: string, newTitle: string): Promise; + /** Archive a thread (reversible). No confirmation dialog — framework calls + * this immediately on click. */ + archive?(threadId: string): Promise; + /** Restore an archived thread to the active list. */ + unarchive?(threadId: string): Promise; } @Component({ @@ -130,6 +140,7 @@ export class ChatThreadListComponent { readonly activeThreadId = input(''); readonly showNewThreadButton = input(false); readonly actions = input(null); + readonly mode = input<'active' | 'archived'>('active'); readonly threadSelected = output(); readonly newThreadRequested = output(); @@ -142,11 +153,14 @@ export class ChatThreadListComponent { protected readonly menuAnchor = signal(null); protected readonly confirmDeleteId = signal(null); - private readonly pendingDeletes = signal>(new Set()); + /** Ids hidden from the rendered list during pending delete, archive, or + * unarchive. The framework doesn't distinguish — all three actions hide + * the row from the current list until the adapter promise settles. */ + private readonly pendingHidden = signal>(new Set()); private readonly pendingRenames = signal>(new Map()); protected readonly visibleThreads = computed(() => { - const hidden = this.pendingDeletes(); + const hidden = this.pendingHidden(); const renames = this.pendingRenames(); return this.threads() .filter((t) => !hidden.has(t.id)) @@ -159,8 +173,14 @@ export class ChatThreadListComponent { const a = this.actions(); if (!a) return []; const items: OverflowMenuItem[] = []; - if (a.rename) items.push({ id: 'rename', label: 'Rename' }); - if (a.delete) items.push({ id: 'delete', label: 'Delete', tone: 'destructive' }); + if (this.mode() === 'active') { + if (a.rename) items.push({ id: 'rename', label: 'Rename' }); + if (a.archive) items.push({ id: 'archive', label: 'Archive' }); + if (a.delete) items.push({ id: 'delete', label: 'Delete', tone: 'destructive' }); + } else { + if (a.unarchive) items.push({ id: 'unarchive', label: 'Unarchive' }); + if (a.delete) items.push({ id: 'delete', label: 'Delete', tone: 'destructive' }); + } return items; }); @@ -187,7 +207,8 @@ export class ChatThreadListComponent { protected showKebab(): boolean { const a = this.actions(); if (!a) return false; - return Boolean(a.rename || a.delete); + if (this.mode() === 'active') return Boolean(a.rename || a.archive || a.delete); + return Boolean(a.unarchive || a.delete); } protected openMenu(threadId: string, anchor: HTMLElement): void { @@ -207,6 +228,10 @@ export class ChatThreadListComponent { queueMicrotask(() => this.editInput()?.nativeElement.focus()); } else if (id === 'delete') { this.confirmDeleteId.set(threadId); + } else if (id === 'archive') { + void this.performArchive(threadId); + } else if (id === 'unarchive') { + void this.performUnarchive(threadId); } } @@ -250,13 +275,47 @@ export class ChatThreadListComponent { const a = this.actions(); if (!a?.delete) return; - this.pendingDeletes.update((s) => new Set([...s, threadId])); + this.pendingHidden.update((s) => new Set([...s, threadId])); try { await a.delete(threadId); } catch { // Rollback: clear override below so the row reappears. } finally { - this.pendingDeletes.update((s) => { + this.pendingHidden.update((s) => { + const n = new Set(s); + n.delete(threadId); + return n; + }); + } + } + + protected async performArchive(threadId: string): Promise { + const a = this.actions(); + if (!a?.archive) return; + this.pendingHidden.update((s) => new Set([...s, threadId])); + try { + await a.archive(threadId); + } catch { + // Rollback: clear override below so the row reappears. + } finally { + this.pendingHidden.update((s) => { + const n = new Set(s); + n.delete(threadId); + return n; + }); + } + } + + protected async performUnarchive(threadId: string): Promise { + const a = this.actions(); + if (!a?.unarchive) return; + this.pendingHidden.update((s) => new Set([...s, threadId])); + try { + await a.unarchive(threadId); + } catch { + // Rollback: clear override below so the row reappears. + } finally { + this.pendingHidden.update((s) => { const n = new Set(s); n.delete(threadId); return n; diff --git a/libs/chat/src/lib/styles/chat-sidenav.styles.ts b/libs/chat/src/lib/styles/chat-sidenav.styles.ts index a6757a3b..45fff77d 100644 --- a/libs/chat/src/lib/styles/chat-sidenav.styles.ts +++ b/libs/chat/src/lib/styles/chat-sidenav.styles.ts @@ -147,4 +147,41 @@ export const CHAT_SIDENAV_STYLES = ` :host([data-mode="collapsed"]) .chat-sidenav__account { padding: var(--ngaf-chat-space-2); } + .chat-sidenav__archived { flex-shrink: 0; } + .chat-sidenav__archived-heading { + display: flex; + align-items: center; + gap: 6px; + width: 100%; + padding: 8px 12px 4px; + border: 0; + background: transparent; + color: var(--ngaf-chat-text-muted); + font: inherit; + font-size: var(--ngaf-chat-font-size-xs); + text-transform: uppercase; + letter-spacing: 0.05em; + text-align: left; + cursor: pointer; + } + .chat-sidenav__archived-heading:hover { color: var(--ngaf-chat-text); } + .chat-sidenav__archived-heading:focus-visible { + outline: 2px solid var(--ngaf-chat-primary); + outline-offset: 2px; + } + .chat-sidenav__archived-chevron { + width: 12px; + height: 12px; + transition: transform 150ms ease; + flex-shrink: 0; + } + .chat-sidenav__archived[data-open="true"] .chat-sidenav__archived-chevron { + transform: rotate(90deg); + } + .chat-sidenav__archived-empty { + padding: 8px 12px; + color: var(--ngaf-chat-text-muted); + font-size: var(--ngaf-chat-font-size-sm); + } + :host([data-mode="collapsed"]) .chat-sidenav__archived { display: none; } `;