Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions apps/website/content/docs/chat/api/api-docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -3758,6 +3758,19 @@
"description": "",
"params": []
},
{
"name": "performPin",
"signature": "performPin(threadId: string)",
"description": "",
"params": [
{
"name": "threadId",
"type": "string",
"description": "",
"optional": false
}
]
},
{
"name": "performUnarchive",
"signature": "performUnarchive(threadId: string)",
Expand All @@ -3771,6 +3784,19 @@
}
]
},
{
"name": "performUnpin",
"signature": "performUnpin(threadId: string)",
"description": "",
"params": [
{
"name": "threadId",
"type": "string",
"description": "",
"optional": false
}
]
},
{
"name": "relativeTime",
"signature": "relativeTime(epochMs: number)",
Expand Down Expand Up @@ -5939,6 +5965,12 @@
"description": "",
"optional": true
},
{
"name": "pin",
"type": "unknown",
"description": "",
"optional": true
},
{
"name": "rename",
"type": "unknown",
Expand All @@ -5950,6 +5982,12 @@
"type": "unknown",
"description": "",
"optional": true
},
{
"name": "unpin",
"type": "unknown",
"description": "",
"optional": true
}
],
"examples": []
Expand Down
56 changes: 56 additions & 0 deletions docs/superpowers/plans/2026-05-12-chat-pin.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# Chat per-row pin (Phase 3d) Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Add per-thread pin/unpin to `@ngaf/chat` plus an archived-search freebie in the example.

**Architecture:** Framework adds typed `Thread.pinned` + adapter `pin/unpin` methods + pin-aware menu + pin icon. No ordering logic — consumer pre-sorts. Example wires LangGraph SDK metadata.

**Tech Stack:** Angular 20 signals, Nx, Vitest, LangGraph SDK.

See: `docs/superpowers/specs/2026-05-12-chat-pin-design.md`.

---

### Task 1: Framework type + menu + icon

**Files:**
- Modify: `libs/chat/src/lib/primitives/chat-thread-list/chat-thread-list.component.ts`
- Modify: `libs/chat/src/lib/styles/chat-thread-list.styles.ts`
- Modify: `libs/chat/src/lib/primitives/chat-thread-list/chat-thread-list.component.spec.ts`

- [ ] Extend `Thread` with optional `pinned?: boolean` (before the `[key: string]: unknown` index signature).
- [ ] Extend `ThreadActionAdapter` with optional `pin?(threadId): Promise<void>` and `unpin?(threadId): Promise<void>`.
- [ ] Update `currentMenuItems` so active mode includes Pin/Unpin (look up thread on `this.threads()`, not `visibleThreads()`).
- [ ] Update `showKebab` to include pin/unpin in the active branch.
- [ ] Route `pin` and `unpin` ids in `onMenuAction` to new `performPin/performUnpin` methods.
- [ ] Add `performPin` and `performUnpin` (no optimistic hide).
- [ ] Update template: prepend a small SVG pin inside `chat-thread-list__item-title` when `thread.pinned`.
- [ ] Append `.chat-thread-list__item-pin` CSS rule.
- [ ] Add 6 spec cases inside `describe('with adapter', ...)`: pin shown when not pinned; pin hidden when pinned; unpin shown when pinned; both provided & not pinned → Pin; both provided & pinned → Unpin; SVG renders only when `thread.pinned === true`.
- [ ] Run `nx run chat:test`; all green.
- [ ] Run `nx run chat:build && nx lint chat`; clean.

---

### Task 2: Example wiring (ThreadsService + demo-shell)

**Files:**
- Modify: `examples/chat/angular/src/app/shell/threads.service.ts`
- Modify: `examples/chat/angular/src/app/shell/demo-shell.component.ts`

