From 85dbddc7629e81320ca4eac4d8dc411ad470643b Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 12 May 2026 16:13:14 -0700 Subject: [PATCH 01/16] =?UTF-8?q?docs(specs):=20chat=20projects=20?= =?UTF-8?q?=E2=80=94=20Phase=204=20design?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Full Projects scope: Project type + ProjectActionAdapter; new chat-project-list primitive; chat-sidenav Projects section with "+ New project"; thread-project association via Thread.projectId?; "Move to project" thread row action via a second overflow-menu submenu. Example wires localStorage-backed ProjectsService. Defers per-project instructions / files / sharing. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../specs/2026-05-12-chat-projects-design.md | 612 ++++++++++++++++++ 1 file changed, 612 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-12-chat-projects-design.md diff --git a/docs/superpowers/specs/2026-05-12-chat-projects-design.md b/docs/superpowers/specs/2026-05-12-chat-projects-design.md new file mode 100644 index 00000000..3b583f05 --- /dev/null +++ b/docs/superpowers/specs/2026-05-12-chat-projects-design.md @@ -0,0 +1,612 @@ +# Chat projects — Phase 4 design + +**Date:** 2026-05-12 +**Surface:** `@ngaf/chat` — new `chat-project-list` primitive; extends `chat-thread-list`, `chat-sidenav`; example wiring with localStorage-backed `ProjectsService`. +**Status:** Design approved; ready for implementation plan + +## Summary + +Introduce Projects as a first-class navigation surface in `@ngaf/chat`. The sidenav gains a Projects section between the primary slot and the Recent thread list. Projects can be created (inline), renamed (inline), and deleted (with confirmation). Threads can be associated with a project at creation time AND moved between projects via a per-row submenu. Selecting a project filters the visible threads to that project. + +Out of scope for this phase: per-project instructions / per-project files / project sharing / membership states. + +## Goals + +- `Project` model + `ProjectActionAdapter` mirror Thread/Adapter patterns (consumer-controlled persistence; framework stays dumb about storage). +- Sidenav exposes Projects above Recent with a "+ New project" affordance, click-to-select navigation, and hover-revealed kebab for Rename/Delete. +- Threads get an optional `projectId` field and a "Move to project" menu item that opens a second overflow menu listing projects + "No project" choice. +- Example uses localStorage to persist projects; LangGraph has no native projects API and a real backend is out of scope for this PR. +- All UX patterns reuse existing primitives (`chat-overflow-menu`, `chat-confirm-dialog`, inline-rename). + +## Non-goals + +- Per-project system prompt / instructions. +- Per-project attached files. +- Project sharing / membership / roles. +- Project icon / color customization (Project shape is open; consumers can add fields but framework doesn't render them). +- Server-side projects backend (the example uses localStorage; consumers with backends bring their own adapter). +- Drag-to-reorder projects or threads within a project. +- "Move multiple threads" bulk action. + +## Decomposition note + +The user explicitly approved scope **C** (full Projects sans instructions/files), then expanded scope back to include **Move-to-project** after recognizing the foundation gap. This spec covers the full landing: Project CRUD + sidenav section + thread filtering + thread move-to-project. + +## File map + +**Create:** +- `libs/chat/src/lib/primitives/chat-project-list/chat-project-list.component.ts` +- `libs/chat/src/lib/primitives/chat-project-list/chat-project-list.component.spec.ts` +- `libs/chat/src/lib/styles/chat-project-list.styles.ts` +- `examples/chat/angular/src/app/shell/projects.service.ts` + +**Modify:** +- `libs/chat/src/lib/primitives/chat-thread-list/chat-thread-list.component.ts` — `Thread.projectId?`, `ThreadActionAdapter.moveToProject?`, `projects` input, `moveMenuOpenForId` state, `moveMenuItems` computed, `performMoveToProject` handler, second `` instance for the move submenu, "Move to project" entry in the main menu. +- `libs/chat/src/lib/primitives/chat-thread-list/chat-thread-list.component.spec.ts` — coverage for the move-to-project flow. +- `libs/chat/src/lib/compositions/chat-sidenav/chat-sidenav.component.ts` — `projects`, `selectedProjectId`, `projectActions` inputs; `(projectSelected)`, `(newProjectRequested)` outputs; Projects section in template; forwards `projects` to inner `chat-thread-list` so the move submenu can list them. +- `libs/chat/src/lib/compositions/chat-sidenav/chat-sidenav.component.spec.ts` — Projects section tests. +- `libs/chat/src/lib/styles/chat-sidenav.styles.ts` — Projects heading styles (matches Archived heading pattern). +- `libs/chat/src/public-api.ts` — export `Project`, `ProjectActionAdapter`, `ChatProjectListComponent`. +- `examples/chat/angular/src/app/shell/demo-shell.component.ts` — define `projectActions`, `selectedProjectId` signal, partition threads by project for the threads input, wire bindings. +- `examples/chat/angular/src/app/shell/demo-shell.component.html` — bind new sidenav inputs/outputs. +- `examples/chat/angular/src/app/shell/threads.service.ts` — `toThread()` reads `metadata.projectId` to populate `Thread.projectId`; new `moveToProject(threadId, projectId | null)` method patches metadata. +- `apps/website/content/docs/chat/api/api-docs.json` — regenerated. + +## Public types + +### Additions + +```ts +export type Project = { + id: string; + name: string; + /** Open shape — consumers may add icon, color, createdAt, etc. */ + [key: string]: unknown; +}; + +export interface ProjectActionAdapter { + /** Create a new project. Returns the new project id; consumer is expected + * to also refresh its projects signal. */ + create?(name: string): Promise<{ id: string }>; + rename?(projectId: string, newName: string): Promise; + /** Permanently delete the project. The framework calls this AFTER user + * confirms via the confirm dialog. */ + delete?(projectId: string): Promise; +} +``` + +### Extensions + +```ts +export type Thread = { + // ... existing fields + /** Optional project association. Consumers pre-filter threads by project + * before passing to the sidenav. */ + projectId?: string | null; + // [key: string]: unknown stays last +}; + +export interface ThreadActionAdapter { + // ... existing + /** Move the thread to a project, or pass null to remove from any project. */ + moveToProject?(threadId: string, projectId: string | null): Promise; +} +``` + +## `chat-project-list` primitive + +Sibling of `chat-thread-list`. Simpler row content (no time, no streaming indicators), reuses the same overflow-menu + confirm-dialog primitives, mirrors the inline-rename pattern. + +### API + +```ts +@Component({ selector: 'chat-project-list', standalone: true }) +export class ChatProjectListComponent { + readonly projects = input.required(); + readonly activeProjectId = input(null); + readonly showNewProjectButton = input(false); + readonly actions = input(null); + + readonly projectSelected = output(); + readonly newProjectRequested = output(); +} +``` + +### Template (sketch) + +```html +@if (showNewProjectButton()) { + +} +
    + @if (creatingProject()) { +
  • + +
  • + } + @for (project of visibleProjects(); track project.id) { +
  • + @if (editingProjectId() === project.id) { + + } @else { + + @if (showKebab()) { + + } + } +
  • + } +
