From e6fe21597db422abff9e28ef36e2b35f58f66e44 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 12 May 2026 14:34:31 -0700 Subject: [PATCH 01/10] =?UTF-8?q?docs(specs):=20chat=20archive=20=E2=80=94?= =?UTF-8?q?=20Phase=203b=20design?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds archive/unarchive to ThreadActionAdapter; introduces mode input on chat-thread-list (active vs archived menu items); adds collapsible Archived section to chat-sidenav. Bundles a ThreadsService rewrite to @langchain/langgraph-sdk for all thread CRUD. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../specs/2026-05-12-chat-archive-design.md | 421 ++++++++++++++++++ 1 file changed, 421 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-12-chat-archive-design.md diff --git a/docs/superpowers/specs/2026-05-12-chat-archive-design.md b/docs/superpowers/specs/2026-05-12-chat-archive-design.md new file mode 100644 index 00000000..bfb78128 --- /dev/null +++ b/docs/superpowers/specs/2026-05-12-chat-archive-design.md @@ -0,0 +1,421 @@ +# Chat archive — Phase 3b design + +**Date:** 2026-05-12 +**Surface:** `@ngaf/chat` (`libs/chat/`) — extends `chat-thread-list` and `chat-sidenav`; bundles `@langchain/langgraph-sdk` migration in the example. +**Status:** Design approved; ready for implementation plan + +## Summary + +Add archive/unarchive support to the sidenav. Active threads remain in the "Recent" section; archived threads appear in a new collapsible "Archived" section below it. Both sections share the same `ThreadActionAdapter` — extended with `archive?` and `unarchive?` methods. The framework owns optimistic UI; no confirmation dialog (archive is reversible). + +Bundles the `examples-chat-angular` `ThreadsService` migration from raw `fetch` to `@langchain/langgraph-sdk`'s `Client` for all thread CRUD operations. + +## Goals + +- Archive is reversible and non-destructive — no confirmation dialog. +- The framework stays dumb about thread lifecycle: consumers pre-filter into separate `threads` and `archivedThreads` inputs. The new `Thread.status?: 'active' | 'archived'` field is typed documentation, not framework-enforced. +- `chat-thread-list` gains a `mode: 'active' | 'archived'` input that drives per-section menu items (Rename/Archive/Delete vs. Unarchive/Delete). +- Archived management lives in the sidenav (collapsible section), not a separate route. +- Migrate the example's `ThreadsService` to `@langchain/langgraph-sdk` to eliminate raw-fetch debt while we're already in this surface. + +## Non-goals + +- Confirmation dialog for archive/unarchive (resolved: no confirm). +- Visual indication of archived state in the search palette (consumer can use `ThreadMatch.subtitle`; framework doesn't add a new field). +- Archive count badge on the heading. +- Persistence of expanded/collapsed state. +- Bulk archive. +- Search palette including archived results in this phase (consumer can extend `searchResults` themselves; a framework-level archived-search story is Phase 3c). +- Backwards compatibility — pre-1.0 (0.0.x), breaking changes ship in patch releases (e.g. renaming `pendingDeletes`). + +## Public type changes + +### `Thread` — additive + +```ts +export type Thread = { + id: string; + title?: string; + 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. */ + status?: 'active' | 'archived'; + [key: string]: unknown; +}; +``` + +### `ThreadActionAdapter` — adds two optional methods + +```ts +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; +} +``` + +No new exported types or primitives. + +## `chat-thread-list` extensions + +### New input + +```ts +readonly mode = input<'active' | 'archived'>('active'); +``` + +Default `'active'` preserves existing call sites (passes implicitly through `chat-sidenav` for the Recent section). The `mode="archived"` instance is rendered by `chat-sidenav` for the Archived section. + +### State rename + +`pendingDeletes` → `pendingHidden`. Same `ReadonlySet` shape; same filter usage in `visibleThreads`. Used for delete, archive, AND unarchive — all three actions hide the row from the current list pending the adapter promise. (Per "no backwards compat" rule, the rename is mechanical.) + +### Menu-items derivation + +`currentMenuItems` becomes: + +```ts +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; +}); +``` + +### Action handlers + +`onMenuAction` routes the new ids: + +```ts +if (id === 'archive') { this.performArchive(threadId); } +else if (id === 'unarchive') { this.performUnarchive(threadId); } +``` + +`performArchive` and `performUnarchive` follow the existing `performDelete` shape minus the confirm step: + +```ts +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 below */ } + finally { + this.pendingHidden.update((s) => { const n = new Set(s); n.delete(threadId); return n; }); + } +} + +protected async performUnarchive(threadId: string): Promise { + /* identical, calls a.unarchive */ +} +``` + +### Showing the kebab + +`showKebab()` currently returns `true` when *any* adapter method exists. That breaks for archived mode: an adapter with `rename` + `archive` but no `unarchive` or `delete` would render an empty-menu kebab on archived rows. Updated check is mode-aware: + +```ts +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); +} +``` + +## `chat-sidenav` extensions + +### New input + +```ts +readonly archivedThreads = input(null); +``` + +When `null` (default) → archived section not rendered. Existing consumers unchanged. + +### New state + +```ts +protected readonly archivedOpen = signal(false); +``` + +Collapsed by default; no persistence in the framework. + +### Template + +Inserted after the existing `@if (threads() !== null)` Recent block, before the `[sidenavSections]` slot: + +```html +@if (archivedThreads() !== null) { +
+ + @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 { + + } +
+ } + +} +``` + +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 (archivedThreads() !== null) { +
+ + @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/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; } `; From e056848721916d1900830b4606b84e74bc9f82b6 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 12 May 2026 14:48:38 -0700 Subject: [PATCH 07/10] test(chat): chat-sidenav archived section coverage --- .../chat-sidenav.component.spec.ts | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/libs/chat/src/lib/compositions/chat-sidenav/chat-sidenav.component.spec.ts b/libs/chat/src/lib/compositions/chat-sidenav/chat-sidenav.component.spec.ts index b446f1af..93754b04 100644 --- a/libs/chat/src/lib/compositions/chat-sidenav/chat-sidenav.component.spec.ts +++ b/libs/chat/src/lib/compositions/chat-sidenav/chat-sidenav.component.spec.ts @@ -89,4 +89,48 @@ describe('ChatSidenavComponent', () => { const fixture = render({ mode: 'drawer', open: false }); expect(fixture.nativeElement.querySelector('.chat-sidenav__scrim')).toBeNull(); }); + + it('archivedThreads=null renders no archived heading', () => { + const fixture = render({ threads: [{ id: 't1' }] }); + expect(fixture.nativeElement.querySelector('.chat-sidenav__archived')).toBeNull(); + }); + + it('archivedThreads=[] renders the heading; clicking expands to show empty state', () => { + const fixture = render({ threads: [{ id: 't1' }] }); + fixture.componentRef.setInput('archivedThreads', []); + fixture.detectChanges(); + const heading = fixture.nativeElement.querySelector('.chat-sidenav__archived-heading') as HTMLButtonElement; + expect(heading).not.toBeNull(); + expect(heading.getAttribute('aria-expanded')).toBe('false'); + heading.click(); + fixture.detectChanges(); + expect(heading.getAttribute('aria-expanded')).toBe('true'); + expect(fixture.nativeElement.querySelector('.chat-sidenav__archived-empty')).not.toBeNull(); + }); + + it('archivedThreads=[t1,t2] renders the heading; expanding shows a chat-thread-list with mode="archived"', () => { + const fixture = render({ threads: [{ id: 't1' }] }); + fixture.componentRef.setInput('archivedThreads', [{ id: 'a1', title: 'A1' }, { id: 'a2', title: 'A2' }]); + fixture.detectChanges(); + const heading = fixture.nativeElement.querySelector('.chat-sidenav__archived-heading') as HTMLButtonElement; + heading.click(); + fixture.detectChanges(); + const lists = fixture.nativeElement.querySelectorAll('chat-thread-list'); + expect(lists.length).toBe(2); + expect(lists[1].getAttribute('mode')).toBe('archived'); + }); + + it('clicking the archived heading toggles aria-expanded', () => { + const fixture = render({ threads: [{ id: 't1' }] }); + fixture.componentRef.setInput('archivedThreads', []); + fixture.detectChanges(); + const heading = fixture.nativeElement.querySelector('.chat-sidenav__archived-heading') as HTMLButtonElement; + expect(heading.getAttribute('aria-expanded')).toBe('false'); + heading.click(); + fixture.detectChanges(); + expect(heading.getAttribute('aria-expanded')).toBe('true'); + heading.click(); + fixture.detectChanges(); + expect(heading.getAttribute('aria-expanded')).toBe('false'); + }); }); From baafd5447dfce572af43485907452bd287bf401f Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 12 May 2026 14:49:59 -0700 Subject: [PATCH 08/10] refactor(examples-chat): migrate ThreadsService to @langchain/langgraph-sdk; add archive/unarchive Co-Authored-By: Claude Sonnet 4.6 --- .../angular/src/app/shell/threads.service.ts | 68 +++++++++---------- 1 file changed, 34 insertions(+), 34 deletions(-) diff --git a/examples/chat/angular/src/app/shell/threads.service.ts b/examples/chat/angular/src/app/shell/threads.service.ts index 278328cf..df33c9c6 100644 --- a/examples/chat/angular/src/app/shell/threads.service.ts +++ b/examples/chat/angular/src/app/shell/threads.service.ts @@ -1,40 +1,31 @@ // SPDX-License-Identifier: MIT 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 res = await fetch(`${API_URL}/threads/search`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ limit: 50 }), - }); - if (!res.ok) return; - const list = await res.json() as Array<{ thread_id: string; metadata?: Record }>; - this.threads.set(list.map(t => ({ - id: t.thread_id, - title: this.titleFor(t), - }))); + 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 threads as-is. + // Backend may be down; leave signals as-is. } } async create(): Promise { try { - const res = await fetch(`${API_URL}/threads`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: '{}', - }); - if (!res.ok) return null; - const t = await res.json() as { thread_id: string }; + const t = await this.client.threads.create({ metadata: {} }); await this.refresh(); return t.thread_id; } catch { @@ -43,27 +34,36 @@ export class ThreadsService { } async delete(threadId: string): Promise { - const res = await fetch(`${API_URL}/threads/${threadId}`, { method: 'DELETE' }); - if (!res.ok) throw new Error(`delete ${threadId} failed: ${res.status}`); + await this.client.threads.delete(threadId); await this.refresh(); } async rename(threadId: string, newTitle: string): Promise { - 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.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(); } - /** 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 { - const meta = t.metadata ?? {}; - const customTitle = (meta as { title?: string }).title; - if (typeof customTitle === 'string' && customTitle.length > 0) return customTitle; - return `Thread ${t.thread_id.slice(0, 8)}`; + /** 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 customTitle = meta.title; + const archived = meta.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', + }; } } From 268869fa58f8e2f5a818a6292650792020d90cb3 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 12 May 2026 14:50:22 -0700 Subject: [PATCH 09/10] feat(examples-chat): wire archive/unarchive + archivedThreads on chat-sidenav Co-Authored-By: Claude Sonnet 4.6 --- .../chat/angular/src/app/shell/demo-shell.component.html | 1 + .../chat/angular/src/app/shell/demo-shell.component.ts | 8 ++++++++ 2 files changed, 9 insertions(+) diff --git a/examples/chat/angular/src/app/shell/demo-shell.component.html b/examples/chat/angular/src/app/shell/demo-shell.component.html index 78fdd7a6..4a90a11d 100644 --- a/examples/chat/angular/src/app/shell/demo-shell.component.html +++ b/examples/chat/angular/src/app/shell/demo-shell.component.html @@ -9,6 +9,7 @@ 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), }; /** From 0ef6d55a505dd1bd08f7318fc5a2fa0eb0feb717 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 12 May 2026 14:50:44 -0700 Subject: [PATCH 10/10] docs(chat): regenerate API docs for archive lifecycle Co-Authored-By: Claude Opus 4.7 (1M context) --- .../content/docs/chat/api/api-docs.json | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) 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": []