- [ ] Add `pin(threadId)` and `unpin(threadId)` to `ThreadsService` (PATCH `metadata.pinned`, refresh).
- [ ] Update `toThread` to read `meta.pinned`.
- [ ] Update `refresh()` to sort active threads pinned-first (stable for archived).
- [ ] Extend `demo-shell.threadActions` with `pin`/`unpin`.
- [ ] Replace `searchResults` computed to concat active + archived (with `subtitle: 'Archived'`), capped at 50.
- [ ] Run `nx run examples-chat-angular:build`; clean.

---

### Task 3: Docs + verification

- [ ] Run `npx tsx apps/website/scripts/generate-api-docs.ts`.
- [ ] Browser-verify pin/unpin path via preview.
- [ ] Commit, open PR `feat(chat): per-row pin (Phase 3d) + archived-search`.
- [ ] Squash-merge on green.
47 changes: 47 additions & 0 deletions docs/superpowers/specs/2026-05-12-chat-pin-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# Chat per-row pin (Phase 3d) — design

**Goal:** Add per-thread pin/unpin to `@ngaf/chat` with a small archived-search example tweak.

## Design principles

- Framework stays dumb about ordering. Consumers pre-sort threads pinned-first.
- No optimistic icon flip — pin icon updates after the consumer refreshes.
- Active mode only — archived threads aren't pinnable.

## Surface additions to `@ngaf/chat`

### `Thread.pinned?: boolean`
Typed documentation field. Framework renders a pin icon when true.

### `ThreadActionAdapter.pin?` / `.unpin?`
```ts
pin?(threadId: string): Promise<void>;
unpin?(threadId: string): Promise<void>;
```

### Menu behavior
- Active mode only.
- Pin shown when adapter has `pin` AND row is not pinned.
- Unpin shown when adapter has `unpin` AND row IS pinned.
- Pinned-state lookup goes against original `threads()` (NOT `visibleThreads()`).

### Pin icon
Small SVG prepended inside `chat-thread-list__item-title` when `thread.pinned === true`.

## Example consumer wiring

- `ThreadsService.pin/unpin`: PATCH `metadata.pinned`, then refresh.
- `toThread`: read `meta.pinned`.
- `refresh()` sorts active threads pinned-first.
- `demo-shell.threadActions`: add `pin` and `unpin`.

## Archived-search freebie (bundled)

`demo-shell.searchResults` extends to include archived matches with `subtitle: 'Archived'`.

## Out of scope

- Drag-to-reorder pinned threads.
- Pin from archived view.
- Per-pin timestamp / pin-order.
- Optimistic pin icon flip.
9 changes: 7 additions & 2 deletions examples/chat/angular/src/app/shell/demo-shell.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,10 +176,13 @@ export class DemoShell {
protected readonly searchResults = computed<ThreadMatch[]>(() => {
const q = this.searchQueryDebounced().toLowerCase().trim();
if (!q) return [];
return this.threadsSvc.threads()
const active = this.threadsSvc.threads()
.filter((t) => (t.title ?? '').toLowerCase().includes(q))
.slice(0, 50)
.map((t) => ({ id: t.id, title: t.title ?? t.id }));
const archived = this.threadsSvc.archivedThreads()
.filter((t) => (t.title ?? '').toLowerCase().includes(q))
.map((t) => ({ id: t.id, title: t.title ?? t.id, subtitle: 'Archived' }));
return [...active, ...archived].slice(0, 50);
});

protected readonly modeOptions = [
Expand Down Expand Up @@ -233,6 +236,8 @@ export class DemoShell {
}
},
unarchive: (id) => this.threadsSvc.unarchive(id),
pin: (id) => this.threadsSvc.pin(id),
unpin: (id) => this.threadsSvc.unpin(id),
};