+ + + +``` + +### State (internal) + +- `creatingProject: signal(false)` — shows the inline-create input row at the top of the list. +- `creatingValue: signal('')` +- `editingProjectId`, `editingValue`, `menuOpenForId`, `menuAnchor`, `confirmDeleteId` — analogous to chat-thread-list. +- `pendingHidden: signal>`, `pendingRenames: signal>` — same pattern. +- `visibleProjects = computed(() => ...)` — applies pending hidden + renames over `projects()`. + +### Menu items + +```ts +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; +}); +``` + +(No "Leave" action this phase; defer with project membership.) + +### Behavior + +- `+ New project` → `onNewProjectClicked` sets `creatingProject = true`, emits `newProjectRequested`. Then focuses the new-input. +- `commitCreate()` → if `actions.create` exists, await it; the consumer is expected to refresh its `projects` signal after success. Framework exits `creatingProject` mode regardless. (Optimistic insert is NOT done because we need the server-assigned id; the visible row appears once the consumer's signal refreshes — typically <50ms with the in-process localStorage example.) +- `cancelCreate()` → exits creating mode. No-op if `creatingValue.length === 0`. +- `commitRename()`, `performDelete()` — analogous to chat-thread-list (optimistic + rollback). +- `selectProject(id)` → emits `projectSelected(id)`. Visual highlight via `[data-active]`. +- `showKebab()` returns `Boolean(actions().rename || actions().delete)`. + +### Styles + +Mirrors `chat-thread-list.styles.ts`. Row class names: `chat-project-list__item`, `chat-project-list__item-wrap`, `chat-project-list__kebab`, `chat-project-list__edit`, `chat-project-list__new`. Same hover-reveal-kebab pattern. Active row gets the same left-border accent as active thread rows. + +### Tests (~10 it() cases) + +- Renders rows from `projects` input. +- Click row emits `projectSelected`. +- `activeProjectId` match → `data-active="true"` attribute. +- `showNewProjectButton=false` → no + New project button. +- `showNewProjectButton=true` + click → emits `newProjectRequested` + enters creating mode (input appears). +- Type + Enter → calls `actions.create(name)`. +- Type + Esc / blur → exits creating mode, no call. +- Rename via kebab → inline editable input prefilled with current name; Enter calls adapter; Esc reverts. +- Delete via kebab → opens confirm dialog (destructive); confirm calls adapter + optimistic hide; cancel no-op. +- Delete adapter rejects → row reappears. + +## `chat-thread-list` extensions + +### Adapter + Type + +`Thread.projectId?: string | null` and `ThreadActionAdapter.moveToProject?` per Public Types above. + +### New input + +```ts +readonly projects = input(null); +``` + +When non-null AND `actions.moveToProject` is provided, the row's main overflow menu gains a "Move to project" entry. When clicked, it opens a second overflow menu (the move submenu). + +### Menu wiring + +Update `currentMenuItems` to include "Move to project" entry in active mode: + +```ts +if (this.mode() === 'active') { + if (a.rename) items.push({ id: 'rename', label: 'Rename' }); + if (a.pin && !isPinned) items.push({ id: 'pin', label: 'Pin' }); // from Phase 3d (spawned) + if (a.unpin && isPinned) items.push({ id: 'unpin', label: 'Unpin' }); // from Phase 3d + if (a.moveToProject && this.projects() !== null) { + items.push({ id: 'move', label: 'Move to project' }); + } + if (a.archive) items.push({ id: 'archive', label: 'Archive' }); + if (a.delete) items.push({ id: 'delete', label: 'Delete', tone: 'destructive' }); +} +``` + +The "Move to project" item only appears when BOTH the adapter method is provided AND a project list is supplied. Without a project list there's nothing to move to. + +### Move submenu + +Second overflow menu instance, separate state: + +```ts +protected readonly moveMenuOpenForId = signal(null); + +protected readonly moveMenuItems = computed(() => { + if (!this.moveMenuOpenForId()) return []; + const list: OverflowMenuItem[] = [{ id: '__none__', label: 'No project' }]; + for (const p of this.projects() ?? []) { + list.push({ id: p.id, label: p.name }); + } + return list; +}); +``` + +In `onMenuAction('move')`: + +```ts +this.menuOpenForId.set(null); +this.moveMenuOpenForId.set(threadId); +``` + +In a new `onMoveMenuAction`: + +```ts +protected onMoveMenuAction(itemId: string): void { + const threadId = this.moveMenuOpenForId(); + this.moveMenuOpenForId.set(null); + if (!threadId) return; + const projectId = itemId === '__none__' ? null : itemId; + void this.performMoveToProject(threadId, projectId); +} +``` + +`performMoveToProject` mirrors `performArchive` — optimistic hide via `pendingHidden` (the row leaves the current project's visible list because the consumer's threads input re-partitions on refresh). + +Template renders TWO `` elements: + +```html + + + + + +``` + +The two menus never appear simultaneously (clicking "Move" closes the first, opens the second). Same anchor for both, so the second menu pops at the same screen position. Acceptable visual continuity. + +### showKebab update + +Add `a.moveToProject && this.projects() !== null` as a contributor in active mode: + +```ts +if (this.mode() === 'active') { + return Boolean( + a.rename || a.pin || a.unpin || a.archive || a.delete || + (a.moveToProject && this.projects() !== null) + ); +} +``` + +### New tests (~4 it() cases) + +- `actions.moveToProject` provided + `projects=[]` (empty array, non-null) → menu includes "Move to project"; submenu has only "No project". +- `actions.moveToProject` provided + `projects=null` → menu does NOT include "Move to project". +- Click "Move to project" → main menu closes, move submenu opens with project items. +- Click a project in the move submenu → calls `actions.moveToProject(threadId, projectId)`, row hidden optimistically. +- Click "No project" in the move submenu → calls `actions.moveToProject(threadId, null)`. + +## `chat-sidenav` extensions + +### New inputs / outputs + +```ts +readonly projects = input(null); +readonly selectedProjectId = input(null); +readonly projectActions = input(null); + +readonly projectSelected = output(); +readonly newProjectRequested = output(); +``` + +### Template + +Insert Projects section between `[sidenavPrimary]` slot and the Recent block: + +```html +
+ +
+ +@if (projects() !== null) { +
+
Projects
+ +
+} + +@if (threads() !== null) { +
+ + + ... +``` + +The same `projects` input is forwarded to BOTH the project list (for navigation/CRUD) AND the thread list (for the move submenu). + +### Styles + +Reuse the existing `.chat-sidenav__threads-heading` style for the "Projects" label. Add a wrapper: + +```css +.chat-sidenav__projects { flex-shrink: 0; } +:host([data-mode="collapsed"]) .chat-sidenav__projects { display: none; } +``` + +### Tests (~4 it() cases) + +- `projects=null` → no Projects heading. +- `projects=[p1,p2]` → heading renders + `` with 2 rows. +- `selectedProjectId='p1'` → that row carries `data-active="true"`. +- `projectActions.create` set → "+ New project" button shows; click emits `newProjectRequested`. + +## Example wiring + +### `ProjectsService` (NEW — localStorage-backed) + +```ts +// examples/chat/angular/src/app/shell/projects.service.ts +import { Injectable, signal } from '@angular/core'; +import type { Project } from '@ngaf/chat'; + +const STORAGE_KEY = 'ngaf-example-projects-v1'; + +@Injectable({ providedIn: 'root' }) +export class ProjectsService { + readonly projects = signal(this.load()); + + async create(name: string): Promise<{ id: string }> { + const id = (typeof crypto !== 'undefined' && 'randomUUID' in crypto) + ? crypto.randomUUID() + : `proj-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; + this.projects.update((p) => [{ id, name }, ...p]); + this.save(this.projects()); + return { id }; + } + + async rename(id: string, name: string): Promise { + this.projects.update((p) => p.map((x) => x.id === id ? { ...x, name } : x)); + this.save(this.projects()); + } + + async delete(id: string): Promise { + this.projects.update((p) => p.filter((x) => x.id !== id)); + this.save(this.projects()); + // Threads associated with this project become "orphaned" (their projectId + // still points at a deleted project). Acceptable for the demo; a real + // backend would either cascade-clear or block deletion. + } + + private load(): Project[] { + if (typeof localStorage === 'undefined') return []; + try { return JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '[]'); } catch { return []; } + } + private save(p: Project[]): void { + if (typeof localStorage === 'undefined') return; + localStorage.setItem(STORAGE_KEY, JSON.stringify(p)); + } +} +``` + +### `ThreadsService` — read + write `projectId` + +`toThread()` extends to read `meta.projectId`: + +```ts +const projectId = typeof meta.projectId === 'string' && meta.projectId.length > 0 + ? meta.projectId + : null; +return { id: t.thread_id, title: ..., status: ..., projectId }; +``` + +New method on `ThreadsService`: + +```ts +async moveToProject(threadId: string, projectId: string | null): Promise { + await this.client.threads.update(threadId, { metadata: { projectId } }); + await this.refresh(); +} +``` + +### `demo-shell.component.ts` + +```ts +protected readonly selectedProjectId = signal(this.persistence.read('selectedProjectId')); + +protected readonly visibleThreads = computed(() => { + const sel = this.selectedProjectId(); + const all = this.threadsSvc.threads(); + return sel === null ? all : all.filter((t) => t.projectId === sel); +}); + +protected readonly projectActions: ProjectActionAdapter = { + create: async (name) => { + const r = await this.projectsSvc.create(name); + this.selectedProjectId.set(r.id); + this.persistence.write('selectedProjectId', r.id); + return r; + }, + rename: (id, name) => this.projectsSvc.rename(id, name), + delete: async (id) => { + await this.projectsSvc.delete(id); + if (this.selectedProjectId() === id) { + this.selectedProjectId.set(null); + this.persistence.write('selectedProjectId', null); + } + }, +}; + +// Extend threadActions with moveToProject +protected readonly threadActions: ThreadActionAdapter = { + // ... existing delete/rename/archive/unarchive (+ pin/unpin if Phase 3d landed) + moveToProject: (id, projectId) => this.threadsSvc.moveToProject(id, projectId), +}; + +protected onProjectSelected(projectId: string): void { + this.selectedProjectId.set(projectId); + this.persistence.write('selectedProjectId', projectId); +} + +// Update onNewThread to start the new thread in the selected project +protected async onNewThread(): Promise { + const sel = this.selectedProjectId(); + // ThreadsService.create() will need an optional projectId param; + // if so, pass it. Otherwise the metadata can be set in a follow-up update. + await this.threadsSvc.create(sel ?? undefined); +} +``` + +`ThreadsService.create()` accepts an optional `projectId` and stamps it in metadata: + +```ts +async create(projectId?: string): Promise { + try { + const t = await this.client.threads.create({ + metadata: projectId !== undefined ? { projectId } : {}, + }); + await this.refresh(); + return t.thread_id; + } catch { + return null; + } +} +``` + +### `demo-shell.component.html` + +```html + +``` + +(`onNewProjectClicked` can be a no-op stub if the project list owns the inline-create flow; the event is mostly informational for the consumer to know a create was triggered.) + +## Edge cases + +1. **Deleting a project with associated threads.** Threads keep their stale `projectId` after the project is gone. Visible-threads computed (filtering by `selectedProjectId === t.projectId`) returns no rows for the deleted project's id. Threads become "orphaned" but reachable via the unfiltered "no project selected" view. Acceptable for demo; real backend should cascade. +2. **Selecting a project that no longer exists** (race: another tab deleted it). The visible-threads computed returns `[]`; the sidenav shows an empty Recent list. Consumer can detect (`selectedProjectId() && !projects().some(p => p.id === sel)`) and reset, but framework doesn't. +3. **Creating a project mid-rename.** The `creatingProject` and `editingProjectId` signals are independent; both can be set simultaneously. UX-wise, mid-rename + click-new should commit/cancel the rename first. The framework does NOT enforce this; consumers / future polish can. +4. **Move-to-project on a thread that's already in that project.** Adapter call is a no-op move (same projectId). Server still receives the PATCH; thread stays in the list. Acceptable. +5. **Move-to-project on a thread when `projects=[]` (empty array, not null).** Submenu shows only "No project". Move to "No project" works (clears the projectId). No infinite-loop bug since the framework doesn't re-trigger menus. +6. **Active thread is moved out of the currently-selected project.** Visible-threads filter immediately excludes it; the chat view should switch to welcome state. Consumer's `onMoveProject` handler is responsible for that side-effect (or the existing reactive-thread-id flow handles it). +7. **`crypto.randomUUID()` unavailable** (older browsers, SSR). Fallback in `ProjectsService.create()` uses timestamp + random suffix. Documented. + +## Testing + +### Unit tests + +- `chat-project-list.component.spec.ts`: ~10 cases per the primitive section. +- `chat-thread-list.component.spec.ts` (additions): ~4 cases for the move-to-project flow. +- `chat-sidenav.component.spec.ts` (additions): ~4 cases for the Projects section. + +### Manual (Chrome MCP) + +1. Initial: sidenav renders empty Projects section heading (no projects yet) + "+ New project" button if adapter has create. +2. Click "+ New project" → inline input appears at top of the projects area, focused. +3. Type "Work" + Enter → row appears with name "Work"; `selectedProjectId` set to the new project. +4. Click "Work" → project row gets active-highlight. +5. With "Work" selected, click "+ New chat" → creates a thread with `metadata.projectId='Work-id'`. Thread appears in the Recent list (visible because filter matches). +6. Hover the new thread → kebab fades in. Click kebab → menu includes "Move to project". +7. Click "Move to project" → main menu closes, move submenu opens with "No project" + "Work". +8. Click "No project" → thread vanishes from Recent (no longer matches the filter). Click the Projects heading area to switch to "no project selected" view → thread visible. +9. Rename "Work" → "Personal". Row updates. Selected project's name updates everywhere. +10. Delete "Work" via the kebab → confirm dialog appears (destructive). Confirm → project gone. Threads previously in "Work" are orphaned (still in LangGraph but their `metadata.projectId` references the deleted id). +11. Reload page → projects persist via localStorage. Selected project persists via the existing `PalettePersistence`. + +### Build / lint + +- `nx run chat:test` — passes (existing + ~18 new). +- `nx run chat:build && nx lint chat` — clean. +- `nx run examples-chat-angular:build` — clean. +- `nx run examples-chat-angular` (dev server) — clean compile, no console errors. + +## Accessibility + +- Project rows are ` + } +
    + @if (creatingProject()) { +
  • + +
  • + } + @for (project of visibleProjects(); track project.id) { +
  • + @if (editingProjectId() === project.id) { + + } @else { + + + @if (showKebab()) { + + } + } +
  • + } +
+ + + + + `, +}) +export class ChatProjectListComponent { + readonly projects = input.required(); + readonly activeProjectId = input(null); + readonly showNewProjectButton = input(false); + readonly actions = input(null); + + readonly projectSelected = output(); + readonly newProjectRequested = output(); + + protected readonly creatingProject = signal(false); + protected readonly creatingValue = signal(''); + protected readonly editingProjectId = signal(null); + protected readonly editingValue = signal(''); + protected readonly menuOpenForId = signal(null); + protected readonly menuAnchor = signal(null); + protected readonly confirmDeleteId = signal(null); + + private readonly pendingHidden = signal>(new Set()); + private readonly pendingRenames = signal>(new Map()); + + protected readonly visibleProjects = computed(() => { + const hidden = this.pendingHidden(); + const renames = this.pendingRenames(); + return this.projects() + .filter((p) => !hidden.has(p.id)) + .map((p) => (renames.has(p.id) ? ({ ...p, name: renames.get(p.id)! }) : p)); + }); + + 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 createInput = viewChild>('createInput'); + private readonly editInput = viewChild>('editInput'); + + constructor() { + effect(() => { + if (this.creatingProject()) { + queueMicrotask(() => this.createInput()?.nativeElement.focus()); + } + }); + } + + protected selectProject(projectId: string): void { + this.projectSelected.emit(projectId); + } + + protected showKebab(): boolean { + const a = this.actions(); + if (!a) return false; + return Boolean(a.rename || a.delete); + } + + protected openMenu(projectId: string, anchor: HTMLElement): void { + this.menuAnchor.set(anchor); + this.menuOpenForId.set(projectId); + } + + protected onMenuAction(id: string): void { + const projectId = this.menuOpenForId(); + this.menuOpenForId.set(null); + if (!projectId) return; + + if (id === 'rename') { + const p = this.projects().find((x) => x.id === projectId); + this.editingValue.set(p?.name ?? ''); + this.editingProjectId.set(projectId); + queueMicrotask(() => this.editInput()?.nativeElement.focus()); + } else if (id === 'delete') { + this.confirmDeleteId.set(projectId); + } + } + + protected onNewProjectClicked(): void { + this.creatingValue.set(''); + this.creatingProject.set(true); + this.newProjectRequested.emit(); + } + + protected onCreateInput(e: Event): void { + this.creatingValue.set((e.target as HTMLInputElement).value); + } + + protected cancelCreate(): void { + this.creatingProject.set(false); + this.creatingValue.set(''); + } + + protected async commitCreate(): Promise { + const name = this.creatingValue().trim(); + this.creatingProject.set(false); + this.creatingValue.set(''); + if (!name) return; + const a = this.actions(); + if (!a?.create) return; + try { await a.create(name); } catch { /* swallow; consumer's refresh won't show the row */ } + } + + protected onEditInput(e: Event): void { + this.editingValue.set((e.target as HTMLInputElement).value); + } + + protected cancelRename(): void { + this.editingProjectId.set(null); + } + + protected async commitRename(projectId: string): Promise { + const newName = this.editingValue().trim(); + this.editingProjectId.set(null); + if (!newName) return; + const a = this.actions(); + if (!a?.rename) return; + + this.pendingRenames.update((m) => { + const n = new Map(m); + n.set(projectId, newName); + return n; + }); + try { + await a.rename(projectId, newName); + } catch { + /* rollback via finally */ + } finally { + this.pendingRenames.update((m) => { + const n = new Map(m); + n.delete(projectId); + return n; + }); + } + } + + protected async performDelete(): Promise { + const projectId = this.confirmDeleteId(); + this.confirmDeleteId.set(null); + if (!projectId) return; + const a = this.actions(); + if (!a?.delete) return; + + this.pendingHidden.update((s) => new Set([...s, projectId])); + try { + await a.delete(projectId); + } catch { + /* rollback */ + } finally { + this.pendingHidden.update((s) => { + const n = new Set(s); + n.delete(projectId); + return n; + }); + } + } +} +``` + +- [ ] **Step 2: Build** + +Run: `npx nx run chat:build && npx nx lint chat 2>&1 | tail -5` +Expected: build PASS, lint clean. + +- [ ] **Step 3: Commit** + +```bash +git add libs/chat/src/lib/primitives/chat-project-list/chat-project-list.component.ts +git commit -m "feat(chat): chat-project-list component" +``` + +--- + +## Task 5: `chat-project-list` spec + +**Files:** +- Create: `libs/chat/src/lib/primitives/chat-project-list/chat-project-list.component.spec.ts` + +- [ ] **Step 1: Write the spec** + +Create `libs/chat/src/lib/primitives/chat-project-list/chat-project-list.component.spec.ts`: + +```typescript +// libs/chat/src/lib/primitives/chat-project-list/chat-project-list.component.spec.ts +// SPDX-License-Identifier: MIT +import { TestBed } from '@angular/core/testing'; +import { describe, expect, it, vi } from 'vitest'; +import { + ChatProjectListComponent, + type Project, + type ProjectActionAdapter, +} from './chat-project-list.component'; + +function render(opts: { + projects?: Project[]; + actions?: ProjectActionAdapter | null; + activeProjectId?: string | null; + showNewProjectButton?: boolean; +} = {}) { + const fixture = TestBed.createComponent(ChatProjectListComponent); + fixture.componentRef.setInput('projects', opts.projects ?? [{ id: 'p1', name: 'Work' }, { id: 'p2', name: 'Personal' }]); + if (opts.actions !== undefined) fixture.componentRef.setInput('actions', opts.actions); + if (opts.activeProjectId !== undefined) fixture.componentRef.setInput('activeProjectId', opts.activeProjectId); + if (opts.showNewProjectButton !== undefined) fixture.componentRef.setInput('showNewProjectButton', opts.showNewProjectButton); + fixture.detectChanges(); + return fixture; +} + +describe('ChatProjectListComponent', () => { + it('renders the project rows', () => { + const fixture = render(); + const items = fixture.nativeElement.querySelectorAll('.chat-project-list__item'); + expect(items.length).toBe(2); + expect((items[0] as HTMLElement).textContent?.trim()).toBe('Work'); + }); + + it('clicking a row emits projectSelected', () => { + const fixture = render(); + let received: string | undefined; + fixture.componentInstance.projectSelected.subscribe((id: string) => { received = id; }); + const items = fixture.nativeElement.querySelectorAll('.chat-project-list__item'); + (items[1] as HTMLElement).click(); + expect(received).toBe('p2'); + }); + + it('activeProjectId match adds data-active', () => { + const fixture = render({ activeProjectId: 'p1' }); + const items = fixture.nativeElement.querySelectorAll('.chat-project-list__item'); + expect((items[0] as HTMLElement).getAttribute('data-active')).toBe('true'); + expect((items[1] as HTMLElement).getAttribute('data-active')).toBeNull(); + }); + + it('showNewProjectButton=false hides + New project', () => { + const fixture = render({ showNewProjectButton: false }); + expect(fixture.nativeElement.querySelector('.chat-project-list__new')).toBeNull(); + }); + + it('clicking + New project emits newProjectRequested and shows inline input', async () => { + const fixture = render({ + showNewProjectButton: true, + actions: { create: vi.fn().mockResolvedValue({ id: 'new' }) }, + }); + let emits = 0; + fixture.componentInstance.newProjectRequested.subscribe(() => { emits++; }); + (fixture.nativeElement.querySelector('.chat-project-list__new') as HTMLElement).click(); + fixture.detectChanges(); + await new Promise((r) => queueMicrotask(() => r(undefined))); + fixture.detectChanges(); + expect(emits).toBe(1); + const input = fixture.nativeElement.querySelector('.chat-project-list__edit') as HTMLInputElement; + expect(input).not.toBeNull(); + expect(input.getAttribute('placeholder')).toBe('New project name'); + }); + + it('Enter on new-project input calls adapter.create', async () => { + const createSpy = vi.fn().mockResolvedValue({ id: 'new' }); + const fixture = render({ showNewProjectButton: true, actions: { create: createSpy } }); + (fixture.nativeElement.querySelector('.chat-project-list__new') as HTMLElement).click(); + fixture.detectChanges(); + await new Promise((r) => queueMicrotask(() => r(undefined))); + fixture.detectChanges(); + const input = fixture.nativeElement.querySelector('.chat-project-list__edit') as HTMLInputElement; + input.value = 'Hobbies'; + input.dispatchEvent(new Event('input', { bubbles: true })); + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + await new Promise((r) => setTimeout(r, 0)); + expect(createSpy).toHaveBeenCalledWith('Hobbies'); + }); + + it('Esc on new-project input cancels without calling adapter', async () => { + const createSpy = vi.fn().mockResolvedValue({ id: 'new' }); + const fixture = render({ showNewProjectButton: true, actions: { create: createSpy } }); + (fixture.nativeElement.querySelector('.chat-project-list__new') as HTMLElement).click(); + fixture.detectChanges(); + await new Promise((r) => queueMicrotask(() => r(undefined))); + fixture.detectChanges(); + const input = fixture.nativeElement.querySelector('.chat-project-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(createSpy).not.toHaveBeenCalled(); + expect(fixture.nativeElement.querySelector('.chat-project-list__edit')).toBeNull(); + }); + + it('Rename via kebab opens edit mode with prefilled name', async () => { + const fixture = render({ actions: { rename: vi.fn().mockResolvedValue(undefined) } }); + (fixture.nativeElement.querySelector('.chat-project-list__kebab') as HTMLElement).click(); + fixture.detectChanges(); + const item = Array.from(document.querySelectorAll('.chat-overflow-menu__item')) + .find((el) => (el as HTMLElement).textContent?.trim() === 'Rename') as HTMLElement; + item.click(); + fixture.detectChanges(); + await new Promise((r) => queueMicrotask(() => r(undefined))); + fixture.detectChanges(); + const input = fixture.nativeElement.querySelector('.chat-project-list__edit') as HTMLInputElement; + expect(input.value).toBe('Work'); + }); + + it('Delete via kebab opens confirm dialog (destructive); confirm calls adapter', async () => { + let resolveDelete!: () => void; + const deleteSpy = vi.fn(() => new Promise((r) => { resolveDelete = r; })); + const fixture = render({ actions: { delete: deleteSpy } }); + (fixture.nativeElement.querySelector('.chat-project-list__kebab') as HTMLElement).click(); + fixture.detectChanges(); + const item = Array.from(document.querySelectorAll('.chat-overflow-menu__item')) + .find((el) => (el as HTMLElement).textContent?.trim() === 'Delete') as HTMLElement; + item.click(); + fixture.detectChanges(); + expect(document.querySelector('.chat-confirm-dialog')).not.toBeNull(); + (document.querySelector('.chat-confirm-dialog__confirm') as HTMLElement).click(); + fixture.detectChanges(); + expect(deleteSpy).toHaveBeenCalledWith('p1'); + const remaining = fixture.nativeElement.querySelectorAll('.chat-project-list__item'); + expect(remaining.length).toBe(1); + resolveDelete(); + await new Promise((r) => setTimeout(r, 0)); + }); + + it('Delete adapter rejects → row reappears', async () => { + const deleteSpy = vi.fn(async () => { throw new Error('boom'); }); + const fixture = render({ actions: { delete: deleteSpy } }); + (fixture.nativeElement.querySelector('.chat-project-list__kebab') as HTMLElement).click(); + fixture.detectChanges(); + const item = Array.from(document.querySelectorAll('.chat-overflow-menu__item')) + .find((el) => (el as HTMLElement).textContent?.trim() === 'Delete') as HTMLElement; + item.click(); + fixture.detectChanges(); + (document.querySelector('.chat-confirm-dialog__confirm') as HTMLElement).click(); + await new Promise((r) => setTimeout(r, 0)); + await new Promise((r) => setTimeout(r, 0)); + fixture.detectChanges(); + const remaining = fixture.nativeElement.querySelectorAll('.chat-project-list__item'); + expect(remaining.length).toBe(2); + }); +}); +``` + +- [ ] **Step 2: Run + commit** + +```bash +npx nx run chat:test 2>&1 | tail -5 +git add libs/chat/src/lib/primitives/chat-project-list/chat-project-list.component.spec.ts +git commit -m "test(chat): chat-project-list coverage" +``` + +--- + +## Task 6: `chat-thread-list` Move-to-project wiring + +**Files:** +- Modify: `libs/chat/src/lib/primitives/chat-thread-list/chat-thread-list.component.ts` +- Modify: `libs/chat/src/lib/primitives/chat-thread-list/chat-thread-list.component.spec.ts` + +- [ ] **Step 1: Add `projects` input** + +In the class body, after the existing `mode` input (line 143), add: + +```typescript +import type { Project } from '../chat-project-list/chat-project-list.component'; +``` + +Add the import alongside the existing imports. Then in the class: + +```typescript +readonly projects = input(null); +``` + +- [ ] **Step 2: Add move-submenu state + computed** + +Below the existing `confirmDeleteId` signal, add: + +```typescript +protected readonly moveMenuOpenForId = signal(null); + +protected readonly moveMenuItems = computed(() => { + if (!this.moveMenuOpenForId()) return []; + const list: OverflowMenuItem[] = [{ id: '__none__', label: 'No project' }]; + for (const p of this.projects() ?? []) { + list.push({ id: p.id, label: p.name }); + } + return list; +}); +``` + +- [ ] **Step 3: Add "Move to project" to active-mode `currentMenuItems`** + +Find the existing `currentMenuItems` computed (around line 170). Update the active-mode branch to include `Move to project`: + +```typescript +if (this.mode() === 'active') { + if (a.rename) items.push({ id: 'rename', label: 'Rename' }); + if (a.moveToProject && this.projects() !== null) { + items.push({ id: 'move', label: 'Move to project' }); + } + if (a.archive) items.push({ id: 'archive', label: 'Archive' }); + if (a.delete) items.push({ id: 'delete', label: 'Delete', tone: 'destructive' }); +} +``` + +(The archived-mode branch is unchanged.) + +- [ ] **Step 4: Update `showKebab`** + +Replace the existing `showKebab` body with: + +```typescript +protected showKebab(): boolean { + const a = this.actions(); + if (!a) return false; + if (this.mode() === 'active') { + return Boolean( + a.rename || a.archive || a.delete || + (a.moveToProject && this.projects() !== null) + ); + } + return Boolean(a.unarchive || a.delete); +} +``` + +- [ ] **Step 5: Route the 'move' menu id** + +In the existing `onMenuAction` method, add a branch for `'move'`: + +```typescript +} else if (id === 'move') { + this.moveMenuOpenForId.set(threadId); +} +``` + +(Insert this before the closing of the `if/else if` chain — after the existing `archive`/`unarchive` branches but as a peer of them.) + +- [ ] **Step 6: Add `onMoveMenuAction` + `performMoveToProject`** + +Below the existing `performDelete` method, add: + +```typescript +protected onMoveMenuAction(itemId: string): void { + const threadId = this.moveMenuOpenForId(); + this.moveMenuOpenForId.set(null); + if (!threadId) return; + const projectId = itemId === '__none__' ? null : itemId; + void this.performMoveToProject(threadId, projectId); +} + +protected async performMoveToProject(threadId: string, projectId: string | null): Promise { + const a = this.actions(); + if (!a?.moveToProject) return; + this.pendingHidden.update((s) => new Set([...s, threadId])); + try { + await a.moveToProject(threadId, projectId); + } catch { + /* rollback via finally */ + } finally { + this.pendingHidden.update((s) => { + const n = new Set(s); + n.delete(threadId); + return n; + }); + } +} +``` + +- [ ] **Step 7: Add the second `` to the template** + +In the template, immediately AFTER the existing `` (the one bound to `menuOpenForId`/`currentMenuItems`), add: + +```html + +``` + +- [ ] **Step 8: Add spec coverage** + +Open `libs/chat/src/lib/primitives/chat-thread-list/chat-thread-list.component.spec.ts`. Inside the existing `describe('with adapter', ...)` block, BEFORE its closing brace, add these test cases: + +```typescript + it('moveToProject + projects=null → menu does NOT include "Move to project"', () => { + const fixture = render({ actions: { moveToProject: vi.fn().mockResolvedValue(undefined) } }); + (fixture.nativeElement.querySelector('.chat-thread-list__kebab') as HTMLElement).click(); + fixture.detectChanges(); + const labels = Array.from(document.querySelectorAll('.chat-overflow-menu__item')) + .map((el) => (el as HTMLElement).textContent?.trim()); + expect(labels).not.toContain('Move to project'); + }); + + it('moveToProject + projects=[] → menu includes "Move to project"', () => { + const fixture = TestBed.createComponent(ChatThreadListComponent); + fixture.componentRef.setInput('threads', [{ id: 't1', title: 'First' }]); + fixture.componentRef.setInput('actions', { moveToProject: vi.fn().mockResolvedValue(undefined) }); + fixture.componentRef.setInput('projects', []); + fixture.detectChanges(); + (fixture.nativeElement.querySelector('.chat-thread-list__kebab') as HTMLElement).click(); + fixture.detectChanges(); + const labels = Array.from(document.querySelectorAll('.chat-overflow-menu__item')) + .map((el) => (el as HTMLElement).textContent?.trim()); + expect(labels).toContain('Move to project'); + }); + + it('Click "Move to project" closes the main menu and opens the move submenu', () => { + const fixture = TestBed.createComponent(ChatThreadListComponent); + fixture.componentRef.setInput('threads', [{ id: 't1', title: 'First' }]); + fixture.componentRef.setInput('actions', { moveToProject: vi.fn().mockResolvedValue(undefined) }); + fixture.componentRef.setInput('projects', [{ id: 'p1', name: 'Work' }, { id: 'p2', name: 'Personal' }]); + fixture.detectChanges(); + (fixture.nativeElement.querySelector('.chat-thread-list__kebab') as HTMLElement).click(); + fixture.detectChanges(); + const moveItem = Array.from(document.querySelectorAll('.chat-overflow-menu__item')) + .find((el) => (el as HTMLElement).textContent?.trim() === 'Move to project') as HTMLElement; + moveItem.click(); + fixture.detectChanges(); + const labels = Array.from(document.querySelectorAll('.chat-overflow-menu__item')) + .map((el) => (el as HTMLElement).textContent?.trim()); + expect(labels).toEqual(['No project', 'Work', 'Personal']); + }); + + it('Clicking a project in the move submenu calls moveToProject with that id', async () => { + const moveSpy = vi.fn().mockResolvedValue(undefined); + const fixture = TestBed.createComponent(ChatThreadListComponent); + fixture.componentRef.setInput('threads', [{ id: 't1', title: 'First' }, { id: 't2', title: 'Second' }]); + fixture.componentRef.setInput('actions', { moveToProject: moveSpy }); + fixture.componentRef.setInput('projects', [{ id: 'p1', name: 'Work' }]); + fixture.detectChanges(); + (fixture.nativeElement.querySelector('.chat-thread-list__kebab') as HTMLElement).click(); + fixture.detectChanges(); + (Array.from(document.querySelectorAll('.chat-overflow-menu__item')) + .find((el) => (el as HTMLElement).textContent?.trim() === 'Move to project') as HTMLElement).click(); + fixture.detectChanges(); + (Array.from(document.querySelectorAll('.chat-overflow-menu__item')) + .find((el) => (el as HTMLElement).textContent?.trim() === 'Work') as HTMLElement).click(); + fixture.detectChanges(); + expect(moveSpy).toHaveBeenCalledWith('t1', 'p1'); + const remaining = fixture.nativeElement.querySelectorAll('.chat-thread-list__item'); + expect(remaining.length).toBe(1); + }); + + it('Clicking "No project" in the move submenu calls moveToProject(id, null)', () => { + const moveSpy = vi.fn().mockResolvedValue(undefined); + const fixture = TestBed.createComponent(ChatThreadListComponent); + fixture.componentRef.setInput('threads', [{ id: 't1', title: 'First' }]); + fixture.componentRef.setInput('actions', { moveToProject: moveSpy }); + fixture.componentRef.setInput('projects', [{ id: 'p1', name: 'Work' }]); + fixture.detectChanges(); + (fixture.nativeElement.querySelector('.chat-thread-list__kebab') as HTMLElement).click(); + fixture.detectChanges(); + (Array.from(document.querySelectorAll('.chat-overflow-menu__item')) + .find((el) => (el as HTMLElement).textContent?.trim() === 'Move to project') as HTMLElement).click(); + fixture.detectChanges(); + (Array.from(document.querySelectorAll('.chat-overflow-menu__item')) + .find((el) => (el as HTMLElement).textContent?.trim() === 'No project') as HTMLElement).click(); + fixture.detectChanges(); + expect(moveSpy).toHaveBeenCalledWith('t1', null); + }); +``` + +- [ ] **Step 9: Run tests + commit** + +Run: +```bash +npx nx run chat:test 2>&1 | tail -5 +npx nx run chat:build 2>&1 | tail -3 +npx nx lint chat 2>&1 | tail -3 +``` +Expected: all pass. + +```bash +git add libs/chat/src/lib/primitives/chat-thread-list/ +git commit -m "feat(chat): chat-thread-list Move-to-project submenu" +``` + +--- + +## Task 7: `chat-sidenav` Projects section + +**Files:** +- Modify: `libs/chat/src/lib/compositions/chat-sidenav/chat-sidenav.component.ts` +- Modify: `libs/chat/src/lib/styles/chat-sidenav.styles.ts` + +- [ ] **Step 1: Add styles** + +Append to the template literal in `libs/chat/src/lib/styles/chat-sidenav.styles.ts`: + +```css + .chat-sidenav__projects { flex-shrink: 0; } + :host([data-mode="collapsed"]) .chat-sidenav__projects { display: none; } +``` + +- [ ] **Step 2: Extend imports** + +At the top of `libs/chat/src/lib/compositions/chat-sidenav/chat-sidenav.component.ts`, add to the existing `@ngaf/chat`-internal import block: + +```typescript +import { + ChatProjectListComponent, + type Project, + type ProjectActionAdapter, +} from '../../primitives/chat-project-list/chat-project-list.component'; +``` + +Add `ChatProjectListComponent` to the `@Component({ imports: [...] })` array. + +- [ ] **Step 3: Add inputs/outputs** + +In the class body, after the existing `archivedThreads` input, add: + +```typescript +readonly projects = input(null); +readonly selectedProjectId = input(null); +readonly projectActions = input(null); +``` + +After the existing outputs (e.g. `searchOpened`, `openChange`), add: + +```typescript +readonly projectSelected = output(); +readonly newProjectRequested = output(); +``` + +- [ ] **Step 4: Add the Projects section to the template** + +In the template, find the `
...
` block (the existing primary slot). IMMEDIATELY AFTER its closing tag, BEFORE the existing `@if (threads() !== null)` block, insert: + +```html +@if (projects() !== null) { +
+
Projects
+ +
+} +``` + +- [ ] **Step 5: Forward `projects` to the inner thread list** + +Find the existing `` element inside the `@if (threads() !== null)` block. Add `[projects]="projects()"` to its bindings (preserve all existing bindings): + +```html + +``` + +(There's also a SECOND `` inside the archived section. Forward `projects` to that one too — though archived mode doesn't show the move-to-project entry, the input shouldn't differ between the two instances.) + +- [ ] **Step 6: Verify** + +Run: +```bash +npx nx run chat:test 2>&1 | tail -5 +npx nx run chat:build 2>&1 | tail -3 +npx nx lint chat 2>&1 | tail -3 +``` + +- [ ] **Step 7: Commit** + +```bash +git add libs/chat/src/lib/compositions/chat-sidenav/chat-sidenav.component.ts libs/chat/src/lib/styles/chat-sidenav.styles.ts +git commit -m "feat(chat): chat-sidenav Projects section + forwards projects to thread list" +``` + +--- + +## Task 8: `chat-sidenav` Projects section tests + +**Files:** +- Modify: `libs/chat/src/lib/compositions/chat-sidenav/chat-sidenav.component.spec.ts` + +- [ ] **Step 1: Add four new test cases** + +Find the closing brace of the outer `describe('ChatSidenavComponent', ...)` block. BEFORE that closing brace, add: + +```typescript + it('projects=null renders no Projects section', () => { + const fixture = render({ threads: [{ id: 't1' }] }); + expect(fixture.nativeElement.querySelector('.chat-sidenav__projects')).toBeNull(); + }); + + it('projects=[p1,p2] renders the Projects section with two rows', () => { + const fixture = render({ threads: [{ id: 't1' }] }); + fixture.componentRef.setInput('projects', [{ id: 'p1', name: 'Work' }, { id: 'p2', name: 'Personal' }]); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('.chat-sidenav__projects')).not.toBeNull(); + const rows = fixture.nativeElement.querySelectorAll('.chat-project-list__item'); + expect(rows.length).toBe(2); + }); + + it('selectedProjectId highlights the matching project row', () => { + const fixture = render({ threads: [{ id: 't1' }] }); + fixture.componentRef.setInput('projects', [{ id: 'p1', name: 'Work' }, { id: 'p2', name: 'Personal' }]); + fixture.componentRef.setInput('selectedProjectId', 'p2'); + fixture.detectChanges(); + const rows = fixture.nativeElement.querySelectorAll('.chat-project-list__item'); + expect(rows[0].getAttribute('data-active')).toBeNull(); + expect(rows[1].getAttribute('data-active')).toBe('true'); + }); + + it('projectActions.create shows "+ New project" and emits newProjectRequested on click', () => { + const fixture = render({ threads: [{ id: 't1' }] }); + fixture.componentRef.setInput('projects', []); + fixture.componentRef.setInput('projectActions', { create: async () => ({ id: 'x' }) }); + fixture.detectChanges(); + let emits = 0; + fixture.componentInstance.newProjectRequested.subscribe(() => { emits++; }); + const btn = fixture.nativeElement.querySelector('.chat-project-list__new') as HTMLButtonElement; + expect(btn).not.toBeNull(); + btn.click(); + fixture.detectChanges(); + expect(emits).toBe(1); + }); +``` + +- [ ] **Step 2: Run + commit** + +```bash +npx nx run chat:test 2>&1 | tail -5 +git add libs/chat/src/lib/compositions/chat-sidenav/chat-sidenav.component.spec.ts +git commit -m "test(chat): chat-sidenav Projects section coverage" +``` + +--- + +## Task 9: Public API exports + +**Files:** +- Modify: `libs/chat/src/public-api.ts` + +- [ ] **Step 1: Add exports** + +In `libs/chat/src/public-api.ts`, near the existing `ChatThreadListComponent` and `ThreadActionAdapter` exports, add: + +```typescript +export { ChatProjectListComponent } from './lib/primitives/chat-project-list/chat-project-list.component'; +export type { Project, ProjectActionAdapter } from './lib/primitives/chat-project-list/chat-project-list.component'; +``` + +- [ ] **Step 2: Build + commit** + +```bash +npx nx run chat:build 2>&1 | tail -3 +git add libs/chat/src/public-api.ts +git commit -m "feat(chat): export chat-project-list + Project + ProjectActionAdapter" +``` + +--- + +## Task 10: `ProjectsService` (localStorage-backed) + +**Files:** +- Create: `examples/chat/angular/src/app/shell/projects.service.ts` + +- [ ] **Step 1: Create the service** + +Create `examples/chat/angular/src/app/shell/projects.service.ts`: + +```typescript +// SPDX-License-Identifier: MIT +import { Injectable, signal } from '@angular/core'; +import type { Project } from '@ngaf/chat'; + +const STORAGE_KEY = 'ngaf-example-projects-v1'; + +@Injectable({ providedIn: 'root' }) +export class ProjectsService { + readonly projects = signal(this.load()); + + async create(name: string): Promise<{ id: string }> { + const id = (typeof crypto !== 'undefined' && 'randomUUID' in crypto) + ? crypto.randomUUID() + : `proj-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; + this.projects.update((p) => [{ id, name }, ...p]); + this.save(this.projects()); + return { id }; + } + + async rename(id: string, name: string): Promise { + this.projects.update((p) => p.map((x) => x.id === id ? { ...x, name } : x)); + this.save(this.projects()); + } + + async delete(id: string): Promise { + this.projects.update((p) => p.filter((x) => x.id !== id)); + this.save(this.projects()); + } + + private load(): Project[] { + if (typeof localStorage === 'undefined') return []; + try { return JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '[]'); } catch { return []; } + } + + private save(p: Project[]): void { + if (typeof localStorage === 'undefined') return; + localStorage.setItem(STORAGE_KEY, JSON.stringify(p)); + } +} +``` + +- [ ] **Step 2: Build + commit** + +```bash +npx nx run examples-chat-angular:build 2>&1 | tail -3 +git add examples/chat/angular/src/app/shell/projects.service.ts +git commit -m "feat(examples-chat): ProjectsService (localStorage-backed)" +``` + +--- + +## Task 11: `ThreadsService` `moveToProject` + projectId mapping + +**Files:** +- Modify: `examples/chat/angular/src/app/shell/threads.service.ts` + +- [ ] **Step 1: Add `moveToProject` method** + +In the existing `ThreadsService`, AFTER the existing `unarchive` method, add: + +```typescript +async moveToProject(threadId: string, projectId: string | null): Promise { + await this.client.threads.update(threadId, { metadata: { projectId } }); + await this.refresh(); +} +``` + +- [ ] **Step 2: Extend `create` to accept optional projectId** + +Find the existing `create()` method. Replace its signature + body with: + +```typescript +async create(projectId?: string): Promise { + try { + const t = await this.client.threads.create({ + metadata: projectId !== undefined ? { projectId } : {}, + }); + await this.refresh(); + return t.thread_id; + } catch { + return null; + } +} +``` + +- [ ] **Step 3: Update `toThread` to read `projectId`** + +Find the existing private `toThread` method. Extend the `meta` destructure + return: + +```typescript +private toThread(t: SdkThread): Thread { + const meta = (t.metadata ?? {}) as { title?: unknown; archived?: unknown; projectId?: unknown }; + const customTitle = meta.title; + const archived = meta.archived === true; + const projectId = typeof meta.projectId === 'string' && meta.projectId.length > 0 + ? meta.projectId + : null; + return { + id: t.thread_id, + title: typeof customTitle === 'string' && customTitle.length > 0 + ? customTitle + : `Thread ${t.thread_id.slice(0, 8)}`, + status: archived ? 'archived' : 'active', + projectId, + }; +} +``` + +- [ ] **Step 4: Build + commit** + +```bash +npx nx run examples-chat-angular:build 2>&1 | tail -3 +git add examples/chat/angular/src/app/shell/threads.service.ts +git commit -m "feat(examples-chat): ThreadsService projectId mapping + moveToProject" +``` + +--- + +## Task 12: Demo shell wiring + +**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: Extend the `@ngaf/chat` imports** + +Open `examples/chat/angular/src/app/shell/demo-shell.component.ts`. Extend the existing `@ngaf/chat` import block to include `type Project` and `type ProjectActionAdapter`: + +```typescript +import { + ChatDebugComponent, + // ... existing + ChatSidenavComponent, + type ChatSidenavMode, + ChatHistorySearchPaletteComponent, + type ThreadMatch, + type ThreadActionAdapter, + type Project, + type ProjectActionAdapter, +} from '@ngaf/chat'; +``` + +(Do not remove or reorder existing identifiers; just add the two new types.) + +- [ ] **Step 2: Import `ProjectsService`** + +Add an import at the top of the file: + +```typescript +import { ProjectsService } from './projects.service'; +``` + +And inject it as a protected field near the existing `threadsSvc`: + +```typescript +protected readonly projectsSvc = inject(ProjectsService); +``` + +- [ ] **Step 3: Add `selectedProjectId` signal** + +Near the existing `threadIdSignal` declaration, add: + +```typescript +protected readonly selectedProjectId = signal( + this.persistence.read('selectedProjectId') ?? null, +); +``` + +- [ ] **Step 4: Add `visibleThreads` computed** + +Near the existing `searchResults` computed (or anywhere in the protected-fields area), add: + +```typescript +/** Active threads filtered by the selected project (or all, when none selected). */ +protected readonly visibleThreads = computed(() => { + const sel = this.selectedProjectId(); + const all = this.threadsSvc.threads(); + return sel === null ? all : all.filter((t) => t.projectId === sel); +}); +``` + +You'll also need to import `type Thread` from `@ngaf/chat` if it's not already there. Add to the existing import block alongside `ThreadActionAdapter`. + +- [ ] **Step 5: Define `projectActions`** + +Near the existing `threadActions` property, add: + +```typescript +protected readonly projectActions: ProjectActionAdapter = { + create: async (name) => { + const r = await this.projectsSvc.create(name); + this.selectedProjectId.set(r.id); + this.persistence.write('selectedProjectId', r.id); + return r; + }, + rename: (id, name) => this.projectsSvc.rename(id, name), + delete: async (id) => { + await this.projectsSvc.delete(id); + if (this.selectedProjectId() === id) { + this.selectedProjectId.set(null); + this.persistence.write('selectedProjectId', null); + } + }, +}; +``` + +- [ ] **Step 6: Extend `threadActions` with `moveToProject`** + +Find the existing `threadActions: ThreadActionAdapter` declaration. Add the new method (preserve all existing ones): + +```typescript +moveToProject: async (id, projectId) => { + await this.threadsSvc.moveToProject(id, projectId); + // If we moved the active thread out of the currently-selected project, + // the chat view will switch to welcome via the visibleThreads filter. +}, +``` + +- [ ] **Step 7: Add `onProjectSelected` + `onNewProjectClicked` handlers** + +Near the existing `onThreadSelected` method, add: + +```typescript +protected onProjectSelected(projectId: string): void { + this.selectedProjectId.set(projectId); + this.persistence.write('selectedProjectId', projectId); +} + +protected onNewProjectClicked(): void { + // Framework's chat-project-list owns the inline-create flow; this is an + // informational event for the consumer. +} +``` + +- [ ] **Step 8: Update `onNewThread` to start in the selected project** + +Find the existing `onNewThread` method. Update its body to pass the selected project id: + +```typescript +protected async onNewThread(): Promise { + const sel = this.selectedProjectId(); + const id = await this.threadsSvc.create(sel ?? undefined); + if (id) { + this.threadIdSignal.set(id); + this.persistence.write('threadId', id); + } +} +``` + +(If the existing implementation has additional logic, preserve it; only the call to `create` changes.) + +- [ ] **Step 9: Update the template** + +Open `examples/chat/angular/src/app/shell/demo-shell.component.html`. Update the existing `` element to: + +```html + +``` + +The key changes: `[threads]` now uses `visibleThreads()` (filtered by project); three new project-related bindings; two new project-related outputs. + +- [ ] **Step 10: Build** + +```bash +npx nx run examples-chat-angular:build 2>&1 | tail -5 +``` + +- [ ] **Step 11: Commit** + +```bash +git add examples/chat/angular/src/app/shell/demo-shell.component.ts examples/chat/angular/src/app/shell/demo-shell.component.html +git commit -m "feat(examples-chat): wire projects + project filtering + move-to-project" +``` + +--- + +## Task 13: Regenerate API docs + +**Files:** +- Modify: `apps/website/content/docs/chat/api/api-docs.json` + +- [ ] **Step 1: Regenerate** + +```bash +npx tsx apps/website/scripts/generate-api-docs.ts 2>&1 | tail -5 +``` + +Expected: `✓ chat/api/api-docs.json (N entries)` with N including new `ChatProjectListComponent`, `Project`, `ProjectActionAdapter`, and updated `ThreadActionAdapter.moveToProject` entries. + +- [ ] **Step 2: Stage + commit** + +```bash +git diff --stat apps/website/content/docs/chat/api/api-docs.json +git add apps/website/content/docs/chat/api/api-docs.json +git commit -m "docs(chat): regenerate API docs for Projects" +``` + +--- + +## Task 14: Manual browser verification + +**Files:** none — verification only. + +Controller-owned. Subagents should stop after Task 13. + +The controller will: + +1. Start the dev server. Note: previous tasks revealed nx serve picks up stale `dist/libs/chat`; pre-clear with `rm -rf dist/libs/chat dist/libs/render`. +2. Resize to desktop preset. +3. Verify each behavior: + - Sidenav renders empty Projects section heading on first load (no projects yet), with **"+ New project"** button visible. + - Click "+ New project" → inline input appears, focused, with placeholder "New project name". + - Type "Work" + Enter → row appears with name "Work", auto-selected (highlighted). + - Click "+ New chat" while Work is selected → new thread created with `metadata.projectId='Work-id'`. Thread visible in Recent. + - Hover the new thread → kebab fades in. Menu includes "Move to project". + - Click "Move to project" → main menu closes, move submenu opens with "No project" + "Work". + - Click "No project" → thread vanishes from Recent (no longer matches the project filter). + - Click Projects heading area to deselect → all threads visible; the moved thread appears. + - Rename "Work" → "Personal" via kebab → row updates. + - Delete "Personal" via kebab → destructive confirm dialog → confirm → row gone. Threads previously associated remain in LangGraph but have orphan `projectId`. + - Reload page → projects persist via localStorage; selected-project persists via `PalettePersistence`. +4. Screenshot: Projects section with rows, move submenu open with project options, confirm dialog for delete. +5. Stop preview. + +--- + +## Self-Review + +**Spec coverage:** + +| Spec requirement | Task | +|---|---| +| `Project` type | Task 2 (initial) + Task 4 (full) | +| `ProjectActionAdapter` interface | Task 2 + Task 4 | +| `Thread.projectId?` | Task 1 | +| `ThreadActionAdapter.moveToProject?` | Task 1 | +| `chat-project-list` styles | Task 3 | +| `chat-project-list` component (inputs, state, template, handlers) | Task 4 | +| `chat-project-list` tests | Task 5 | +| `chat-thread-list` `projects` input | Task 6 | +| `chat-thread-list` move submenu state + items computed | Task 6 | +| `chat-thread-list` "Move to project" menu entry | Task 6 | +| `chat-thread-list` `performMoveToProject` | Task 6 | +| `chat-thread-list` second `` instance | Task 6 | +| `chat-thread-list` move-to-project tests | Task 6 | +| `chat-sidenav.projects` / `selectedProjectId` / `projectActions` inputs | Task 7 | +| `chat-sidenav` `(projectSelected)` / `(newProjectRequested)` outputs | Task 7 | +| `chat-sidenav` template Projects section | Task 7 | +| `chat-sidenav` forwards `projects` to thread-list | Task 7 | +| `chat-sidenav` Projects section tests | Task 8 | +| Public-api exports | Task 9 | +| `ProjectsService` (localStorage) | Task 10 | +| `ThreadsService.moveToProject` + `projectId` mapping + create-with-projectId | Task 11 | +| Demo-shell wiring | Task 12 | +| API docs regen | Task 13 | +| Manual verification | Task 14 | + +**Placeholder scan:** None. All steps contain complete code. + +**Type consistency:** +- `Project` shape (`id`, `name`, open) defined in Task 2/4, consumed in Tasks 6, 7, 10, 12. +- `ProjectActionAdapter` (`create`/`rename`/`delete`) consistent across Tasks 2/4, 7, 10, 12. +- `Thread.projectId` (`string | null`) defined in Task 1, consumed in Tasks 6, 11, 12. +- `ThreadActionAdapter.moveToProject` (`(id, projectId | null) => Promise`) defined in Task 1, consumed in Tasks 6, 11, 12. +- Output names consistent: `projectSelected`, `newProjectRequested`. Method names consistent: `commitCreate`, `cancelCreate`, `onCreateInput`, `performDelete`, `performMoveToProject`, `onMoveMenuAction`. +- The internal "No project" sentinel uses the literal `'__none__'` consistently across the template, computed, and handler. From b419307e2359eb92809db59ef246dee3a9c735de Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 12 May 2026 16:24:05 -0700 Subject: [PATCH 03/16] docs(plans): rebase Projects plan onto post-pin chat-thread-list Pin (Phase 3d, PR #267) landed first. Updates Task 6's chat-thread-list patches so "Move to project" inserts alongside existing pin/unpin entries instead of replacing them. Tasks 1, 7, 8, 10-13 remain valid. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../plans/2026-05-12-chat-projects.md | 41 ++++++++++++++++--- 1 file changed, 35 insertions(+), 6 deletions(-) diff --git a/docs/superpowers/plans/2026-05-12-chat-projects.md b/docs/superpowers/plans/2026-05-12-chat-projects.md index 069c3ef8..4470799b 100644 --- a/docs/superpowers/plans/2026-05-12-chat-projects.md +++ b/docs/superpowers/plans/2026-05-12-chat-projects.md @@ -10,7 +10,7 @@ **Spec:** [docs/superpowers/specs/2026-05-12-chat-projects-design.md](../specs/2026-05-12-chat-projects-design.md) -> **Important:** Phase 3d (pin) is a parallel spawned task at the time of writing. This plan does NOT touch any pin-related code. The chat-thread-list menu updates here only add the "Move to project" entry — they do NOT add pin/unpin. If pin lands first, the active-mode menu-items computed will need a follow-up merge that puts pin/unpin alongside move. If this lands first, pin's PR will do the merge instead. +> **Important — pin landed first (PR #267).** The chat-thread-list active-mode menu already includes `pin`/`unpin` items and a `pinned?` field on Thread. This plan inserts "Move to project" alongside the existing pin/unpin entries, NOT in place of them. Tasks 1 and 6 use code blocks that reflect the post-pin baseline. The implementer should `git log --oneline -3` first to confirm pin's commit (`3d56792c`) is in the branch history. --- @@ -786,11 +786,29 @@ protected readonly moveMenuItems = computed(() => { - [ ] **Step 3: Add "Move to project" to active-mode `currentMenuItems`** -Find the existing `currentMenuItems` computed (around line 170). Update the active-mode branch to include `Move to project`: +Find the existing `currentMenuItems` computed (around line 185). The post-pin baseline looks like: ```typescript if (this.mode() === 'active') { + const thread = this.threads().find((t) => t.id === id); + const isPinned = thread?.pinned === true; if (a.rename) items.push({ id: 'rename', label: 'Rename' }); + if (a.pin && !isPinned) items.push({ id: 'pin', label: 'Pin' }); + if (a.unpin && isPinned) items.push({ id: 'unpin', label: 'Unpin' }); + if (a.archive) items.push({ id: 'archive', label: 'Archive' }); + if (a.delete) items.push({ id: 'delete', label: 'Delete', tone: 'destructive' }); +} +``` + +Insert ONE new line for "Move to project" between the `unpin` line and the `archive` line. The active-mode branch should become: + +```typescript +if (this.mode() === 'active') { + const thread = this.threads().find((t) => t.id === id); + const isPinned = thread?.pinned === true; + if (a.rename) items.push({ id: 'rename', label: 'Rename' }); + if (a.pin && !isPinned) items.push({ id: 'pin', label: 'Pin' }); + if (a.unpin && isPinned) items.push({ id: 'unpin', label: 'Unpin' }); if (a.moveToProject && this.projects() !== null) { items.push({ id: 'move', label: 'Move to project' }); } @@ -803,7 +821,18 @@ if (this.mode() === 'active') { - [ ] **Step 4: Update `showKebab`** -Replace the existing `showKebab` body with: +The post-pin baseline reads: + +```typescript +protected showKebab(): boolean { + const a = this.actions(); + if (!a) return false; + if (this.mode() === 'active') return Boolean(a.rename || a.pin || a.unpin || a.archive || a.delete); + return Boolean(a.unarchive || a.delete); +} +``` + +Extend the active-mode condition to also consider `moveToProject` (when `projects` is provided): ```typescript protected showKebab(): boolean { @@ -811,7 +840,7 @@ protected showKebab(): boolean { if (!a) return false; if (this.mode() === 'active') { return Boolean( - a.rename || a.archive || a.delete || + a.rename || a.pin || a.unpin || a.archive || a.delete || (a.moveToProject && this.projects() !== null) ); } @@ -821,7 +850,7 @@ protected showKebab(): boolean { - [ ] **Step 5: Route the 'move' menu id** -In the existing `onMenuAction` method, add a branch for `'move'`: +The post-pin `onMenuAction` chain has branches for: `rename`, `delete`, `archive`, `unarchive`, `pin`, `unpin`. Add a new branch for `'move'` at the END of the chain (just before the closing brace of the method): ```typescript } else if (id === 'move') { @@ -829,7 +858,7 @@ In the existing `onMenuAction` method, add a branch for `'move'`: } ``` -(Insert this before the closing of the `if/else if` chain — after the existing `archive`/`unarchive` branches but as a peer of them.) +The full chain should then end with `... pin → unpin → move` (in that order or interleaved; order within the if/else-if doesn't affect behavior since each branch is mutually exclusive). - [ ] **Step 6: Add `onMoveMenuAction` + `performMoveToProject`** From f96b9fb9e8d24b2b90226c19e416a4748f9eb780 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 12 May 2026 16:26:49 -0700 Subject: [PATCH 04/16] feat(chat): Thread.projectId + ThreadActionAdapter.moveToProject --- .../chat-thread-list/chat-thread-list.component.ts | 7 +++++++ 1 file changed, 7 insertions(+) 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 77d10ff5..c7d3f242 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 @@ -38,6 +38,9 @@ export type Thread = { * renders a pin icon when true but does NOT sort — the consumer pre-sorts * pinned threads to the top of the `threads` input. */ pinned?: boolean; + /** Optional project association. Consumers pre-filter threads by project + * before passing to the sidenav. Null/undefined means no project. */ + projectId?: string | null; [key: string]: unknown; }; @@ -62,6 +65,10 @@ export interface ThreadActionAdapter { pin?(threadId: string): Promise; /** Unpin a previously pinned thread. */ unpin?(threadId: string): Promise; + /** Move thread to a project (or pass null to remove from any project). + * Optimistically hides the row from the current project's visible list; + * consumer is expected to refresh the threads input. */ + moveToProject?(threadId: string, projectId: string | null): Promise; } @Component({ From dfca7f2dcee41c6379619c761f9838dd96a26671 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 12 May 2026 16:26:56 -0700 Subject: [PATCH 05/16] feat(chat): Project + ProjectActionAdapter types; chat-project-list skeleton --- .../chat-project-list.component.ts | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 libs/chat/src/lib/primitives/chat-project-list/chat-project-list.component.ts diff --git a/libs/chat/src/lib/primitives/chat-project-list/chat-project-list.component.ts b/libs/chat/src/lib/primitives/chat-project-list/chat-project-list.component.ts new file mode 100644 index 00000000..e4cb5c0c --- /dev/null +++ b/libs/chat/src/lib/primitives/chat-project-list/chat-project-list.component.ts @@ -0,0 +1,37 @@ +// libs/chat/src/lib/primitives/chat-project-list/chat-project-list.component.ts +// SPDX-License-Identifier: MIT +import { Component, ChangeDetectionStrategy, input, output } from '@angular/core'; +import { CHAT_HOST_TOKENS } from '../../styles/chat-tokens'; + +export type Project = { + id: string; + name: string; + /** Open shape — consumers may add icon, color, createdAt, etc. */ + [key: string]: unknown; +}; + +export interface ProjectActionAdapter { + /** Create a new project. Returns the new project id; consumer is expected + * to also refresh its projects signal. */ + create?(name: string): Promise<{ id: string }>; + rename?(projectId: string, newName: string): Promise; + /** Permanently delete the project. The framework calls this AFTER user + * confirms via the confirm dialog. */ + delete?(projectId: string): Promise; +} + +@Component({ + selector: 'chat-project-list', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + styles: [CHAT_HOST_TOKENS], + template: ``, +}) +export class ChatProjectListComponent { + readonly projects = input.required(); + readonly activeProjectId = input(null); + readonly showNewProjectButton = input(false); + readonly actions = input(null); + readonly projectSelected = output(); + readonly newProjectRequested = output(); +} From f192a0be8a0c5c4182ca997d70bce59f402955c3 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 12 May 2026 16:28:26 -0700 Subject: [PATCH 06/16] feat(chat): chat-project-list styles --- .../lib/styles/chat-project-list.styles.ts | 92 +++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 libs/chat/src/lib/styles/chat-project-list.styles.ts diff --git a/libs/chat/src/lib/styles/chat-project-list.styles.ts b/libs/chat/src/lib/styles/chat-project-list.styles.ts new file mode 100644 index 00000000..7bdd7148 --- /dev/null +++ b/libs/chat/src/lib/styles/chat-project-list.styles.ts @@ -0,0 +1,92 @@ +// libs/chat/src/lib/styles/chat-project-list.styles.ts +// SPDX-License-Identifier: MIT +export const CHAT_PROJECT_LIST_STYLES = ` + :host { display: block; padding: var(--ngaf-chat-space-2); } + .chat-project-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 2px; } + .chat-project-list__item-wrap { + position: relative; + display: flex; + align-items: center; + gap: 4px; + } + .chat-project-list__item { + flex: 1 1 auto; + min-width: 0; + min-height: 32px; + padding: 6px 12px; + border-radius: var(--ngaf-chat-radius-button); + cursor: pointer; + color: var(--ngaf-chat-text); + font-size: var(--ngaf-chat-font-size-sm); + background: transparent; + border: 0; + text-align: left; + box-sizing: border-box; + transition: background-color 150ms ease; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .chat-project-list__item:hover { background: color-mix(in srgb, var(--ngaf-chat-text) 5%, transparent); } + .chat-project-list__item[data-active="true"] { + background: var(--ngaf-chat-surface-alt); + font-weight: 500; + box-shadow: inset 2px 0 0 var(--a2ui-primary, var(--ngaf-chat-primary)); + } + .chat-project-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-project-list__item-wrap:hover .chat-project-list__kebab, + .chat-project-list__item-wrap:focus-within .chat-project-list__kebab { + opacity: 1; + } + .chat-project-list__kebab:hover { + background: var(--ngaf-chat-surface-alt); + color: var(--ngaf-chat-text); + } + .chat-project-list__kebab:focus-visible { + opacity: 1; + outline: 2px solid var(--ngaf-chat-primary); + outline-offset: 2px; + } + .chat-project-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: 32px; + outline: none; + box-sizing: border-box; + } + .chat-project-list__new { + display: block; + width: 100%; + height: 32px; + margin-bottom: var(--ngaf-chat-space-2); + border: 1px dashed var(--ngaf-chat-separator); + border-radius: var(--ngaf-chat-radius-button); + background: transparent; + color: var(--ngaf-chat-primary); + cursor: pointer; + font-size: var(--ngaf-chat-font-size-sm); + box-sizing: border-box; + transition: background 150ms ease; + } + .chat-project-list__new:hover { background: var(--ngaf-chat-surface-alt); } +`; From 184038354f392be81a5d2d4f620e7c82f52dfdc6 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 12 May 2026 16:29:16 -0700 Subject: [PATCH 07/16] feat(chat): chat-project-list component --- .../chat-project-list.component.ts | 256 +++++++++++++++++- 1 file changed, 253 insertions(+), 3 deletions(-) diff --git a/libs/chat/src/lib/primitives/chat-project-list/chat-project-list.component.ts b/libs/chat/src/lib/primitives/chat-project-list/chat-project-list.component.ts index e4cb5c0c..58a60cac 100644 --- a/libs/chat/src/lib/primitives/chat-project-list/chat-project-list.component.ts +++ b/libs/chat/src/lib/primitives/chat-project-list/chat-project-list.component.ts @@ -1,7 +1,23 @@ // libs/chat/src/lib/primitives/chat-project-list/chat-project-list.component.ts // SPDX-License-Identifier: MIT -import { Component, ChangeDetectionStrategy, input, output } from '@angular/core'; +import { + Component, + ChangeDetectionStrategy, + ElementRef, + computed, + effect, + input, + output, + signal, + viewChild, +} from '@angular/core'; import { CHAT_HOST_TOKENS } from '../../styles/chat-tokens'; +import { CHAT_PROJECT_LIST_STYLES } from '../../styles/chat-project-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 Project = { id: string; @@ -10,6 +26,11 @@ export type Project = { [key: string]: unknown; }; +/** + * Consumer-provided adapter for project lifecycle actions. The framework calls + * these methods after user confirmation (delete) or commit (create/rename) and + * manages optimistic UI + rollback on rejection. + */ export interface ProjectActionAdapter { /** Create a new project. Returns the new project id; consumer is expected * to also refresh its projects signal. */ @@ -23,15 +44,244 @@ export interface ProjectActionAdapter { @Component({ selector: 'chat-project-list', standalone: true, + imports: [ChatOverflowMenuComponent, ChatConfirmDialogComponent], changeDetection: ChangeDetectionStrategy.OnPush, - styles: [CHAT_HOST_TOKENS], - template: ``, + styles: [CHAT_HOST_TOKENS, CHAT_PROJECT_LIST_STYLES], + template: ` + @if (showNewProjectButton()) { + + } +
    + @if (creatingProject()) { +
  • + +
  • + } + @for (project of visibleProjects(); track project.id) { +
  • + @if (editingProjectId() === project.id) { + + } @else { + + + @if (showKebab()) { + + } + } +
  • + } +
+ + + + + `, }) export class ChatProjectListComponent { readonly projects = input.required(); readonly activeProjectId = input(null); readonly showNewProjectButton = input(false); readonly actions = input(null); + readonly projectSelected = output(); readonly newProjectRequested = output(); + + protected readonly creatingProject = signal(false); + protected readonly creatingValue = signal(''); + protected readonly editingProjectId = signal(null); + protected readonly editingValue = signal(''); + protected readonly menuOpenForId = signal(null); + protected readonly menuAnchor = signal(null); + protected readonly confirmDeleteId = signal(null); + + private readonly pendingHidden = signal>(new Set()); + private readonly pendingRenames = signal>(new Map()); + + protected readonly visibleProjects = computed(() => { + const hidden = this.pendingHidden(); + const renames = this.pendingRenames(); + return this.projects() + .filter((p) => !hidden.has(p.id)) + .map((p) => (renames.has(p.id) ? ({ ...p, name: renames.get(p.id)! }) : p)); + }); + + 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 createInput = viewChild>('createInput'); + private readonly editInput = viewChild>('editInput'); + + constructor() { + effect(() => { + if (this.creatingProject()) { + queueMicrotask(() => this.createInput()?.nativeElement.focus()); + } + }); + } + + protected selectProject(projectId: string): void { + this.projectSelected.emit(projectId); + } + + protected showKebab(): boolean { + const a = this.actions(); + if (!a) return false; + return Boolean(a.rename || a.delete); + } + + protected openMenu(projectId: string, anchor: HTMLElement): void { + this.menuAnchor.set(anchor); + this.menuOpenForId.set(projectId); + } + + protected onMenuAction(id: string): void { + const projectId = this.menuOpenForId(); + this.menuOpenForId.set(null); + if (!projectId) return; + + if (id === 'rename') { + const p = this.projects().find((x) => x.id === projectId); + this.editingValue.set(p?.name ?? ''); + this.editingProjectId.set(projectId); + queueMicrotask(() => this.editInput()?.nativeElement.focus()); + } else if (id === 'delete') { + this.confirmDeleteId.set(projectId); + } + } + + protected onNewProjectClicked(): void { + this.creatingValue.set(''); + this.creatingProject.set(true); + this.newProjectRequested.emit(); + } + + protected onCreateInput(e: Event): void { + this.creatingValue.set((e.target as HTMLInputElement).value); + } + + protected cancelCreate(): void { + this.creatingProject.set(false); + this.creatingValue.set(''); + } + + protected async commitCreate(): Promise { + const name = this.creatingValue().trim(); + this.creatingProject.set(false); + this.creatingValue.set(''); + if (!name) return; + const a = this.actions(); + if (!a?.create) return; + try { await a.create(name); } catch { /* consumer's refresh won't show the row */ } + } + + protected onEditInput(e: Event): void { + this.editingValue.set((e.target as HTMLInputElement).value); + } + + protected cancelRename(): void { + this.editingProjectId.set(null); + } + + protected async commitRename(projectId: string): Promise { + const newName = this.editingValue().trim(); + this.editingProjectId.set(null); + if (!newName) return; + const a = this.actions(); + if (!a?.rename) return; + + this.pendingRenames.update((m) => { + const n = new Map(m); + n.set(projectId, newName); + return n; + }); + try { + await a.rename(projectId, newName); + } catch { + /* rollback via finally */ + } finally { + this.pendingRenames.update((m) => { + const n = new Map(m); + n.delete(projectId); + return n; + }); + } + } + + protected async performDelete(): Promise { + const projectId = this.confirmDeleteId(); + this.confirmDeleteId.set(null); + if (!projectId) return; + const a = this.actions(); + if (!a?.delete) return; + + this.pendingHidden.update((s) => new Set([...s, projectId])); + try { + await a.delete(projectId); + } catch { + /* rollback */ + } finally { + this.pendingHidden.update((s) => { + const n = new Set(s); + n.delete(projectId); + return n; + }); + } + } } From 789164d79923fbc18f8c6863e9b8c1a507f62f4a Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 12 May 2026 16:31:28 -0700 Subject: [PATCH 08/16] test(chat): chat-project-list coverage --- .../chat-project-list.component.spec.ts | 153 ++++++++++++++++++ 1 file changed, 153 insertions(+) create mode 100644 libs/chat/src/lib/primitives/chat-project-list/chat-project-list.component.spec.ts diff --git a/libs/chat/src/lib/primitives/chat-project-list/chat-project-list.component.spec.ts b/libs/chat/src/lib/primitives/chat-project-list/chat-project-list.component.spec.ts new file mode 100644 index 00000000..1ddadc53 --- /dev/null +++ b/libs/chat/src/lib/primitives/chat-project-list/chat-project-list.component.spec.ts @@ -0,0 +1,153 @@ +// libs/chat/src/lib/primitives/chat-project-list/chat-project-list.component.spec.ts +// SPDX-License-Identifier: MIT +import { TestBed } from '@angular/core/testing'; +import { describe, expect, it, vi } from 'vitest'; +import { + ChatProjectListComponent, + type Project, + type ProjectActionAdapter, +} from './chat-project-list.component'; + +function render(opts: { + projects?: Project[]; + actions?: ProjectActionAdapter | null; + activeProjectId?: string | null; + showNewProjectButton?: boolean; +} = {}) { + const fixture = TestBed.createComponent(ChatProjectListComponent); + fixture.componentRef.setInput('projects', opts.projects ?? [{ id: 'p1', name: 'Work' }, { id: 'p2', name: 'Personal' }]); + if (opts.actions !== undefined) fixture.componentRef.setInput('actions', opts.actions); + if (opts.activeProjectId !== undefined) fixture.componentRef.setInput('activeProjectId', opts.activeProjectId); + if (opts.showNewProjectButton !== undefined) fixture.componentRef.setInput('showNewProjectButton', opts.showNewProjectButton); + fixture.detectChanges(); + return fixture; +} + +describe('ChatProjectListComponent', () => { + it('renders the project rows', () => { + const fixture = render(); + const items = fixture.nativeElement.querySelectorAll('.chat-project-list__item'); + expect(items.length).toBe(2); + expect((items[0] as HTMLElement).textContent?.trim()).toBe('Work'); + }); + + it('clicking a row emits projectSelected', () => { + const fixture = render(); + let received: string | undefined; + fixture.componentInstance.projectSelected.subscribe((id: string) => { received = id; }); + const items = fixture.nativeElement.querySelectorAll('.chat-project-list__item'); + (items[1] as HTMLElement).click(); + expect(received).toBe('p2'); + }); + + it('activeProjectId match adds data-active', () => { + const fixture = render({ activeProjectId: 'p1' }); + const items = fixture.nativeElement.querySelectorAll('.chat-project-list__item'); + expect((items[0] as HTMLElement).getAttribute('data-active')).toBe('true'); + expect((items[1] as HTMLElement).getAttribute('data-active')).toBeNull(); + }); + + it('showNewProjectButton=false hides + New project', () => { + const fixture = render({ showNewProjectButton: false }); + expect(fixture.nativeElement.querySelector('.chat-project-list__new')).toBeNull(); + }); + + it('clicking + New project emits newProjectRequested and shows inline input', async () => { + const fixture = render({ + showNewProjectButton: true, + actions: { create: vi.fn().mockResolvedValue({ id: 'new' }) }, + }); + let emits = 0; + fixture.componentInstance.newProjectRequested.subscribe(() => { emits++; }); + (fixture.nativeElement.querySelector('.chat-project-list__new') as HTMLElement).click(); + fixture.detectChanges(); + await new Promise((r) => queueMicrotask(() => r(undefined))); + fixture.detectChanges(); + expect(emits).toBe(1); + const input = fixture.nativeElement.querySelector('.chat-project-list__edit') as HTMLInputElement; + expect(input).not.toBeNull(); + expect(input.getAttribute('placeholder')).toBe('New project name'); + }); + + it('Enter on new-project input calls adapter.create', async () => { + const createSpy = vi.fn().mockResolvedValue({ id: 'new' }); + const fixture = render({ showNewProjectButton: true, actions: { create: createSpy } }); + (fixture.nativeElement.querySelector('.chat-project-list__new') as HTMLElement).click(); + fixture.detectChanges(); + await new Promise((r) => queueMicrotask(() => r(undefined))); + fixture.detectChanges(); + const input = fixture.nativeElement.querySelector('.chat-project-list__edit') as HTMLInputElement; + input.value = 'Hobbies'; + input.dispatchEvent(new Event('input', { bubbles: true })); + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + await new Promise((r) => setTimeout(r, 0)); + expect(createSpy).toHaveBeenCalledWith('Hobbies'); + }); + + it('Esc on new-project input cancels without calling adapter', async () => { + const createSpy = vi.fn().mockResolvedValue({ id: 'new' }); + const fixture = render({ showNewProjectButton: true, actions: { create: createSpy } }); + (fixture.nativeElement.querySelector('.chat-project-list__new') as HTMLElement).click(); + fixture.detectChanges(); + await new Promise((r) => queueMicrotask(() => r(undefined))); + fixture.detectChanges(); + const input = fixture.nativeElement.querySelector('.chat-project-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(createSpy).not.toHaveBeenCalled(); + expect(fixture.nativeElement.querySelector('.chat-project-list__edit')).toBeNull(); + }); + + it('Rename via kebab opens edit mode with prefilled name', async () => { + const fixture = render({ actions: { rename: vi.fn().mockResolvedValue(undefined) } }); + (fixture.nativeElement.querySelector('.chat-project-list__kebab') as HTMLElement).click(); + fixture.detectChanges(); + const item = Array.from(document.querySelectorAll('.chat-overflow-menu__item')) + .find((el) => (el as HTMLElement).textContent?.trim() === 'Rename') as HTMLElement; + item.click(); + fixture.detectChanges(); + await new Promise((r) => queueMicrotask(() => r(undefined))); + fixture.detectChanges(); + const input = fixture.nativeElement.querySelector('.chat-project-list__edit') as HTMLInputElement; + expect(input.value).toBe('Work'); + }); + + it('Delete via kebab opens confirm dialog (destructive); confirm calls adapter', async () => { + let resolveDelete!: () => void; + const deleteSpy = vi.fn(() => new Promise((r) => { resolveDelete = r; })); + const fixture = render({ actions: { delete: deleteSpy } }); + (fixture.nativeElement.querySelector('.chat-project-list__kebab') as HTMLElement).click(); + fixture.detectChanges(); + const item = Array.from(document.querySelectorAll('.chat-overflow-menu__item')) + .find((el) => (el as HTMLElement).textContent?.trim() === 'Delete') as HTMLElement; + item.click(); + fixture.detectChanges(); + expect(document.querySelector('.chat-confirm-dialog')).not.toBeNull(); + (document.querySelector('.chat-confirm-dialog__confirm') as HTMLElement).click(); + fixture.detectChanges(); + expect(deleteSpy).toHaveBeenCalledWith('p1'); + const remaining = fixture.nativeElement.querySelectorAll('.chat-project-list__item'); + expect(remaining.length).toBe(1); + resolveDelete(); + await new Promise((r) => setTimeout(r, 0)); + }); + + it('Delete adapter rejects → row reappears', async () => { + const deleteSpy = vi.fn(async () => { throw new Error('boom'); }); + const fixture = render({ actions: { delete: deleteSpy } }); + (fixture.nativeElement.querySelector('.chat-project-list__kebab') as HTMLElement).click(); + fixture.detectChanges(); + const item = Array.from(document.querySelectorAll('.chat-overflow-menu__item')) + .find((el) => (el as HTMLElement).textContent?.trim() === 'Delete') as HTMLElement; + item.click(); + fixture.detectChanges(); + (document.querySelector('.chat-confirm-dialog__confirm') as HTMLElement).click(); + await new Promise((r) => setTimeout(r, 0)); + await new Promise((r) => setTimeout(r, 0)); + fixture.detectChanges(); + const remaining = fixture.nativeElement.querySelectorAll('.chat-project-list__item'); + expect(remaining.length).toBe(2); + }); +}); From 747d30c73754b02369c730b76d30fd43e5d5a85c Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 12 May 2026 16:40:02 -0700 Subject: [PATCH 09/16] feat(chat): chat-thread-list Move-to-project submenu --- .../chat-thread-list.component.spec.ts | 84 +++++++++++++++++++ .../chat-thread-list.component.ts | 58 ++++++++++++- 2 files changed, 141 insertions(+), 1 deletion(-) 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 b250bab7..fe48df5c 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 @@ -397,5 +397,89 @@ describe('ChatThreadListComponent', () => { fixture.detectChanges(); expect(fixture.nativeElement.querySelector('.chat-thread-list__kebab')).toBeNull(); }); + + it('moveToProject + projects=null → kebab hidden (no kebab means no "Move to project")', () => { + const fixture = render({ actions: { moveToProject: vi.fn().mockResolvedValue(undefined) } }); + // When projects is null, moveToProject alone does not qualify a kebab. + const kebab = fixture.nativeElement.querySelector('.chat-thread-list__kebab'); + if (kebab) { + (kebab as HTMLElement).click(); + fixture.detectChanges(); + const labels = Array.from(document.querySelectorAll('.chat-overflow-menu__item')) + .map((el) => (el as HTMLElement).textContent?.trim()); + expect(labels).not.toContain('Move to project'); + } else { + // No kebab → Move to project is definitely absent. + expect(kebab).toBeNull(); + } + }); + + it('moveToProject + projects=[] → menu includes "Move to project"', () => { + const fixture = TestBed.createComponent(ChatThreadListComponent); + fixture.componentRef.setInput('threads', [{ id: 't1', title: 'First' }]); + fixture.componentRef.setInput('actions', { moveToProject: vi.fn().mockResolvedValue(undefined) }); + fixture.componentRef.setInput('projects', []); + fixture.detectChanges(); + (fixture.nativeElement.querySelector('.chat-thread-list__kebab') as HTMLElement).click(); + fixture.detectChanges(); + const labels = Array.from(document.querySelectorAll('.chat-overflow-menu__item')) + .map((el) => (el as HTMLElement).textContent?.trim()); + expect(labels).toContain('Move to project'); + }); + + it('Click "Move to project" closes the main menu and opens the move submenu', () => { + const fixture = TestBed.createComponent(ChatThreadListComponent); + fixture.componentRef.setInput('threads', [{ id: 't1', title: 'First' }]); + fixture.componentRef.setInput('actions', { moveToProject: vi.fn().mockResolvedValue(undefined) }); + fixture.componentRef.setInput('projects', [{ id: 'p1', name: 'Work' }, { id: 'p2', name: 'Personal' }]); + fixture.detectChanges(); + (fixture.nativeElement.querySelector('.chat-thread-list__kebab') as HTMLElement).click(); + fixture.detectChanges(); + const moveItem = Array.from(document.querySelectorAll('.chat-overflow-menu__item')) + .find((el) => (el as HTMLElement).textContent?.trim() === 'Move to project') as HTMLElement; + moveItem.click(); + fixture.detectChanges(); + const labels = Array.from(document.querySelectorAll('.chat-overflow-menu__item')) + .map((el) => (el as HTMLElement).textContent?.trim()); + expect(labels).toEqual(['No project', 'Work', 'Personal']); + }); + + it('Clicking a project in the move submenu calls moveToProject with that id', async () => { + const moveSpy = vi.fn().mockResolvedValue(undefined); + const fixture = TestBed.createComponent(ChatThreadListComponent); + fixture.componentRef.setInput('threads', [{ id: 't1', title: 'First' }, { id: 't2', title: 'Second' }]); + fixture.componentRef.setInput('actions', { moveToProject: moveSpy }); + fixture.componentRef.setInput('projects', [{ id: 'p1', name: 'Work' }]); + fixture.detectChanges(); + (fixture.nativeElement.querySelector('.chat-thread-list__kebab') as HTMLElement).click(); + fixture.detectChanges(); + (Array.from(document.querySelectorAll('.chat-overflow-menu__item')) + .find((el) => (el as HTMLElement).textContent?.trim() === 'Move to project') as HTMLElement).click(); + fixture.detectChanges(); + (Array.from(document.querySelectorAll('.chat-overflow-menu__item')) + .find((el) => (el as HTMLElement).textContent?.trim() === 'Work') as HTMLElement).click(); + fixture.detectChanges(); + expect(moveSpy).toHaveBeenCalledWith('t1', 'p1'); + const remaining = fixture.nativeElement.querySelectorAll('.chat-thread-list__item'); + expect(remaining.length).toBe(1); + }); + + it('Clicking "No project" in the move submenu calls moveToProject(id, null)', () => { + const moveSpy = vi.fn().mockResolvedValue(undefined); + const fixture = TestBed.createComponent(ChatThreadListComponent); + fixture.componentRef.setInput('threads', [{ id: 't1', title: 'First' }]); + fixture.componentRef.setInput('actions', { moveToProject: moveSpy }); + fixture.componentRef.setInput('projects', [{ id: 'p1', name: 'Work' }]); + fixture.detectChanges(); + (fixture.nativeElement.querySelector('.chat-thread-list__kebab') as HTMLElement).click(); + fixture.detectChanges(); + (Array.from(document.querySelectorAll('.chat-overflow-menu__item')) + .find((el) => (el as HTMLElement).textContent?.trim() === 'Move to project') as HTMLElement).click(); + fixture.detectChanges(); + (Array.from(document.querySelectorAll('.chat-overflow-menu__item')) + .find((el) => (el as HTMLElement).textContent?.trim() === 'No project') as HTMLElement).click(); + fixture.detectChanges(); + expect(moveSpy).toHaveBeenCalledWith('t1', null); + }); }); }); 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 c7d3f242..fef7cbf7 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 @@ -20,6 +20,7 @@ import { type OverflowMenuItem, } from '../chat-overflow-menu/chat-overflow-menu.component'; import { ChatConfirmDialogComponent } from '../chat-confirm-dialog/chat-confirm-dialog.component'; +import type { Project } from '../chat-project-list/chat-project-list.component'; export type Thread = { id: string; @@ -146,6 +147,14 @@ export interface ThreadActionAdapter { (closed)="menuOpenForId.set(null)" /> + + (false); readonly actions = input(null); readonly mode = input<'active' | 'archived'>('active'); + readonly projects = input(null); readonly threadSelected = output(); readonly newThreadRequested = output(); @@ -175,6 +185,17 @@ export class ChatThreadListComponent { protected readonly menuAnchor = signal(null); protected readonly confirmDeleteId = signal(null); + protected readonly moveMenuOpenForId = signal(null); + + protected readonly moveMenuItems = computed(() => { + if (!this.moveMenuOpenForId()) return []; + const list: OverflowMenuItem[] = [{ id: '__none__', label: 'No project' }]; + for (const p of this.projects() ?? []) { + list.push({ id: p.id, label: p.name }); + } + return list; + }); + /** Ids hidden from the rendered list during pending delete, archive, or * unarchive. The framework doesn't distinguish — all three actions hide * the row from the current list until the adapter promise settles. */ @@ -201,6 +222,9 @@ export class ChatThreadListComponent { if (a.rename) items.push({ id: 'rename', label: 'Rename' }); if (a.pin && !isPinned) items.push({ id: 'pin', label: 'Pin' }); if (a.unpin && isPinned) items.push({ id: 'unpin', label: 'Unpin' }); + if (a.moveToProject && this.projects() !== null) { + items.push({ id: 'move', label: 'Move to project' }); + } if (a.archive) items.push({ id: 'archive', label: 'Archive' }); if (a.delete) items.push({ id: 'delete', label: 'Delete', tone: 'destructive' }); } else { @@ -233,7 +257,12 @@ export class ChatThreadListComponent { protected showKebab(): boolean { const a = this.actions(); if (!a) return false; - if (this.mode() === 'active') return Boolean(a.rename || a.pin || a.unpin || a.archive || a.delete); + if (this.mode() === 'active') { + return Boolean( + a.rename || a.pin || a.unpin || a.archive || a.delete || + (a.moveToProject && this.projects() !== null) + ); + } return Boolean(a.unarchive || a.delete); } @@ -262,6 +291,8 @@ export class ChatThreadListComponent { void this.performPin(threadId); } else if (id === 'unpin') { void this.performUnpin(threadId); + } else if (id === 'move') { + this.moveMenuOpenForId.set(threadId); } } @@ -348,6 +379,31 @@ export class ChatThreadListComponent { } } + protected onMoveMenuAction(itemId: string): void { + const threadId = this.moveMenuOpenForId(); + this.moveMenuOpenForId.set(null); + if (!threadId) return; + const projectId = itemId === '__none__' ? null : itemId; + void this.performMoveToProject(threadId, projectId); + } + + protected async performMoveToProject(threadId: string, projectId: string | null): Promise { + const a = this.actions(); + if (!a?.moveToProject) return; + this.pendingHidden.update((s) => new Set([...s, threadId])); + try { + await a.moveToProject(threadId, projectId); + } catch { + /* rollback via finally */ + } finally { + this.pendingHidden.update((s) => { + const n = new Set(s); + n.delete(threadId); + return n; + }); + } + } + protected async performUnarchive(threadId: string): Promise { const a = this.actions(); if (!a?.unarchive) return; From 0b4d7d5cf30edefbe6ba5da1e26404f416584cb2 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 12 May 2026 16:42:55 -0700 Subject: [PATCH 10/16] feat(chat): chat-sidenav Projects section + forwards projects to thread list Co-Authored-By: Claude Sonnet 4.6 --- .../chat-sidenav/chat-sidenav.component.ts | 28 ++++++++++++++++++- .../src/lib/styles/chat-sidenav.styles.ts | 2 ++ 2 files changed, 29 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 d439e14d..11d027a7 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 @@ -18,13 +18,18 @@ import { type Thread, type ThreadActionAdapter, } from '../../primitives/chat-thread-list/chat-thread-list.component'; +import { + ChatProjectListComponent, + type Project, + type ProjectActionAdapter, +} from '../../primitives/chat-project-list/chat-project-list.component'; export type ChatSidenavMode = 'expanded' | 'collapsed' | 'drawer'; @Component({ selector: 'chat-sidenav', standalone: true, - imports: [ChatThreadListComponent], + imports: [ChatThreadListComponent, ChatProjectListComponent], changeDetection: ChangeDetectionStrategy.OnPush, host: { '[attr.data-mode]': 'mode()', @@ -103,6 +108,20 @@ export type ChatSidenavMode = 'expanded' | 'collapsed' | 'drawer';
+ @if (projects() !== null) { +
+
Projects
+ +
+ } + @if (threads() !== null) {
Recent
@@ -110,6 +129,7 @@ export type ChatSidenavMode = 'expanded' | 'collapsed' | 'drawer'; [threads]="threads()!" [activeThreadId]="activeThreadId() ?? ''" [actions]="actions()" + [projects]="projects()" (threadSelected)="threadSelected.emit($event)" />
@@ -142,6 +162,7 @@ export type ChatSidenavMode = 'expanded' | 'collapsed' | 'drawer'; [threads]="archivedThreads()!" [activeThreadId]="activeThreadId() ?? ''" [actions]="actions()" + [projects]="projects()" (threadSelected)="threadSelected.emit($event)" /> } @@ -167,12 +188,17 @@ export class ChatSidenavComponent { readonly activeThreadId = input(null); readonly actions = input(null); readonly archivedThreads = input(null); + readonly projects = input(null); + readonly selectedProjectId = input(null); + readonly projectActions = input(null); readonly newChat = output(); readonly threadSelected = output(); readonly searchOpened = output(); readonly openChange = output(); readonly modeChange = output(); + readonly projectSelected = output(); + readonly newProjectRequested = output(); protected readonly archivedOpen = signal(false); diff --git a/libs/chat/src/lib/styles/chat-sidenav.styles.ts b/libs/chat/src/lib/styles/chat-sidenav.styles.ts index 45fff77d..a429eb9e 100644 --- a/libs/chat/src/lib/styles/chat-sidenav.styles.ts +++ b/libs/chat/src/lib/styles/chat-sidenav.styles.ts @@ -184,4 +184,6 @@ export const CHAT_SIDENAV_STYLES = ` font-size: var(--ngaf-chat-font-size-sm); } :host([data-mode="collapsed"]) .chat-sidenav__archived { display: none; } + .chat-sidenav__projects { flex-shrink: 0; } + :host([data-mode="collapsed"]) .chat-sidenav__projects { display: none; } `; From 978db8f05dacb334d8c1c654f449a0765bca1bcf Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 12 May 2026 16:43:41 -0700 Subject: [PATCH 11/16] test(chat): chat-sidenav Projects section coverage Co-Authored-By: Claude Sonnet 4.6 --- .../chat-sidenav.component.spec.ts | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/libs/chat/src/lib/compositions/chat-sidenav/chat-sidenav.component.spec.ts b/libs/chat/src/lib/compositions/chat-sidenav/chat-sidenav.component.spec.ts index 9e53dc6b..50f5b596 100644 --- a/libs/chat/src/lib/compositions/chat-sidenav/chat-sidenav.component.spec.ts +++ b/libs/chat/src/lib/compositions/chat-sidenav/chat-sidenav.component.spec.ts @@ -194,4 +194,42 @@ describe('ChatSidenavComponent', () => { fixture.detectChanges(); expect(heading.getAttribute('aria-expanded')).toBe('false'); }); + + it('projects=null renders no Projects section', () => { + const fixture = render({ threads: [{ id: 't1' }] }); + expect(fixture.nativeElement.querySelector('.chat-sidenav__projects')).toBeNull(); + }); + + it('projects=[p1,p2] renders the Projects section with two rows', () => { + const fixture = render({ threads: [{ id: 't1' }] }); + fixture.componentRef.setInput('projects', [{ id: 'p1', name: 'Work' }, { id: 'p2', name: 'Personal' }]); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('.chat-sidenav__projects')).not.toBeNull(); + const rows = fixture.nativeElement.querySelectorAll('.chat-project-list__item'); + expect(rows.length).toBe(2); + }); + + it('selectedProjectId highlights the matching project row', () => { + const fixture = render({ threads: [{ id: 't1' }] }); + fixture.componentRef.setInput('projects', [{ id: 'p1', name: 'Work' }, { id: 'p2', name: 'Personal' }]); + fixture.componentRef.setInput('selectedProjectId', 'p2'); + fixture.detectChanges(); + const rows = fixture.nativeElement.querySelectorAll('.chat-project-list__item'); + expect(rows[0].getAttribute('data-active')).toBeNull(); + expect(rows[1].getAttribute('data-active')).toBe('true'); + }); + + it('projectActions.create shows "+ New project" and emits newProjectRequested on click', () => { + const fixture = render({ threads: [{ id: 't1' }] }); + fixture.componentRef.setInput('projects', []); + fixture.componentRef.setInput('projectActions', { create: async () => ({ id: 'x' }) }); + fixture.detectChanges(); + let emits = 0; + fixture.componentInstance.newProjectRequested.subscribe(() => { emits++; }); + const btn = fixture.nativeElement.querySelector('.chat-project-list__new') as HTMLButtonElement; + expect(btn).not.toBeNull(); + btn.click(); + fixture.detectChanges(); + expect(emits).toBe(1); + }); }); From 7f0662671f98fffad8bf2a8b5bf6294d9f9d55f1 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 12 May 2026 16:43:57 -0700 Subject: [PATCH 12/16] feat(chat): export chat-project-list + Project + ProjectActionAdapter Co-Authored-By: Claude Sonnet 4.6 --- libs/chat/src/public-api.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/libs/chat/src/public-api.ts b/libs/chat/src/public-api.ts index 16a6cdf5..89b8c6e8 100644 --- a/libs/chat/src/public-api.ts +++ b/libs/chat/src/public-api.ts @@ -61,6 +61,8 @@ export type { ChatToolCallTemplateContext } from './lib/primitives/chat-tool-cal 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, ThreadActionAdapter } from './lib/primitives/chat-thread-list/chat-thread-list.component'; +export { ChatProjectListComponent } from './lib/primitives/chat-project-list/chat-project-list.component'; +export type { Project, ProjectActionAdapter } from './lib/primitives/chat-project-list/chat-project-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 eb31be476f63656236317a61788e3d8001b94971 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 12 May 2026 16:46:48 -0700 Subject: [PATCH 13/16] feat(examples-chat): ProjectsService (localStorage-backed) Co-Authored-By: Claude Sonnet 4.6 --- .../angular/src/app/shell/projects.service.ts | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 examples/chat/angular/src/app/shell/projects.service.ts diff --git a/examples/chat/angular/src/app/shell/projects.service.ts b/examples/chat/angular/src/app/shell/projects.service.ts new file mode 100644 index 00000000..69528470 --- /dev/null +++ b/examples/chat/angular/src/app/shell/projects.service.ts @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MIT +import { Injectable, signal } from '@angular/core'; +import type { Project } from '@ngaf/chat'; + +const STORAGE_KEY = 'ngaf-example-projects-v1'; + +@Injectable({ providedIn: 'root' }) +export class ProjectsService { + readonly projects = signal(this.load()); + + async create(name: string): Promise<{ id: string }> { + const id = (typeof crypto !== 'undefined' && 'randomUUID' in crypto) + ? crypto.randomUUID() + : `proj-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; + this.projects.update((p) => [{ id, name }, ...p]); + this.save(this.projects()); + return { id }; + } + + async rename(id: string, name: string): Promise { + this.projects.update((p) => p.map((x) => x.id === id ? { ...x, name } : x)); + this.save(this.projects()); + } + + async delete(id: string): Promise { + this.projects.update((p) => p.filter((x) => x.id !== id)); + this.save(this.projects()); + } + + private load(): Project[] { + if (typeof localStorage === 'undefined') return []; + try { return JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '[]'); } catch { return []; } + } + + private save(p: Project[]): void { + if (typeof localStorage === 'undefined') return; + localStorage.setItem(STORAGE_KEY, JSON.stringify(p)); + } +} From 2a1ad26f43b7674fe1e8aa9b0a3b74b5ebcd4b4a Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 12 May 2026 16:46:51 -0700 Subject: [PATCH 14/16] feat(examples-chat): ThreadsService projectId mapping + moveToProject Co-Authored-By: Claude Sonnet 4.6 --- .../angular/src/app/shell/threads.service.ts | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/examples/chat/angular/src/app/shell/threads.service.ts b/examples/chat/angular/src/app/shell/threads.service.ts index 2d148615..18246419 100644 --- a/examples/chat/angular/src/app/shell/threads.service.ts +++ b/examples/chat/angular/src/app/shell/threads.service.ts @@ -27,9 +27,11 @@ export class ThreadsService { } } - async create(): Promise { + async create(projectId?: string): Promise { try { - const t = await this.client.threads.create({ metadata: {} }); + const t = await this.client.threads.create({ + metadata: projectId !== undefined ? { projectId } : {}, + }); await this.refresh(); return t.thread_id; } catch { @@ -57,6 +59,11 @@ export class ThreadsService { await this.refresh(); } + async moveToProject(threadId: string, projectId: string | null): Promise { + await this.client.threads.update(threadId, { metadata: { projectId } }); + await this.refresh(); + } + async pin(threadId: string): Promise { await this.client.threads.update(threadId, { metadata: { pinned: true } }); await this.refresh(); @@ -69,10 +76,13 @@ export class ThreadsService { /** Best-effort title from thread metadata; falls back to a truncated id. */ private toThread(t: SdkThread): Thread { - const meta = (t.metadata ?? {}) as { title?: unknown; archived?: unknown; pinned?: unknown }; + const meta = (t.metadata ?? {}) as { title?: unknown; archived?: unknown; pinned?: unknown; projectId?: unknown }; const customTitle = meta.title; const archived = meta.archived === true; const pinned = meta.pinned === true; + const projectId = typeof meta.projectId === 'string' && meta.projectId.length > 0 + ? meta.projectId + : null; return { id: t.thread_id, title: typeof customTitle === 'string' && customTitle.length > 0 @@ -80,6 +90,7 @@ export class ThreadsService { : `Thread ${t.thread_id.slice(0, 8)}`, status: archived ? 'archived' : 'active', pinned, + projectId, }; } } From b5b3056cee46ef411cbd973e1f7bbc5de765ea49 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 12 May 2026 16:46:53 -0700 Subject: [PATCH 15/16] feat(examples-chat): wire projects + project filtering + move-to-project Co-Authored-By: Claude Sonnet 4.6 --- .../src/app/shell/demo-shell.component.html | 7 ++- .../src/app/shell/demo-shell.component.ts | 48 ++++++++++++++++++- .../app/shell/palette-persistence.service.ts | 1 + 3 files changed, 54 insertions(+), 2 deletions(-) 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 344faf9f..7c859e96 100644 --- a/examples/chat/angular/src/app/shell/demo-shell.component.html +++ b/examples/chat/angular/src/app/shell/demo-shell.component.html @@ -10,14 +10,19 @@ } so the @@ -180,6 +184,13 @@ export class DemoShell { this.viewportWidth() >= 1024 ? this.storedDesktopMode() : 'drawer', ); + /** Active threads filtered by the selected project (or all when none selected). */ + protected readonly visibleThreads = computed(() => { + const sel = this.selectedProjectId(); + const all = this.threadsSvc.threads(); + return sel === null ? all : all.filter((t) => t.projectId === sel); + }); + /** Client-side title filter over the loaded threads. */ protected readonly searchResults = computed(() => { const q = this.searchQueryDebounced().toLowerCase().trim(); @@ -227,6 +238,27 @@ 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 selectedProjectId = signal( + this.persistence.read('selectedProjectId') ?? null, + ); + + protected readonly projectActions: ProjectActionAdapter = { + create: async (name) => { + const r = await this.projectsSvc.create(name); + this.selectedProjectId.set(r.id); + this.persistence.write('selectedProjectId', r.id); + return r; + }, + rename: (id, name) => this.projectsSvc.rename(id, name), + delete: async (id) => { + await this.projectsSvc.delete(id); + if (this.selectedProjectId() === id) { + this.selectedProjectId.set(null); + this.persistence.write('selectedProjectId', null); + } + }, + }; + protected readonly threadActions: ThreadActionAdapter = { delete: async (id) => { await this.threadsSvc.delete(id); @@ -246,6 +278,9 @@ export class DemoShell { unarchive: (id) => this.threadsSvc.unarchive(id), pin: (id) => this.threadsSvc.pin(id), unpin: (id) => this.threadsSvc.unpin(id), + moveToProject: async (id, projectId) => { + await this.threadsSvc.moveToProject(id, projectId); + }, }; /** @@ -345,6 +380,16 @@ export class DemoShell { this.persistence.write('threadId', threadId); } + protected onProjectSelected(projectId: string): void { + this.selectedProjectId.set(projectId); + this.persistence.write('selectedProjectId', projectId); + } + + protected onNewProjectClicked(): void { + // Framework's chat-project-list owns the inline-create flow; this is an + // informational event for the consumer. + } + protected onSearchSelect(threadId: string): void { this.onThreadSelected(threadId); this.paletteOpen.set(false); @@ -353,7 +398,8 @@ export class DemoShell { /** Create a new thread via the backend and switch to it. */ protected async onNewThread(): Promise { - const id = await this.threadsSvc.create(); + const sel = this.selectedProjectId(); + const id = await this.threadsSvc.create(sel ?? undefined); if (id) { this.threadIdSignal.set(id); this.persistence.write('threadId', id); diff --git a/examples/chat/angular/src/app/shell/palette-persistence.service.ts b/examples/chat/angular/src/app/shell/palette-persistence.service.ts index 1e798d2e..6bf77235 100644 --- a/examples/chat/angular/src/app/shell/palette-persistence.service.ts +++ b/examples/chat/angular/src/app/shell/palette-persistence.service.ts @@ -11,6 +11,7 @@ interface PaletteState { threadId?: string | null; drawerOpen?: boolean | null; sidenavMode?: 'expanded' | 'collapsed' | null; + selectedProjectId?: string | null; } type PaletteKey = keyof PaletteState; From 6d2254ceb9ab18eddfa135f3d942bb41d4d60913 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 12 May 2026 16:47:15 -0700 Subject: [PATCH 16/16] docs(chat): regenerate API docs for Projects (Phase 4) Co-Authored-By: Claude Opus 4.7 (1M context) --- .../content/docs/chat/api/api-docs.json | 340 ++++++++++++++++++ 1 file changed, 340 insertions(+) diff --git a/apps/website/content/docs/chat/api/api-docs.json b/apps/website/content/docs/chat/api/api-docs.json index 0d50687a..2e654adb 100644 --- a/apps/website/content/docs/chat/api/api-docs.json +++ b/apps/website/content/docs/chat/api/api-docs.json @@ -3113,6 +3113,227 @@ } ] }, + { + "name": "ChatProjectListComponent", + "kind": "class", + "description": "", + "params": [], + "examples": [], + "properties": [ + { + "name": "actions", + "type": "InputSignal", + "description": "", + "optional": false + }, + { + "name": "activeProjectId", + "type": "InputSignal", + "description": "", + "optional": false + }, + { + "name": "confirmDeleteId", + "type": "WritableSignal", + "description": "", + "optional": false + }, + { + "name": "creatingProject", + "type": "WritableSignal", + "description": "", + "optional": false + }, + { + "name": "creatingValue", + "type": "WritableSignal", + "description": "", + "optional": false + }, + { + "name": "currentMenuItems", + "type": "Signal", + "description": "", + "optional": false + }, + { + "name": "editingProjectId", + "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": "newProjectRequested", + "type": "OutputEmitterRef", + "description": "", + "optional": false + }, + { + "name": "projects", + "type": "InputSignal", + "description": "", + "optional": false + }, + { + "name": "projectSelected", + "type": "OutputEmitterRef", + "description": "", + "optional": false + }, + { + "name": "showNewProjectButton", + "type": "InputSignal", + "description": "", + "optional": false + }, + { + "name": "visibleProjects", + "type": "Signal", + "description": "", + "optional": false + } + ], + "methods": [ + { + "name": "cancelCreate", + "signature": "cancelCreate()", + "description": "", + "params": [] + }, + { + "name": "cancelRename", + "signature": "cancelRename()", + "description": "", + "params": [] + }, + { + "name": "commitCreate", + "signature": "commitCreate()", + "description": "", + "params": [] + }, + { + "name": "commitRename", + "signature": "commitRename(projectId: string)", + "description": "", + "params": [ + { + "name": "projectId", + "type": "string", + "description": "", + "optional": false + } + ] + }, + { + "name": "onCreateInput", + "signature": "onCreateInput(e: Event)", + "description": "", + "params": [ + { + "name": "e", + "type": "Event", + "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": "onNewProjectClicked", + "signature": "onNewProjectClicked()", + "description": "", + "params": [] + }, + { + "name": "openMenu", + "signature": "openMenu(projectId: string, anchor: HTMLElement)", + "description": "", + "params": [ + { + "name": "projectId", + "type": "string", + "description": "", + "optional": false + }, + { + "name": "anchor", + "type": "HTMLElement", + "description": "", + "optional": false + } + ] + }, + { + "name": "performDelete", + "signature": "performDelete()", + "description": "", + "params": [] + }, + { + "name": "selectProject", + "signature": "selectProject(projectId: string)", + "description": "", + "params": [ + { + "name": "projectId", + "type": "string", + "description": "", + "optional": false + } + ] + }, + { + "name": "showKebab", + "signature": "showKebab()", + "description": "", + "params": [] + } + ] + }, { "name": "ChatReasoningComponent", "kind": "class", @@ -3424,6 +3645,12 @@ "description": "", "optional": false }, + { + "name": "newProjectRequested", + "type": "OutputEmitterRef", + "description": "", + "optional": false + }, { "name": "open", "type": "InputSignal", @@ -3436,12 +3663,36 @@ "description": "", "optional": false }, + { + "name": "projectActions", + "type": "InputSignal", + "description": "", + "optional": false + }, + { + "name": "projects", + "type": "InputSignal", + "description": "", + "optional": false + }, + { + "name": "projectSelected", + "type": "OutputEmitterRef", + "description": "", + "optional": false + }, { "name": "searchOpened", "type": "OutputEmitterRef", "description": "", "optional": false }, + { + "name": "selectedProjectId", + "type": "InputSignal", + "description": "", + "optional": false + }, { "name": "threads", "type": "InputSignal", @@ -3649,12 +3900,30 @@ "description": "", "optional": false }, + { + "name": "moveMenuItems", + "type": "Signal", + "description": "", + "optional": false + }, + { + "name": "moveMenuOpenForId", + "type": "WritableSignal", + "description": "", + "optional": false + }, { "name": "newThreadRequested", "type": "OutputEmitterRef", "description": "", "optional": false }, + { + "name": "projects", + "type": "InputSignal", + "description": "", + "optional": false + }, { "name": "showNewThreadButton", "type": "InputSignal", @@ -3732,6 +4001,19 @@ } ] }, + { + "name": "onMoveMenuAction", + "signature": "onMoveMenuAction(itemId: string)", + "description": "", + "params": [ + { + "name": "itemId", + "type": "string", + "description": "", + "optional": false + } + ] + }, { "name": "openMenu", "signature": "openMenu(threadId: string, anchor: HTMLElement)", @@ -3770,6 +4052,25 @@ "description": "", "params": [] }, + { + "name": "performMoveToProject", + "signature": "performMoveToProject(threadId: string, projectId: string | null)", + "description": "", + "params": [ + { + "name": "threadId", + "type": "string", + "description": "", + "optional": false + }, + { + "name": "projectId", + "type": "string | null", + "description": "", + "optional": false + } + ] + }, { "name": "performPin", "signature": "performPin(threadId: string)", @@ -5862,6 +6163,32 @@ ], "examples": [] }, + { + "name": "ProjectActionAdapter", + "kind": "interface", + "description": "Consumer-provided adapter for project lifecycle actions. The framework calls\nthese methods after user confirmation (delete) or commit (create/rename) and\nmanages optimistic UI + rollback on rejection.", + "properties": [ + { + "name": "create", + "type": "unknown", + "description": "", + "optional": true + }, + { + "name": "delete", + "type": "unknown", + "description": "", + "optional": true + }, + { + "name": "rename", + "type": "unknown", + "description": "", + "optional": true + } + ], + "examples": [] + }, { "name": "ResolvedCitation", "kind": "interface", @@ -5977,6 +6304,12 @@ "description": "", "optional": true }, + { + "name": "moveToProject", + "type": "unknown", + "description": "", + "optional": true + }, { "name": "pin", "type": "unknown", @@ -6217,6 +6550,13 @@ "signature": "\"human\" | \"ai\" | \"tool\" | \"system\" | \"function\"", "examples": [] }, + { + "name": "Project", + "kind": "type", + "description": "", + "signature": "unknown", + "examples": [] + }, { "name": "Role", "kind": "type",