From c1a01c02dcbf34d8d02d00e24bacb3e6fa98c10b Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 12 May 2026 09:57:47 -0700 Subject: [PATCH 01/13] =?UTF-8?q?docs(specs):=20chat=20row=20actions=20?= =?UTF-8?q?=E2=80=94=20Phase=203a=20design?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-row overflow menu (kebab) with Rename + Delete. Adapter-driven (ThreadActionAdapter); framework owns optimistic UI + rollback. Adds two reusable primitives: chat-overflow-menu and chat-confirm-dialog. Defers archive, share, pin/move to later phases. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../2026-05-12-chat-row-actions-design.md | 552 ++++++++++++++++++ 1 file changed, 552 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-12-chat-row-actions-design.md diff --git a/docs/superpowers/specs/2026-05-12-chat-row-actions-design.md b/docs/superpowers/specs/2026-05-12-chat-row-actions-design.md new file mode 100644 index 00000000..6a4b1066 --- /dev/null +++ b/docs/superpowers/specs/2026-05-12-chat-row-actions-design.md @@ -0,0 +1,552 @@ +# Chat row actions — Phase 3a design (overflow menu + delete + rename) + +**Date:** 2026-05-12 +**Surface:** `@ngaf/chat` (`libs/chat/`) — two new primitives, one composition input added, one extended primitive, one example shell extension +**Status:** Design approved; ready for implementation plan + +## Summary + +Add per-thread-row contextual actions to the sidenav: a hover-revealed kebab opens an overflow menu with **Rename** and **Delete** items. Rename morphs the row title into an inline ``. Delete opens a destructive-toned confirmation dialog. Both actions are adapter-driven (consumer provides `ThreadActionAdapter`), with the framework owning the optimistic UI (immediate row hide / title swap) and rollback on rejection. + +This is **Phase 3a** of the larger row-actions decomposition. Out of scope here: archive (Phase 3b), share-link (Phase 3c, needs backend), pin/move (defer until Projects). + +## Goals + +- Add Delete + Rename via an adapter contract; no other actions in this phase. +- Build the overflow-menu and confirm-dialog as reusable primitives (not row-specific) so future destructive actions / contextual menus across `@ngaf/chat` can use them. +- Optimistic UI owned by `chat-thread-list`; consumer doesn't write rollback logic. +- Hover-revealed kebab; full keyboard accessibility via row focus. +- Inline rename UX (ChatGPT-style); modal confirmation for destructive delete. + +## Non-goals + +- Archive semantics (Phase 3b). +- Share-link flow. +- Pinned / moved threads. +- Bulk row actions. +- Long-press / right-click on mobile. +- Persisting partial rename input across closes. + +## File map + +**Create:** +- `libs/chat/src/lib/primitives/chat-overflow-menu/chat-overflow-menu.component.ts` +- `libs/chat/src/lib/primitives/chat-overflow-menu/chat-overflow-menu.component.spec.ts` +- `libs/chat/src/lib/styles/chat-overflow-menu.styles.ts` +- `libs/chat/src/lib/primitives/chat-confirm-dialog/chat-confirm-dialog.component.ts` +- `libs/chat/src/lib/primitives/chat-confirm-dialog/chat-confirm-dialog.component.spec.ts` +- `libs/chat/src/lib/styles/chat-confirm-dialog.styles.ts` + +**Modify:** +- `libs/chat/src/lib/primitives/chat-thread-list/chat-thread-list.component.ts` — adds `actions` input, internal edit/menu/confirm state, optimistic rename + delete, kebab + inline edit template. +- `libs/chat/src/lib/styles/chat-thread-list.styles.ts` — adds kebab hover-reveal + inline-edit input styles. +- `libs/chat/src/lib/compositions/chat-sidenav/chat-sidenav.component.ts` — adds `actions` pass-through input forwarded to the inner `chat-thread-list`. +- `libs/chat/src/public-api.ts` — exports new primitives, `ThreadActionAdapter`, `OverflowMenuItem`. +- `examples/chat/angular/src/app/shell/threads.service.ts` — adds `delete()` and `rename()` methods that hit LangGraph. +- `examples/chat/angular/src/app/shell/demo-shell.component.ts` — defines `threadActions: ThreadActionAdapter` and binds it on ``. +- `examples/chat/angular/src/app/shell/demo-shell.component.html` — adds `[actions]="threadActions"` on ``. +- `apps/website/content/docs/chat/api/api-docs.json` — regenerated. + +## New public types + +```typescript +export interface ThreadActionAdapter { + /** Delete the thread permanently. The framework calls this AFTER user + * confirms via the confirm dialog. The framework optimistically removes + * the row before awaiting; on rejection it puts the row back. */ + delete?(threadId: string): Promise; + + /** Rename the thread's title. The framework optimistically swaps the + * rendered title before awaiting; on rejection it reverts. */ + rename?(threadId: string, newTitle: string): Promise; +} + +export interface OverflowMenuItem { + /** Stable id emitted via (itemSelected). Used to route to the action handler. */ + id: string; + label: string; + /** Visual tone. 'destructive' applies a red label. Default 'normal'. */ + tone?: 'normal' | 'destructive'; + /** When true, the item is rendered grayed out and clicks/Enter are no-ops. */ + disabled?: boolean; +} +``` + +## `chat-overflow-menu` primitive + +### API + +```ts +@Component({ selector: 'chat-overflow-menu', standalone: true, changeDetection: OnPush }) +export class ChatOverflowMenuComponent { + readonly open = input(false); + readonly items = input([]); + /** Element the menu positions itself against. The menu pops out + * just below the anchor's bottom-right corner. If null, the menu + * is centered in the viewport. */ + readonly anchor = input(null); + readonly itemSelected = output(); // item.id + readonly closed = output(); +} +``` + +Parent is the source of truth for `open`. The component never sets `open` itself — it only emits `closed` (or `itemSelected`, which the parent should treat as an implicit close). + +### Behavior + +- Renders nothing when `open=false`. +- Renders a scrim button + a `
    ` when `open=true`. +- Position: computed from `anchor()?.getBoundingClientRect()` on render. Default rule: `top: rect.bottom + 4`, `left: rect.right - menuWidth`. `position: fixed` so it escapes scroll containers. +- Item click (enabled): emits `itemSelected(item.id)` then `closed`. +- Item click (disabled): no-op. +- Scrim click: emits `closed`. +- Esc: emits `closed`. +- ArrowDown / ArrowUp: move keyboard focus through enabled `
  • `s, clamps at ends. +- Enter/Space on focused item: same as click. +- On open: first enabled item receives focus. + +### Styles (high level) + +- `position: fixed`, `z-index: 60`, surface background, separator border, rounded corners, subtle shadow. +- `chat-overflow-menu__item--destructive` → red text color from existing `--ngaf-chat-error-text` token. +- `chat-overflow-menu__item--disabled` → muted color, `cursor: not-allowed`. +- `:focus-visible` outline using `--ngaf-chat-primary`. + +### ARIA + +- Scrim: ` + + @if (actions() && menuItemsFor(thread).length > 0) { + + } + } +
  • +} +``` + +After the `@for` loop, the menu + confirm dialog are rendered once each (state-driven by `menuOpenForId` and `confirmDeleteId`). + +### Action handlers + +```ts +protected openMenu(threadId: string, anchor: HTMLElement): void { + this.menuAnchor.set(anchor); + this.menuOpenForId.set(threadId); +} + +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(t?.title ?? ''); + this.editingThreadId.set(threadId); + queueMicrotask(() => this.editInputEl()?.nativeElement.focus()); + } else if (id === 'delete') { + this.confirmDeleteId.set(threadId); + } +} + +protected cancelRename(): void { + this.editingThreadId.set(null); +} + +protected async commitRename(threadId: string): Promise { + const newTitle = this.editingValue().trim(); + this.editingThreadId.set(null); + if (!newTitle) return; + const a = this.actions(); + if (!a?.rename) return; + + // optimistic + this.pendingRenames.update(m => { const n = new Map(m); n.set(threadId, newTitle); return n; }); + try { + await a.rename(threadId, newTitle); + } catch { + // rollback — pendingRenames clear below restores the original title + } finally { + this.pendingRenames.update(m => { const n = new Map(m); n.delete(threadId); return n; }); + } +} + +protected async performDelete(): Promise { + const threadId = this.confirmDeleteId(); + this.confirmDeleteId.set(null); + if (!threadId) return; + const a = this.actions(); + if (!a?.delete) return; + + this.pendingDeletes.update(s => new Set(s).add(threadId)); + try { + await a.delete(threadId); + // Consumer is expected to refresh threads(); the row stays hidden via + // pendingDeletes until the input list no longer contains it. Clear the + // override regardless so a future thread with the same id (rare) renders. + } catch { + // rollback + } finally { + // After success, threads() input no longer contains the id; the filter + // hides it naturally and the override becomes dead state. After failure + // we must clear so the row reappears. + this.pendingDeletes.update(s => { const n = new Set(s); n.delete(threadId); return n; }); + } +} +``` + +### Wiring the menu + dialog + +```html + + + +``` + +### Style additions + +In `chat-thread-list.styles.ts`: + +```css +.chat-thread-list__item-wrap { + position: relative; + display: flex; + align-items: center; +} +.chat-thread-list__item { + flex: 1 1 auto; + /* (existing rules preserved) */ +} +.chat-thread-list__kebab { + flex-shrink: 0; + width: 28px; + height: 28px; + margin-right: 4px; + border: 0; + background: transparent; + color: var(--ngaf-chat-text-muted); + border-radius: 4px; + cursor: pointer; + opacity: 0; + transition: opacity 100ms ease; +} +.chat-thread-list__item-wrap:hover .chat-thread-list__kebab, +.chat-thread-list__item-wrap:focus-within .chat-thread-list__kebab { + opacity: 1; +} +.chat-thread-list__kebab:hover { background: var(--ngaf-chat-surface-alt); color: var(--ngaf-chat-text); } +.chat-thread-list__kebab:focus-visible { + opacity: 1; + outline: 2px solid var(--ngaf-chat-primary); + outline-offset: 2px; +} +.chat-thread-list__edit { + flex: 1 1 auto; + border: 1px solid var(--ngaf-chat-primary); + border-radius: 4px; + background: var(--ngaf-chat-bg); + color: var(--ngaf-chat-text); + font: inherit; + padding: 6px 8px; +} +``` + +## `chat-sidenav` extension + +Pass-through input. No other changes. + +```ts +readonly actions = input(null); +``` + +Template: + +```html + +``` + +Consumers who don't pass `[actions]` get the unchanged read-only experience. + +## Example wiring (`examples-chat-angular`) + +In `threads.service.ts`: + +```ts +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.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.refresh(); +} +``` + +In `demo-shell.component.ts`: + +```ts +protected readonly threadActions: ThreadActionAdapter = { + delete: (id) => this.threadsSvc.delete(id), + rename: (id, title) => this.threadsSvc.rename(id, title), +}; +``` + +If the deleted thread is the active thread, the shell should clear `threadIdSignal` so the chat returns to the welcome state. Add to the shell: + +```ts +private deleteOriginal = this.threadsSvc.delete.bind(this.threadsSvc); + +protected readonly threadActions: ThreadActionAdapter = { + delete: async (id) => { + await this.deleteOriginal(id); + if (this.threadIdSignal() === id) { + this.threadIdSignal.set(null); + this.persistence.write('threadId', null); + } + }, + rename: (id, title) => this.threadsSvc.rename(id, title), +}; +``` + +Template: + +```html + +``` + +## Edge cases + +1. **Active thread deleted.** Consumer-side concern (shell clears `threadIdSignal`). Framework just hides the row. +2. **Rename to empty / whitespace-only.** `commitRename` trims; empty → no-op cancel. +3. **Rename to same title.** Optimistic update is a no-op visually; the adapter is still called. If adapter no-ops too, fine. If it errors, the rollback is harmless. +4. **Delete while menu still open.** `onMenuAction` closes the menu before opening the dialog. No race. +5. **Optimistic delete success then quick refresh.** Consumer's `threads()` already excludes the deleted id when the override clears; row stays hidden. +6. **Two pending deletes for same id.** Set semantics; second add is idempotent. +7. **Adapter resolves but consumer never refreshes threads.** Rare, but the optimistic override clears in `finally`, so after `await` the row would re-appear (because it's still in `threads()`). The contract: consumers MUST refresh `threads()` on success. Documented on `ThreadActionAdapter`. +8. **Blur during rename click on Enter.** Blur fires AFTER keydown.enter on most browsers; commit fires first, blur becomes a no-op cancel of an already-committed state. Safe. +9. **Menu position when row is near viewport bottom.** Current rule places menu below the kebab. If `rect.bottom + menuHeight > window.innerHeight`, position above instead (`top: rect.top - menuHeight - 4`). Implement only if visible issue surfaces in browser verification. + +## Testing + +### Unit / component + +`chat-overflow-menu.component.spec.ts` (8+ cases): +- Renders nothing when closed. +- Renders items when open. +- Item click emits id + closed. +- Disabled item click no-op. +- Scrim click emits closed. +- Esc emits closed. +- ArrowDown/Up moves focus. +- Destructive class applied. + +`chat-confirm-dialog.component.spec.ts` (8+ cases): +- Renders nothing when closed. +- Renders title + body. +- Body omitted when empty. +- Confirm/Cancel buttons emit correct outputs. +- Scrim/Esc emit cancelled. +- Cancel button receives focus on open. +- Destructive class applied to confirm button when `tone="destructive"`. + +`chat-thread-list.component.spec.ts` (additions, existing tests intact): +- Kebab not rendered when `actions=null`. +- Kebab not rendered when `actions={}`. +- Kebab rendered when adapter has methods. +- `menuItemsFor` reflects only provided actions. +- Rename: clicking item enters edit mode; Enter calls adapter; Esc cancels. +- Rename: blur cancels. +- Rename: adapter rejects → title reverts after promise settles. +- Delete: clicking item opens confirm dialog. +- Delete: confirm calls adapter; row optimistically hidden. +- Delete: cancel/Esc closes dialog without calling adapter. +- Delete: adapter rejects → row reappears. + +### Manual (Chrome MCP) + +1. Hover row → kebab fades in. +2. Click kebab → menu anchored just below it; Rename + Delete (destructive red). +3. Outside click → menu closes. +4. Esc with menu open → menu closes. +5. Click Rename → row morphs to input, focused, prefilled with title. +6. Enter → row reverts to button with new title; LangGraph PATCH succeeds. +7. Esc → row reverts to old title; no PATCH. +8. Click Delete → confirm dialog, focus on Cancel, destructive Delete styled red. +9. Esc → dialog closes; no DELETE. +10. Click Delete (in dialog) → row disappears immediately; LangGraph DELETE succeeds. +11. Simulate adapter rejection (temporarily throw) → row reappears. + +## Accessibility + +- All buttons are `type="button"` with `aria-label`. +- Kebab carries `aria-haspopup="menu"` and `aria-expanded`. +- Menu is `role="menu"` with `role="menuitem"` children. +- Confirm dialog is `role="dialog" aria-modal="true"` with `aria-labelledby` and conditional `aria-describedby`. +- Initial focus on cancel button in destructive dialogs (intentional). +- ArrowDown/Up nav inside menu. +- Esc closes menu, edit, and dialog. + +## Performance + +- Three new state signals on `chat-thread-list` (`editingThreadId`, `menuOpenForId`, `confirmDeleteId`) plus two override maps. All update via `signal.update`, OnPush-friendly. +- `visibleThreads` computed re-runs only when `threads`, `pendingDeletes`, or `pendingRenames` change. O(n) per re-run, fine for sidebar-sized lists. +- Menu and dialog primitives render nothing when closed — no DOM cost. + +## Open questions / assumptions + +- **Assumption:** LangGraph supports `DELETE /threads/{id}` and `PATCH /threads/{id}` with `metadata.title` updates. Implementer should verify against the running backend in Task 6 (example wiring) and adjust if either method differs. +- **Assumption:** No backend share-link infrastructure. Therefore no Share action surfaced — adapter contract leaves room to add it later. +- **Open:** Whether `chat-sidenav` should automatically pass through `(rowAction)` events or just `actions`. Spec: only `actions` is forwarded; if a consumer needs to inspect raw row events, they should embed `chat-thread-list` directly in `[sidenavSections]` slot. +- **Open:** Menu auto-position-above when near viewport bottom — deferred until browser verification surfaces an issue. From f381367967dbd0c913b6d11b8f3fc4efe1f50545 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 12 May 2026 13:24:27 -0700 Subject: [PATCH 02/13] =?UTF-8?q?docs(plans):=20chat=20row=20actions=20?= =?UTF-8?q?=E2=80=94=20Phase=203a=20implementation=20plan?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eleven tasks: overflow-menu + confirm-dialog primitives (TDD), chat-thread-list extension with optimistic rename + delete, sidenav pass-through, public-api exports, examples wiring, API doc regen, and browser verification. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../plans/2026-05-12-chat-row-actions.md | 1499 +++++++++++++++++ 1 file changed, 1499 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-12-chat-row-actions.md diff --git a/docs/superpowers/plans/2026-05-12-chat-row-actions.md b/docs/superpowers/plans/2026-05-12-chat-row-actions.md new file mode 100644 index 00000000..ed68c50e --- /dev/null +++ b/docs/superpowers/plans/2026-05-12-chat-row-actions.md @@ -0,0 +1,1499 @@ +# Chat row actions — Phase 3a 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-row Rename + Delete actions in `@ngaf/chat` — hover-revealed kebab opens an overflow menu, Rename morphs the row into an inline input, Delete opens a destructive-toned confirm dialog. Adapter-driven (consumer-provided `ThreadActionAdapter`), framework owns optimistic UI + rollback. + +**Architecture:** Two reusable primitives (`chat-overflow-menu`, `chat-confirm-dialog`) plus extended `chat-thread-list`. Adapter shape is a single object input with optional `delete?` and `rename?` async methods. Menu items derive from which adapter methods are provided — no method = no menu item = no kebab. + +**Tech Stack:** Angular 21 standalone components, signal inputs/outputs, plain CSS strings under `libs/chat/src/lib/styles/`, Vitest + Angular TestBed. + +**Spec:** [docs/superpowers/specs/2026-05-12-chat-row-actions-design.md](../specs/2026-05-12-chat-row-actions-design.md) + +--- + +## File map + +**Create:** +- `libs/chat/src/lib/primitives/chat-overflow-menu/chat-overflow-menu.component.ts` +- `libs/chat/src/lib/primitives/chat-overflow-menu/chat-overflow-menu.component.spec.ts` +- `libs/chat/src/lib/styles/chat-overflow-menu.styles.ts` +- `libs/chat/src/lib/primitives/chat-confirm-dialog/chat-confirm-dialog.component.ts` +- `libs/chat/src/lib/primitives/chat-confirm-dialog/chat-confirm-dialog.component.spec.ts` +- `libs/chat/src/lib/styles/chat-confirm-dialog.styles.ts` + +**Modify:** +- `libs/chat/src/lib/primitives/chat-thread-list/chat-thread-list.component.ts` — types + adapter input + state + optimistic flows +- `libs/chat/src/lib/primitives/chat-thread-list/chat-thread-list.component.spec.ts` — additions (keep existing tests intact) +- `libs/chat/src/lib/styles/chat-thread-list.styles.ts` — kebab + edit-input styles +- `libs/chat/src/lib/compositions/chat-sidenav/chat-sidenav.component.ts` — pass-through `actions` input +- `libs/chat/src/public-api.ts` — export new primitives + types +- `examples/chat/angular/src/app/shell/threads.service.ts` — adds `delete()` + `rename()` +- `examples/chat/angular/src/app/shell/demo-shell.component.ts` — defines `threadActions` +- `examples/chat/angular/src/app/shell/demo-shell.component.html` — adds `[actions]="threadActions"` +- `apps/website/content/docs/chat/api/api-docs.json` — regenerated + +--- + +## Task 1: `chat-overflow-menu` styles + +**Files:** +- Create: `libs/chat/src/lib/styles/chat-overflow-menu.styles.ts` + +- [ ] **Step 1: Create the styles file** + +Create `libs/chat/src/lib/styles/chat-overflow-menu.styles.ts`: + +```typescript +// libs/chat/src/lib/styles/chat-overflow-menu.styles.ts +// SPDX-License-Identifier: MIT +export const CHAT_OVERFLOW_MENU_STYLES = ` + :host { display: contents; } + .chat-overflow-menu__scrim { + position: fixed; + inset: 0; + background: transparent; + z-index: 59; + border: 0; + padding: 0; + cursor: default; + } + .chat-overflow-menu { + position: fixed; + z-index: 60; + min-width: 160px; + padding: 4px; + margin: 0; + list-style: none; + background: var(--ngaf-chat-bg); + border: 1px solid var(--ngaf-chat-separator); + border-radius: 8px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15); + } + .chat-overflow-menu__item { + display: block; + padding: 8px 12px; + border-radius: 4px; + color: var(--ngaf-chat-text); + font-size: var(--ngaf-chat-font-size-sm); + cursor: pointer; + user-select: none; + } + .chat-overflow-menu__item:hover { + background: var(--ngaf-chat-surface-alt); + } + .chat-overflow-menu__item:focus-visible { + outline: 2px solid var(--ngaf-chat-primary); + outline-offset: -2px; + } + .chat-overflow-menu__item--destructive { + color: var(--ngaf-chat-error-text); + } + .chat-overflow-menu__item--disabled { + color: var(--ngaf-chat-text-muted); + cursor: not-allowed; + pointer-events: none; + } +`; +``` + +- [ ] **Step 2: Build** + +Run: `npx nx run chat:build` +Expected: PASS. + +- [ ] **Step 3: Commit** + +```bash +git add libs/chat/src/lib/styles/chat-overflow-menu.styles.ts +git commit -m "feat(chat): chat-overflow-menu styles" +``` + +--- + +## Task 2: `chat-overflow-menu` component (TDD) + +**Files:** +- Create: `libs/chat/src/lib/primitives/chat-overflow-menu/chat-overflow-menu.component.spec.ts` +- Create: `libs/chat/src/lib/primitives/chat-overflow-menu/chat-overflow-menu.component.ts` + +- [ ] **Step 1: Write the failing spec** + +Create `libs/chat/src/lib/primitives/chat-overflow-menu/chat-overflow-menu.component.spec.ts`: + +```typescript +// libs/chat/src/lib/primitives/chat-overflow-menu/chat-overflow-menu.component.spec.ts +// SPDX-License-Identifier: MIT +import { TestBed } from '@angular/core/testing'; +import { describe, expect, it } from 'vitest'; +import { ChatOverflowMenuComponent, type OverflowMenuItem } from './chat-overflow-menu.component'; + +function render(opts: { open?: boolean; items?: OverflowMenuItem[] } = {}) { + const fixture = TestBed.createComponent(ChatOverflowMenuComponent); + fixture.componentRef.setInput('open', opts.open ?? true); + if (opts.items !== undefined) fixture.componentRef.setInput('items', opts.items); + fixture.detectChanges(); + return fixture; +} + +describe('ChatOverflowMenuComponent', () => { + it('renders nothing when open is false', () => { + const fixture = render({ open: false, items: [{ id: 'a', label: 'A' }] }); + expect(fixture.nativeElement.querySelector('.chat-overflow-menu')).toBeNull(); + }); + + it('renders items list when open is true', () => { + const fixture = render({ + items: [ + { id: 'rename', label: 'Rename' }, + { id: 'delete', label: 'Delete', tone: 'destructive' }, + ], + }); + const items = fixture.nativeElement.querySelectorAll('.chat-overflow-menu__item'); + expect(items.length).toBe(2); + expect(items[0].textContent.trim()).toBe('Rename'); + expect(items[1].textContent.trim()).toBe('Delete'); + }); + + it('applies destructive class to destructive-tone items', () => { + const fixture = render({ items: [{ id: 'd', label: 'D', tone: 'destructive' }] }); + const item = fixture.nativeElement.querySelector('.chat-overflow-menu__item'); + expect(item.classList.contains('chat-overflow-menu__item--destructive')).toBe(true); + }); + + it('applies disabled class and aria-disabled to disabled items', () => { + const fixture = render({ items: [{ id: 'x', label: 'X', disabled: true }] }); + const item = fixture.nativeElement.querySelector('.chat-overflow-menu__item'); + expect(item.classList.contains('chat-overflow-menu__item--disabled')).toBe(true); + expect(item.getAttribute('aria-disabled')).toBe('true'); + }); + + it('item click emits itemSelected and closed', () => { + const fixture = render({ items: [{ id: 'a', label: 'A' }, { id: 'b', label: 'B' }] }); + let selected: string | undefined; + let closes = 0; + fixture.componentInstance.itemSelected.subscribe((id: string) => { selected = id; }); + fixture.componentInstance.closed.subscribe(() => { closes++; }); + const items = fixture.nativeElement.querySelectorAll('.chat-overflow-menu__item'); + (items[1] as HTMLElement).click(); + expect(selected).toBe('b'); + expect(closes).toBe(1); + }); + + it('disabled item click is a no-op', () => { + const fixture = render({ items: [{ id: 'x', label: 'X', disabled: true }] }); + let emits = 0; + fixture.componentInstance.itemSelected.subscribe(() => { emits++; }); + fixture.componentInstance.closed.subscribe(() => { emits++; }); + (fixture.nativeElement.querySelector('.chat-overflow-menu__item') as HTMLElement).click(); + expect(emits).toBe(0); + }); + + it('scrim click emits closed', () => { + const fixture = render({ items: [{ id: 'a', label: 'A' }] }); + let closes = 0; + fixture.componentInstance.closed.subscribe(() => { closes++; }); + (fixture.nativeElement.querySelector('.chat-overflow-menu__scrim') as HTMLElement).click(); + expect(closes).toBe(1); + }); + + it('Esc on the menu emits closed', () => { + const fixture = render({ items: [{ id: 'a', label: 'A' }] }); + let closes = 0; + fixture.componentInstance.closed.subscribe(() => { closes++; }); + const menu = fixture.nativeElement.querySelector('.chat-overflow-menu'); + menu.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + expect(closes).toBe(1); + }); +}); +``` + +- [ ] **Step 2: Run, confirm FAIL** + +Run: `npx nx run chat:test 2>&1 | grep -E "chat-overflow-menu|FAIL" | head -5` +Expected: FAIL (component does not exist yet). + +- [ ] **Step 3: Create the component** + +Create `libs/chat/src/lib/primitives/chat-overflow-menu/chat-overflow-menu.component.ts`: + +```typescript +// libs/chat/src/lib/primitives/chat-overflow-menu/chat-overflow-menu.component.ts +// SPDX-License-Identifier: MIT +import { + Component, + ChangeDetectionStrategy, + computed, + effect, + input, + output, +} from '@angular/core'; +import { CHAT_HOST_TOKENS } from '../../styles/chat-tokens'; +import { CHAT_OVERFLOW_MENU_STYLES } from '../../styles/chat-overflow-menu.styles'; + +export interface OverflowMenuItem { + /** Stable id emitted via (itemSelected). */ + id: string; + label: string; + /** 'destructive' renders the label in red. Default 'normal'. */ + tone?: 'normal' | 'destructive'; + /** Disabled items render muted and ignore clicks/keypresses. */ + disabled?: boolean; +} + +@Component({ + selector: 'chat-overflow-menu', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + styles: [CHAT_HOST_TOKENS, CHAT_OVERFLOW_MENU_STYLES], + template: ` + @if (open()) { + + + } + `, +}) +export class ChatOverflowMenuComponent { + readonly open = input(false); + readonly items = input([]); + /** Element the menu anchors against (positions just below its bottom-right corner). */ + readonly anchor = input(null); + readonly itemSelected = output(); + readonly closed = output(); + + protected readonly position = computed<{ top: number; left: number }>(() => { + if (!this.open()) return { top: 0, left: 0 }; + const el = this.anchor(); + if (!el) { + // Center fallback when no anchor. + const vw = typeof window === 'undefined' ? 0 : window.innerWidth; + const vh = typeof window === 'undefined' ? 0 : window.innerHeight; + return { top: Math.max(vh / 3, 0), left: Math.max(vw / 2 - 80, 0) }; + } + const rect = el.getBoundingClientRect(); + // Position just below the anchor's bottom-right, with menu width 160. + return { top: rect.bottom + 4, left: Math.max(rect.right - 160, 8) }; + }); + + constructor() { + effect(() => { + if (!this.open()) return; + // Focus the first enabled item on open. + queueMicrotask(() => { + const root = document.querySelector('.chat-overflow-menu'); + const first = root?.querySelector('.chat-overflow-menu__item:not(.chat-overflow-menu__item--disabled)'); + first?.focus(); + }); + }); + } + + protected onItemClick(item: OverflowMenuItem): void { + if (item.disabled) return; + this.itemSelected.emit(item.id); + this.closed.emit(); + } + + protected onMenuKeydown(e: KeyboardEvent): void { + if (e.key === 'Escape') { + e.preventDefault(); + this.closed.emit(); + return; + } + if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { + e.preventDefault(); + const root = (e.currentTarget as HTMLElement); + const items = Array.from(root.querySelectorAll('.chat-overflow-menu__item:not(.chat-overflow-menu__item--disabled)')); + if (items.length === 0) return; + const current = document.activeElement as HTMLElement | null; + const idx = current ? items.indexOf(current) : -1; + const next = e.key === 'ArrowDown' + ? Math.min((idx < 0 ? 0 : idx + 1), items.length - 1) + : Math.max(idx - 1, 0); + items[next]?.focus(); + } + } +} +``` + +- [ ] **Step 4: Run, confirm PASS** + +Run: `npx nx run chat:test 2>&1 | tail -5` +Expected: PASS (existing + 8 new). + +- [ ] **Step 5: Build + lint** + +Run: `npx nx run chat:build && npx nx lint chat 2>&1 | tail -5` +Expected: build PASS, lint with no errors. + +If lint flags `interactive-supports-focus` on the `
  • `, the `[attr.tabindex]` binding makes it focusable — that should satisfy. If lint still complains, add a static `tabindex="0"` attribute on the element (it'll be overridden by the binding when item.disabled is true). + +- [ ] **Step 6: Commit** + +```bash +git add libs/chat/src/lib/primitives/chat-overflow-menu +git commit -m "feat(chat): chat-overflow-menu primitive" +``` + +--- + +## Task 3: `chat-confirm-dialog` styles + +**Files:** +- Create: `libs/chat/src/lib/styles/chat-confirm-dialog.styles.ts` + +- [ ] **Step 1: Create the styles file** + +Create `libs/chat/src/lib/styles/chat-confirm-dialog.styles.ts`: + +```typescript +// libs/chat/src/lib/styles/chat-confirm-dialog.styles.ts +// SPDX-License-Identifier: MIT +export const CHAT_CONFIRM_DIALOG_STYLES = ` + :host { display: contents; } + .chat-confirm-dialog__scrim { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.4); + z-index: 70; + border: 0; + padding: 0; + cursor: pointer; + } + .chat-confirm-dialog { + position: fixed; + top: 30vh; + left: 50%; + transform: translateX(-50%); + width: min(420px, 90vw); + z-index: 71; + padding: 20px; + background: var(--ngaf-chat-bg); + border: 1px solid var(--ngaf-chat-separator); + border-radius: 12px; + box-shadow: 0 16px 48px rgba(0, 0, 0, 0.25); + } + .chat-confirm-dialog__title { + margin: 0 0 8px 0; + color: var(--ngaf-chat-text); + font-size: 1.125rem; + font-weight: 600; + } + .chat-confirm-dialog__body { + margin: 0 0 16px 0; + color: var(--ngaf-chat-text-muted); + font-size: var(--ngaf-chat-font-size-sm); + line-height: 1.5; + } + .chat-confirm-dialog__actions { + display: flex; + justify-content: flex-end; + gap: 8px; + } + .chat-confirm-dialog__cancel, + .chat-confirm-dialog__confirm { + padding: 8px 16px; + border-radius: 6px; + font: inherit; + font-size: var(--ngaf-chat-font-size-sm); + cursor: pointer; + border: 1px solid var(--ngaf-chat-separator); + background: var(--ngaf-chat-bg); + color: var(--ngaf-chat-text); + } + .chat-confirm-dialog__cancel:hover { background: var(--ngaf-chat-surface-alt); } + .chat-confirm-dialog__confirm { + background: var(--ngaf-chat-text); + color: var(--ngaf-chat-bg); + border-color: transparent; + } + .chat-confirm-dialog__confirm--destructive { + background: var(--ngaf-chat-error-text); + color: #fff; + } + .chat-confirm-dialog__cancel:focus-visible, + .chat-confirm-dialog__confirm:focus-visible { + outline: 2px solid var(--ngaf-chat-primary); + outline-offset: 2px; + } +`; +``` + +- [ ] **Step 2: Build + commit** + +```bash +npx nx run chat:build && git add libs/chat/src/lib/styles/chat-confirm-dialog.styles.ts && git commit -m "feat(chat): chat-confirm-dialog styles" +``` + +--- + +## Task 4: `chat-confirm-dialog` component (TDD) + +**Files:** +- Create: `libs/chat/src/lib/primitives/chat-confirm-dialog/chat-confirm-dialog.component.spec.ts` +- Create: `libs/chat/src/lib/primitives/chat-confirm-dialog/chat-confirm-dialog.component.ts` + +- [ ] **Step 1: Write the failing spec** + +Create `libs/chat/src/lib/primitives/chat-confirm-dialog/chat-confirm-dialog.component.spec.ts`: + +```typescript +// libs/chat/src/lib/primitives/chat-confirm-dialog/chat-confirm-dialog.component.spec.ts +// SPDX-License-Identifier: MIT +import { TestBed } from '@angular/core/testing'; +import { describe, expect, it } from 'vitest'; +import { ChatConfirmDialogComponent } from './chat-confirm-dialog.component'; + +function render(opts: { + open?: boolean; + title?: string; + body?: string; + confirmLabel?: string; + cancelLabel?: string; + tone?: 'destructive' | 'normal'; +} = {}) { + const fixture = TestBed.createComponent(ChatConfirmDialogComponent); + fixture.componentRef.setInput('open', opts.open ?? true); + if (opts.title !== undefined) fixture.componentRef.setInput('title', opts.title); + if (opts.body !== undefined) fixture.componentRef.setInput('body', opts.body); + if (opts.confirmLabel !== undefined) fixture.componentRef.setInput('confirmLabel', opts.confirmLabel); + if (opts.cancelLabel !== undefined) fixture.componentRef.setInput('cancelLabel', opts.cancelLabel); + if (opts.tone !== undefined) fixture.componentRef.setInput('tone', opts.tone); + fixture.detectChanges(); + return fixture; +} + +describe('ChatConfirmDialogComponent', () => { + it('renders nothing when open is false', () => { + const fixture = render({ open: false }); + expect(fixture.nativeElement.querySelector('.chat-confirm-dialog')).toBeNull(); + }); + + it('renders title and body when provided', () => { + const fixture = render({ title: 'Delete?', body: 'This cannot be undone.' }); + expect(fixture.nativeElement.querySelector('.chat-confirm-dialog__title').textContent.trim()).toBe('Delete?'); + expect(fixture.nativeElement.querySelector('.chat-confirm-dialog__body').textContent.trim()).toBe('This cannot be undone.'); + }); + + it('omits body element when body is empty', () => { + const fixture = render({ title: 'T', body: '' }); + expect(fixture.nativeElement.querySelector('.chat-confirm-dialog__body')).toBeNull(); + const dialog = fixture.nativeElement.querySelector('.chat-confirm-dialog'); + expect(dialog.getAttribute('aria-describedby')).toBeNull(); + }); + + it('aria-labelledby points at the title element id', () => { + const fixture = render({ title: 'T' }); + const dialog = fixture.nativeElement.querySelector('.chat-confirm-dialog'); + const labelId = dialog.getAttribute('aria-labelledby'); + expect(labelId).toBeTruthy(); + expect(fixture.nativeElement.querySelector(`#${labelId}`).textContent.trim()).toBe('T'); + }); + + it('confirm button click emits confirmed', () => { + const fixture = render(); + let emits = 0; + fixture.componentInstance.confirmed.subscribe(() => { emits++; }); + (fixture.nativeElement.querySelector('.chat-confirm-dialog__confirm') as HTMLElement).click(); + expect(emits).toBe(1); + }); + + it('cancel button click emits cancelled', () => { + const fixture = render(); + let emits = 0; + fixture.componentInstance.cancelled.subscribe(() => { emits++; }); + (fixture.nativeElement.querySelector('.chat-confirm-dialog__cancel') as HTMLElement).click(); + expect(emits).toBe(1); + }); + + it('scrim click emits cancelled', () => { + const fixture = render(); + let emits = 0; + fixture.componentInstance.cancelled.subscribe(() => { emits++; }); + (fixture.nativeElement.querySelector('.chat-confirm-dialog__scrim') as HTMLElement).click(); + expect(emits).toBe(1); + }); + + it('Esc on the dialog emits cancelled', () => { + const fixture = render(); + let emits = 0; + fixture.componentInstance.cancelled.subscribe(() => { emits++; }); + const dialog = fixture.nativeElement.querySelector('.chat-confirm-dialog'); + dialog.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + expect(emits).toBe(1); + }); + + it('destructive tone applies destructive class to confirm button', () => { + const fixture = render({ tone: 'destructive' }); + const confirm = fixture.nativeElement.querySelector('.chat-confirm-dialog__confirm'); + expect(confirm.classList.contains('chat-confirm-dialog__confirm--destructive')).toBe(true); + }); + + it('confirm button has labelled text from confirmLabel input', () => { + const fixture = render({ confirmLabel: 'Yes do it' }); + const confirm = fixture.nativeElement.querySelector('.chat-confirm-dialog__confirm'); + expect(confirm.textContent.trim()).toBe('Yes do it'); + }); +}); +``` + +- [ ] **Step 2: Run, confirm FAIL** + +Run: `npx nx run chat:test 2>&1 | grep -E "chat-confirm-dialog|FAIL" | head -5` +Expected: FAIL (component does not exist). + +- [ ] **Step 3: Create the component** + +Create `libs/chat/src/lib/primitives/chat-confirm-dialog/chat-confirm-dialog.component.ts`: + +```typescript +// libs/chat/src/lib/primitives/chat-confirm-dialog/chat-confirm-dialog.component.ts +// SPDX-License-Identifier: MIT +import { + Component, + ChangeDetectionStrategy, + ElementRef, + effect, + input, + output, + viewChild, +} from '@angular/core'; +import { CHAT_HOST_TOKENS } from '../../styles/chat-tokens'; +import { CHAT_CONFIRM_DIALOG_STYLES } from '../../styles/chat-confirm-dialog.styles'; + +let confirmDialogInstanceCounter = 0; + +@Component({ + selector: 'chat-confirm-dialog', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + styles: [CHAT_HOST_TOKENS, CHAT_CONFIRM_DIALOG_STYLES], + template: ` + @if (open()) { + + + } + `, +}) +export class ChatConfirmDialogComponent { + readonly open = input(false); + readonly title = input('Are you sure?'); + readonly body = input(''); + readonly confirmLabel = input('Confirm'); + readonly cancelLabel = input('Cancel'); + readonly tone = input<'destructive' | 'normal'>('normal'); + + readonly confirmed = output(); + readonly cancelled = output(); + + private readonly instanceId = ++confirmDialogInstanceCounter; + protected readonly titleId = `chat-confirm-dialog__title-${this.instanceId}`; + protected readonly bodyId = `chat-confirm-dialog__body-${this.instanceId}`; + + private readonly cancelBtn = viewChild>('cancelBtn'); + + constructor() { + effect(() => { + if (!this.open()) return; + // Focus cancel on open (deliberately, so destructive confirm requires + // an explicit Tab + Enter — never a single key from "nothing focused"). + queueMicrotask(() => this.cancelBtn()?.nativeElement.focus()); + }); + } + + protected onDialogKeydown(e: KeyboardEvent): void { + if (e.key === 'Escape') { + e.preventDefault(); + this.cancelled.emit(); + } + } +} +``` + +- [ ] **Step 4: Run, confirm PASS** + +Run: `npx nx run chat:test 2>&1 | tail -5` +Expected: PASS. + +- [ ] **Step 5: Build + lint** + +Run: `npx nx run chat:build && npx nx lint chat 2>&1 | tail -3` +Expected: both PASS. + +- [ ] **Step 6: Commit** + +```bash +git add libs/chat/src/lib/primitives/chat-confirm-dialog +git commit -m "feat(chat): chat-confirm-dialog primitive" +``` + +--- + +## Task 5: `chat-thread-list` extensions + +**Files:** +- Modify: `libs/chat/src/lib/primitives/chat-thread-list/chat-thread-list.component.ts` +- Create: `libs/chat/src/lib/primitives/chat-thread-list/chat-thread-list.component.spec.ts` (or modify if exists) +- Modify: `libs/chat/src/lib/styles/chat-thread-list.styles.ts` + +This is the biggest task. Done in three sub-steps: types + state, template + handlers, styles. + +- [ ] **Step 1: Add the styles for kebab + edit input** + +In `libs/chat/src/lib/styles/chat-thread-list.styles.ts`, append these rules INSIDE the existing template-literal (before the closing backtick): + +```css + .chat-thread-list__item-wrap { + position: relative; + display: flex; + align-items: center; + gap: 4px; + } + .chat-thread-list__item-wrap .chat-thread-list__item { + flex: 1 1 auto; + min-width: 0; + } + .chat-thread-list__kebab { + flex-shrink: 0; + width: 28px; + height: 28px; + border: 0; + background: transparent; + color: var(--ngaf-chat-text-muted); + border-radius: 4px; + cursor: pointer; + opacity: 0; + transition: opacity 100ms ease; + padding: 0; + line-height: 1; + font-size: 18px; + } + .chat-thread-list__item-wrap:hover .chat-thread-list__kebab, + .chat-thread-list__item-wrap:focus-within .chat-thread-list__kebab { + opacity: 1; + } + .chat-thread-list__kebab:hover { + background: var(--ngaf-chat-surface-alt); + color: var(--ngaf-chat-text); + } + .chat-thread-list__kebab:focus-visible { + opacity: 1; + outline: 2px solid var(--ngaf-chat-primary); + outline-offset: 2px; + } + .chat-thread-list__edit { + flex: 1 1 auto; + border: 1px solid var(--ngaf-chat-primary); + border-radius: var(--ngaf-chat-radius-button); + background: var(--ngaf-chat-bg); + color: var(--ngaf-chat-text); + font: inherit; + font-size: var(--ngaf-chat-font-size-sm); + padding: 6px 10px; + min-height: 36px; + outline: none; + box-sizing: border-box; + } +``` + +- [ ] **Step 2: Replace the component with extended version** + +Replace the ENTIRE contents of `libs/chat/src/lib/primitives/chat-thread-list/chat-thread-list.component.ts` with: + +```typescript +// libs/chat/src/lib/primitives/chat-thread-list/chat-thread-list.component.ts +// SPDX-License-Identifier: MIT +import { + Component, + ChangeDetectionStrategy, + ElementRef, + computed, + contentChild, + input, + output, + signal, + TemplateRef, + viewChild, +} from '@angular/core'; +import { NgTemplateOutlet } from '@angular/common'; +import { CHAT_HOST_TOKENS } from '../../styles/chat-tokens'; +import { CHAT_THREAD_LIST_STYLES } from '../../styles/chat-thread-list.styles'; +import { + ChatOverflowMenuComponent, + type OverflowMenuItem, +} from '../chat-overflow-menu/chat-overflow-menu.component'; +import { ChatConfirmDialogComponent } from '../chat-confirm-dialog/chat-confirm-dialog.component'; + +export type Thread = { + id: string; + /** Optional human-friendly label. Falls back to a slice of the id. */ + title?: string; + /** Optional epoch-ms timestamp used by the default item template to + * render a relative-time line ("just now" / "5 min ago"). When absent + * the default template omits the second line. */ + updatedAt?: number; + [key: string]: unknown; +}; + +/** + * Per-thread row-action adapter. Consumer-provided. The framework calls + * these methods after user confirmation (delete) or commit (rename) and + * manages optimistic UI + rollback on rejection. + * + * Consumers MUST refresh their `threads` signal on success — the framework + * clears optimistic overrides in a `finally` block, so a successful adapter + * call that leaves the input list unchanged would re-render the row. + */ +export interface ThreadActionAdapter { + delete?(threadId: string): Promise; + rename?(threadId: string, newTitle: string): Promise; +} + +@Component({ + selector: 'chat-thread-list', + standalone: true, + imports: [NgTemplateOutlet, ChatOverflowMenuComponent, ChatConfirmDialogComponent], + changeDetection: ChangeDetectionStrategy.OnPush, + styles: [CHAT_HOST_TOKENS, CHAT_THREAD_LIST_STYLES], + template: ` + @if (showNewThreadButton()) { + + } +
      + @for (thread of visibleThreads(); track thread.id) { +
    • + @if (templateRef()) { + + } @else if (editingThreadId() === thread.id) { + + } @else { + + + @if (showKebabFor(thread)) { + + } + } +
    • + } +
    + + + + + `, +}) +export class ChatThreadListComponent { + readonly threads = input.required(); + readonly activeThreadId = input(''); + readonly showNewThreadButton = input(false); + readonly actions = input(null); + + readonly threadSelected = output(); + readonly newThreadRequested = output(); + + readonly templateRef = contentChild(TemplateRef); + + protected readonly editingThreadId = signal(null); + protected readonly editingValue = signal(''); + protected readonly menuOpenForId = signal(null); + protected readonly menuAnchor = signal(null); + protected readonly confirmDeleteId = signal(null); + + private readonly pendingDeletes = signal>(new Set()); + private readonly pendingRenames = signal>(new Map()); + + protected readonly visibleThreads = computed(() => { + const hidden = this.pendingDeletes(); + 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)); + }); + + protected readonly currentMenuItems = computed(() => { + const id = this.menuOpenForId(); + if (!id) return []; + 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' }); + return items; + }); + + private readonly editInput = viewChild>('editInput'); + + selectThread(threadId: string): void { + this.threadSelected.emit(threadId); + } + + protected threadLabel(thread: Thread): string { + const title = thread['title']; + if (typeof title === 'string' && title.length > 0) return title; + return thread.id; + } + + protected relativeTime(epochMs: number): string { + const delta = Date.now() - epochMs; + if (delta < 60_000) return 'just now'; + if (delta < 3_600_000) return `${Math.floor(delta / 60_000)} min ago`; + if (delta < 86_400_000) return `${Math.floor(delta / 3_600_000)} hr ago`; + return `${Math.floor(delta / 86_400_000)} day ago`; + } + + protected showKebabFor(_thread: Thread): boolean { + const a = this.actions(); + if (!a) return false; + return Boolean(a.rename || a.delete); + } + + protected openMenu(threadId: string, anchor: HTMLElement): void { + this.menuAnchor.set(anchor); + this.menuOpenForId.set(threadId); + } + + 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); + } + } + + protected onEditInput(e: Event): void { + this.editingValue.set((e.target as HTMLInputElement).value); + } + + protected cancelRename(): void { + this.editingThreadId.set(null); + } + + protected async commitRename(threadId: string): Promise { + const newTitle = this.editingValue().trim(); + this.editingThreadId.set(null); + if (!newTitle) return; + const a = this.actions(); + if (!a?.rename) return; + + this.pendingRenames.update((m) => { + const n = new Map(m); + n.set(threadId, newTitle); + return n; + }); + try { + await a.rename(threadId, newTitle); + } catch { + // Rollback happens via the finally clear below — pending override is removed, + // and threads() input still contains the original title. + } finally { + this.pendingRenames.update((m) => { + const n = new Map(m); + n.delete(threadId); + return n; + }); + } + } + + protected async performDelete(): Promise { + const threadId = this.confirmDeleteId(); + this.confirmDeleteId.set(null); + if (!threadId) return; + const a = this.actions(); + if (!a?.delete) return; + + this.pendingDeletes.update((s) => new Set([...s, threadId])); + try { + await a.delete(threadId); + } catch { + // Rollback: clear override so the row reappears. + } finally { + this.pendingDeletes.update((s) => { + const n = new Set(s); + n.delete(threadId); + return n; + }); + } + } +} +``` + +- [ ] **Step 3: Write the spec (or extend if existing)** + +Create (or modify if exists) `libs/chat/src/lib/primitives/chat-thread-list/chat-thread-list.component.spec.ts`: + +```typescript +// libs/chat/src/lib/primitives/chat-thread-list/chat-thread-list.component.spec.ts +// SPDX-License-Identifier: MIT +import { TestBed } from '@angular/core/testing'; +import { describe, expect, it, vi } from 'vitest'; +import { + ChatThreadListComponent, + type Thread, + type ThreadActionAdapter, +} from './chat-thread-list.component'; + +function render(opts: { threads?: Thread[]; actions?: ThreadActionAdapter | null; activeThreadId?: string } = {}) { + const fixture = TestBed.createComponent(ChatThreadListComponent); + fixture.componentRef.setInput('threads', opts.threads ?? [{ id: 't1', title: 'First' }, { id: 't2', title: 'Second' }]); + if (opts.actions !== undefined) fixture.componentRef.setInput('actions', opts.actions); + if (opts.activeThreadId !== undefined) fixture.componentRef.setInput('activeThreadId', opts.activeThreadId); + fixture.detectChanges(); + return fixture; +} + +describe('ChatThreadListComponent', () => { + describe('without adapter', () => { + it('renders the thread rows', () => { + const fixture = render(); + const items = fixture.nativeElement.querySelectorAll('.chat-thread-list__item'); + expect(items.length).toBe(2); + }); + + it('clicking a row emits threadSelected', () => { + const fixture = render(); + let received: string | undefined; + fixture.componentInstance.threadSelected.subscribe((id: string) => { received = id; }); + const items = fixture.nativeElement.querySelectorAll('.chat-thread-list__item'); + (items[1] as HTMLElement).click(); + expect(received).toBe('t2'); + }); + + it('renders no kebab when actions is null', () => { + const fixture = render({ actions: null }); + expect(fixture.nativeElement.querySelector('.chat-thread-list__kebab')).toBeNull(); + }); + + it('renders no kebab when actions is empty object', () => { + const fixture = render({ actions: {} }); + expect(fixture.nativeElement.querySelector('.chat-thread-list__kebab')).toBeNull(); + }); + }); + + describe('with adapter', () => { + it('renders a kebab per row when adapter has methods', () => { + const fixture = render({ actions: { delete: async () => {}, rename: async () => {} } }); + const kebabs = fixture.nativeElement.querySelectorAll('.chat-thread-list__kebab'); + expect(kebabs.length).toBe(2); + }); + + it('clicking kebab opens menu with both items when both methods provided', () => { + const fixture = render({ actions: { delete: async () => {}, rename: async () => {} } }); + const kebab = fixture.nativeElement.querySelector('.chat-thread-list__kebab') as HTMLElement; + kebab.click(); + fixture.detectChanges(); + const items = document.querySelectorAll('.chat-overflow-menu__item'); + const labels = Array.from(items).map((el) => (el as HTMLElement).textContent?.trim()); + expect(labels).toContain('Rename'); + expect(labels).toContain('Delete'); + }); + + it('clicking Rename enters edit mode and focuses the input', async () => { + const fixture = render({ actions: { rename: async () => {} } }); + const kebab = fixture.nativeElement.querySelector('.chat-thread-list__kebab') as HTMLElement; + kebab.click(); + fixture.detectChanges(); + const renameItem = Array.from(document.querySelectorAll('.chat-overflow-menu__item')).find((el) => (el as HTMLElement).textContent?.trim() === 'Rename') as HTMLElement; + renameItem.click(); + fixture.detectChanges(); + await new Promise((r) => queueMicrotask(() => r(undefined))); + fixture.detectChanges(); + const input = fixture.nativeElement.querySelector('.chat-thread-list__edit') as HTMLInputElement | null; + expect(input).not.toBeNull(); + expect(input!.value).toBe('First'); + }); + + it('Enter on rename input calls adapter.rename and shows new title optimistically', async () => { + const renameSpy = vi.fn(async () => {}); + const fixture = render({ actions: { rename: renameSpy } }); + // Open menu, click Rename. + (fixture.nativeElement.querySelector('.chat-thread-list__kebab') as HTMLElement).click(); + fixture.detectChanges(); + const renameItem = Array.from(document.querySelectorAll('.chat-overflow-menu__item')).find((el) => (el as HTMLElement).textContent?.trim() === 'Rename') as HTMLElement; + renameItem.click(); + fixture.detectChanges(); + await new Promise((r) => queueMicrotask(() => r(undefined))); + fixture.detectChanges(); + const input = fixture.nativeElement.querySelector('.chat-thread-list__edit') as HTMLInputElement; + input.value = 'Renamed'; + input.dispatchEvent(new Event('input', { bubbles: true })); + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + await new Promise((r) => setTimeout(r, 0)); + fixture.detectChanges(); + expect(renameSpy).toHaveBeenCalledWith('t1', 'Renamed'); + }); + + it('Esc cancels rename without calling adapter', () => { + const renameSpy = vi.fn(async () => {}); + const fixture = render({ actions: { rename: renameSpy } }); + (fixture.nativeElement.querySelector('.chat-thread-list__kebab') as HTMLElement).click(); + fixture.detectChanges(); + const renameItem = Array.from(document.querySelectorAll('.chat-overflow-menu__item')).find((el) => (el as HTMLElement).textContent?.trim() === 'Rename') as HTMLElement; + renameItem.click(); + fixture.detectChanges(); + const input = fixture.nativeElement.querySelector('.chat-thread-list__edit') as HTMLInputElement; + input.value = 'X'; + input.dispatchEvent(new Event('input', { bubbles: true })); + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + fixture.detectChanges(); + expect(renameSpy).not.toHaveBeenCalled(); + }); + + it('Delete menu item opens the confirm dialog', () => { + const fixture = render({ actions: { delete: async () => {} } }); + (fixture.nativeElement.querySelector('.chat-thread-list__kebab') as HTMLElement).click(); + fixture.detectChanges(); + const delItem = Array.from(document.querySelectorAll('.chat-overflow-menu__item')).find((el) => (el as HTMLElement).textContent?.trim() === 'Delete') as HTMLElement; + delItem.click(); + fixture.detectChanges(); + expect(document.querySelector('.chat-confirm-dialog')).not.toBeNull(); + }); + + it('Confirming delete calls adapter and hides the row optimistically', async () => { + let resolveDelete!: () => void; + const deleteSpy = vi.fn(() => new Promise((r) => { resolveDelete = r; })); + const fixture = render({ actions: { delete: deleteSpy } }); + (fixture.nativeElement.querySelector('.chat-thread-list__kebab') as HTMLElement).click(); + fixture.detectChanges(); + const delItem = Array.from(document.querySelectorAll('.chat-overflow-menu__item')).find((el) => (el as HTMLElement).textContent?.trim() === 'Delete') as HTMLElement; + delItem.click(); + fixture.detectChanges(); + (document.querySelector('.chat-confirm-dialog__confirm') as HTMLElement).click(); + fixture.detectChanges(); + // Row should be hidden while the promise is pending. + const remaining = fixture.nativeElement.querySelectorAll('.chat-thread-list__item'); + expect(remaining.length).toBe(1); // 't1' optimistically hidden, only 't2' remains + expect(deleteSpy).toHaveBeenCalledWith('t1'); + resolveDelete(); + await new Promise((r) => setTimeout(r, 0)); + }); + + it('Cancelling the confirm dialog does not call adapter', () => { + const deleteSpy = vi.fn(async () => {}); + const fixture = render({ actions: { delete: deleteSpy } }); + (fixture.nativeElement.querySelector('.chat-thread-list__kebab') as HTMLElement).click(); + fixture.detectChanges(); + const delItem = Array.from(document.querySelectorAll('.chat-overflow-menu__item')).find((el) => (el as HTMLElement).textContent?.trim() === 'Delete') as HTMLElement; + delItem.click(); + fixture.detectChanges(); + (document.querySelector('.chat-confirm-dialog__cancel') as HTMLElement).click(); + fixture.detectChanges(); + expect(deleteSpy).not.toHaveBeenCalled(); + }); + }); +}); +``` + +- [ ] **Step 4: Run tests, expect PASS** + +Run: `npx nx run chat:test 2>&1 | tail -10` + +Note: tests may interact with `document` (because `chat-overflow-menu` is `position: fixed` and rendered via the component's template, so it lives in the test's DOM). Vitest+TestBed setup in this repo supports that. + +- [ ] **Step 5: Build + lint** + +Run: `npx nx run chat:build && npx nx lint chat 2>&1 | tail -5` +Expected: both pass. + +If lint flags `interactive-supports-focus` on the `` (inputs are focusable by default, but the rule may insist on a tabindex), add `tabindex="0"` to the input — does no harm. + +- [ ] **Step 6: Commit** + +```bash +git add libs/chat/src/lib/primitives/chat-thread-list libs/chat/src/lib/styles/chat-thread-list.styles.ts +git commit -m "feat(chat): chat-thread-list row actions (rename + delete) with optimistic UI" +``` + +--- + +## Task 6: `chat-sidenav` pass-through `actions` input + +**Files:** +- Modify: `libs/chat/src/lib/compositions/chat-sidenav/chat-sidenav.component.ts` + +- [ ] **Step 1: Add the input and forward it** + +In the component imports block, add `ThreadActionAdapter` to the existing chat-thread-list import: + +```typescript +import { + ChatThreadListComponent, + type Thread, + type ThreadActionAdapter, +} from '../../primitives/chat-thread-list/chat-thread-list.component'; +``` + +In the class body, add the input next to the other inputs (after `activeThreadId`): + +```typescript +readonly actions = input(null); +``` + +In the template, locate the `` element inside the `@if (threads() !== null)` block. Add the `[actions]` binding: + +```html + +``` + +- [ ] **Step 2: Build + lint** + +Run: `npx nx run chat:build && npx nx lint chat 2>&1 | tail -3` +Expected: both pass. + +- [ ] **Step 3: Commit** + +```bash +git add libs/chat/src/lib/compositions/chat-sidenav/chat-sidenav.component.ts +git commit -m "feat(chat): chat-sidenav forwards actions input to thread list" +``` + +--- + +## Task 7: Public API exports + +**Files:** +- Modify: `libs/chat/src/public-api.ts` + +- [ ] **Step 1: Add the new exports** + +Search `public-api.ts` for the line that exports `ChatThreadListComponent`: + +```typescript +export { ChatThreadListComponent } from './lib/primitives/chat-thread-list/chat-thread-list.component'; +``` + +Below it, add the `ThreadActionAdapter` type export: + +```typescript +export type { ThreadActionAdapter } from './lib/primitives/chat-thread-list/chat-thread-list.component'; +``` + +Near the other primitive exports (e.g. after `ChatHistorySearchPaletteComponent`), add: + +```typescript +export { ChatOverflowMenuComponent } from './lib/primitives/chat-overflow-menu/chat-overflow-menu.component'; +export type { OverflowMenuItem } from './lib/primitives/chat-overflow-menu/chat-overflow-menu.component'; +export { ChatConfirmDialogComponent } from './lib/primitives/chat-confirm-dialog/chat-confirm-dialog.component'; +``` + +- [ ] **Step 2: Build** + +Run: `npx nx run chat:build` +Expected: PASS. + +- [ ] **Step 3: Commit** + +```bash +git add libs/chat/src/public-api.ts +git commit -m "feat(chat): export overflow menu, confirm dialog, and ThreadActionAdapter" +``` + +--- + +## Task 8: Example shell — `ThreadsService` extensions + +**Files:** +- Modify: `examples/chat/angular/src/app/shell/threads.service.ts` + +- [ ] **Step 1: Add delete + rename methods** + +Open `examples/chat/angular/src/app/shell/threads.service.ts`. Inside the `ThreadsService` class, append two new methods (after the existing `create()` method): + +```typescript +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.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.refresh(); +} +``` + +- [ ] **Step 2: Verify** + +Run: `npx nx run examples-chat-angular:build 2>&1 | tail -3` +Expected: PASS. + +- [ ] **Step 3: Commit** + +```bash +git add examples/chat/angular/src/app/shell/threads.service.ts +git commit -m "feat(examples-chat): threads.service.ts gains delete + rename" +``` + +--- + +## Task 9: Example shell — wire `threadActions` + +**Files:** +- Modify: `examples/chat/angular/src/app/shell/demo-shell.component.ts` +- Modify: `examples/chat/angular/src/app/shell/demo-shell.component.html` + +- [ ] **Step 1: Add the import and threadActions property** + +In `demo-shell.component.ts`, expand the existing `@ngaf/chat` import to include `type ThreadActionAdapter`: + +```typescript +import { + // ... existing imports preserved + type ChatSidenavMode, + ChatHistorySearchPaletteComponent, + type ThreadMatch, + type ThreadActionAdapter, +} from '@ngaf/chat'; +``` + +(Preserve every other identifier currently in that import block.) + +In the class body, add the `threadActions` adapter. The delete method also clears the active thread id when the deleted thread is the active one. Place it near the existing `threadIdSignal`: + +```typescript +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), +}; +``` + +- [ ] **Step 2: Bind in the template** + +In `demo-shell.component.html`, find the `` element and add the `[actions]` binding (the rest of the bindings remain unchanged): + +```html + +``` + +- [ ] **Step 3: Build** + +Run: `npx nx run examples-chat-angular:build 2>&1 | tail -5` +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +git add examples/chat/angular/src/app/shell/ +git commit -m "feat(examples-chat): wire threadActions adapter on chat-sidenav" +``` + +--- + +## Task 10: Regenerate API docs + +**Files:** +- Modify: `apps/website/content/docs/chat/api/api-docs.json` + +- [ ] **Step 1: Regenerate** + +Run from the repo root: + +```bash +npx tsx apps/website/scripts/generate-api-docs.ts +``` + +Expected output includes `✓ chat/api/api-docs.json (N entries)` with N reflecting the new `ChatOverflowMenuComponent`, `OverflowMenuItem`, `ChatConfirmDialogComponent`, `ThreadActionAdapter` entries. + +- [ ] **Step 2: Stage + verify the diff is reasonable** + +Run: `git diff --stat apps/website/content/docs/chat/api/api-docs.json` +Expected: changes only to that one file, net positive lines. + +- [ ] **Step 3: Commit** + +```bash +git add apps/website/content/docs/chat/api/api-docs.json +git commit -m "docs(chat): regenerate API docs for row actions" +``` + +--- + +## Task 11: Manual browser verification + +**Files:** none — verification only. + +This task is the controller's job; subagents should stop after Task 10 and report. + +The controller will: + +1. Start the dev server (`examples-chat` from `.claude/launch.json`, port 4400). Must be run from a worktree at origin/main+impl-branch so libraries resolve to the new code. +2. Click a populated thread that has content. +3. Verify each behavior: + - Hover row → kebab fades in. + - Click kebab → menu pops anchored near it; Rename + Delete (destructive red). + - Click outside menu → menu closes. + - Esc with menu open → menu closes. + - Click Rename → row morphs into input prefilled with the title; input focused. + - Type something + Enter → row reverts to button with the new title; LangGraph PATCH succeeded. + - Click Rename again, type something, then Esc → row reverts to old title; no PATCH. + - Click Rename again, type something, blur (click elsewhere) → row reverts; no PATCH. + - Click Delete → confirm dialog appears; focus on Cancel; destructive red Delete button. + - Esc → dialog closes; no DELETE. + - Click Delete (in dialog) → row vanishes immediately; LangGraph DELETE succeeded. +4. Screenshots for: menu open with both items, confirm dialog open, row in edit mode. +5. Stop the preview server. + +If any visual / behavioral issue surfaces, fix it, re-verify, commit. (E.g., the menu auto-position-above-when-near-bottom note in the spec — implement only if it visibly bites.) + +--- + +## Self-Review + +**Spec coverage:** +- `ThreadActionAdapter` type → Task 5 (defined + exported in Task 7). +- `OverflowMenuItem` type → Task 2 (defined + exported in Task 7). +- `chat-overflow-menu` primitive → Tasks 1 + 2 (styles + component + spec). +- `chat-confirm-dialog` primitive → Tasks 3 + 4 (styles + component + spec). +- `chat-thread-list` actions input + state + optimistic flows → Task 5. +- Hover-revealed kebab → Task 5 (CSS) + Task 5 (template). +- Inline rename UX → Task 5 template. +- Destructive confirm dialog initial-focus-cancel → Task 4 component. +- `chat-sidenav` pass-through → Task 6. +- `examples-chat` wiring → Tasks 8 + 9. +- Active-thread-cleared-on-delete (consumer responsibility) → Task 9. +- API docs regen → Task 10. +- Manual verification → Task 11. + +**Placeholder scan:** None. All steps contain complete code. + +**Type consistency:** +- `Thread` is unchanged from existing definition (re-exported by Task 5). +- `ThreadActionAdapter` is defined once in `chat-thread-list.component.ts`; consumed in `chat-sidenav.component.ts` (Task 6) and `examples-chat-angular/demo-shell` (Task 9). +- `OverflowMenuItem` defined in `chat-overflow-menu.component.ts` (Task 2), consumed in `chat-thread-list.component.ts` (Task 5). +- Output names consistent: `itemSelected`, `closed`, `confirmed`, `cancelled`. All wired in `chat-thread-list` template (Task 5). +- Method names consistent: `openMenu`, `onMenuAction`, `commitRename`, `cancelRename`, `performDelete`, `onEditInput`, `showKebabFor`. From f1c321d3ef90e49a23c93b2aeff288d4e63cc418 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 12 May 2026 13:26:13 -0700 Subject: [PATCH 03/13] feat(chat): chat-overflow-menu styles --- .../lib/styles/chat-overflow-menu.styles.ts | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 libs/chat/src/lib/styles/chat-overflow-menu.styles.ts diff --git a/libs/chat/src/lib/styles/chat-overflow-menu.styles.ts b/libs/chat/src/lib/styles/chat-overflow-menu.styles.ts new file mode 100644 index 00000000..6e7a2f28 --- /dev/null +++ b/libs/chat/src/lib/styles/chat-overflow-menu.styles.ts @@ -0,0 +1,50 @@ +// libs/chat/src/lib/styles/chat-overflow-menu.styles.ts +// SPDX-License-Identifier: MIT +export const CHAT_OVERFLOW_MENU_STYLES = ` + :host { display: contents; } + .chat-overflow-menu__scrim { + position: fixed; + inset: 0; + background: transparent; + z-index: 59; + border: 0; + padding: 0; + cursor: default; + } + .chat-overflow-menu { + position: fixed; + z-index: 60; + min-width: 160px; + padding: 4px; + margin: 0; + list-style: none; + background: var(--ngaf-chat-bg); + border: 1px solid var(--ngaf-chat-separator); + border-radius: 8px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15); + } + .chat-overflow-menu__item { + display: block; + padding: 8px 12px; + border-radius: 4px; + color: var(--ngaf-chat-text); + font-size: var(--ngaf-chat-font-size-sm); + cursor: pointer; + user-select: none; + } + .chat-overflow-menu__item:hover { + background: var(--ngaf-chat-surface-alt); + } + .chat-overflow-menu__item:focus-visible { + outline: 2px solid var(--ngaf-chat-primary); + outline-offset: -2px; + } + .chat-overflow-menu__item--destructive { + color: var(--ngaf-chat-error-text); + } + .chat-overflow-menu__item--disabled { + color: var(--ngaf-chat-text-muted); + cursor: not-allowed; + pointer-events: none; + } +`; From c2fb4c21735e7c2b634635686c0f505f25ef1961 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 12 May 2026 13:30:28 -0700 Subject: [PATCH 04/13] feat(chat): chat-overflow-menu primitive --- .../chat-overflow-menu.component.spec.ts | 84 ++++++++++++ .../chat-overflow-menu.component.ts | 121 ++++++++++++++++++ 2 files changed, 205 insertions(+) create mode 100644 libs/chat/src/lib/primitives/chat-overflow-menu/chat-overflow-menu.component.spec.ts create mode 100644 libs/chat/src/lib/primitives/chat-overflow-menu/chat-overflow-menu.component.ts diff --git a/libs/chat/src/lib/primitives/chat-overflow-menu/chat-overflow-menu.component.spec.ts b/libs/chat/src/lib/primitives/chat-overflow-menu/chat-overflow-menu.component.spec.ts new file mode 100644 index 00000000..bdb99921 --- /dev/null +++ b/libs/chat/src/lib/primitives/chat-overflow-menu/chat-overflow-menu.component.spec.ts @@ -0,0 +1,84 @@ +// libs/chat/src/lib/primitives/chat-overflow-menu/chat-overflow-menu.component.spec.ts +// SPDX-License-Identifier: MIT +import { TestBed } from '@angular/core/testing'; +import { describe, expect, it } from 'vitest'; +import { ChatOverflowMenuComponent, type OverflowMenuItem } from './chat-overflow-menu.component'; + +function render(opts: { open?: boolean; items?: OverflowMenuItem[] } = {}) { + const fixture = TestBed.createComponent(ChatOverflowMenuComponent); + fixture.componentRef.setInput('open', opts.open ?? true); + if (opts.items !== undefined) fixture.componentRef.setInput('items', opts.items); + fixture.detectChanges(); + return fixture; +} + +describe('ChatOverflowMenuComponent', () => { + it('renders nothing when open is false', () => { + const fixture = render({ open: false, items: [{ id: 'a', label: 'A' }] }); + expect(fixture.nativeElement.querySelector('.chat-overflow-menu')).toBeNull(); + }); + + it('renders items list when open is true', () => { + const fixture = render({ + items: [ + { id: 'rename', label: 'Rename' }, + { id: 'delete', label: 'Delete', tone: 'destructive' }, + ], + }); + const items = fixture.nativeElement.querySelectorAll('.chat-overflow-menu__item'); + expect(items.length).toBe(2); + expect(items[0].textContent.trim()).toBe('Rename'); + expect(items[1].textContent.trim()).toBe('Delete'); + }); + + it('applies destructive class to destructive-tone items', () => { + const fixture = render({ items: [{ id: 'd', label: 'D', tone: 'destructive' }] }); + const item = fixture.nativeElement.querySelector('.chat-overflow-menu__item'); + expect(item.classList.contains('chat-overflow-menu__item--destructive')).toBe(true); + }); + + it('applies disabled class and aria-disabled to disabled items', () => { + const fixture = render({ items: [{ id: 'x', label: 'X', disabled: true }] }); + const item = fixture.nativeElement.querySelector('.chat-overflow-menu__item'); + expect(item.classList.contains('chat-overflow-menu__item--disabled')).toBe(true); + expect(item.getAttribute('aria-disabled')).toBe('true'); + }); + + it('item click emits itemSelected and closed', () => { + const fixture = render({ items: [{ id: 'a', label: 'A' }, { id: 'b', label: 'B' }] }); + let selected: string | undefined; + let closes = 0; + fixture.componentInstance.itemSelected.subscribe((id: string) => { selected = id; }); + fixture.componentInstance.closed.subscribe(() => { closes++; }); + const items = fixture.nativeElement.querySelectorAll('.chat-overflow-menu__item'); + (items[1] as HTMLElement).click(); + expect(selected).toBe('b'); + expect(closes).toBe(1); + }); + + it('disabled item click is a no-op', () => { + const fixture = render({ items: [{ id: 'x', label: 'X', disabled: true }] }); + let emits = 0; + fixture.componentInstance.itemSelected.subscribe(() => { emits++; }); + fixture.componentInstance.closed.subscribe(() => { emits++; }); + (fixture.nativeElement.querySelector('.chat-overflow-menu__item') as HTMLElement).click(); + expect(emits).toBe(0); + }); + + it('scrim click emits closed', () => { + const fixture = render({ items: [{ id: 'a', label: 'A' }] }); + let closes = 0; + fixture.componentInstance.closed.subscribe(() => { closes++; }); + (fixture.nativeElement.querySelector('.chat-overflow-menu__scrim') as HTMLElement).click(); + expect(closes).toBe(1); + }); + + it('Esc on the menu emits closed', () => { + const fixture = render({ items: [{ id: 'a', label: 'A' }] }); + let closes = 0; + fixture.componentInstance.closed.subscribe(() => { closes++; }); + const menu = fixture.nativeElement.querySelector('.chat-overflow-menu'); + menu.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + expect(closes).toBe(1); + }); +}); diff --git a/libs/chat/src/lib/primitives/chat-overflow-menu/chat-overflow-menu.component.ts b/libs/chat/src/lib/primitives/chat-overflow-menu/chat-overflow-menu.component.ts new file mode 100644 index 00000000..2bfa941f --- /dev/null +++ b/libs/chat/src/lib/primitives/chat-overflow-menu/chat-overflow-menu.component.ts @@ -0,0 +1,121 @@ +// libs/chat/src/lib/primitives/chat-overflow-menu/chat-overflow-menu.component.ts +// SPDX-License-Identifier: MIT +import { + Component, + ChangeDetectionStrategy, + computed, + effect, + input, + output, +} from '@angular/core'; +import { CHAT_HOST_TOKENS } from '../../styles/chat-tokens'; +import { CHAT_OVERFLOW_MENU_STYLES } from '../../styles/chat-overflow-menu.styles'; + +export interface OverflowMenuItem { + /** Stable id emitted via (itemSelected). */ + id: string; + label: string; + /** 'destructive' renders the label in red. Default 'normal'. */ + tone?: 'normal' | 'destructive'; + /** Disabled items render muted and ignore clicks/keypresses. */ + disabled?: boolean; +} + +@Component({ + selector: 'chat-overflow-menu', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + styles: [CHAT_HOST_TOKENS, CHAT_OVERFLOW_MENU_STYLES], + template: ` + @if (open()) { + + + } + `, +}) +export class ChatOverflowMenuComponent { + readonly open = input(false); + readonly items = input([]); + /** Element the menu anchors against (positions just below its bottom-right corner). */ + readonly anchor = input(null); + readonly itemSelected = output(); + readonly closed = output(); + + protected readonly position = computed<{ top: number; left: number }>(() => { + if (!this.open()) return { top: 0, left: 0 }; + const el = this.anchor(); + if (!el) { + const vw = typeof window === 'undefined' ? 0 : window.innerWidth; + const vh = typeof window === 'undefined' ? 0 : window.innerHeight; + return { top: Math.max(vh / 3, 0), left: Math.max(vw / 2 - 80, 0) }; + } + const rect = el.getBoundingClientRect(); + return { top: rect.bottom + 4, left: Math.max(rect.right - 160, 8) }; + }); + + constructor() { + effect(() => { + if (!this.open()) return; + queueMicrotask(() => { + const root = document.querySelector('.chat-overflow-menu'); + const first = root?.querySelector('.chat-overflow-menu__item:not(.chat-overflow-menu__item--disabled)'); + first?.focus(); + }); + }); + } + + protected onItemClick(item: OverflowMenuItem): void { + if (item.disabled) return; + this.itemSelected.emit(item.id); + this.closed.emit(); + } + + protected onMenuKeydown(e: KeyboardEvent): void { + if (e.key === 'Escape') { + e.preventDefault(); + this.closed.emit(); + return; + } + if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { + e.preventDefault(); + const root = (e.currentTarget as HTMLElement); + const items = Array.from(root.querySelectorAll('.chat-overflow-menu__item:not(.chat-overflow-menu__item--disabled)')); + if (items.length === 0) return; + const current = document.activeElement as HTMLElement | null; + const idx = current ? items.indexOf(current) : -1; + const next = e.key === 'ArrowDown' + ? Math.min((idx < 0 ? 0 : idx + 1), items.length - 1) + : Math.max(idx - 1, 0); + items[next]?.focus(); + } + } +} From 10610bb630f0c1f13958bf731460bf43cf462327 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 12 May 2026 13:32:20 -0700 Subject: [PATCH 05/13] feat(chat): chat-confirm-dialog styles --- .../lib/styles/chat-confirm-dialog.styles.ts | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 libs/chat/src/lib/styles/chat-confirm-dialog.styles.ts diff --git a/libs/chat/src/lib/styles/chat-confirm-dialog.styles.ts b/libs/chat/src/lib/styles/chat-confirm-dialog.styles.ts new file mode 100644 index 00000000..96293c3d --- /dev/null +++ b/libs/chat/src/lib/styles/chat-confirm-dialog.styles.ts @@ -0,0 +1,70 @@ +// libs/chat/src/lib/styles/chat-confirm-dialog.styles.ts +// SPDX-License-Identifier: MIT +export const CHAT_CONFIRM_DIALOG_STYLES = ` + :host { display: contents; } + .chat-confirm-dialog__scrim { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.4); + z-index: 70; + border: 0; + padding: 0; + cursor: pointer; + } + .chat-confirm-dialog { + position: fixed; + top: 30vh; + left: 50%; + transform: translateX(-50%); + width: min(420px, 90vw); + z-index: 71; + padding: 20px; + background: var(--ngaf-chat-bg); + border: 1px solid var(--ngaf-chat-separator); + border-radius: 12px; + box-shadow: 0 16px 48px rgba(0, 0, 0, 0.25); + } + .chat-confirm-dialog__title { + margin: 0 0 8px 0; + color: var(--ngaf-chat-text); + font-size: 1.125rem; + font-weight: 600; + } + .chat-confirm-dialog__body { + margin: 0 0 16px 0; + color: var(--ngaf-chat-text-muted); + font-size: var(--ngaf-chat-font-size-sm); + line-height: 1.5; + } + .chat-confirm-dialog__actions { + display: flex; + justify-content: flex-end; + gap: 8px; + } + .chat-confirm-dialog__cancel, + .chat-confirm-dialog__confirm { + padding: 8px 16px; + border-radius: 6px; + font: inherit; + font-size: var(--ngaf-chat-font-size-sm); + cursor: pointer; + border: 1px solid var(--ngaf-chat-separator); + background: var(--ngaf-chat-bg); + color: var(--ngaf-chat-text); + } + .chat-confirm-dialog__cancel:hover { background: var(--ngaf-chat-surface-alt); } + .chat-confirm-dialog__confirm { + background: var(--ngaf-chat-text); + color: var(--ngaf-chat-bg); + border-color: transparent; + } + .chat-confirm-dialog__confirm--destructive { + background: var(--ngaf-chat-error-text); + color: #fff; + } + .chat-confirm-dialog__cancel:focus-visible, + .chat-confirm-dialog__confirm:focus-visible { + outline: 2px solid var(--ngaf-chat-primary); + outline-offset: 2px; + } +`; From 5c78c5f339b295305f02b534d221f2fad0bd16dd Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 12 May 2026 13:34:08 -0700 Subject: [PATCH 06/13] feat(chat): chat-confirm-dialog primitive --- .../chat-confirm-dialog.component.spec.ts | 97 +++++++++++++++++++ .../chat-confirm-dialog.component.ts | 91 +++++++++++++++++ 2 files changed, 188 insertions(+) create mode 100644 libs/chat/src/lib/primitives/chat-confirm-dialog/chat-confirm-dialog.component.spec.ts create mode 100644 libs/chat/src/lib/primitives/chat-confirm-dialog/chat-confirm-dialog.component.ts diff --git a/libs/chat/src/lib/primitives/chat-confirm-dialog/chat-confirm-dialog.component.spec.ts b/libs/chat/src/lib/primitives/chat-confirm-dialog/chat-confirm-dialog.component.spec.ts new file mode 100644 index 00000000..9a58b89c --- /dev/null +++ b/libs/chat/src/lib/primitives/chat-confirm-dialog/chat-confirm-dialog.component.spec.ts @@ -0,0 +1,97 @@ +// libs/chat/src/lib/primitives/chat-confirm-dialog/chat-confirm-dialog.component.spec.ts +// SPDX-License-Identifier: MIT +import { TestBed } from '@angular/core/testing'; +import { describe, expect, it } from 'vitest'; +import { ChatConfirmDialogComponent } from './chat-confirm-dialog.component'; + +function render(opts: { + open?: boolean; + title?: string; + body?: string; + confirmLabel?: string; + cancelLabel?: string; + tone?: 'destructive' | 'normal'; +} = {}) { + const fixture = TestBed.createComponent(ChatConfirmDialogComponent); + fixture.componentRef.setInput('open', opts.open ?? true); + if (opts.title !== undefined) fixture.componentRef.setInput('title', opts.title); + if (opts.body !== undefined) fixture.componentRef.setInput('body', opts.body); + if (opts.confirmLabel !== undefined) fixture.componentRef.setInput('confirmLabel', opts.confirmLabel); + if (opts.cancelLabel !== undefined) fixture.componentRef.setInput('cancelLabel', opts.cancelLabel); + if (opts.tone !== undefined) fixture.componentRef.setInput('tone', opts.tone); + fixture.detectChanges(); + return fixture; +} + +describe('ChatConfirmDialogComponent', () => { + it('renders nothing when open is false', () => { + const fixture = render({ open: false }); + expect(fixture.nativeElement.querySelector('.chat-confirm-dialog')).toBeNull(); + }); + + it('renders title and body when provided', () => { + const fixture = render({ title: 'Delete?', body: 'This cannot be undone.' }); + expect(fixture.nativeElement.querySelector('.chat-confirm-dialog__title').textContent.trim()).toBe('Delete?'); + expect(fixture.nativeElement.querySelector('.chat-confirm-dialog__body').textContent.trim()).toBe('This cannot be undone.'); + }); + + it('omits body element when body is empty', () => { + const fixture = render({ title: 'T', body: '' }); + expect(fixture.nativeElement.querySelector('.chat-confirm-dialog__body')).toBeNull(); + const dialog = fixture.nativeElement.querySelector('.chat-confirm-dialog'); + expect(dialog.getAttribute('aria-describedby')).toBeNull(); + }); + + it('aria-labelledby points at the title element id', () => { + const fixture = render({ title: 'T' }); + const dialog = fixture.nativeElement.querySelector('.chat-confirm-dialog'); + const labelId = dialog.getAttribute('aria-labelledby'); + expect(labelId).toBeTruthy(); + expect(fixture.nativeElement.querySelector(`#${labelId}`).textContent.trim()).toBe('T'); + }); + + it('confirm button click emits confirmed', () => { + const fixture = render(); + let emits = 0; + fixture.componentInstance.confirmed.subscribe(() => { emits++; }); + (fixture.nativeElement.querySelector('.chat-confirm-dialog__confirm') as HTMLElement).click(); + expect(emits).toBe(1); + }); + + it('cancel button click emits cancelled', () => { + const fixture = render(); + let emits = 0; + fixture.componentInstance.cancelled.subscribe(() => { emits++; }); + (fixture.nativeElement.querySelector('.chat-confirm-dialog__cancel') as HTMLElement).click(); + expect(emits).toBe(1); + }); + + it('scrim click emits cancelled', () => { + const fixture = render(); + let emits = 0; + fixture.componentInstance.cancelled.subscribe(() => { emits++; }); + (fixture.nativeElement.querySelector('.chat-confirm-dialog__scrim') as HTMLElement).click(); + expect(emits).toBe(1); + }); + + it('Esc on the dialog emits cancelled', () => { + const fixture = render(); + let emits = 0; + fixture.componentInstance.cancelled.subscribe(() => { emits++; }); + const dialog = fixture.nativeElement.querySelector('.chat-confirm-dialog'); + dialog.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + expect(emits).toBe(1); + }); + + it('destructive tone applies destructive class to confirm button', () => { + const fixture = render({ tone: 'destructive' }); + const confirm = fixture.nativeElement.querySelector('.chat-confirm-dialog__confirm'); + expect(confirm.classList.contains('chat-confirm-dialog__confirm--destructive')).toBe(true); + }); + + it('confirm button has labelled text from confirmLabel input', () => { + const fixture = render({ confirmLabel: 'Yes do it' }); + const confirm = fixture.nativeElement.querySelector('.chat-confirm-dialog__confirm'); + expect(confirm.textContent.trim()).toBe('Yes do it'); + }); +}); diff --git a/libs/chat/src/lib/primitives/chat-confirm-dialog/chat-confirm-dialog.component.ts b/libs/chat/src/lib/primitives/chat-confirm-dialog/chat-confirm-dialog.component.ts new file mode 100644 index 00000000..fcbdef0d --- /dev/null +++ b/libs/chat/src/lib/primitives/chat-confirm-dialog/chat-confirm-dialog.component.ts @@ -0,0 +1,91 @@ +// libs/chat/src/lib/primitives/chat-confirm-dialog/chat-confirm-dialog.component.ts +// SPDX-License-Identifier: MIT +import { + Component, + ChangeDetectionStrategy, + ElementRef, + effect, + input, + output, + viewChild, +} from '@angular/core'; +import { CHAT_HOST_TOKENS } from '../../styles/chat-tokens'; +import { CHAT_CONFIRM_DIALOG_STYLES } from '../../styles/chat-confirm-dialog.styles'; + +let confirmDialogInstanceCounter = 0; + +@Component({ + selector: 'chat-confirm-dialog', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + styles: [CHAT_HOST_TOKENS, CHAT_CONFIRM_DIALOG_STYLES], + template: ` + @if (open()) { + + + } + `, +}) +export class ChatConfirmDialogComponent { + readonly open = input(false); + readonly title = input('Are you sure?'); + readonly body = input(''); + readonly confirmLabel = input('Confirm'); + readonly cancelLabel = input('Cancel'); + readonly tone = input<'destructive' | 'normal'>('normal'); + + readonly confirmed = output(); + readonly cancelled = output(); + + private readonly instanceId = ++confirmDialogInstanceCounter; + protected readonly titleId = `chat-confirm-dialog__title-${this.instanceId}`; + protected readonly bodyId = `chat-confirm-dialog__body-${this.instanceId}`; + + private readonly cancelBtn = viewChild>('cancelBtn'); + + constructor() { + effect(() => { + if (!this.open()) return; + queueMicrotask(() => this.cancelBtn()?.nativeElement.focus()); + }); + } + + protected onDialogKeydown(e: KeyboardEvent): void { + if (e.key === 'Escape') { + e.preventDefault(); + this.cancelled.emit(); + } + } +} From 589a209360a60f833c1b935f0a6f5723a2e19685 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 12 May 2026 13:40:56 -0700 Subject: [PATCH 07/13] feat(chat): chat-thread-list row actions (rename + delete) with optimistic UI Co-Authored-By: Claude Sonnet 4.6 --- .../chat-thread-list.component.spec.ts | 247 ++++++++++-------- .../chat-thread-list.component.ts | 184 ++++++++++++- .../src/lib/styles/chat-thread-list.styles.ts | 51 ++++ 3 files changed, 374 insertions(+), 108 deletions(-) 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 7061f033..b51b8f86 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 @@ -1,120 +1,159 @@ +// libs/chat/src/lib/primitives/chat-thread-list/chat-thread-list.component.spec.ts // SPDX-License-Identifier: MIT -import { describe, it, expect } from 'vitest'; -import { signal, computed } from '@angular/core'; -import type { Thread } from './chat-thread-list.component'; - -const threads: Thread[] = [ - { id: 'thread-1', title: 'First Thread' }, - { id: 'thread-2', title: 'Second Thread' }, - { id: 'thread-3', title: 'Third Thread' }, -]; - -describe('ChatThreadListComponent — structure', () => { - it('threads input signal holds provided threads', () => { - const threads$ = signal(threads); - expect(threads$()).toHaveLength(3); - expect(threads$()[0].id).toBe('thread-1'); - }); - - it('activeThreadId input defaults to empty string', () => { - const activeThreadId$ = signal(''); - expect(activeThreadId$()).toBe(''); - }); - - it('isActive context is true when thread.id matches activeThreadId', () => { - const activeThreadId$ = signal('thread-2'); - - const contextForThread = (thread: Thread) => ({ - $implicit: thread, - isActive: thread.id === activeThreadId$(), +import { TestBed } from '@angular/core/testing'; +import { describe, expect, it, vi } from 'vitest'; +import { + ChatThreadListComponent, + type Thread, + type ThreadActionAdapter, +} from './chat-thread-list.component'; + +const noop = vi.fn().mockResolvedValue(undefined); + +function render(opts: { threads?: Thread[]; actions?: ThreadActionAdapter | null; activeThreadId?: string } = {}) { + const fixture = TestBed.createComponent(ChatThreadListComponent); + fixture.componentRef.setInput('threads', opts.threads ?? [{ id: 't1', title: 'First' }, { id: 't2', title: 'Second' }]); + if (opts.actions !== undefined) fixture.componentRef.setInput('actions', opts.actions); + if (opts.activeThreadId !== undefined) fixture.componentRef.setInput('activeThreadId', opts.activeThreadId); + fixture.detectChanges(); + return fixture; +} + +describe('ChatThreadListComponent', () => { + describe('without adapter', () => { + it('renders the thread rows', () => { + const fixture = render(); + const items = fixture.nativeElement.querySelectorAll('.chat-thread-list__item'); + expect(items.length).toBe(2); }); - expect(contextForThread(threads[0]).isActive).toBe(false); - expect(contextForThread(threads[1]).isActive).toBe(true); - expect(contextForThread(threads[2]).isActive).toBe(false); - }); - - it('isActive updates reactively when activeThreadId changes', () => { - const activeThreadId$ = signal('thread-1'); - - const isActive = (thread: Thread) => - computed(() => thread.id === activeThreadId$()); - - const thread1Active = isActive(threads[0]); - const thread2Active = isActive(threads[1]); - - expect(thread1Active()).toBe(true); - expect(thread2Active()).toBe(false); - - activeThreadId$.set('thread-2'); - - expect(thread1Active()).toBe(false); - expect(thread2Active()).toBe(true); - }); - - it('renders context with $implicit thread reference', () => { - const threads$ = signal(threads); - const activeThreadId$ = signal('thread-3'); + it('clicking a row emits threadSelected', () => { + const fixture = render(); + let received: string | undefined; + fixture.componentInstance.threadSelected.subscribe((id: string) => { received = id; }); + const items = fixture.nativeElement.querySelectorAll('.chat-thread-list__item'); + (items[1] as HTMLElement).click(); + expect(received).toBe('t2'); + }); - const contexts = computed(() => - threads$().map(thread => ({ - $implicit: thread, - isActive: thread.id === activeThreadId$(), - })) - ); + it('renders no kebab when actions is null', () => { + const fixture = render({ actions: null }); + expect(fixture.nativeElement.querySelector('.chat-thread-list__kebab')).toBeNull(); + }); - const result = contexts(); - expect(result).toHaveLength(3); - expect(result[2].$implicit.id).toBe('thread-3'); - expect(result[2].isActive).toBe(true); + it('renders no kebab when actions is empty object', () => { + const fixture = render({ actions: {} }); + expect(fixture.nativeElement.querySelector('.chat-thread-list__kebab')).toBeNull(); + }); }); - it('threads updates reactively when thread list changes', () => { - const threads$ = signal(threads.slice(0, 2)); - expect(threads$()).toHaveLength(2); + describe('with adapter', () => { + it('renders a kebab per row when adapter has methods', () => { + const fixture = render({ actions: { delete: noop, rename: noop } }); + const kebabs = fixture.nativeElement.querySelectorAll('.chat-thread-list__kebab'); + expect(kebabs.length).toBe(2); + }); - threads$.set(threads); - expect(threads$()).toHaveLength(3); - }); -}); + it('clicking kebab opens menu with both items when both methods provided', () => { + const fixture = render({ actions: { delete: noop, rename: noop } }); + const kebab = fixture.nativeElement.querySelector('.chat-thread-list__kebab') as HTMLElement; + kebab.click(); + fixture.detectChanges(); + const items = document.querySelectorAll('.chat-overflow-menu__item'); + const labels = Array.from(items).map((el) => (el as HTMLElement).textContent?.trim()); + expect(labels).toContain('Rename'); + expect(labels).toContain('Delete'); + }); -describe('ChatThreadListComponent — default item template', () => { - // Helper function that mirrors the component's relativeTime method - const relativeTime = (epochMs: number): string => { - const delta = Date.now() - epochMs; - if (delta < 60_000) return 'just now'; - if (delta < 3_600_000) return `${Math.floor(delta / 60_000)} min ago`; - if (delta < 86_400_000) return `${Math.floor(delta / 3_600_000)} hr ago`; - return `${Math.floor(delta / 86_400_000)} day ago`; - }; + it('clicking Rename enters edit mode and focuses the input', async () => { + const fixture = render({ actions: { rename: noop } }); + const kebab = fixture.nativeElement.querySelector('.chat-thread-list__kebab') as HTMLElement; + kebab.click(); + fixture.detectChanges(); + const renameItem = Array.from(document.querySelectorAll('.chat-overflow-menu__item')).find((el) => (el as HTMLElement).textContent?.trim() === 'Rename') as HTMLElement; + renameItem.click(); + fixture.detectChanges(); + await new Promise((r) => queueMicrotask(() => r(undefined))); + fixture.detectChanges(); + const input = fixture.nativeElement.querySelector('.chat-thread-list__edit') as HTMLInputElement; + expect(input).not.toBeNull(); + expect(input.value).toBe('First'); + }); - it('Thread type includes optional updatedAt field', () => { - const threadWithTime: Thread = { id: 'a', title: 'Test', updatedAt: Date.now() }; - const threadWithoutTime: Thread = { id: 'b', title: 'Test' }; - expect(threadWithTime.updatedAt).toBeDefined(); - expect(threadWithoutTime.updatedAt).toBeUndefined(); - }); + it('Enter on rename input calls adapter.rename and shows new title optimistically', async () => { + const renameSpy = vi.fn().mockResolvedValue(undefined); + const fixture = render({ actions: { rename: renameSpy } }); + (fixture.nativeElement.querySelector('.chat-thread-list__kebab') as HTMLElement).click(); + fixture.detectChanges(); + const renameItem = Array.from(document.querySelectorAll('.chat-overflow-menu__item')).find((el) => (el as HTMLElement).textContent?.trim() === 'Rename') as HTMLElement; + renameItem.click(); + fixture.detectChanges(); + await new Promise((r) => queueMicrotask(() => r(undefined))); + fixture.detectChanges(); + const input = fixture.nativeElement.querySelector('.chat-thread-list__edit') as HTMLInputElement; + input.value = 'Renamed'; + input.dispatchEvent(new Event('input', { bubbles: true })); + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + await new Promise((r) => setTimeout(r, 0)); + fixture.detectChanges(); + expect(renameSpy).toHaveBeenCalledWith('t1', 'Renamed'); + }); - it('relativeTime returns "just now" for < 60s delta', () => { - const now = Date.now(); - expect(relativeTime(now - 30_000)).toBe('just now'); - }); + it('Esc cancels rename without calling adapter', () => { + const renameSpy = vi.fn().mockResolvedValue(undefined); + const fixture = render({ actions: { rename: renameSpy } }); + (fixture.nativeElement.querySelector('.chat-thread-list__kebab') as HTMLElement).click(); + fixture.detectChanges(); + const renameItem = Array.from(document.querySelectorAll('.chat-overflow-menu__item')).find((el) => (el as HTMLElement).textContent?.trim() === 'Rename') as HTMLElement; + renameItem.click(); + fixture.detectChanges(); + const input = fixture.nativeElement.querySelector('.chat-thread-list__edit') as HTMLInputElement; + input.value = 'X'; + input.dispatchEvent(new Event('input', { bubbles: true })); + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + fixture.detectChanges(); + expect(renameSpy).not.toHaveBeenCalled(); + }); - it('relativeTime returns "X min ago" for < 1h delta', () => { - const now = Date.now(); - const result = relativeTime(now - 300_000); // 5 min ago - expect(result).toMatch(/\d+ min ago/); - }); + it('Delete menu item opens the confirm dialog', () => { + const fixture = render({ actions: { delete: noop } }); + (fixture.nativeElement.querySelector('.chat-thread-list__kebab') as HTMLElement).click(); + fixture.detectChanges(); + const delItem = Array.from(document.querySelectorAll('.chat-overflow-menu__item')).find((el) => (el as HTMLElement).textContent?.trim() === 'Delete') as HTMLElement; + delItem.click(); + fixture.detectChanges(); + expect(document.querySelector('.chat-confirm-dialog')).not.toBeNull(); + }); - it('relativeTime returns "X hr ago" for < 1d delta', () => { - const now = Date.now(); - const result = relativeTime(now - 7_200_000); // 2 hr ago - expect(result).toMatch(/\d+ hr ago/); - }); + it('Confirming delete calls adapter and hides the row optimistically', async () => { + let resolveDelete!: () => void; + const deleteSpy = vi.fn(() => new Promise((r) => { resolveDelete = r; })); + const fixture = render({ actions: { delete: deleteSpy } }); + (fixture.nativeElement.querySelector('.chat-thread-list__kebab') as HTMLElement).click(); + fixture.detectChanges(); + const delItem = Array.from(document.querySelectorAll('.chat-overflow-menu__item')).find((el) => (el as HTMLElement).textContent?.trim() === 'Delete') as HTMLElement; + delItem.click(); + fixture.detectChanges(); + (document.querySelector('.chat-confirm-dialog__confirm') as HTMLElement).click(); + fixture.detectChanges(); + const remaining = fixture.nativeElement.querySelectorAll('.chat-thread-list__item'); + expect(remaining.length).toBe(1); + expect(deleteSpy).toHaveBeenCalledWith('t1'); + resolveDelete(); + await new Promise((r) => setTimeout(r, 0)); + }); - it('relativeTime returns "X day ago" for >= 1d delta', () => { - const now = Date.now(); - const result = relativeTime(now - 172_800_000); // 2 day ago - expect(result).toMatch(/\d+ day ago/); + it('Cancelling the confirm dialog does not call adapter', () => { + const deleteSpy = vi.fn().mockResolvedValue(undefined); + const fixture = render({ actions: { delete: deleteSpy } }); + (fixture.nativeElement.querySelector('.chat-thread-list__kebab') as HTMLElement).click(); + fixture.detectChanges(); + const delItem = Array.from(document.querySelectorAll('.chat-overflow-menu__item')).find((el) => (el as HTMLElement).textContent?.trim() === 'Delete') as HTMLElement; + delItem.click(); + fixture.detectChanges(); + (document.querySelector('.chat-confirm-dialog__cancel') as HTMLElement).click(); + fixture.detectChanges(); + expect(deleteSpy).not.toHaveBeenCalled(); + }); }); }); 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 c50fa9a6..b48f0563 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 @@ -2,15 +2,24 @@ // SPDX-License-Identifier: MIT import { Component, + ChangeDetectionStrategy, + ElementRef, + computed, contentChild, input, output, + signal, TemplateRef, - ChangeDetectionStrategy, + viewChild, } from '@angular/core'; import { NgTemplateOutlet } from '@angular/common'; import { CHAT_HOST_TOKENS } from '../../styles/chat-tokens'; import { CHAT_THREAD_LIST_STYLES } from '../../styles/chat-thread-list.styles'; +import { + ChatOverflowMenuComponent, + type OverflowMenuItem, +} from '../chat-overflow-menu/chat-overflow-menu.component'; +import { ChatConfirmDialogComponent } from '../chat-confirm-dialog/chat-confirm-dialog.component'; export type Thread = { id: string; @@ -23,10 +32,24 @@ export type Thread = { [key: string]: unknown; }; +/** + * Per-thread row-action adapter. Consumer-provided. The framework calls + * these methods after user confirmation (delete) or commit (rename) and + * manages optimistic UI + rollback on rejection. + * + * Consumers MUST refresh their `threads` signal on success — the framework + * clears optimistic overrides in a `finally` block, so a successful adapter + * call that leaves the input list unchanged would re-render the row. + */ +export interface ThreadActionAdapter { + delete?(threadId: string): Promise; + rename?(threadId: string, newTitle: string): Promise; +} + @Component({ selector: 'chat-thread-list', standalone: true, - imports: [NgTemplateOutlet], + imports: [NgTemplateOutlet, ChatOverflowMenuComponent, ChatConfirmDialogComponent], changeDetection: ChangeDetectionStrategy.OnPush, styles: [CHAT_HOST_TOKENS, CHAT_THREAD_LIST_STYLES], template: ` @@ -34,13 +57,25 @@ export type Thread = { }
      - @for (thread of threads(); track thread.id) { -
    • + @for (thread of visibleThreads(); track thread.id) { +
    • @if (templateRef()) { + } @else if (editingThreadId() === thread.id) { + } @else { + + @if (showKebab()) { + + } }
    • }
    + + + + `, }) export class ChatThreadListComponent { readonly threads = input.required(); readonly activeThreadId = input(''); readonly showNewThreadButton = input(false); + readonly actions = input(null); readonly threadSelected = output(); readonly newThreadRequested = output(); readonly templateRef = contentChild(TemplateRef); + protected readonly editingThreadId = signal(null); + protected readonly editingValue = signal(''); + protected readonly menuOpenForId = signal(null); + protected readonly menuAnchor = signal(null); + protected readonly confirmDeleteId = signal(null); + + private readonly pendingDeletes = signal>(new Set()); + private readonly pendingRenames = signal>(new Map()); + + protected readonly visibleThreads = computed(() => { + const hidden = this.pendingDeletes(); + 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)); + }); + + protected readonly currentMenuItems = computed(() => { + const id = this.menuOpenForId(); + if (!id) return []; + 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' }); + return items; + }); + + private readonly editInput = viewChild>('editInput'); + selectThread(threadId: string): void { this.threadSelected.emit(threadId); } @@ -87,4 +183,84 @@ export class ChatThreadListComponent { if (delta < 86_400_000) return `${Math.floor(delta / 3_600_000)} hr ago`; return `${Math.floor(delta / 86_400_000)} day ago`; } + + protected showKebab(): boolean { + const a = this.actions(); + if (!a) return false; + return Boolean(a.rename || a.delete); + } + + protected openMenu(threadId: string, anchor: HTMLElement): void { + this.menuAnchor.set(anchor); + this.menuOpenForId.set(threadId); + } + + 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); + } + } + + protected onEditInput(e: Event): void { + this.editingValue.set((e.target as HTMLInputElement).value); + } + + protected cancelRename(): void { + this.editingThreadId.set(null); + } + + protected async commitRename(threadId: string): Promise { + const newTitle = this.editingValue().trim(); + this.editingThreadId.set(null); + if (!newTitle) return; + const a = this.actions(); + if (!a?.rename) return; + + this.pendingRenames.update((m) => { + const n = new Map(m); + n.set(threadId, newTitle); + return n; + }); + try { + await a.rename(threadId, newTitle); + } catch { + // Rollback happens via the finally clear below. + } finally { + this.pendingRenames.update((m) => { + const n = new Map(m); + n.delete(threadId); + return n; + }); + } + } + + protected async performDelete(): Promise { + const threadId = this.confirmDeleteId(); + this.confirmDeleteId.set(null); + if (!threadId) return; + const a = this.actions(); + if (!a?.delete) return; + + this.pendingDeletes.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) => { + const n = new Set(s); + n.delete(threadId); + return n; + }); + } + } } 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 0d622119..474ef658 100644 --- a/libs/chat/src/lib/styles/chat-thread-list.styles.ts +++ b/libs/chat/src/lib/styles/chat-thread-list.styles.ts @@ -51,4 +51,55 @@ export const CHAT_THREAD_LIST_STYLES = ` transition: background 150ms ease; } .chat-thread-list__new:hover { background: var(--ngaf-chat-surface-alt); } + .chat-thread-list__item-wrap { + position: relative; + display: flex; + align-items: center; + gap: 4px; + } + .chat-thread-list__item-wrap .chat-thread-list__item { + flex: 1 1 auto; + min-width: 0; + } + .chat-thread-list__kebab { + flex-shrink: 0; + width: 28px; + height: 28px; + border: 0; + background: transparent; + color: var(--ngaf-chat-text-muted); + border-radius: 4px; + cursor: pointer; + opacity: 0; + transition: opacity 100ms ease; + padding: 0; + line-height: 1; + font-size: 18px; + } + .chat-thread-list__item-wrap:hover .chat-thread-list__kebab, + .chat-thread-list__item-wrap:focus-within .chat-thread-list__kebab { + opacity: 1; + } + .chat-thread-list__kebab:hover { + background: var(--ngaf-chat-surface-alt); + color: var(--ngaf-chat-text); + } + .chat-thread-list__kebab:focus-visible { + opacity: 1; + outline: 2px solid var(--ngaf-chat-primary); + outline-offset: 2px; + } + .chat-thread-list__edit { + flex: 1 1 auto; + border: 1px solid var(--ngaf-chat-primary); + border-radius: var(--ngaf-chat-radius-button); + background: var(--ngaf-chat-bg); + color: var(--ngaf-chat-text); + font: inherit; + font-size: var(--ngaf-chat-font-size-sm); + padding: 6px 10px; + min-height: 36px; + outline: none; + box-sizing: border-box; + } `; From 40d47ba7bab248c8efe4a6943585d06e71f441a0 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 12 May 2026 13:44:05 -0700 Subject: [PATCH 08/13] test(chat): rollback cases for chat-thread-list rename + delete --- .../chat-thread-list.component.spec.ts | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) 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 b51b8f86..89b79fad 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 @@ -155,5 +155,50 @@ describe('ChatThreadListComponent', () => { fixture.detectChanges(); expect(deleteSpy).not.toHaveBeenCalled(); }); + + it('Rename: when adapter rejects, the title reverts after settle', async () => { + const renameSpy = vi.fn(async () => { throw new Error('boom'); }); + const fixture = render({ actions: { rename: renameSpy } }); + (fixture.nativeElement.querySelector('.chat-thread-list__kebab') as HTMLElement).click(); + fixture.detectChanges(); + const renameItem = Array.from(document.querySelectorAll('.chat-overflow-menu__item')) + .find((el) => (el as HTMLElement).textContent?.trim() === 'Rename') as HTMLElement; + renameItem.click(); + fixture.detectChanges(); + await new Promise((r) => queueMicrotask(() => r(undefined))); + fixture.detectChanges(); + const input = fixture.nativeElement.querySelector('.chat-thread-list__edit') as HTMLInputElement; + input.value = 'BadRename'; + input.dispatchEvent(new Event('input', { bubbles: true })); + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + // Wait for the rejection + finally clear to settle. + await new Promise((r) => setTimeout(r, 0)); + await new Promise((r) => setTimeout(r, 0)); + fixture.detectChanges(); + // The visible title should be back to the original (the pending override has been cleared). + const firstItemTitle = fixture.nativeElement.querySelectorAll('.chat-thread-list__item-title')[0] as HTMLElement; + expect(firstItemTitle.textContent?.trim()).toBe('First'); + expect(renameSpy).toHaveBeenCalledWith('t1', 'BadRename'); + }); + + it('Delete: when adapter rejects, the hidden row reappears', async () => { + const deleteSpy = vi.fn(async () => { throw new Error('boom'); }); + const fixture = render({ actions: { delete: deleteSpy } }); + (fixture.nativeElement.querySelector('.chat-thread-list__kebab') as HTMLElement).click(); + fixture.detectChanges(); + const delItem = Array.from(document.querySelectorAll('.chat-overflow-menu__item')) + .find((el) => (el as HTMLElement).textContent?.trim() === 'Delete') as HTMLElement; + delItem.click(); + fixture.detectChanges(); + (document.querySelector('.chat-confirm-dialog__confirm') as HTMLElement).click(); + fixture.detectChanges(); + // Wait for the rejection + finally clear to settle. + 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); + expect(deleteSpy).toHaveBeenCalledWith('t1'); + }); }); }); From fd6f8fbf425f5a0a6b4453955cc49488db48e58d Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 12 May 2026 13:45:51 -0700 Subject: [PATCH 09/13] feat(chat): chat-sidenav forwards actions input to thread list Co-Authored-By: Claude Sonnet 4.6 --- .../compositions/chat-sidenav/chat-sidenav.component.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/libs/chat/src/lib/compositions/chat-sidenav/chat-sidenav.component.ts b/libs/chat/src/lib/compositions/chat-sidenav/chat-sidenav.component.ts index 609369d1..ce0e636f 100644 --- a/libs/chat/src/lib/compositions/chat-sidenav/chat-sidenav.component.ts +++ b/libs/chat/src/lib/compositions/chat-sidenav/chat-sidenav.component.ts @@ -12,7 +12,11 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { fromEvent } from 'rxjs'; import { CHAT_HOST_TOKENS } from '../../styles/chat-tokens'; import { CHAT_SIDENAV_STYLES } from '../../styles/chat-sidenav.styles'; -import { ChatThreadListComponent, type Thread } from '../../primitives/chat-thread-list/chat-thread-list.component'; +import { + ChatThreadListComponent, + type Thread, + type ThreadActionAdapter, +} from '../../primitives/chat-thread-list/chat-thread-list.component'; export type ChatSidenavMode = 'expanded' | 'collapsed' | 'drawer'; @@ -84,6 +88,7 @@ export type ChatSidenavMode = 'expanded' | 'collapsed' | 'drawer'; @@ -104,6 +109,7 @@ export class ChatSidenavComponent { readonly open = input(false); readonly threads = input(null); readonly activeThreadId = input(null); + readonly actions = input(null); readonly newChat = output(); readonly threadSelected = output(); From 9a0e77b44393d8df5ba1609671d3b82d9aa14986 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 12 May 2026 13:45:53 -0700 Subject: [PATCH 10/13] feat(chat): export overflow menu, confirm dialog, and ThreadActionAdapter Co-Authored-By: Claude Sonnet 4.6 --- libs/chat/src/public-api.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/libs/chat/src/public-api.ts b/libs/chat/src/public-api.ts index e54ec35b..dde8d916 100644 --- a/libs/chat/src/public-api.ts +++ b/libs/chat/src/public-api.ts @@ -47,6 +47,9 @@ export { ChatSuggestionsComponent } from './lib/primitives/chat-suggestions/chat export { ChatInputComponent, submitMessage } from './lib/primitives/chat-input/chat-input.component'; export { ChatTypingIndicatorComponent, isTyping } from './lib/primitives/chat-typing-indicator/chat-typing-indicator.component'; export { ChatHistorySearchPaletteComponent } from './lib/primitives/chat-history-search-palette/chat-history-search-palette.component'; +export { ChatOverflowMenuComponent } from './lib/primitives/chat-overflow-menu/chat-overflow-menu.component'; +export type { OverflowMenuItem } from './lib/primitives/chat-overflow-menu/chat-overflow-menu.component'; +export { ChatConfirmDialogComponent } from './lib/primitives/chat-confirm-dialog/chat-confirm-dialog.component'; export type { ThreadMatch } from './lib/primitives/chat-history-search-palette/chat-history-search-palette.component'; export { ChatScrollBubbleComponent } from './lib/primitives/chat-scroll-bubble/chat-scroll-bubble.component'; export type { ChatScrollBubbleMode } from './lib/primitives/chat-scroll-bubble/chat-scroll-bubble.component'; @@ -57,7 +60,7 @@ export { ChatToolCallTemplateDirective } from './lib/primitives/chat-tool-calls/ export type { ChatToolCallTemplateContext } from './lib/primitives/chat-tool-calls/chat-tool-call-template.directive'; export { ChatSubagentsComponent } from './lib/primitives/chat-subagents/chat-subagents.component'; export { ChatThreadListComponent } from './lib/primitives/chat-thread-list/chat-thread-list.component'; -export type { Thread } from './lib/primitives/chat-thread-list/chat-thread-list.component'; +export type { Thread, ThreadActionAdapter } from './lib/primitives/chat-thread-list/chat-thread-list.component'; export { ChatCheckpointMarkerComponent } from './lib/primitives/chat-checkpoint-marker/chat-checkpoint-marker.component'; export { ChatGenuiSkeletonComponent } from './lib/primitives/chat-genui-skeleton/chat-genui-skeleton.component'; export { ChatTimelineComponent } from './lib/primitives/chat-timeline/chat-timeline.component'; From 976a348f9b69052cae8194bd828fb900aae60f83 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 12 May 2026 13:47:02 -0700 Subject: [PATCH 11/13] feat(examples-chat): threads.service.ts gains delete + rename --- .../angular/src/app/shell/threads.service.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/examples/chat/angular/src/app/shell/threads.service.ts b/examples/chat/angular/src/app/shell/threads.service.ts index ef6e3710..278328cf 100644 --- a/examples/chat/angular/src/app/shell/threads.service.ts +++ b/examples/chat/angular/src/app/shell/threads.service.ts @@ -42,6 +42,22 @@ 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.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.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 { From 4561d48f526efc61e65983267b5c3a6b78ff6770 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 12 May 2026 13:47:05 -0700 Subject: [PATCH 12/13] feat(examples-chat): wire threadActions adapter on chat-sidenav --- .../angular/src/app/shell/demo-shell.component.html | 1 + .../angular/src/app/shell/demo-shell.component.ts | 12 ++++++++++++ 2 files changed, 13 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 9d58c818..78fdd7a6 100644 --- a/examples/chat/angular/src/app/shell/demo-shell.component.html +++ b/examples/chat/angular/src/app/shell/demo-shell.component.html @@ -12,6 +12,7 @@ [activeThreadId]="threadIdSignal() ?? ''" [mode]="sidenavMode()" [(open)]="drawerOpen" + [actions]="threadActions" (newChat)="onNewThread()" (threadSelected)="onThreadSelected($event)" (searchOpened)="paletteOpen.set(true)" 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 637a2c03..dcfa0874 100644 --- a/examples/chat/angular/src/app/shell/demo-shell.component.ts +++ b/examples/chat/angular/src/app/shell/demo-shell.component.ts @@ -27,6 +27,7 @@ import { type ChatSidenavMode, type InterruptAction, type ThreadMatch, + type ThreadActionAdapter, } from '@ngaf/chat'; import { PalettePersistence } from './palette-persistence.service'; import { ThreadsService } from './threads.service'; @@ -215,6 +216,17 @@ export class DemoShell { /** Persisted thread id (null on first run). Reactive so reload reconnects to the same thread. */ protected readonly threadIdSignal = signal(this.persistence.read('threadId') ?? null); + protected readonly threadActions: ThreadActionAdapter = { + delete: async (id) => { + await this.threadsSvc.delete(id); + if (this.threadIdSignal() === id) { + this.threadIdSignal.set(null); + this.persistence.write('threadId', null); + } + }, + rename: (id, title) => this.threadsSvc.rename(id, title), + }; + /** * Shared agent instance. Patched submit injects state.model on every * submission so the graph picks up the latest model selection without From 48727e0963a5a8ea0efadc14446bfc6544cb3024 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 12 May 2026 13:47:29 -0700 Subject: [PATCH 13/13] docs(chat): regenerate API docs for row actions Co-Authored-By: Claude Opus 4.7 (1M context) --- .../content/docs/chat/api/api-docs.json | 339 ++++++++++++++++++ 1 file changed, 339 insertions(+) diff --git a/apps/website/content/docs/chat/api/api-docs.json b/apps/website/content/docs/chat/api/api-docs.json index dd148174..c07987a3 100644 --- a/apps/website/content/docs/chat/api/api-docs.json +++ b/apps/website/content/docs/chat/api/api-docs.json @@ -1921,6 +1921,90 @@ } ] }, + { + "name": "ChatConfirmDialogComponent", + "kind": "class", + "description": "", + "params": [], + "examples": [], + "properties": [ + { + "name": "body", + "type": "InputSignal", + "description": "", + "optional": false + }, + { + "name": "bodyId", + "type": "string", + "description": "", + "optional": false + }, + { + "name": "cancelLabel", + "type": "InputSignal", + "description": "", + "optional": false + }, + { + "name": "cancelled", + "type": "OutputEmitterRef", + "description": "", + "optional": false + }, + { + "name": "confirmed", + "type": "OutputEmitterRef", + "description": "", + "optional": false + }, + { + "name": "confirmLabel", + "type": "InputSignal", + "description": "", + "optional": false + }, + { + "name": "open", + "type": "InputSignal", + "description": "", + "optional": false + }, + { + "name": "title", + "type": "InputSignal", + "description": "", + "optional": false + }, + { + "name": "titleId", + "type": "string", + "description": "", + "optional": false + }, + { + "name": "tone", + "type": "InputSignal<\"normal\" | \"destructive\">", + "description": "", + "optional": false + } + ], + "methods": [ + { + "name": "onDialogKeydown", + "signature": "onDialogKeydown(e: KeyboardEvent)", + "description": "", + "params": [ + { + "name": "e", + "type": "KeyboardEvent", + "description": "", + "optional": false + } + ] + } + ] + }, { "name": "ChatDebugActionComponent", "kind": "class", @@ -2879,6 +2963,79 @@ } ] }, + { + "name": "ChatOverflowMenuComponent", + "kind": "class", + "description": "", + "params": [], + "examples": [], + "properties": [ + { + "name": "anchor", + "type": "InputSignal", + "description": "Element the menu anchors against (positions just below its bottom-right corner).", + "optional": false + }, + { + "name": "closed", + "type": "OutputEmitterRef", + "description": "", + "optional": false + }, + { + "name": "items", + "type": "InputSignal", + "description": "", + "optional": false + }, + { + "name": "itemSelected", + "type": "OutputEmitterRef", + "description": "", + "optional": false + }, + { + "name": "open", + "type": "InputSignal", + "description": "", + "optional": false + }, + { + "name": "position", + "type": "Signal", + "description": "", + "optional": false + } + ], + "methods": [ + { + "name": "onItemClick", + "signature": "onItemClick(item: OverflowMenuItem)", + "description": "", + "params": [ + { + "name": "item", + "type": "OverflowMenuItem", + "description": "", + "optional": false + } + ] + }, + { + "name": "onMenuKeydown", + "signature": "onMenuKeydown(e: KeyboardEvent)", + "description": "", + "params": [ + { + "name": "e", + "type": "KeyboardEvent", + "description": "", + "optional": false + } + ] + } + ] + }, { "name": "ChatPopupComponent", "kind": "class", @@ -3219,6 +3376,12 @@ "params": [], "examples": [], "properties": [ + { + "name": "actions", + "type": "InputSignal", + "description": "", + "optional": false + }, { "name": "activeThreadId", "type": "InputSignal", @@ -3402,12 +3565,54 @@ "params": [], "examples": [], "properties": [ + { + "name": "actions", + "type": "InputSignal", + "description": "", + "optional": false + }, { "name": "activeThreadId", "type": "InputSignal", "description": "", "optional": false }, + { + "name": "confirmDeleteId", + "type": "WritableSignal", + "description": "", + "optional": false + }, + { + "name": "currentMenuItems", + "type": "Signal", + "description": "", + "optional": false + }, + { + "name": "editingThreadId", + "type": "WritableSignal", + "description": "", + "optional": false + }, + { + "name": "editingValue", + "type": "WritableSignal", + "description": "", + "optional": false + }, + { + "name": "menuAnchor", + "type": "WritableSignal", + "description": "", + "optional": false + }, + { + "name": "menuOpenForId", + "type": "WritableSignal", + "description": "", + "optional": false + }, { "name": "newThreadRequested", "type": "OutputEmitterRef", @@ -3437,9 +3642,85 @@ "type": "OutputEmitterRef", "description": "", "optional": false + }, + { + "name": "visibleThreads", + "type": "Signal", + "description": "", + "optional": false } ], "methods": [ + { + "name": "cancelRename", + "signature": "cancelRename()", + "description": "", + "params": [] + }, + { + "name": "commitRename", + "signature": "commitRename(threadId: string)", + "description": "", + "params": [ + { + "name": "threadId", + "type": "string", + "description": "", + "optional": false + } + ] + }, + { + "name": "onEditInput", + "signature": "onEditInput(e: Event)", + "description": "", + "params": [ + { + "name": "e", + "type": "Event", + "description": "", + "optional": false + } + ] + }, + { + "name": "onMenuAction", + "signature": "onMenuAction(id: string)", + "description": "", + "params": [ + { + "name": "id", + "type": "string", + "description": "", + "optional": false + } + ] + }, + { + "name": "openMenu", + "signature": "openMenu(threadId: string, anchor: HTMLElement)", + "description": "", + "params": [ + { + "name": "threadId", + "type": "string", + "description": "", + "optional": false + }, + { + "name": "anchor", + "type": "HTMLElement", + "description": "", + "optional": false + } + ] + }, + { + "name": "performDelete", + "signature": "performDelete()", + "description": "", + "params": [] + }, { "name": "relativeTime", "signature": "relativeTime(epochMs: number)", @@ -3466,6 +3747,12 @@ } ] }, + { + "name": "showKebab", + "signature": "showKebab()", + "description": "", + "params": [] + }, { "name": "threadLabel", "signature": "threadLabel(thread: Thread)", @@ -5397,6 +5684,38 @@ ], "examples": [] }, + { + "name": "OverflowMenuItem", + "kind": "interface", + "description": "", + "properties": [ + { + "name": "disabled", + "type": "boolean", + "description": "Disabled items render muted and ignore clicks/keypresses.", + "optional": true + }, + { + "name": "id", + "type": "string", + "description": "Stable id emitted via (itemSelected).", + "optional": false + }, + { + "name": "label", + "type": "string", + "description": "", + "optional": false + }, + { + "name": "tone", + "type": "\"normal\" | \"destructive\"", + "description": "'destructive' renders the label in red. Default 'normal'.", + "optional": true + } + ], + "examples": [] + }, { "name": "ParseTreeStore", "kind": "interface", @@ -5521,6 +5840,26 @@ ], "examples": [] }, + { + "name": "ThreadActionAdapter", + "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": "delete", + "type": "unknown", + "description": "", + "optional": true + }, + { + "name": "rename", + "type": "unknown", + "description": "", + "optional": true + } + ], + "examples": [] + }, { "name": "ThreadMatch", "kind": "interface",