From 251d1a960c64de5ca5d61797c9a9e81dae3ca699 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 12 May 2026 19:03:30 -0700 Subject: [PATCH 01/10] docs(specs): chat pinned-thread reorder design Drag-to-reorder via grip handle on desktop; "Move up" / "Move down" menu items on mobile (and as discoverable fallback on desktop). Anchor-based adapter event: reorderPinned(threadId, beforeId | null). Framework owns optimistic visual reorder + rollback. Scoped to pinned threads only. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../2026-05-12-chat-pinned-reorder-design.md | 448 ++++++++++++++++++ 1 file changed, 448 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-12-chat-pinned-reorder-design.md diff --git a/docs/superpowers/specs/2026-05-12-chat-pinned-reorder-design.md b/docs/superpowers/specs/2026-05-12-chat-pinned-reorder-design.md new file mode 100644 index 000000000..fdf9bce75 --- /dev/null +++ b/docs/superpowers/specs/2026-05-12-chat-pinned-reorder-design.md @@ -0,0 +1,448 @@ +# Chat pinned-thread reorder — design + +**Date:** 2026-05-12 +**Surface:** `@ngaf/chat` — extends `chat-thread-list` and `ThreadActionAdapter`. No new primitives. +**Status:** Design approved; ready for implementation plan + +## Summary + +Add drag-to-reorder for **pinned** threads. Desktop: a hover-revealed grip handle initiates native HTML5 drag-and-drop. Mobile (and as a discoverable fallback on desktop): "Move up" / "Move down" entries in the row's existing overflow menu. Both code paths converge on `ThreadActionAdapter.reorderPinned?(threadId, beforeId | null)` — framework owns optimistic visual reorder + rollback on rejection. + +Out of scope: reordering unpinned threads (recency stays the sort key); full manual ordering of all threads; touch-drag gestures (replaced by the menu items for mobile). + +## Goals + +- Scope drag/reorder to pinned threads only. +- Reuse existing primitives (overflow menu) — no new components. +- Adapter-driven, consistent with rename/archive/delete/pin/moveToProject. +- Anchor-based event shape (`beforeId | null`) — stable across concurrent state mutation. +- Discoverable on mobile without touch-drag complexity. + +## Non-goals + +- Reordering unpinned threads. +- Custom ordering per-project (project filter applies independently of reorder). +- Touch-drag long-press gesture (resolved during scoping; mobile users use Move-up/Move-down menu items). +- Adding `@angular/cdk` as a dep for CDK DragDrop (~150KB gzipped; rejected to keep the framework slim). +- Drag-handle keyboard alternative (the menu items cover this; keyboard users select via Tab to kebab → arrow keys). + +## Public type addition + +```ts +export interface ThreadActionAdapter { + // ... existing + /** 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; +} +``` + +No change to the `Thread` type. The framework does not require a `pinnedOrder` field on `Thread`; consumers pre-sort pinned threads however they like. The example happens to use `metadata.pinnedOrder` in LangGraph, but other consumers could use array index, lexicographic keys, or anything else. + +## `chat-thread-list` extensions + +### New input + +None — `projects` and `actions` already cover everything needed. + +### New internal state + +```ts +private readonly pendingOrder = signal>(new Map()); +protected readonly draggingThreadId = signal(null); +protected readonly dropTarget = signal<{ threadId: string; position: 'before' | 'after' } | null>(null); +``` + +`pendingOrder` is an override map: each entry says "move thread X to before thread Y" (or `null` for end-of-pinned-list). Multiple entries compose by insertion order. + +### Updated `visibleThreads` computed + +```ts +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; +}); +``` + +### Grip handle in the row template + +A ` +} +``` + +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. From b9e89a0fa1813e9589c4f6fb3e474dfb38125cf3 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 12 May 2026 19:06:48 -0700 Subject: [PATCH 02/10] docs(plans): pinned-thread reorder implementation plan 11 tasks: ThreadActionAdapter.reorderPinned, pendingOrder state + visibleThreads override, drag handlers, Move-up/Move-down menu items, grip handle template + drag attributes, styles, tests, example ThreadsService.reorderPinned (LangGraph metadata.pinnedOrder), demo-shell threadAction, API docs regen, browser verification. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../plans/2026-05-12-chat-pinned-reorder.md | 936 ++++++++++++++++++ 1 file changed, 936 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-12-chat-pinned-reorder.md 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 ` + }