/**
Expand Down
20 changes: 18 additions & 2 deletions examples/chat/angular/src/app/shell/threads.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,11 @@ export class ThreadsService {
try {
const list = await this.client.threads.search({ limit: 50 });
const mapped = list.map((t) => this.toThread(t));
this.threads.set(mapped.filter((t) => t.status !== 'archived'));
this.threads.set(
mapped
.filter((t) => t.status !== 'archived')
.sort((a, b) => Number(b.pinned ?? false) - Number(a.pinned ?? false)),
);
this.archivedThreads.set(mapped.filter((t) => t.status === 'archived'));
} catch {
// Backend may be down; leave signals as-is.
Expand Down Expand Up @@ -53,17 +57,29 @@ export class ThreadsService {
await this.refresh();
}

async pin(threadId: string): Promise<void> {
await this.client.threads.update(threadId, { metadata: { pinned: true } });
await this.refresh();
}

async unpin(threadId: string): Promise<void> {
await this.client.threads.update(threadId, { metadata: { pinned: false } });
await this.refresh();
}

/** Best-effort title from thread metadata; falls back to a truncated id. */
private toThread(t: SdkThread): Thread {
const meta = (t.metadata ?? {}) as { title?: unknown; archived?: unknown };
const meta = (t.metadata ?? {}) as { title?: unknown; archived?: unknown; pinned?: unknown };
const customTitle = meta.title;
const archived = meta.archived === true;
const pinned = meta.pinned === true;
return {
id: t.thread_id,
title: typeof customTitle === 'string' && customTitle.length > 0
? customTitle
: `Thread ${t.thread_id.slice(0, 8)}`,
status: archived ? 'archived' : 'active',
pinned,
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,77 @@ describe('ChatThreadListComponent', () => {
expect(remaining.length).toBe(2);
});

it('Pin: action provided + row not pinned → menu includes "Pin"', () => {
const fixture = render({ actions: { pin: noop } });
(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('Pin');
expect(labels).not.toContain('Unpin');
});

it('Pin: action provided + row pinned → menu does NOT include "Pin" (and no Unpin since unpin not provided)', () => {
const fixture = render({
threads: [{ id: 't1', title: 'First', pinned: true }, { id: 't2', title: 'Second' }],
actions: { pin: noop },
});
(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('Pin');
expect(labels).not.toContain('Unpin');
});

it('Unpin: action provided + row pinned → menu includes "Unpin"', () => {
const fixture = render({
threads: [{ id: 't1', title: 'First', pinned: true }, { id: 't2', title: 'Second' }],
actions: { unpin: noop },
});
(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('Unpin');
expect(labels).not.toContain('Pin');
});

it('Pin+Unpin both provided, row not pinned → menu has "Pin" not "Unpin"', () => {
const fixture = render({ actions: { pin: noop, unpin: noop } });
(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('Pin');
expect(labels).not.toContain('Unpin');
});

it('Pin+Unpin both provided, row pinned → menu has "Unpin" not "Pin"', () => {
const fixture = render({
threads: [{ id: 't1', title: 'First', pinned: true }, { id: 't2', title: 'Second' }],
actions: { pin: noop, unpin: noop },
});
(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('Unpin');
expect(labels).not.toContain('Pin');
});

it('Pin icon SVG renders only when thread.pinned === true', () => {
const fixture = render({
threads: [{ id: 't1', title: 'First', pinned: true }, { id: 't2', title: 'Second' }],
actions: { pin: noop, unpin: noop },
});
const pins = fixture.nativeElement.querySelectorAll('.chat-thread-list__item-pin');
expect(pins.length).toBe(1);
const titles = fixture.nativeElement.querySelectorAll('.chat-thread-list__item-title');
expect(titles[0].querySelector('.chat-thread-list__item-pin')).not.toBeNull();
expect(titles[1].querySelector('.chat-thread-list__item-pin')).toBeNull();
});

it('mode="archived" with only rename+archive (no unarchive/delete) hides the kebab', () => {
const fixture = TestBed.createComponent(ChatThreadListComponent);
fixture.componentRef.setInput('threads', [{ id: 't1', title: 'A', status: 'archived' as const }]);
Expand Down
Loading
Loading