Files
kandev/docs/plans/message-queue-pin/plan.md
T
2026-08-24 11:09:03 +00:00

157 lines
6.7 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
spec: docs/specs/ui/requirements/message-queue-pin.md
created: 2026-08-12
status: building
---
# Implementation Plan: Pin the Message Queue Panel
## Overview
Add a per-session pin toggle to the expanded message queue panel header
(placed between **Clear all** and **X**), persisted in `localStorage`. While
pinned, the queue panel opens automatically on mount so navigating away and
back (or reloading) no longer forces the user to click the queue chip. The
change is frontend-only: a small localStorage-backed hook, a header button,
open-state integration in `QueueAffordance`, i18n keys, unit tests, and one
E2E spec. No backend contract changes.
---
## Frontend
### New hook: `apps/web/hooks/use-queue-pinned.ts` (+ `.test.ts`)
Per-session boolean preference, modeled on the existing
`useLocalStorageBoolean` pattern (`useSyncExternalStore` + `storage` event +
custom sync event):
- Storage key `kandev:queue:pinned:<session_id>:v1`; absent/unreadable → `false`.
- Sync event name: `kandev:queue:pinned-changed`.
- Accept `sessionId: string | null`; when `null`, return `{ value: false,
setValue: noop }` without touching storage.
- `setValue(next)` writes the literal `"true"`/`"false"` and broadcasts the
sync event; failed writes throw like `useLocalStorageBoolean` (UI callers
catch and ignore to satisfy the spec's silent-degradation failure mode).
### `apps/web/components/task/chat/queued-ghost-panel-header.tsx`
- New props: `pinned: boolean`, `onTogglePin: () => void`.
- Insert a toggle button between the **Clear all** button and the **X** close
button:
- `data-testid="queue-pin"`, `aria-pressed={pinned}`,
`title`/`aria-label` from `t("chat:pinQueuedMessages")` /
`t("chat:unpinQueuedMessages")`.
- Icon: `IconPinned` when pinned, `IconPin` otherwise (verify names against
the installed `@tabler/icons-react`; fall back to filled/outline pair that
exists).
- Same sizing/touch classes as the sibling buttons
(`h-6 px-1.5` desktop; `[@media(pointer:coarse)]:h-11
[@media(pointer:coarse)]:w-11`).
### `apps/web/components/task/chat/queued-ghost-list.tsx`
- `useQueuePanelOpenState(sessionId, entryCount, pinned)`:
- Initial `isOpen` = `pinned && entryCount > 0`.
- Session switch → `setIsOpen(pinned && entryCount > 0)` for the new session.
- Entry-count drop to `0` → close (unchanged).
- `QueueAffordance`: call `useQueuePinned(sessionId)`, pass `pinned` and a
toggle handler (`setValue(!pinned)`, errors swallowed) into
`useQueuePanelOpenState` and through `QueuePanelDisclosure`/`QueuePanel` to
`QueuePanelHeader`.
### i18n (`apps/web/src/locales/{en,pseudo,pt-pt,zh-cn}/chat.json`)
- `"pinQueuedMessages": "Pin message queue"` (+ pseudo/pt-pt/zh-cn).
- `"unpinQueuedMessages": "Unpin message queue"` (+ pseudo/pt-pt/zh-cn).
---
## Tests
### Unit — `apps/web/components/task/chat/queued-ghost-list.test.tsx`
Existing suite mocks `useQueue` and renders `QueueAffordance` inside
`StateProvider`; `useQueuePinned` needs a localStorage mock (`makeLocalStorageMock`
helper, as in `use-local-storage-boolean.test.ts`). Behaviors:
- **What:** pin toggle renders between Clear all and X in the expanded panel.
**How:** open panel via chip click, assert `queue-pin` exists and its DOM
position sits between `queue-clear-all` and `queue-close`.
- **What:** clicking pin sets `aria-pressed=true` and persists; clicking again
reverts. **How:** fireEvent.click + assert `aria-pressed`, then assert the
storage key holds `"true"` / `"false"`.
- **What:** pinned + entries remount → panel opens without chip. **How:**
set localStorage before render, assert `queued-ghost-list` present and
`queue-chip` absent.
- **What:** unpinned remount → chip shown, panel closed (regression guard).
- **What:** pinned + zero entries → no chip, no panel.
- **What:** session-switch remount follows the new session's pin
(pin `sess-1`, render with `sess-2` unpinned → closed; pinned `sess-2` →
open).
### Unit — `apps/web/hooks/use-queue-pinned.test.ts`
- Defaults `false` when absent; reads literal `"true"` only.
- `null` session → `{ value: false }` and no storage access.
- `setValue` persists and updates; storage failures degrade to default.
### E2E — `apps/web/e2e/tests/chat/message-queue-pin.spec.ts`
Pattern from `apps/web/e2e/tests/chat/message-queue.spec.ts` (queue a
message, open via `queue-chip`, navigate with `page.goto`).
- **Scenario:** queue a message → expand panel → click `queue-pin` → navigate
away (`/`) and back to `/t/<id>` → panel `queued-ghost-list` visible and
`queue-chip` absent without clicking anything.
- **Scenario:** without pinning, the same navigation leaves the panel
collapsed (chip visible) — regression guard.
---
## E2E Tests
See the two scenarios above in
`apps/web/e2e/tests/chat/message-queue-pin.spec.ts`; run with
`cd apps/web && pnpm e2e:raw -- tests/chat/message-queue-pin.spec.ts` (after
the e2e fixture backend is available per `e2e/README.md`).
---
## Verification Results
- `cd apps && pnpm --filter @kandev/web test -- hooks/use-queue-pinned.test.ts components/task/chat/queued-ghost-list.test.tsx components/task/chat/queued-ghost-pin.test.tsx` → 56/56 passed.
- `cd apps/web && pnpm run typecheck` → passed.
- `cd apps/web && pnpm run lint` → passed (0 warnings).
- `cd apps/web && pnpm run i18n:check` + `i18n:ratchet` → passed.
- `make fmt` (repo) → no changes; `make typecheck` (all TS apps) → passed; `make lint` (backend Go + web + harness + architecture) → passed.
- E2E (managed runner, production build): `tests/chat/message-queue-pin.spec.ts` 2/2; `--project mobile-chrome tests/chat/mobile-message-queue-management.spec.ts` 3/3 (incl. new mobile pin test).
- `make test` full-suite: backend failures are pre-existing/environmental (zero Go changes; launcher tests assert a Homebrew install path absent on this machine — reproduced on clean HEAD). Web suite: all queue/pin suites pass; `http-git-server.test.ts` failures reproduce on clean HEAD; the remaining sentry/file-browser/i18n failures pass in isolation (load flakiness), none import the changed files.
### Adversarial review loop (10-luna-review-fix)
Rounds 1–3 produced findings (storage-sync pin flip, empty→nonempty reopen,
pinned-empty unpin leak), each fixed, tested, and committed (`dabb6c80e`,
`8fb2a3de3`, `fdaf84ee9`); round 4 returned `NO_FINDINGS` and the loop
stopped. Final unit count 63/63; desktop pin E2E 2/2 and mobile pin E2E 1/1
re-run green against the final code.
---
## Implementation Waves And Parallel Candidates
Sequential; frontend change must land before its E2E.
```
Wave 1:
- [x] [task-01-frontend-pin](task-01-frontend-pin.md)
Wave 2:
- [x] [task-02-e2e-pin](task-02-e2e-pin.md)
```
---
## Open Questions
None.