diff --git a/apps/website/content/docs/chat/api/api-docs.json b/apps/website/content/docs/chat/api/api-docs.json index 2e654adbc..64b18ccfb 100644 --- a/apps/website/content/docs/chat/api/api-docs.json +++ b/apps/website/content/docs/chat/api/api-docs.json @@ -3870,6 +3870,18 @@ "description": "", "optional": false }, + { + "name": "draggingThreadId", + "type": "WritableSignal", + "description": "Id of the thread currently being dragged via HTML5 drag-and-drop.", + "optional": false + }, + { + "name": "dropTarget", + "type": "WritableSignal", + "description": "Drop target during a drag: which row, and whether the indicator shows\n on the top edge ('before') or bottom edge ('after').", + "optional": false + }, { "name": "editingThreadId", "type": "WritableSignal", @@ -3975,6 +3987,101 @@ } ] }, + { + "name": "dropPositionFor", + "signature": "dropPositionFor(threadId: string)", + "description": "", + "params": [ + { + "name": "threadId", + "type": "string", + "description": "", + "optional": false + } + ] + }, + { + "name": "onDragEnd", + "signature": "onDragEnd()", + "description": "", + "params": [] + }, + { + "name": "onDragLeave", + "signature": "onDragLeave(_e: DragEvent, threadId: string)", + "description": "", + "params": [ + { + "name": "_e", + "type": "DragEvent", + "description": "", + "optional": false + }, + { + "name": "threadId", + "type": "string", + "description": "", + "optional": false + } + ] + }, + { + "name": "onDragOver", + "signature": "onDragOver(e: DragEvent, threadId: string)", + "description": "", + "params": [ + { + "name": "e", + "type": "DragEvent", + "description": "", + "optional": false + }, + { + "name": "threadId", + "type": "string", + "description": "", + "optional": false + } + ] + }, + { + "name": "onDragStart", + "signature": "onDragStart(e: DragEvent, threadId: string)", + "description": "", + "params": [ + { + "name": "e", + "type": "DragEvent", + "description": "", + "optional": false + }, + { + "name": "threadId", + "type": "string", + "description": "", + "optional": false + } + ] + }, + { + "name": "onDrop", + "signature": "onDrop(e: DragEvent, targetThreadId: string)", + "description": "", + "params": [ + { + "name": "e", + "type": "DragEvent", + "description": "", + "optional": false + }, + { + "name": "targetThreadId", + "type": "string", + "description": "", + "optional": false + } + ] + }, { "name": "onEditInput", "signature": "onEditInput(e: Event)", @@ -4052,6 +4159,19 @@ "description": "", "params": [] }, + { + "name": "performMoveDown", + "signature": "performMoveDown(threadId: string)", + "description": "", + "params": [ + { + "name": "threadId", + "type": "string", + "description": "", + "optional": false + } + ] + }, { "name": "performMoveToProject", "signature": "performMoveToProject(threadId: string, projectId: string | null)", @@ -4071,6 +4191,19 @@ } ] }, + { + "name": "performMoveUp", + "signature": "performMoveUp(threadId: string)", + "description": "", + "params": [ + { + "name": "threadId", + "type": "string", + "description": "", + "optional": false + } + ] + }, { "name": "performPin", "signature": "performPin(threadId: string)", @@ -4084,6 +4217,25 @@ } ] }, + { + "name": "performReorderPinned", + "signature": "performReorderPinned(threadId: string, beforeId: string | null)", + "description": "", + "params": [ + { + "name": "threadId", + "type": "string", + "description": "", + "optional": false + }, + { + "name": "beforeId", + "type": "string | null", + "description": "", + "optional": false + } + ] + }, { "name": "performUnarchive", "signature": "performUnarchive(threadId: string)", @@ -6322,6 +6474,12 @@ "description": "", "optional": true }, + { + "name": "reorderPinned", + "type": "unknown", + "description": "", + "optional": true + }, { "name": "unarchive", "type": "unknown", diff --git a/docs/superpowers/plans/2026-05-12-chat-pinned-reorder.md b/docs/superpowers/plans/2026-05-12-chat-pinned-reorder.md new file mode 100644 index 000000000..5cf17bd56 --- /dev/null +++ b/docs/superpowers/plans/2026-05-12-chat-pinned-reorder.md @@ -0,0 +1,936 @@ +# Chat pinned-thread reorder — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Drag-to-reorder pinned threads in `@ngaf/chat` via a grip handle (desktop) or Move-up/Move-down menu items (mobile + discoverable fallback). Adapter-driven via `ThreadActionAdapter.reorderPinned?(threadId, beforeId | null)`. Framework owns optimistic visual reorder + rollback on rejection. + +**Architecture:** No new primitives. Extends `chat-thread-list` with `pendingOrder` override state and HTML5 drag-and-drop handlers on each `
  • `. Native drag-drop (no `@angular/cdk` dep). Menu items reuse the existing overflow-menu primitive. Anchor-based event shape (`beforeId | null`) — stable across concurrent mutation. + +**Tech Stack:** Angular 21 standalone components, signal inputs/outputs, plain CSS strings, Vitest + Angular TestBed, native HTML5 drag-and-drop API, `@langchain/langgraph-sdk` for thread metadata updates. + +**Spec:** [docs/superpowers/specs/2026-05-12-chat-pinned-reorder-design.md](../specs/2026-05-12-chat-pinned-reorder-design.md) + +--- + +## File map + +**Modify:** +- `libs/chat/src/lib/primitives/chat-thread-list/chat-thread-list.component.ts` — `ThreadActionAdapter.reorderPinned?`; `pendingOrder` + drag state signals; updated `visibleThreads`, `currentMenuItems`, `showKebab`; new drag handlers + Move-up/Down handlers; grip handle + `
  • ` drag attributes in template; second handler set for the second overflow-menu instance is not needed (Move-up/Down items reuse the main menu). +- `libs/chat/src/lib/primitives/chat-thread-list/chat-thread-list.component.spec.ts` — reorder coverage. +- `libs/chat/src/lib/styles/chat-thread-list.styles.ts` — grip handle + drop indicator styles. +- `examples/chat/angular/src/app/shell/threads.service.ts` — `reorderPinned` method; `toThread` reads `metadata.pinnedOrder`; `refresh` sort prioritizes `pinnedOrder` within pinned. +- `examples/chat/angular/src/app/shell/demo-shell.component.ts` — extend `threadActions` with `reorderPinned`. +- `apps/website/content/docs/chat/api/api-docs.json` — regenerated. + +**No new files.** + +--- + +## Task 1: ThreadActionAdapter.reorderPinned + +**Files:** +- Modify: `libs/chat/src/lib/primitives/chat-thread-list/chat-thread-list.component.ts` + +- [ ] **Step 1: Add `reorderPinned?` to the interface** + +In `libs/chat/src/lib/primitives/chat-thread-list/chat-thread-list.component.ts`, find the `ThreadActionAdapter` interface. After the existing `moveToProject?` method (around line 70-72), add: + +```typescript +/** Reorder a pinned thread. `beforeId` is the id of the pinned thread it + * should be placed before, or null to move to the end of the pinned list. + * Framework optimistically reorders the visible list and awaits this + * method; rejection rolls back. */ +reorderPinned?(threadId: string, beforeId: string | null): Promise; +``` + +- [ ] **Step 2: Build** + +Run: `npx nx run chat:build` +Expected: PASS. + +- [ ] **Step 3: Commit** + +```bash +git add libs/chat/src/lib/primitives/chat-thread-list/chat-thread-list.component.ts +git commit -m "feat(chat): ThreadActionAdapter.reorderPinned" +``` + +--- + +## Task 2: pendingOrder state + visibleThreads update + +**Files:** +- Modify: `libs/chat/src/lib/primitives/chat-thread-list/chat-thread-list.component.ts` + +- [ ] **Step 1: Add three new state signals** + +After the existing `pendingRenames` declaration (around line 203), add: + +```typescript +/** Pending reorder overrides for pinned threads. Each entry: "move this id + * to before that id (or to end if null)". Cleared in `finally` after the + * adapter call settles. */ +private readonly pendingOrder = signal>(new Map()); + +/** Id of the thread currently being dragged via HTML5 drag-and-drop. */ +protected readonly draggingThreadId = signal(null); + +/** Drop target during a drag: which row, and whether the indicator shows + * on the top edge ('before') or bottom edge ('after'). */ +protected readonly dropTarget = signal<{ threadId: string; position: 'before' | 'after' } | null>(null); +``` + +- [ ] **Step 2: Update `visibleThreads` computed** + +Find the existing `visibleThreads` computed (around line 205). Replace its body to also apply `pendingOrder`: + +```typescript +protected readonly visibleThreads = computed(() => { + const hidden = this.pendingHidden(); + const renames = this.pendingRenames(); + const pending = this.pendingOrder(); + + let result = this.threads() + .filter((t) => !hidden.has(t.id)) + .map((t) => (renames.has(t.id) ? ({ ...t, title: renames.get(t.id) }) : t)); + + if (pending.size > 0) { + const pinned = result.filter((t) => t.pinned === true); + const unpinned = result.filter((t) => t.pinned !== true); + for (const [threadId, beforeId] of pending) { + const idx = pinned.findIndex((t) => t.id === threadId); + if (idx < 0) continue; + const [moved] = pinned.splice(idx, 1); + if (beforeId === null) { + pinned.push(moved); + } else { + const beforeIdx = pinned.findIndex((t) => t.id === beforeId); + if (beforeIdx < 0) pinned.push(moved); + else pinned.splice(beforeIdx, 0, moved); + } + } + result = [...pinned, ...unpinned]; + } + return result; +}); +``` + +- [ ] **Step 3: Build** + +Run: `npx nx run chat:build` +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +git add libs/chat/src/lib/primitives/chat-thread-list/chat-thread-list.component.ts +git commit -m "feat(chat): pendingOrder + drag state signals for reorder" +``` + +--- + +## Task 3: Drag handlers + reorder methods + +**Files:** +- Modify: `libs/chat/src/lib/primitives/chat-thread-list/chat-thread-list.component.ts` + +- [ ] **Step 1: Add `performReorderPinned` method** + +Below the existing `performMoveToProject` method (around line 405-420), add: + +```typescript +protected async performReorderPinned(threadId: string, beforeId: string | null): Promise { + const a = this.actions(); + if (!a?.reorderPinned) return; + this.pendingOrder.update((m) => { + const n = new Map(m); + n.set(threadId, beforeId); + return n; + }); + try { + await a.reorderPinned(threadId, beforeId); + } catch { + /* rollback via finally */ + } finally { + this.pendingOrder.update((m) => { + const n = new Map(m); + n.delete(threadId); + return n; + }); + } +} +``` + +- [ ] **Step 2: Add Move-up / Move-down helpers** + +Below `performReorderPinned`, add: + +```typescript +protected async performMoveUp(threadId: string): Promise { + const pinned = this.threads().filter((t) => t.pinned === true); + const idx = pinned.findIndex((t) => t.id === threadId); + if (idx <= 0) return; + const beforeId = pinned[idx - 1].id; + await this.performReorderPinned(threadId, beforeId); +} + +protected async performMoveDown(threadId: string): Promise { + const pinned = this.threads().filter((t) => t.pinned === true); + const idx = pinned.findIndex((t) => t.id === threadId); + if (idx < 0 || idx >= pinned.length - 1) return; + const beforeId = idx + 2 < pinned.length ? pinned[idx + 2].id : null; + await this.performReorderPinned(threadId, beforeId); +} +``` + +- [ ] **Step 3: Add drag handlers** + +Below the move helpers, add: + +```typescript +protected onDragStart(e: DragEvent, threadId: string): void { + const dt = e.dataTransfer; + if (!dt) return; + dt.setData('text/plain', threadId); + dt.effectAllowed = 'move'; + this.draggingThreadId.set(threadId); +} + +protected onDragOver(e: DragEvent, threadId: string): void { + const dragging = this.draggingThreadId(); + if (!dragging || dragging === threadId) return; + // Only allow drop on pinned rows. + const target = this.threads().find((t) => t.id === threadId); + if (target?.pinned !== true) return; + e.preventDefault(); + if (e.dataTransfer) e.dataTransfer.dropEffect = 'move'; + // Compute drop position from offset within the row. + const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); + const offsetY = e.clientY - rect.top; + const position: 'before' | 'after' = offsetY < rect.height / 2 ? 'before' : 'after'; + const cur = this.dropTarget(); + if (!cur || cur.threadId !== threadId || cur.position !== position) { + this.dropTarget.set({ threadId, position }); + } +} + +protected onDragLeave(_e: DragEvent, threadId: string): void { + if (this.dropTarget()?.threadId === threadId) { + this.dropTarget.set(null); + } +} + +protected onDrop(e: DragEvent, targetThreadId: string): void { + e.preventDefault(); + const dragId = e.dataTransfer?.getData('text/plain') ?? this.draggingThreadId(); + const target = this.dropTarget(); + this.draggingThreadId.set(null); + this.dropTarget.set(null); + if (!dragId || dragId === targetThreadId || !target) return; + + // Compute beforeId from drop position. + const pinned = this.threads().filter((t) => t.pinned === true); + const targetIdx = pinned.findIndex((t) => t.id === targetThreadId); + if (targetIdx < 0) return; + let beforeId: string | null; + if (target.position === 'before') { + beforeId = targetThreadId; + } else { + // Drop AFTER targetThreadId — find the pinned thread that currently + // comes after the target (skipping the dragged thread itself). + const filteredPinned = pinned.filter((t) => t.id !== dragId); + const filteredTargetIdx = filteredPinned.findIndex((t) => t.id === targetThreadId); + beforeId = filteredTargetIdx + 1 < filteredPinned.length + ? filteredPinned[filteredTargetIdx + 1].id + : null; + } + void this.performReorderPinned(dragId, beforeId); +} + +protected onDragEnd(): void { + this.draggingThreadId.set(null); + this.dropTarget.set(null); +} + +protected dropPositionFor(threadId: string): 'before' | 'after' | null { + const t = this.dropTarget(); + return t?.threadId === threadId ? t.position : null; +} +``` + +- [ ] **Step 4: Build** + +Run: `npx nx run chat:build` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add libs/chat/src/lib/primitives/chat-thread-list/chat-thread-list.component.ts +git commit -m "feat(chat): drag handlers + reorder methods" +``` + +--- + +## Task 4: Update menu items, showKebab, onMenuAction + +**Files:** +- Modify: `libs/chat/src/lib/primitives/chat-thread-list/chat-thread-list.component.ts` + +- [ ] **Step 1: Update `currentMenuItems`** + +Find the existing `currentMenuItems` computed (around line 213). The active-mode branch currently has rename → pin/unpin → moveToProject → archive → delete. Insert Move-up/Move-down between pin/unpin and moveToProject: + +```typescript +protected readonly currentMenuItems = computed(() => { + const id = this.menuOpenForId(); + if (!id) return []; + const a = this.actions(); + if (!a) return []; + const items: OverflowMenuItem[] = []; + if (this.mode() === 'active') { + 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 (isPinned && a.reorderPinned) { + const pinned = this.threads().filter((t) => t.pinned === true); + const pinnedIdx = pinned.findIndex((t) => t.id === id); + if (pinnedIdx > 0) items.push({ id: 'move-up', label: 'Move up' }); + if (pinnedIdx >= 0 && pinnedIdx < pinned.length - 1) items.push({ id: 'move-down', label: 'Move down' }); + } + + 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 { + if (a.unarchive) items.push({ id: 'unarchive', label: 'Unarchive' }); + if (a.delete) items.push({ id: 'delete', label: 'Delete', tone: 'destructive' }); + } + return items; +}); +``` + +- [ ] **Step 2: Update `showKebab` for active mode** + +Find the existing `showKebab` method (around line 257). Replace its active-mode body to include `reorderPinned`: + +```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 || + a.reorderPinned || + (a.moveToProject && this.projects() !== null) + ); + } + return Boolean(a.unarchive || a.delete); +} +``` + +- [ ] **Step 3: Update `onMenuAction`** + +Find the existing `onMenuAction` method (around line 274). It currently has branches for rename/delete/archive/unarchive/pin/unpin/move. Add two more branches BEFORE the closing brace of the method: + +```typescript +} else if (id === 'move-up') { + void this.performMoveUp(threadId); +} else if (id === 'move-down') { + void this.performMoveDown(threadId); +} +``` + +- [ ] **Step 4: Build + lint** + +Run: `npx nx run chat:build && npx nx lint chat 2>&1 | tail -3` +Expected: build PASS, lint clean. + +- [ ] **Step 5: Commit** + +```bash +git add libs/chat/src/lib/primitives/chat-thread-list/chat-thread-list.component.ts +git commit -m "feat(chat): Move-up/Move-down menu items" +``` + +--- + +## Task 5: Template — grip handle + drag attributes + +**Files:** +- Modify: `libs/chat/src/lib/primitives/chat-thread-list/chat-thread-list.component.ts` + +- [ ] **Step 1: Update the `
  • ` to accept drag** + +Find the existing `
  • ` in the template (around line 87). Replace its opening tag with: + +```html +
  • +``` + +- [ ] **Step 2: Add the grip handle** + +Inside the `@else {` branch (the branch that renders the regular row button + kebab — the one not in editing or template mode), at the START of that branch (BEFORE the existing ` +} +``` + +- [ ] **Step 3: Build + lint** + +Run: `npx nx run chat:build && npx nx lint chat 2>&1 | tail -3` +Expected: build PASS, lint clean. If lint flags `interactive-supports-focus` on the grip button, it's a real ` +} +``` + +The `draggable="false"` on the button is intentional — the `
  • ` is the actual drag source so the button can be hit-tested separately for things like keyboard focus and the menu it triggers (none for now; the grip is just a visual affordance). + +### Row drag mechanics + +The `
  • ` gets `draggable` based on pinned + adapter availability: + +```html +
  • +``` + +Handler logic (concrete code in the plan; sketches here): + +- `onDragStart(e, threadId)`: + - Set `draggingThreadId.set(threadId)`. + - `e.dataTransfer.setData('text/plain', threadId)`; `e.dataTransfer.effectAllowed = 'move'`. +- `onDragOver(e, threadId)`: + - If `draggingThreadId() === threadId` (dragging over self) → do nothing. + - If target thread is NOT pinned → do nothing (drop disallowed; cursor will show no-drop). + - Else `e.preventDefault()` (allow drop); compute position from `e.offsetY` vs. row midpoint; set `dropTarget`. +- `onDragLeave(e, threadId)`: + - Clear `dropTarget` only if `dropTarget()?.threadId === threadId`. +- `onDrop(e, threadId)`: + - `e.preventDefault()`. + - Read `dragId = e.dataTransfer.getData('text/plain')` (or use `draggingThreadId()`). + - Compute `beforeId`: if position='before', `threadId`; if position='after', the next pinned thread's id (or `null` if this was the last pinned). + - Call `performReorderPinned(dragId, beforeId)`. +- `onDragEnd()`: + - Clear `draggingThreadId` and `dropTarget`. + +Helper `dropPositionFor(threadId)`: +```ts +protected dropPositionFor(threadId: string): 'before' | 'after' | null { + const t = this.dropTarget(); + return t?.threadId === threadId ? t.position : null; +} +``` + +### Menu items: Move up / Move down + +`currentMenuItems` (active mode) gets two new conditional entries between Pin/Unpin and the existing items. Only show when: +- `thread.pinned === true` +- `actions().reorderPinned` exists +- The thread has at least one pinned sibling that direction + +Implementation: derive the pinned-index when computing the menu items. + +```ts +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 (isPinned && a.reorderPinned) { + const pinned = this.threads().filter((t) => t.pinned === true); + const pinnedIdx = pinned.findIndex((t) => t.id === id); + if (pinnedIdx > 0) items.push({ id: 'move-up', label: 'Move up' }); + if (pinnedIdx >= 0 && pinnedIdx < pinned.length - 1) items.push({ id: 'move-down', label: 'Move down' }); + } + + 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' }); +} +``` + +### `onMenuAction` routing + +Add two new branches: + +```ts +} else if (id === 'move-up') { + void this.performMoveUp(threadId); +} else if (id === 'move-down') { + void this.performMoveDown(threadId); +} +``` + +### `performMoveUp` / `performMoveDown` + +```ts +protected async performMoveUp(threadId: string): Promise { + const pinned = this.threads().filter((t) => t.pinned === true); + const idx = pinned.findIndex((t) => t.id === threadId); + if (idx <= 0) return; + // Move before the previous pinned thread. + const beforeId = pinned[idx - 1].id; + return this.performReorderPinned(threadId, beforeId); +} + +protected async performMoveDown(threadId: string): Promise { + const pinned = this.threads().filter((t) => t.pinned === true); + const idx = pinned.findIndex((t) => t.id === threadId); + if (idx < 0 || idx >= pinned.length - 1) return; + // Move before the thread that is currently two positions ahead, or null if that's past the end. + const beforeId = idx + 2 < pinned.length ? pinned[idx + 2].id : null; + return this.performReorderPinned(threadId, beforeId); +} + +protected async performReorderPinned(threadId: string, beforeId: string | null): Promise { + const a = this.actions(); + if (!a?.reorderPinned) return; + this.pendingOrder.update((m) => { + const n = new Map(m); + n.set(threadId, beforeId); + return n; + }); + try { + await a.reorderPinned(threadId, beforeId); + } catch { + /* rollback via finally */ + } finally { + this.pendingOrder.update((m) => { + const n = new Map(m); + n.delete(threadId); + return n; + }); + } +} +``` + +### Update `showKebab` + +The existing kebab logic already returns true for pinned threads when actions has pin/unpin. Add `reorderPinned` to the active-mode check: + +```ts +if (this.mode() === 'active') { + return Boolean( + a.rename || a.pin || a.unpin || a.archive || a.delete || + a.reorderPinned || + (a.moveToProject && this.projects() !== null) + ); +} +``` + +### Styles + +In `chat-thread-list.styles.ts`: + +```css +.chat-thread-list__grip { + flex-shrink: 0; + width: 16px; + height: 28px; + margin-right: 2px; + padding: 0; + border: 0; + background: transparent; + color: var(--ngaf-chat-text-muted); + cursor: grab; + opacity: 0; + transition: opacity 100ms ease; + font-size: 11px; + line-height: 1; + letter-spacing: -1px; + user-select: none; +} +.chat-thread-list__item-wrap:hover .chat-thread-list__grip, +.chat-thread-list__item-wrap:focus-within .chat-thread-list__grip { + opacity: 1; +} +.chat-thread-list__grip:active { cursor: grabbing; } + +.chat-thread-list__item-wrap[data-dragging="true"] { + opacity: 0.4; +} + +.chat-thread-list__item-wrap[data-drop-position="before"]::before, +.chat-thread-list__item-wrap[data-drop-position="after"]::after { + content: ''; + position: absolute; + left: 4px; + right: 4px; + height: 2px; + background: var(--ngaf-chat-primary); + border-radius: 1px; + pointer-events: none; +} +.chat-thread-list__item-wrap[data-drop-position="before"]::before { top: -1px; } +.chat-thread-list__item-wrap[data-drop-position="after"]::after { bottom: -1px; } +``` + +(Note: the existing `.chat-thread-list__item-wrap` already has `position: relative` for the kebab — verify this is true in the current source; if not, add it.) + +## Example wiring + +### `ThreadsService.reorderPinned` + +LangGraph has no native ordering for threads. We store `metadata.pinnedOrder: number` and re-sequence the affected pinned threads to integers 0..N-1 each time the order changes. + +```ts +async reorderPinned(threadId: string, beforeId: string | null): Promise { + // Pull the current pinned set (read-only; pending optimistic moves + // are in the framework, not visible here). + const current = this.threads().filter((t) => t.pinned === true); + const moved = current.find((t) => t.id === threadId); + if (!moved) return; + const rest = current.filter((t) => t.id !== threadId); + const next: Thread[] = []; + for (const t of rest) { + if (t.id === beforeId) next.push(moved); + next.push(t); + } + if (beforeId === null) next.push(moved); + + // `next` is the desired pinned order. Stamp metadata.pinnedOrder = 0,1,2,... + await Promise.all(next.map((t, idx) => + this.client.threads.update(t.id, { metadata: { pinnedOrder: idx } }) + )); + await this.refresh(); +} +``` + +### `toThread` reads `metadata.pinnedOrder` + +```ts +const pinnedOrder = typeof meta.pinnedOrder === 'number' ? meta.pinnedOrder : undefined; +return { + id: t.thread_id, + title: ..., + status: ..., + projectId: ..., + pinned: ..., + pinnedOrder, +}; +``` + +(`pinnedOrder` lives in the open `[key: string]: unknown` shape of Thread — no public type change needed.) + +### `refresh` sorts pinned threads by `pinnedOrder` + +The existing sort puts pinned threads first. Refine: pinned threads sorted by `pinnedOrder ascending` (treating undefined as `Infinity` so legacy un-numbered pins sink to the bottom of the pinned section), then unpinned threads in their existing recency order. + +```ts +this.threads.set( + mapped + .filter((t) => t.status !== 'archived') + .sort((a, b) => { + const aPinned = a.pinned === true; + const bPinned = b.pinned === true; + if (aPinned !== bPinned) return Number(bPinned) - Number(aPinned); + if (aPinned && bPinned) { + const aOrd = typeof a.pinnedOrder === 'number' ? a.pinnedOrder : Infinity; + const bOrd = typeof b.pinnedOrder === 'number' ? b.pinnedOrder : Infinity; + return aOrd - bOrd; + } + return 0; // existing recency order between unpinned threads preserved + }) +); +``` + +### Demo shell + +Add to existing `threadActions`: + +```ts +reorderPinned: (id, beforeId) => this.threadsSvc.reorderPinned(id, beforeId), +``` + +No template changes — the new behavior is fully internal to `chat-thread-list`. + +## Edge cases + +1. **Drop on self.** `dragover` short-circuits; the drop indicator never shows for the source row. Safe. +2. **Drag onto unpinned row.** `dragover` short-circuits (no `preventDefault`); the browser shows "no-drop" cursor. Drop never fires. +3. **`pinnedOrder` not present on some pinned threads.** Legacy/unpinned-via-different-path. Treated as `Infinity` → sinks to bottom of pinned section. First reorder re-sequences and fixes it. +4. **Concurrent drag-and-menu use.** Both code paths converge on `performReorderPinned`. Pending overrides compose by insertion order in the map. If a user clicks "Move up" while a drag is mid-flight, the drag's pending entry remains until its adapter promise settles; the click's entry is added on top. Practically rare. +5. **`reorderPinned` adapter rejects.** Pending entry cleared in `finally`; visible order falls back to `threads()` input (the unchanged consumer state). User sees the row snap back. +6. **Two pinned threads.** "Move up" on the second moves it to first (`beforeId` = first thread's id). "Move down" on the first moves to last (`beforeId = null`). Edge case but correct. +7. **Browser drag-cancellation (Esc during drag).** `dragend` fires without a preceding `drop`. State clears; no adapter call. Correct. + +## Testing + +### Unit tests (~10 new it cases) + +In `chat-thread-list.component.spec.ts`, inside the `describe('with adapter', ...)` block: + +1. `actions.reorderPinned` + pinned row → grip handle renders (`.chat-thread-list__grip` selector). +2. `actions.reorderPinned` + unpinned row → grip handle does NOT render. +3. `actions.reorderPinned` not provided → grip handle does NOT render on pinned rows. +4. Pinned thread that is NOT first → menu includes "Move up". +5. Pinned thread that is NOT last → menu includes "Move down". +6. First pinned thread → menu does NOT include "Move up" (but DOES include "Move down" if not also last). +7. Last pinned thread → menu does NOT include "Move down" (but DOES include "Move up" if not also first). +8. Only pinned thread (singleton) → menu has NEITHER "Move up" nor "Move down". +9. Click "Move up" → calls `actions.reorderPinned(threadId, previousPinned.id)`. +10. Click "Move down" on last → calls `actions.reorderPinned(threadId, null)`. +11. Click "Move down" on second-of-three → calls `actions.reorderPinned(threadId, thirdPinned.id)`. +12. Reorder adapter rejects → row falls back to original position in visibleThreads. + +### Manual (Chrome MCP) + +1. Pin two threads (use existing Pin menu action twice on different threads). +2. Hover the second pinned row → grip handle (⋮⋮) fades in on the left. +3. Drag the grip onto the first pinned row's TOP half → drop indicator line appears above the first row. +4. Release → row order swaps; LangGraph metadata persisted (each pinned thread's `metadata.pinnedOrder` re-stamped). +5. Open kebab on the (now-first) pinned row → menu shows "Move down" but NOT "Move up". +6. Click Move down → row swaps back via menu path. +7. Reload page → pinned order persists (read from `metadata.pinnedOrder`). + +### Build / lint + +- `nx run chat:test` — passes (existing + ~12 new). +- `nx run chat:build && nx lint chat` — clean. +- `nx run examples-chat-angular:build` — clean. + +## Accessibility + +- Grip button has `aria-label="Drag to reorder"`. It is focusable and renders only on hover/focus-within of the row, so keyboard users see it when they Tab to it. +- Menu items "Move up" / "Move down" are accessible via the existing overflow-menu pattern (`role="menuitem"`, ArrowUp/Down nav). +- Drop indicator is purely visual; not announced. Users on screen readers should use the menu items. +- Drag operations are not announced by default (HTML5 drag-drop is not screen-reader-friendly). The menu items are the screen-reader-accessible path. +- `prefers-reduced-motion`: the row opacity transition during drag is 100ms; light enough that reduced-motion doesn't materially benefit from gating. The drop-indicator pseudo-elements have no transition. The next phase (prefers-reduced-motion audit) can revisit if needed. + +## Performance + +- One new state signal (`pendingOrder`); two transient ones (`draggingThreadId`, `dropTarget`). +- `visibleThreads` computed cost grows from O(n) to O(n + k·m) where k = pendingOrder.size and m = pinned threads. Realistic n is small (~50); k is 1-2; m is ~5. Trivial. +- Native HTML5 drag-drop has no library overhead. +- Drop indicator via `::before`/`::after` pseudo-elements; no extra DOM nodes. + +## Open questions / assumptions + +- **Assumption:** Consumers who don't provide `reorderPinned` get no grip handle and no Move-up/down menu items — the feature is fully opt-in. +- **Assumption:** The LangGraph example's `Promise.all(next.map(...))` for re-stamping `pinnedOrder` is acceptable. A real production backend would do this in a single transaction; for the demo, N parallel PATCHes is fine (typically N ≤ 10). +- **Open:** Whether to add keyboard reordering (Ctrl+ArrowUp/Down while a row is focused). Deferred — Move-up/Move-down menu items cover keyboard users; adding bare-key shortcuts is a nice-to-have. +- **Open:** Whether the grip should be on the LEFT (current spec) or RIGHT (next to the kebab). LEFT matches Slack/ChatGPT; RIGHT is more visually balanced with the kebab. Current spec says LEFT. diff --git a/examples/chat/angular/src/app/shell/demo-shell.component.ts b/examples/chat/angular/src/app/shell/demo-shell.component.ts index ea6040c91..a5ad1fd57 100644 --- a/examples/chat/angular/src/app/shell/demo-shell.component.ts +++ b/examples/chat/angular/src/app/shell/demo-shell.component.ts @@ -281,6 +281,7 @@ export class DemoShell { moveToProject: async (id, projectId) => { await this.threadsSvc.moveToProject(id, projectId); }, + reorderPinned: (id, beforeId) => this.threadsSvc.reorderPinned(id, beforeId), }; /** diff --git a/examples/chat/angular/src/app/shell/threads.service.ts b/examples/chat/angular/src/app/shell/threads.service.ts index b76fd2c13..4a323207b 100644 --- a/examples/chat/angular/src/app/shell/threads.service.ts +++ b/examples/chat/angular/src/app/shell/threads.service.ts @@ -19,7 +19,17 @@ export class ThreadsService { this.threads.set( mapped .filter((t) => t.status !== 'archived') - .sort((a, b) => Number(b.pinned ?? false) - Number(a.pinned ?? false)), + .sort((a, b) => { + const aPinned = a.pinned === true; + const bPinned = b.pinned === true; + if (aPinned !== bPinned) return Number(bPinned) - Number(aPinned); + if (aPinned && bPinned) { + const aOrd = typeof a.pinnedOrder === 'number' ? a.pinnedOrder : Infinity; + const bOrd = typeof b.pinnedOrder === 'number' ? b.pinnedOrder : Infinity; + return aOrd - bOrd; + } + return 0; + }), ); this.archivedThreads.set(mapped.filter((t) => t.status === 'archived')); } catch { @@ -74,6 +84,27 @@ export class ThreadsService { await this.refresh(); } + async reorderPinned(threadId: string, beforeId: string | null): Promise { + const current = this.threads().filter((t) => t.pinned === true); + const moved = current.find((t) => t.id === threadId); + if (!moved) return; + const rest = current.filter((t) => t.id !== threadId); + const next: Thread[] = []; + for (const t of rest) { + if (t.id === beforeId) next.push(moved); + next.push(t); + } + if (beforeId === null) next.push(moved); + + // Re-stamp metadata.pinnedOrder = 0,1,2,... in the desired order. + await Promise.all( + next.map((t, idx) => + this.client.threads.update(t.id, { metadata: { pinnedOrder: idx } }), + ), + ); + await this.refresh(); + } + /** Best-effort title from thread metadata. * * The backend writes `metadata.title` from the first user message in a @@ -84,13 +115,14 @@ export class ThreadsService { * chat apps surface drafts. */ private toThread(t: SdkThread): Thread { - const meta = (t.metadata ?? {}) as { title?: unknown; archived?: unknown; pinned?: unknown; projectId?: unknown }; + const meta = (t.metadata ?? {}) as { title?: unknown; archived?: unknown; pinned?: unknown; projectId?: unknown; pinnedOrder?: 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; + const pinnedOrder = typeof meta.pinnedOrder === 'number' ? meta.pinnedOrder : undefined; return { id: t.thread_id, title: typeof customTitle === 'string' && customTitle.length > 0 @@ -99,6 +131,7 @@ export class ThreadsService { status: archived ? 'archived' : 'active', pinned, projectId, + pinnedOrder, }; } } 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 fe48df5cd..8f60035e1 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 @@ -481,5 +481,231 @@ describe('ChatThreadListComponent', () => { fixture.detectChanges(); expect(moveSpy).toHaveBeenCalledWith('t1', null); }); + + it('grip handle renders for pinned rows when reorderPinned provided', () => { + const fixture = TestBed.createComponent(ChatThreadListComponent); + fixture.componentRef.setInput('threads', [ + { id: 'p1', title: 'P1', pinned: true }, + { id: 't1', title: 'T1' }, + ]); + fixture.componentRef.setInput('actions', { reorderPinned: vi.fn().mockResolvedValue(undefined) }); + fixture.detectChanges(); + const grips = fixture.nativeElement.querySelectorAll('.chat-thread-list__grip'); + expect(grips.length).toBe(1); + }); + + it('grip handle does NOT render for unpinned rows', () => { + const fixture = TestBed.createComponent(ChatThreadListComponent); + fixture.componentRef.setInput('threads', [{ id: 't1', title: 'T1' }]); + fixture.componentRef.setInput('actions', { reorderPinned: vi.fn().mockResolvedValue(undefined) }); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('.chat-thread-list__grip')).toBeNull(); + }); + + it('grip handle does NOT render when reorderPinned absent', () => { + const fixture = TestBed.createComponent(ChatThreadListComponent); + fixture.componentRef.setInput('threads', [{ id: 'p1', title: 'P1', pinned: true }]); + fixture.componentRef.setInput('actions', {}); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('.chat-thread-list__grip')).toBeNull(); + }); + + it('menu on pinned thread that is NOT first → includes "Move up"', () => { + const fixture = TestBed.createComponent(ChatThreadListComponent); + fixture.componentRef.setInput('threads', [ + { id: 'p1', title: 'P1', pinned: true }, + { id: 'p2', title: 'P2', pinned: true }, + ]); + fixture.componentRef.setInput('actions', { reorderPinned: vi.fn().mockResolvedValue(undefined) }); + fixture.detectChanges(); + const kebabs = fixture.nativeElement.querySelectorAll('.chat-thread-list__kebab'); + (kebabs[1] 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 up'); + expect(labels).not.toContain('Move down'); + }); + + it('menu on pinned thread that is NOT last → includes "Move down"', () => { + const fixture = TestBed.createComponent(ChatThreadListComponent); + fixture.componentRef.setInput('threads', [ + { id: 'p1', title: 'P1', pinned: true }, + { id: 'p2', title: 'P2', pinned: true }, + ]); + fixture.componentRef.setInput('actions', { reorderPinned: vi.fn().mockResolvedValue(undefined) }); + fixture.detectChanges(); + const kebabs = fixture.nativeElement.querySelectorAll('.chat-thread-list__kebab'); + (kebabs[0] 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 down'); + expect(labels).not.toContain('Move up'); + }); + + it('singleton pinned thread → menu has neither Move up nor Move down', () => { + const fixture = TestBed.createComponent(ChatThreadListComponent); + fixture.componentRef.setInput('threads', [{ id: 'p1', title: 'P1', pinned: true }]); + fixture.componentRef.setInput('actions', { reorderPinned: vi.fn().mockResolvedValue(undefined) }); + 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).not.toContain('Move up'); + expect(labels).not.toContain('Move down'); + }); + + it('Click Move up → calls reorderPinned with previous-pinned id', async () => { + const spy = vi.fn().mockResolvedValue(undefined); + const fixture = TestBed.createComponent(ChatThreadListComponent); + fixture.componentRef.setInput('threads', [ + { id: 'p1', title: 'P1', pinned: true }, + { id: 'p2', title: 'P2', pinned: true }, + { id: 'p3', title: 'P3', pinned: true }, + ]); + fixture.componentRef.setInput('actions', { reorderPinned: spy }); + fixture.detectChanges(); + const kebabs = fixture.nativeElement.querySelectorAll('.chat-thread-list__kebab'); + (kebabs[2] as HTMLElement).click(); + fixture.detectChanges(); + const moveUp = Array.from(document.querySelectorAll('.chat-overflow-menu__item')) + .find((el) => (el as HTMLElement).textContent?.trim() === 'Move up') as HTMLElement; + moveUp.click(); + await new Promise((r) => setTimeout(r, 0)); + expect(spy).toHaveBeenCalledWith('p3', 'p2'); + }); + + it('Click Move down on last → calls reorderPinned with null', async () => { + const spy = vi.fn().mockResolvedValue(undefined); + const fixture = TestBed.createComponent(ChatThreadListComponent); + fixture.componentRef.setInput('threads', [ + { id: 'p1', title: 'P1', pinned: true }, + { id: 'p2', title: 'P2', pinned: true }, + ]); + fixture.componentRef.setInput('actions', { reorderPinned: spy }); + fixture.detectChanges(); + const kebabs = fixture.nativeElement.querySelectorAll('.chat-thread-list__kebab'); + (kebabs[0] as HTMLElement).click(); + fixture.detectChanges(); + const moveDown = Array.from(document.querySelectorAll('.chat-overflow-menu__item')) + .find((el) => (el as HTMLElement).textContent?.trim() === 'Move down') as HTMLElement; + moveDown.click(); + await new Promise((r) => setTimeout(r, 0)); + expect(spy).toHaveBeenCalledWith('p1', null); + }); + + it('Click Move down on second-of-three → calls reorderPinned with null (moves to end)', async () => { + const spy = vi.fn().mockResolvedValue(undefined); + const fixture = TestBed.createComponent(ChatThreadListComponent); + fixture.componentRef.setInput('threads', [ + { id: 'p1', title: 'P1', pinned: true }, + { id: 'p2', title: 'P2', pinned: true }, + { id: 'p3', title: 'P3', pinned: true }, + ]); + fixture.componentRef.setInput('actions', { reorderPinned: spy }); + fixture.detectChanges(); + const kebabs = fixture.nativeElement.querySelectorAll('.chat-thread-list__kebab'); + (kebabs[1] as HTMLElement).click(); + fixture.detectChanges(); + const moveDown = Array.from(document.querySelectorAll('.chat-overflow-menu__item')) + .find((el) => (el as HTMLElement).textContent?.trim() === 'Move down') as HTMLElement; + moveDown.click(); + await new Promise((r) => setTimeout(r, 0)); + // p2 moves to after p3 — beforeId is whatever comes after p3, which is null (end). + expect(spy).toHaveBeenCalledWith('p2', null); + }); + + it('reorder adapter rejects → visible order falls back to input', async () => { + const spy = vi.fn(async () => { throw new Error('boom'); }); + const fixture = TestBed.createComponent(ChatThreadListComponent); + fixture.componentRef.setInput('threads', [ + { id: 'p1', title: 'P1', pinned: true }, + { id: 'p2', title: 'P2', pinned: true }, + ]); + fixture.componentRef.setInput('actions', { reorderPinned: spy }); + fixture.detectChanges(); + const kebabs = fixture.nativeElement.querySelectorAll('.chat-thread-list__kebab'); + (kebabs[1] as HTMLElement).click(); + fixture.detectChanges(); + const moveUp = Array.from(document.querySelectorAll('.chat-overflow-menu__item')) + .find((el) => (el as HTMLElement).textContent?.trim() === 'Move up') as HTMLElement; + moveUp.click(); + await new Promise((r) => setTimeout(r, 0)); + await new Promise((r) => setTimeout(r, 0)); + fixture.detectChanges(); + const titles = Array.from(fixture.nativeElement.querySelectorAll('.chat-thread-list__item-title')) + .map((el) => (el as HTMLElement).textContent?.trim().replace(/\s+/g, ' ')); + expect(titles[0]).toContain('P1'); + expect(titles[1]).toContain('P2'); + }); + + it('drag-and-drop: drop "before" target calls reorderPinned with target id', async () => { + const spy = vi.fn().mockResolvedValue(undefined); + const fixture = TestBed.createComponent(ChatThreadListComponent); + fixture.componentRef.setInput('threads', [ + { id: 'p1', title: 'P1', pinned: true }, + { id: 'p2', title: 'P2', pinned: true }, + ]); + fixture.componentRef.setInput('actions', { reorderPinned: spy }); + fixture.detectChanges(); + const wraps = fixture.nativeElement.querySelectorAll('.chat-thread-list__item-wrap'); + // jsdom doesn't layout; stub rects so before/after threshold works. + (wraps[0] as HTMLElement).getBoundingClientRect = () => ({ top: 0, bottom: 40, height: 40, left: 0, right: 100, width: 100, x: 0, y: 0, toJSON: () => ({}) }) as DOMRect; + (wraps[1] as HTMLElement).getBoundingClientRect = () => ({ top: 40, bottom: 80, height: 40, left: 0, right: 100, width: 100, x: 0, y: 40, toJSON: () => ({}) }) as DOMRect; + // jsdom doesn't construct DragEvent with dataTransfer; stub it. + const dt = { setData: vi.fn(), getData: vi.fn((k: string) => k === 'text/plain' ? 'p2' : ''), effectAllowed: 'move', dropEffect: 'move' } as unknown as DataTransfer; + const dragStart = new Event('dragstart', { bubbles: true }) as DragEvent; + Object.defineProperty(dragStart, 'dataTransfer', { value: dt }); + wraps[1].dispatchEvent(dragStart); + fixture.detectChanges(); + + const dragOver = new Event('dragover', { bubbles: true }) as DragEvent; + Object.defineProperty(dragOver, 'dataTransfer', { value: dt }); + Object.defineProperty(dragOver, 'clientY', { value: 2 }); + Object.defineProperty(dragOver, 'currentTarget', { value: wraps[0] }); + wraps[0].dispatchEvent(dragOver); + fixture.detectChanges(); + + const drop = new Event('drop', { bubbles: true }) as DragEvent; + Object.defineProperty(drop, 'dataTransfer', { value: dt }); + wraps[0].dispatchEvent(drop); + await new Promise((r) => setTimeout(r, 0)); + expect(spy).toHaveBeenCalledWith('p2', 'p1'); + }); + + it('drag-and-drop: drop "after" last pinned calls reorderPinned with null', async () => { + const spy = vi.fn().mockResolvedValue(undefined); + const fixture = TestBed.createComponent(ChatThreadListComponent); + fixture.componentRef.setInput('threads', [ + { id: 'p1', title: 'P1', pinned: true }, + { id: 'p2', title: 'P2', pinned: true }, + ]); + fixture.componentRef.setInput('actions', { reorderPinned: spy }); + fixture.detectChanges(); + const wraps = fixture.nativeElement.querySelectorAll('.chat-thread-list__item-wrap'); + // jsdom doesn't layout; stub rects so before/after threshold works. + (wraps[0] as HTMLElement).getBoundingClientRect = () => ({ top: 0, bottom: 40, height: 40, left: 0, right: 100, width: 100, x: 0, y: 0, toJSON: () => ({}) }) as DOMRect; + (wraps[1] as HTMLElement).getBoundingClientRect = () => ({ top: 40, bottom: 80, height: 40, left: 0, right: 100, width: 100, x: 0, y: 40, toJSON: () => ({}) }) as DOMRect; + const dt = { setData: vi.fn(), getData: vi.fn((k: string) => k === 'text/plain' ? 'p1' : ''), effectAllowed: 'move', dropEffect: 'move' } as unknown as DataTransfer; + const dragStart = new Event('dragstart', { bubbles: true }) as DragEvent; + Object.defineProperty(dragStart, 'dataTransfer', { value: dt }); + wraps[0].dispatchEvent(dragStart); + fixture.detectChanges(); + + const dragOver = new Event('dragover', { bubbles: true }) as DragEvent; + Object.defineProperty(dragOver, 'dataTransfer', { value: dt }); + Object.defineProperty(dragOver, 'clientY', { value: 78 }); + Object.defineProperty(dragOver, 'currentTarget', { value: wraps[1] }); + wraps[1].dispatchEvent(dragOver); + fixture.detectChanges(); + + const drop = new Event('drop', { bubbles: true }) as DragEvent; + Object.defineProperty(drop, 'dataTransfer', { value: dt }); + wraps[1].dispatchEvent(drop); + await new Promise((r) => setTimeout(r, 0)); + expect(spy).toHaveBeenCalledWith('p1', 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 fef7cbf78..57fec0538 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 @@ -70,6 +70,11 @@ export interface ThreadActionAdapter { * 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; + /** Reorder a pinned thread. `beforeId` is the id of the pinned thread it + * should be placed before, or null to move to the end of the pinned list. + * Framework optimistically reorders the visible list and awaits this + * method; rejection rolls back. */ + reorderPinned?(threadId: string, beforeId: string | null): Promise; } @Component({ @@ -84,7 +89,17 @@ export interface ThreadActionAdapter { }
      @for (thread of visibleThreads(); track thread.id) { -
    • +
    • @if (templateRef()) { } @else { + @if (thread.pinned && actions()?.reorderPinned) { + + }