feat: LangChain integration & AI autocomplete (#9)#27
Merged
multiandrewlab merged 47 commits intoApr 13, 2026
Merged
Conversation
Add VoteValue, VoteResponse, BookmarkToggleResponse, Tag, TagSubscriptionResponse types and voteSchema Zod validator. Update FeedSort to include 'personalized'. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
POST /api/posts/:id/vote — idempotent toggle (same value removes) DELETE /api/posts/:id/vote — explicit vote removal Adds getUserVote query. DB trigger handles vote_count updates. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
POST /api/posts/:id/bookmark — toggles bookmark on/off GET /api/bookmarks — paginated list of bookmarked posts Adds getUserBookmark query. Reuses findFeedPosts with filter=bookmarked. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
GET /api/tags — list/search tags with autocomplete GET /api/tags/popular — top tags by post count POST/DELETE /api/tags/:id/subscribe — tag subscriptions GET /api/tags/subscriptions — user's subscribed tags POST /api/posts now processes tags array (find-or-create + link) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
sort=personalized checks user tag subscriptions, filters posts by subscribed tags via EXISTS clause, and ranks by hotness score. Falls back to trending when user has no subscriptions. Also adds shared type/validator tests for full coverage. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Extend feed store with userVotes/userBookmarks reactive state - useVotes composable: vote toggle + explicit removal via API - useBookmarks composable: bookmark toggle via API - PostActions rewritten: upvote, downvote, bookmark (functional), fork and history (disabled placeholders) - PostListItem vote count reactivity verified via store mutation Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Tags Pinia store: subscribedTags, popularTags, subscribe/unsubscribe - useTags composable: loadSubscriptions, searchTags, subscribe/unsubscribe - TheSidebar: dynamic followed tags from API, empty state, tag click filters feed via useFeed().setTag() Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Debounced search-as-you-type via useTags().searchTags(), dropdown suggestions with post counts, click/Enter to add, inline tag creation, max 10 tags enforced. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
New request collections: - votes/: upvote, downvote, remove-vote - bookmarks/: toggle-bookmark, list-bookmarks - tags/: list-tags, popular-tags, subscribe, unsubscribe, subscriptions Update get-feed.bru docs for sort=personalized. Add tagId to local environment variables. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- V8 finally block coverage artifact workaround - Vue SFC browser globals ESLint pattern - ON CONFLICT DO NOTHING toggle pattern for bookmarks - Parallel subagent route registration safety Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…19 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…etection Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds threaded comment system with general comments below posts and inline comments anchored to specific code lines and revisions. - 5 REST endpoints (GET with revision filter, POST, PATCH, DELETE) - CommentThread, CommentInput, InlineComment, CommentSection components - Pinia store with tree building, inline grouping, stale detection - Edit/delete with ownership checks - Bruno API collection for all comment endpoints - 1159 tests passing across all packages Closes #19 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…nd test Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add v8 ignore for unreachable DOM guard in CodeViewer - Add timeAgo branch tests (minutes, hours, days) for CommentThread - Add inline comment indicator tests for PostDetail - Add authStore.user null path test for PostDetail Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Bruno requests are now a blocking gate for any feature that adds or modifies API endpoints — same enforcement level as unit tests and coverage thresholds. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(shared): add WebSocket message type schemas (WU-001) Add discriminated-union TypeScript types and zod schemas for every client<->server WebSocket message, plus per-variant schemas for granular server-side validation. DoD items verified: - [x] ClientMessage union (auth/subscribe/unsubscribe/presence) - [x] ServerMessage union (auth:*/comment:*/vote:*/revision:*/post:*/presence:update) - [x] clientMessageSchema + serverMessageSchema via z.discriminatedUnion - [x] All 17 types + schemas re-exported from packages/shared/src/types/index.ts - [x] 69 tests, 100% coverage on websocket.ts Reviewed-by: adversarial-review (PASS) Issue: #1 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(server): add ConnectionManager for WebSocket multi-tab support (WU-002) Tracks WebSocket connections per user via Map<userId, Set<socket>> and indexes by clientId UUID for later sender-exclusion broadcasts (WU-006). DoD items verified: - [x] Class with add/remove/get/getAll/findByClientId - [x] Last connection removed → user key pruned - [x] clientId removed from index on disconnect - [x] 11 tests, 100% coverage Reviewed-by: adversarial-review (PASS) Issue: #1 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(server): add ChannelManager for WebSocket pub/sub (WU-003) In-memory channel subscriber registry. `broadcast` serializes once, skips non-OPEN sockets, and supports per-call sender exclusion. `removeFromAll` is called on socket close to clean up subscriptions. DoD items verified: - [x] subscribe/unsubscribe/broadcast/getSubscribers/removeFromAll - [x] broadcast stringifies once, excludes sender, skips closed sockets - [x] Empty channel entries pruned on unsubscribe/removeFromAll - [x] 14 tests, 100% coverage Reviewed-by: adversarial-review (PASS) Issue: #1 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(server): add PresenceTracker with eviction interval (WU-004) Tracks per-channel per-user last-seen timestamps. Entries older than 60s are evicted. A factory creates a 15s interval that evicts and broadcasts presence:update for affected channels. DoD items verified: - [x] Class exports update/remove/evict/getViewers - [x] PRESENCE_EVICTION_THRESHOLD_MS constant (60_000) - [x] createPresenceEvictionInterval factory - [x] Eviction boundary: strict >, exact 60s entries retained - [x] 23 tests with fake timers, 100% coverage Reviewed-by: adversarial-review (PASS) Issue: #1 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(server): add WebSocket plugin with auth handshake (WU-005) Register @fastify/websocket@^11 at /ws, implement an auth-handshake state machine, and expose ConnectionManager/ChannelManager/PresenceTracker via app.websocket so REST routes can broadcast in WU-006. State machine: - awaiting-auth: non-auth frame or malformed JSON → close(4001, 'auth-required') - auth + valid JWT → auth:ok, register with UUID clientId - auth + invalid JWT → auth:error + close(4002, 'auth-failed') - authenticated + subscribe/unsubscribe → ChannelManager - authenticated + presence (viewing) → PresenceTracker - authenticated + unknown/malformed → log and ignore - JWT expiry mid-session → auth:expired, revert to awaiting-auth (socket stays open) - close → removeFromAll + removeConnection (if authenticated) Includes app.test.ts boot-resilience smoke test and @types/ws devDep. DoD items verified: - [x] @fastify/websocket pinned to ^11 (Fastify-5 compatible line) - [x] handler.ts state machine covers all 12 branches - [x] index.ts fastify-plugin with app.websocket decoration, onClose cleanup - [x] app.ts registers plugin after auth, before routes - [x] 100% coverage, 29 new tests (24 handler + 4 index + 1 app smoke) Reviewed-by: adversarial-review (PASS) Issue: #1 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(server): broadcast REST mutations over WebSocket (WU-006) Wire every mutation route to the WebSocket ChannelManager so connected clients receive comment/vote/revision/post events in real time. Broadcasts added: - comment:new / comment:updated / comment:deleted on post:<id> - vote:updated on post:<id> - revision:new on post:<id> - post:new / post:updated on feed - post:new on POST / - post:updated on PATCH /:id, POST /:id/publish, POST /:id/revisions Sender exclusion via new x-ws-client-id request header, read through getExcludeWs helper that looks up the socket by clientId. Feed-channel broadcasts fetch a PostWithAuthor via new findFeedPostById query helper. Soft-delete (DELETE /:id) intentionally does not broadcast; clients invalidate via cache expiration or a separate refresh path (documented). DoD items verified: - [x] getExcludeWs helper (5 tests) - [x] All 3 comment mutations broadcast - [x] Both vote mutations broadcast - [x] post:new, post:updated x3, revision:new all broadcast - [x] findFeedPostById helper with 3 tests - [x] Existing REST tests updated to assert broadcasts - [x] 100% coverage maintained Reviewed-by: adversarial-review (PASS, attempt 2) Issue: #1 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(client): add useWebSocket composable and realtime store (WU-007) Singleton Vue composable owning one WebSocket per app load with: - First-message auth handshake, token refresh on auth:expired - Auto-reconnect exponential backoff 1s→30s cap, reset on auth:ok - Channel re-subscription after reconnect - Handler cleanup via returned unsubscribe function - Send queue buffers messages while disconnected Pinia realtime store exposes connection status + per-channel presence (populated by WU-008). clientId (UUID generated once at module load) is the token threaded through REST calls in WU-009 to drive server sender-exclusion. DoD items verified: - [x] useRealtimeStore with status, presenceByChannel, actions - [x] useWebSocket singleton with subscribe/send/connect/disconnect/clientId - [x] Exponential backoff with 30s cap (6+ retry test) - [x] Channel re-subscription on reconnect - [x] auth:expired → re-auth (handlers kept) - [x] auth:error → status disconnected, no reconnect - [x] 56 new tests, 100% coverage Reviewed-by: adversarial-review (PASS) Issue: #1 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(client): add usePresence composable and PresenceIndicator (WU-008) usePresence(postId) sends a presence heartbeat to the server immediately on mount (and when postId changes to a truthy value), then every 30s. Subscribes to presence:update events and writes the latest viewers list into the realtime store. On postId change or unmount, cleans up interval, unsubscribes, and clears the old channel's presence. PresenceIndicator.vue renders up to 5 avatars stacked with -ml-2 overlap and shows +N overflow for additional viewers. Hidden when no viewers. Reactivity: activeChannel is a ref so the viewers computed re-evaluates when postId switches to a channel with pre-seeded store presence — a regression test guards this. DoD items verified: - [x] Immediate heartbeat + 30s interval - [x] Subscribe to presence:update, write to realtime store - [x] Cleanup on postId change and unmount - [x] Null/undefined postId handled - [x] 5 avatar max + overflow badge, v-if empty state - [x] aria-label on root - [x] 32 new tests, 100% coverage Reviewed-by: adversarial-review (PASS, attempt 2) Issue: #1 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(client): wire real-time events into comments, votes, feed, presence (WU-009) Integrate WebSocket subscriptions with existing composables and app shell so real-time events mutate the stores as they arrive, and so REST calls carry the x-ws-client-id header for server-side sender exclusion. Changes (all strictly additive — no existing exports changed): - apiFetch injects x-ws-client-id on POST/PATCH/PUT/DELETE only - useComments.subscribeRealtime: comment:new/updated/deleted → store - useVotes.subscribeRealtime: vote:updated → new store.updateVoteCount (aggregate voteCount only; preserves userVote for current user) - useFeed.subscribeRealtime: post:new prepends, post:updated replaces in place - PostViewPage mounts PresenceIndicator and subscribes comments/votes on mount; cleans up on unmount - AppLayout connects WS + feed subscription on auth, disconnects on logout; cleans up on unmount DoD items verified: - [x] x-ws-client-id header on mutating methods only - [x] Comment/vote/feed subscribeRealtime functions - [x] PresenceIndicator mounted on post detail - [x] WS connect/disconnect lifecycle in AppLayout - [x] Composable API strictly additive (existing tests still pass) - [x] 42 new tests, 100% coverage maintained Reviewed-by: adversarial-review (PASS) Issue: #1 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * docs(knowledge): capture learnings from WebSocket feature Extract 6 high-value learnings from the WebSocket infrastructure implementation (issue #1): Gotchas: - Vue computed reading a plain `let` returns stale data — use ref - @fastify/* plugin majors must match Fastify major (v10=4, v11=5) - Importing from 'ws' needs @types/ws; prefer @fastify/websocket's types - Bruno `run -r` is alphabetical, not dependency-ordered Decisions: - Sender exclusion via x-ws-client-id header (REST/WS are separate TCP connections, so attaching socket to request doesn't work) - /ws endpoint is exempt from the Bruno gate (HTTP-only CLI limitation) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(ci): add typecheck script and resolve HomePage TS2532 CI was failing on `npm run typecheck` because no such script existed in the root package.json. Add a per-workspace `typecheck` script that runs `tsc --noEmit` (vue-tsc for client) and aggregate them via the root script. While verifying the new typecheck step locally, fix the only standing TS2532 in `packages/client/src/pages/HomePage.vue:57`: `posts.value[0]` is `T | undefined` under `noUncheckedIndexedAccess`, and the `length > 0` guard does not narrow array indexing. Pull the first element into a const and gate on its truthiness. Verified locally: - npm run typecheck → exit 0 across shared/server/client - npm run lint → 0 errors (130 pre-existing warnings unchanged) - 1458 tests / 89 files pass - Coverage 100% (lines / branches / functions / statements) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(ci): build shared before typecheck CI failed because the client typecheck (vue-tsc) cannot resolve '@forge/shared' until that workspace's dist/index.d.ts exists. Locally we got lucky — prior test runs had already produced the dist. Add a `pretypecheck` hook on the root package.json that builds the shared workspace first, mirroring the existing `pretest` pattern. Verified locally from a fresh state (rm -rf packages/shared/dist): - npm run typecheck → exit 0 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* docs(search): add implementation plan for issue #3 Plan approved after 3 iterations of adversarial review gate (Feasibility / Completeness / Scope & Alignment). Decomposes the PostgreSQL full-text + Cmd+K modal feature into 11 work units. Notes that migration 001 already ships the search_vector column, forge_search config, weighted A/B/C trigger, and GIN indexes — so WU-1 verifies rather than re-implements that infrastructure. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * test(search): add skipped integration test for search_vector trigger (WU-1) Migration 001 already ships the forge_search config, search_vector column, weighted trigger (A/B/C), and refresh triggers. This suite will verify them end-to-end, but no integration-test harness exists yet in this repo so the suite is intentionally `.skip`-ed with a TODO. A follow-up will add Testcontainers-Postgres (or similar) and remove the `.skip`. DoD items verified: - [x] Integration file lives at the planned path - [x] Test body captures the weighted A/B/C token assertions + tsquery match - [x] `.skip` gate with a clear TODO - [x] No regressions: 434 pass, 1 skip, typecheck clean Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(shared): add search types + Zod schema (WU-2) Adds SearchQuery / SearchSnippet / AiAction / UserSummary / SearchResponse plus searchQuerySchema for validating GET /api/search query strings on both server and client. Replaces the plan's z.coerce.boolean() for `fuzzy` with a z.preprocess() guard — z.coerce.boolean() converts the string "false" to true (any non-empty string is truthy), which would silently mis-handle the intended query-string semantics. DoD items verified: - [x] searchQuerySchema matches spec shape - [x] 5 interfaces with exact plan shapes (including matchedBy and excerpt on SearchSnippet) - [x] Re-exports via types/index.ts - [x] 22 tests covering round-trip, rejections, boundaries, coercion - [x] typecheck clean - [x] 100% coverage on search.ts - [x] No `any`, no non-null assertions Reviewed-by: adversarial-review (PASS) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(server): add search query layer (WU-3) Three raw-SQL query functions in db/queries/search.ts: - searchPostsByTsvector — primary full-text path using plainto_tsquery('forge_search', $1) + ts_rank - searchPostsByTrigram — fuzzy fallback using similarity(p.title, $1) > 0.3 AND title % $1 - searchUsers — display_name match (trigram + ILIKE) with post_count via COUNT FILTER on public non-draft posts Both post queries LATERAL-join the latest revision for an excerpt and left-truncate to 200 chars. Filters (contentType, tag) are parameterised via EXISTS for tag to avoid duplicates. Similarity score is aliased as `rank` in both post queries so WU-4 can treat them as a uniform row shape. DoD verified: - [x] All 3 functions export SearchPostRow / SearchUserRow - [x] Soft-delete + visibility filters present on both post queries - [x] 0.3 similarity threshold literal - [x] 15 tests: every filter branch + single-quote parameterisation + users - [x] typecheck clean, 100% coverage on search.ts Reviewed-by: adversarial-review (PASS) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(server): add /api/search route + service (WU-4) - services/search.ts: pure transforms (toSearchSnippet, toUserSummary) + buildAiActions stub that returns 2 hardcoded actions ("Generate a {q} tutorial" / "Explain {q}") when q.length >= 2, else []. - routes/search.ts: GET /search (public, no auth). Validates with searchQuerySchema; empty q short-circuits to empty response; tsvector primary; trigram fallback when <5 hits OR fuzzy=true; tsvector wins on id collision; users + ai actions in Promise.all; bare-catch DB errors -> 500 { error: 'internal_error' } (no leak). - app.ts: register searchRoutes with prefix /api so the path is GET /api/search. DoD verified: - [x] Public endpoint, no preHandler - [x] Empty q -> 200 with empty shape, no DB calls - [x] tsvector then trigram fallback at threshold 5 - [x] fuzzy=true skips tsvector - [x] Filters (type, tag) pass through - [x] 400 on bad type, 500 on DB throw with sanitized body - [x] 23 tests (11 service + 12 route) - [x] 100% coverage on services/search.ts and routes/search.ts - [x] typecheck clean Reviewed-by: adversarial-review (PASS) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * test(bruno): add /api/search collection (WU-5) Six requests under bruno/search/ covering basic, type filter, tag filter, fuzzy, empty query (asserts totalResults=0), and people match. All public — no bearer auth. Gate verification (server on :3002 to avoid the dev :3001): bruno search collection: 6/6 requests, 31/31 asserts PASS full bruno regression: 41/41 requests, 31/31 asserts PASS DoD verified: - [x] All 6 .bru files use {{baseUrl}}, no hardcoded URLs - [x] auth: none on every request (public endpoint) - [x] empty-query asserts totalResults: eq 0 - [x] basic-query captures firstSnippetId for follow-ups - [x] BLOCKING gate (live server) passed - [x] No regression in full collection Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(client): add debounce util + Pinia search store (WU-6) - lib/debounce.ts: 15-line generic debounce with cancel(), no @vueuse dependency. - stores/search.ts: composition-API Pinia store. State = query, results, isLoading, isOpen, recentQueries (cap 10, case-insensitive dedupe, latest-first), activeIndex. - localStorage persistence is best-effort: getItem/setItem failures are swallowed so the store works in SSR / test environments where localStorage is undefined. Coder typed the debounce return as `((...args) => void) & { cancel }` rather than the plan's `T & { cancel }` because debounced calls always return void — the original signature in the plan was technically unsound TypeScript. Adversarial review iteration 1 caught a missing SSR-guard test path; added two tests that stub globalThis.localStorage to undefined and verify init + pushRecent behave correctly. Re-review PASSED. DoD verified: - [x] debounce + cancel + re-invoke - [x] All 6 refs + 8 mutations - [x] localStorage init: missing / invalid JSON / non-array → [] - [x] setItem throw swallowed; in-memory still updates - [x] SSR (localStorage undefined) path covered by test - [x] 30 tests, 100% coverage on both files - [x] typecheck clean Reviewed-by: adversarial-review (PASS, iter 2) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(client): add useSearch composable with debounced search Implements the useSearch composable (WU-7) that wraps the search Pinia store with a 300ms debounced apiFetch call. Returns stable query, results, isLoading refs via storeToRefs, plus search() and clearResults() functions. Empty/whitespace queries short-circuit without fetching. Non-2xx and network errors are handled gracefully with console.warn. 17 tests, 100% coverage on all metrics. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(client): add useKeyboard composable with cross-platform mod+k (WU-8) useKeyboard().register('mod+k', handler) attaches a window keydown listener once and dispatches by parsed shortcut. On macOS `mod` resolves to event.metaKey; elsewhere ctrlKey. SSR-safe (typeof navigator guard, falls back to ctrlKey). When a registered shortcut matches, preventDefault() fires (so browser Cmd+K is intercepted). The window listener is attached lazily on first register and detached when the last handler is unregistered, then re-attached on the next register — verified via vi.spyOn(addEventListener) tests. Also supports single-key tokens (escape, arrowup, arrowdown, enter) for the modal's keyboard navigation. Auto-cleanup via onScopeDispose when called inside a Vue component setup (uses getCurrentScope to detect context; silently no-ops when called outside a scope). A `_resetForTesting()` helper resets the module singleton between test cases — prefixed with `_` to signal internal, not exposed via useKeyboard()'s return value. DoD verified: - [x] mod+k → metaKey on Mac, ctrlKey elsewhere - [x] Single-key shortcuts (escape, arrows, enter) - [x] preventDefault only on match - [x] Listener lifecycle (attach/detach/re-attach) - [x] Auto-cleanup via onScopeDispose - [x] Shortcut name case-normalised - [x] 22 tests, 100% coverage - [x] No `any`, no `!`; typecheck clean Reviewed-by: adversarial-review (PASS) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(client): add SearchResultItem + SearchResultGroup (WU-9) SearchResultItem renders one of three variants — snippet, person, or aiAction — bound to the same SearchSnippet | UserSummary | AiAction discriminated-union prop. role="option", aria-selected mirrors active. Click emits select (no payload — parent decides navigation per variant). Active state uses bg-primary/10 (consistent with existing components; the plan's bg-primary-50 isn't defined in this project's Tailwind v4 config). SearchResultGroup renders a heading + v-for of items. Empty items produce no DOM. Per-item active = activeGlobalIndex === startIndex + i. Bubbles select with the global index payload, giving the modal a single integer to address any item across groups. Initials fallback: split on spaces, take first char of each word, cap at 2, uppercase. Singular/plural agreement on the "X post(s)" line. DoD verified: - [x] 3 variants render the right fields - [x] role="option" + aria-selected on root - [x] Active class only when active=true - [x] Empty group renders nothing - [x] Group bubbles correct global index - [x] 29 tests, 100% coverage on both .vue files - [x] vue-tsc clean Reviewed-by: adversarial-review (PASS); pre-commit eslint caught test-file `!` non-null assertions, replaced with `?.` optional chaining (project rule per memory note). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(client): add TheSearchModal — first modal in the codebase (WU-10) The Cmd+K modal: dialog with role="dialog" + aria-modal, auto-focus on open, debounced typeahead via useSearch, Esc to close, ArrowUp/Down with wrap-around through the combined snippets+aiActions+people list, Enter on the active item, Tab focus trap between input and close button, backdrop click closes (dialog click .stop'd), focus restored on close, "See all results" footer that deep-links to /search?q=… Per-variant select handlers (all push the *search query*, not the item label, into recents BEFORE close): snippet → pushRecent(q) → /posts/<id> → close person → pushRecent(q) → close → /search?q=<displayName> aiAction → pushRecent(q) → close → console.info (Phase 3 will replace this with real action dispatch) Empty input shows the recent-search list (clicking a recent populates input + triggers search). Loading shows a "Searching..." line while in flight. DoD verified: - [x] Dialog ARIA, autofocus, Esc/backdrop close - [x] Arrow nav with wrap-around - [x] Tab focus trap forward + backward - [x] Per-variant select (pushRecent before close, exact navigation) - [x] "See all results" hidden when empty input - [x] Recent searches in empty state - [x] Loading state - [x] 31 tests, 100% coverage, vue-tsc clean Reviewed-by: adversarial-review (PASS); pre-commit eslint caught missing browser-globals declaration in <script> and an unused test import — added /* global ... */ comment per the existing pattern in CodeViewer.vue, and dropped the flushPromises import. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(client): add /search page + shell wiring (WU-11, final) Wires the search feature into the app: - pages/SearchPage.vue — /search?q&type&tag&fuzzy page with route-query-driven search on mount + on change; filter chips (type, tag) with X-to-remove; "Try fuzzy search" link when fuzzy=false and no results; empty-state CTA opens the Cmd+K modal; Snippets / AI Actions / People sections via SearchResultGroup. - plugins/router.ts — registers /search as a child of the AppLayout parent with meta.requiresAuth=false so unauthenticated users can deep-link. Vue Router 4 merges meta child-wins, overriding the parent's requiresAuth=true for this specific path. - layouts/AppLayout.vue — mounts <TheSearchModal /> once at layout root so Cmd+K works from any authenticated route. - components/shell/TheTopBar.vue — replaces the readonly placeholder input with a click-to-open button (visual affordance: "Search…" placeholder + Cmd+K kbd badge); registers mod+k via useKeyboard at mount. Auto-cleanup handled by useKeyboard's onScopeDispose hook. Tests (91 across 4 files, all passing): - SearchPage.test.ts (31 new): mount search, route-query change re-search, filter-chip removal w/ cross-filter preservation, fuzzy-link visibility rules, loading and empty and no-results states. - TheTopBar.test.ts (new file, 11 tests): preserves the 7 previously-unreferenced behaviors + 4 new — search-button click opens modal; Cmd+K on Mac; Ctrl+K on non-Mac; handler cleaned up on unmount. - AppLayout.test.ts: +1 smoke that TheSearchModal is rendered at layout root. - router.test.ts: +4 for the /search route — existence, meta.requiresAuth=false, unauthenticated access without redirect, authenticated access. Pre-commit hook caught: - File named Search.vue violated vue/multi-word-component- names — renamed to SearchPage.vue to match the project's *Page.vue convention (HomePage, LoginPage, etc.). - Unused `nextTick` and `mountPage` in the test — removed. DoD verified: - [x] /search reads q/type/tag/fuzzy and re-searches on change - [x] Filter chips remove via router.push preserving others - [x] Fuzzy link hidden when already fuzzy - [x] Empty/loading/no-results states - [x] Cmd+K globally registered on mount, cleaned on unmount - [x] TheSearchModal mounted once in AppLayout - [x] /search public (meta override verified) - [x] 100% coverage on SearchPage.vue, TheTopBar.vue, AppLayout.vue, router.ts - [x] vue-tsc clean, no any, no `!` Reviewed-by: adversarial-review (PASS, pre-rename) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * docs(knowledge): capture 6 learnings from search implementation From the 11-WU orchestrated execution of /api/search + Cmd+K modal (Issue #3), extracted the highest-value insights that will save future agents a debugging cycle: gotchas.jsonl: - gotcha-zod-coerce-boolean-querystring: z.coerce.boolean() decodes "false" as true; use z.preprocess instead. - gotcha-vue-multi-word-page-name: vue/multi-word-component- names rejects Search.vue; follow the *Page.vue convention. - gotcha-non-null-assertion-in-tests: ESLint enforces the no-non-null-assertion rule in test files too; use ?.[0] optional chaining, not ![0]. codebase-facts.jsonl: - fact-migration-001-search-infrastructure: migration 001 pre-ships pg_trgm, unaccent, forge_search config, search_vector column, weighted A/B/C trigger, GIN indexes, and refresh triggers; future search work should verify, not re-create. decisions.jsonl: - decision-tailwind-v4-primary-opacity-palette: the project uses a single --color-primary with opacity modifiers (bg-primary/10, bg-primary/20), not numbered palette variants. patterns.jsonl: - pattern-worktree-setup-forge-monorepo: npm install + cp .env + unique PORT are required to use a new worktree for running tests and the server. Deduplicated against existing /* global ... */ and Bruno alphabetical-order facts (already captured on prior issues). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
… to direct DoD items verified: - [x] @langchain/community ^0.3.14 (installed ^0.3.59) - [x] @langchain/core ^0.3.18 (installed ^0.3.80) - [x] @langchain/google-vertexai ^0.1.3 (installed ^0.1.8) - [x] @langchain/openai ^0.3.14 (installed ^0.3.17) - [x] @codemirror/view ^6.27.0 (installed ^6.41.0) — direct client dep - [x] .env.example already contains LLM_PROVIDER, LLM_MODEL, OLLAMA_BASE_URL - [x] docker-compose.yml already has ollama service - [x] npm install + typecheck clean; 1694 tests passing (no regression) Reviewed-by: adversarial-review (PASS, iteration 2 — iter 1 misinterpreted phase ordering) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
DoD items verified: - [x] AI_CONTEXT_MAX = 8000 exported - [x] aiCompleteRequestSchema: before/after (max 8000), language (1-32) - [x] AiCompleteRequest = z.infer<...> type exported - [x] 5 tests (exact names), all green via TDD - [x] Barrel export appended - [x] typecheck clean; 1699 tests pass Reviewed-by: adversarial-review (PASS) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
DoD items verified:
- [x] createChatModel() reads LLM_PROVIDER (default ollama), LLM_MODEL (default gemma4)
- [x] Ollama arm: OLLAMA_BASE_URL default http://ollama:11434
- [x] OpenAI arm: modelName + OPENAI_API_KEY + streaming:true
- [x] Vertex arm: ChatVertexAI({ model })
- [x] Unknown provider throws Error
- [x] 5 tests (exact names), all green via TDD; process.env restored per test
- [x] vi.mock on all 3 LangChain modules; lint clean
Reviewed-by: adversarial-review (PASS)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
DoD items verified: - [x] AiRateLimiter: acquire(userId) -> AiSlot | null; release() idempotent - [x] Default timeoutMs = 60_000; injectable now() for deterministic tests - [x] Strict > boundary: 59_999ms still-live, 60_001ms reclaimed+aborted - [x] Users tracked independently via Map - [x] 7 tests (exact names), all green via TDD; lint clean (no !) Reviewed-by: adversarial-review (PASS) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
DoD items verified:
- [x] autocompletePrompt: ChatPromptTemplate with system (few-shot) + human (language/before/after)
- [x] createAutocompleteChain(model) pipes prompt -> model -> StringOutputParser
- [x] streamAutocomplete(chain, input, {signal}) yields chunks; aborts cleanly
- [x] 3 tests green via TDD; lint + typecheck clean
Deviation: tests use FakeListChatModel + hand-rolled chain mock (the plan's
original fake-pipe helper couldn't work with LangChain RunnableSequence).
Adversarial reviewer accepted the deviation.
Reviewed-by: adversarial-review (PASS)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
DoD items verified: - [x] langchainPlugin via fp(...) with name 'langchain-plugin' and auth-plugin dep - [x] app.aiProvider: lazy createChatModel(), cached - [x] app.aiRateLimit: preHandler (401 if unauth, 429 + Retry-After:5 on contention, attaches request.aiSlot) - [x] app.aiGate = [authenticate, aiRateLimit] - [x] Module augmentation: FastifyInstance + FastifyRequest.aiSlot - [x] onResponse + onError hooks release slot - [x] app.ts: registered after authPlugin, before websocketPlugin - [x] 2 tests green; 1716 full-suite tests pass (no regressions); lint + typecheck clean Reviewed-by: adversarial-review (PASS) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
DoD items verified:
- [x] POST /api/ai/complete with preHandler: app.aiGate
- [x] createAbortHandlers(request, slot, timeoutMs) helper exported for unit tests
- [x] SSE headers: text/event-stream + no-cache + keep-alive + X-Accel-Buffering:no
- [x] Token events: event: token\ndata: {"text":"..."}\n\n
- [x] done event on success; error event on model failure
- [x] finally: cleanupAborts + slot.release + reply.raw.end
- [x] 8 tests green (3 createAbortHandlers + 5 route); 1724 full-suite pass; lint + typecheck clean
- [x] app.ts registers aiRoutes with prefix '/api/ai'
Reviewed-by: adversarial-review (PASS)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
DoD items verified: - [x] bruno/ai/complete.bru with meta/post/auth:bearer/body:json - [x] post-response script checks for 'event: done' in SSE body - [x] tests assert status 200 + content-type text/event-stream - [x] Follows collection convention (matches bruno/comments/create-comment.bru format) End-to-end execution against a running server happens in WU-014. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
DoD items verified: - [x] setGhostText StateEffect + ghostTextField StateField + ghostTextExtension bundle - [x] acceptGhostText(view): inserts at cursor, clears, returns true; false when empty - [x] currentGhostText(state): read current text - [x] Widget: class cm-ghost-text, opacity 0.4, color var(--text-muted, #999) - [x] docChanged clears ghost inside StateField.update - [x] 6 tests green via TDD; lint + typecheck clean Reviewed-by: adversarial-review (PASS) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
DoD items verified:
- [x] parseSseStream(ReadableStream<Uint8Array>) async generator yields {event, data}
- [x] TextDecoder streaming mode; splits on \n\n; frames across chunks handled
- [x] Default event='message' when no event: line; malformed JSON frames skipped
- [x] Reader released in finally
- [x] 5 tests green via TDD; lint + typecheck clean
Reviewed-by: adversarial-review (PASS)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
DoD items verified: - [x] suggestion, isLoading refs; requestCompletion/acceptSuggestion/dismissSuggestion/cancel - [x] 300ms debounce; new call cancels previous debounce + aborts in-flight - [x] Fetches POST /api/ai/complete with AbortController signal - [x] parseSseStream accumulates token.text; breaks on done; errors clear suggestion - [x] acceptSuggestion returns text + clears; dismissSuggestion clears + cancels - [x] 8 tests green via TDD; lint + typecheck clean Test adjustments: hanging ReadableStream for abort tests (empty-closed stream completes synchronously and clears controller before the abort assertion). Reviewed-by: adversarial-review (PASS) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…orView
DoD items verified:
- [x] props.editorView: EditorView (required)
- [x] watch(suggestion) -> dispatch setGhostText.of(val)
- [x] keydown listener on contentDOM: Tab accepts + preventDefault (when ghost present); other keys dismiss; no-op when empty
- [x] onMounted adds, onBeforeUnmount removes + calls cancel()
- [x] defineExpose({ requestCompletion })
- [x] Template: hidden span wrapper
- [x] 5 tests green (real EditorView + ghostTextExtension); lint + typecheck clean
Reviewed-by: adversarial-review (PASS)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
DoD items verified: - [x] CodeEditor.vue: @ready handler captures EditorView; ghostTextExtension added to base + language extensions - [x] CodeEditor.vue: defineExpose({ view }) (shallowRef auto-unwraps via template ref) - [x] PostEditor.vue: editorRef + editorView computed + aiRef; watcher on props.modelValue/language/editorView - [x] PostEditor.vue: <AiSuggestion v-if="editorView" ref="aiRef"/> adjacent to CodeEditor - [x] CodeEditor.test.ts: 21 -> 22 tests (added "exposes EditorView via defineExpose after ready event"); extension counts updated for ghostTextExtension - [x] PostEditor.test.ts: 21 -> 23 tests (mounts AiSuggestion / forwards requestCompletion on modelValue change) - [x] 1751 full-suite tests pass; lint + typecheck clean (only 2 pre-existing warnings on untouched lines) Reviewed-by: adversarial-review (PASS) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Added tests for defensive / fallback branches that weren't reachable via the main tests: - plugin.test.ts: aiRateLimit 401 when request.user missing; onError hook releases the slot so subsequent requests succeed - ai.test.ts: 500 defensive guard when aiSlot missing (minimal app without aiRateLimit preHandler); non-Error throw falls back to 'stream_error' - ghost-text.test.ts: GhostWidget.eq() and ignoreEvent() unit-tested directly (GhostWidget now exported) - sse-stream.test.ts: frame with only event: line (no data:) is skipped - useAiComplete.test.ts: cancel() clears a pending debounce before it fires; cancel() no-op path; non-ok fetch response clears suggestion - PostEditor.vue: removed dead `?? 'plaintext'` fallback (language is required by the prop type) Coverage: 100% lines/branches/functions/statements (was 99.79/99.54/99.54/99.79 after WU-013). Full suite: 1761 passing, 1 skipped. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Gotchas (6):
- Fastify onRequest fires before preHandler; request.user undefined there
- Vue 3 defineExpose auto-unwraps shallowRef one level (editorRef.value.view)
- LangChain ChatPromptTemplate requires {{ }} to escape literal braces
- Fake-pipe pattern doesn't work for LangChain chains; use FakeListChatModel
- Vitest v8 counts ?? branches; remove dead fallbacks on required props
- Husky post-failure staging carryover can combine logical commits
Patterns (2):
- Fastify plugins that reference app.authenticate must declare
dependencies: ["auth-plugin"] via fp() config
- Extract testable helpers (createAbortHandlers) for SSE route branches
that light-my-request cannot reach (client-disconnect + setTimeout)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
Important Review skippedToo many files! This PR contains 195 files, which is 45 over the limit of 150. ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: ⛔ Files ignored due to path filters (1)
📒 Files selected for processing (195)
You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
In `bruno -r` recursive runs, directories execute alphabetically so `ai/` fires before `auth/` — the `accessToken` collection variable is empty and `ai/complete` returns 401. The pre-request script logs in if the token is not yet set, making the request self-contained. Verified: full `-r` regression now reports 42/42 passed, 31/31 assertions, 2/2 tests; ai/complete returns 200 with streamed tokens. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
5 tasks
dc79726
into
feat/database-schema-migrations
1 check passed
multiandrewlab
added a commit
that referenced
this pull request
Apr 13, 2026
* feat(shared): add vote, bookmark, tag types and vote validator Add VoteValue, VoteResponse, BookmarkToggleResponse, Tag, TagSubscriptionResponse types and voteSchema Zod validator. Update FeedSort to include 'personalized'. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(server): add vote toggle and removal routes POST /api/posts/:id/vote — idempotent toggle (same value removes) DELETE /api/posts/:id/vote — explicit vote removal Adds getUserVote query. DB trigger handles vote_count updates. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(server): add bookmark toggle and bookmark list routes POST /api/posts/:id/bookmark — toggles bookmark on/off GET /api/bookmarks — paginated list of bookmarked posts Adds getUserBookmark query. Reuses findFeedPosts with filter=bookmarked. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(server): add tag routes, subscriptions, and post-tag assignment GET /api/tags — list/search tags with autocomplete GET /api/tags/popular — top tags by post count POST/DELETE /api/tags/:id/subscribe — tag subscriptions GET /api/tags/subscriptions — user's subscribed tags POST /api/posts now processes tags array (find-or-create + link) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(server): add personalized feed sort with subscription filter sort=personalized checks user tag subscriptions, filters posts by subscribed tags via EXISTS clause, and ranks by hotness score. Falls back to trending when user has no subscriptions. Also adds shared type/validator tests for full coverage. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(client): add vote/bookmark composables and wire PostActions - Extend feed store with userVotes/userBookmarks reactive state - useVotes composable: vote toggle + explicit removal via API - useBookmarks composable: bookmark toggle via API - PostActions rewritten: upvote, downvote, bookmark (functional), fork and history (disabled placeholders) - PostListItem vote count reactivity verified via store mutation Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(client): add tags store/composable and wire sidebar followed tags - Tags Pinia store: subscribedTags, popularTags, subscribe/unsubscribe - useTags composable: loadSubscriptions, searchTags, subscribe/unsubscribe - TheSidebar: dynamic followed tags from API, empty state, tag click filters feed via useFeed().setTag() Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(client): add tag autocomplete to editor toolbar Debounced search-as-you-type via useTags().searchTags(), dropdown suggestions with post counts, click/Enter to add, inline tag creation, max 10 tags enforced. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(bruno): add API requests for votes, bookmarks, and tags New request collections: - votes/: upvote, downvote, remove-vote - bookmarks/: toggle-bookmark, list-bookmarks - tags/: list-tags, popular-tags, subscribe, unsubscribe, subscriptions Update get-feed.bru docs for sort=personalized. Add tagId to local environment variables. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: capture learnings from issue #18 implementation - V8 finally block coverage artifact workaround - Vue SFC browser globals ESLint pattern - ON CONFLICT DO NOTHING toggle pattern for bookmarks - Parallel subagent route registration safety Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(shared): add Comment types and validator schemas for issue #19 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(server): add comment query functions with author joins for issue #19 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(server): add toComment service transformer for issue #19 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(client): add comments Pinia store with tree building and stale detection Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(client): add CommentInput component for issue #19 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(server): add comment CRUD routes for issue #19 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(client): add useComments composable for comment CRUD Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(client): add CommentThread component with reply/edit/delete Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(client): add CommentSection, InlineComment and wire into PostDetail Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * test: improve coverage for comment routes and PostDetail integration Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(bruno): add API requests for comments Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(bruno): capture commentId and revisionId in post-response scripts Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: exclude type-only files from coverage and add PATCH post-not-found test Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * test: achieve 100% coverage for comment components - Add v8 ignore for unreachable DOM guard in CodeViewer - Add timeAgo branch tests (minutes, hours, days) for CommentThread - Add inline comment indicator tests for PostDetail - Add authStore.user null path test for PostDetail Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * test: achieve 100% coverage across all metrics for comment components Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * docs: add Bruno API test requirements to project instructions Bruno requests are now a blocking gate for any feature that adds or modifies API endpoints — same enforcement level as unit tests and coverage thresholds. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: WebSocket infrastructure & real-time events (#1) (#24) * feat(shared): add WebSocket message type schemas (WU-001) Add discriminated-union TypeScript types and zod schemas for every client<->server WebSocket message, plus per-variant schemas for granular server-side validation. DoD items verified: - [x] ClientMessage union (auth/subscribe/unsubscribe/presence) - [x] ServerMessage union (auth:*/comment:*/vote:*/revision:*/post:*/presence:update) - [x] clientMessageSchema + serverMessageSchema via z.discriminatedUnion - [x] All 17 types + schemas re-exported from packages/shared/src/types/index.ts - [x] 69 tests, 100% coverage on websocket.ts Reviewed-by: adversarial-review (PASS) Issue: #1 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(server): add ConnectionManager for WebSocket multi-tab support (WU-002) Tracks WebSocket connections per user via Map<userId, Set<socket>> and indexes by clientId UUID for later sender-exclusion broadcasts (WU-006). DoD items verified: - [x] Class with add/remove/get/getAll/findByClientId - [x] Last connection removed → user key pruned - [x] clientId removed from index on disconnect - [x] 11 tests, 100% coverage Reviewed-by: adversarial-review (PASS) Issue: #1 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(server): add ChannelManager for WebSocket pub/sub (WU-003) In-memory channel subscriber registry. `broadcast` serializes once, skips non-OPEN sockets, and supports per-call sender exclusion. `removeFromAll` is called on socket close to clean up subscriptions. DoD items verified: - [x] subscribe/unsubscribe/broadcast/getSubscribers/removeFromAll - [x] broadcast stringifies once, excludes sender, skips closed sockets - [x] Empty channel entries pruned on unsubscribe/removeFromAll - [x] 14 tests, 100% coverage Reviewed-by: adversarial-review (PASS) Issue: #1 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(server): add PresenceTracker with eviction interval (WU-004) Tracks per-channel per-user last-seen timestamps. Entries older than 60s are evicted. A factory creates a 15s interval that evicts and broadcasts presence:update for affected channels. DoD items verified: - [x] Class exports update/remove/evict/getViewers - [x] PRESENCE_EVICTION_THRESHOLD_MS constant (60_000) - [x] createPresenceEvictionInterval factory - [x] Eviction boundary: strict >, exact 60s entries retained - [x] 23 tests with fake timers, 100% coverage Reviewed-by: adversarial-review (PASS) Issue: #1 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(server): add WebSocket plugin with auth handshake (WU-005) Register @fastify/websocket@^11 at /ws, implement an auth-handshake state machine, and expose ConnectionManager/ChannelManager/PresenceTracker via app.websocket so REST routes can broadcast in WU-006. State machine: - awaiting-auth: non-auth frame or malformed JSON → close(4001, 'auth-required') - auth + valid JWT → auth:ok, register with UUID clientId - auth + invalid JWT → auth:error + close(4002, 'auth-failed') - authenticated + subscribe/unsubscribe → ChannelManager - authenticated + presence (viewing) → PresenceTracker - authenticated + unknown/malformed → log and ignore - JWT expiry mid-session → auth:expired, revert to awaiting-auth (socket stays open) - close → removeFromAll + removeConnection (if authenticated) Includes app.test.ts boot-resilience smoke test and @types/ws devDep. DoD items verified: - [x] @fastify/websocket pinned to ^11 (Fastify-5 compatible line) - [x] handler.ts state machine covers all 12 branches - [x] index.ts fastify-plugin with app.websocket decoration, onClose cleanup - [x] app.ts registers plugin after auth, before routes - [x] 100% coverage, 29 new tests (24 handler + 4 index + 1 app smoke) Reviewed-by: adversarial-review (PASS) Issue: #1 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(server): broadcast REST mutations over WebSocket (WU-006) Wire every mutation route to the WebSocket ChannelManager so connected clients receive comment/vote/revision/post events in real time. Broadcasts added: - comment:new / comment:updated / comment:deleted on post:<id> - vote:updated on post:<id> - revision:new on post:<id> - post:new / post:updated on feed - post:new on POST / - post:updated on PATCH /:id, POST /:id/publish, POST /:id/revisions Sender exclusion via new x-ws-client-id request header, read through getExcludeWs helper that looks up the socket by clientId. Feed-channel broadcasts fetch a PostWithAuthor via new findFeedPostById query helper. Soft-delete (DELETE /:id) intentionally does not broadcast; clients invalidate via cache expiration or a separate refresh path (documented). DoD items verified: - [x] getExcludeWs helper (5 tests) - [x] All 3 comment mutations broadcast - [x] Both vote mutations broadcast - [x] post:new, post:updated x3, revision:new all broadcast - [x] findFeedPostById helper with 3 tests - [x] Existing REST tests updated to assert broadcasts - [x] 100% coverage maintained Reviewed-by: adversarial-review (PASS, attempt 2) Issue: #1 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(client): add useWebSocket composable and realtime store (WU-007) Singleton Vue composable owning one WebSocket per app load with: - First-message auth handshake, token refresh on auth:expired - Auto-reconnect exponential backoff 1s→30s cap, reset on auth:ok - Channel re-subscription after reconnect - Handler cleanup via returned unsubscribe function - Send queue buffers messages while disconnected Pinia realtime store exposes connection status + per-channel presence (populated by WU-008). clientId (UUID generated once at module load) is the token threaded through REST calls in WU-009 to drive server sender-exclusion. DoD items verified: - [x] useRealtimeStore with status, presenceByChannel, actions - [x] useWebSocket singleton with subscribe/send/connect/disconnect/clientId - [x] Exponential backoff with 30s cap (6+ retry test) - [x] Channel re-subscription on reconnect - [x] auth:expired → re-auth (handlers kept) - [x] auth:error → status disconnected, no reconnect - [x] 56 new tests, 100% coverage Reviewed-by: adversarial-review (PASS) Issue: #1 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(client): add usePresence composable and PresenceIndicator (WU-008) usePresence(postId) sends a presence heartbeat to the server immediately on mount (and when postId changes to a truthy value), then every 30s. Subscribes to presence:update events and writes the latest viewers list into the realtime store. On postId change or unmount, cleans up interval, unsubscribes, and clears the old channel's presence. PresenceIndicator.vue renders up to 5 avatars stacked with -ml-2 overlap and shows +N overflow for additional viewers. Hidden when no viewers. Reactivity: activeChannel is a ref so the viewers computed re-evaluates when postId switches to a channel with pre-seeded store presence — a regression test guards this. DoD items verified: - [x] Immediate heartbeat + 30s interval - [x] Subscribe to presence:update, write to realtime store - [x] Cleanup on postId change and unmount - [x] Null/undefined postId handled - [x] 5 avatar max + overflow badge, v-if empty state - [x] aria-label on root - [x] 32 new tests, 100% coverage Reviewed-by: adversarial-review (PASS, attempt 2) Issue: #1 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(client): wire real-time events into comments, votes, feed, presence (WU-009) Integrate WebSocket subscriptions with existing composables and app shell so real-time events mutate the stores as they arrive, and so REST calls carry the x-ws-client-id header for server-side sender exclusion. Changes (all strictly additive — no existing exports changed): - apiFetch injects x-ws-client-id on POST/PATCH/PUT/DELETE only - useComments.subscribeRealtime: comment:new/updated/deleted → store - useVotes.subscribeRealtime: vote:updated → new store.updateVoteCount (aggregate voteCount only; preserves userVote for current user) - useFeed.subscribeRealtime: post:new prepends, post:updated replaces in place - PostViewPage mounts PresenceIndicator and subscribes comments/votes on mount; cleans up on unmount - AppLayout connects WS + feed subscription on auth, disconnects on logout; cleans up on unmount DoD items verified: - [x] x-ws-client-id header on mutating methods only - [x] Comment/vote/feed subscribeRealtime functions - [x] PresenceIndicator mounted on post detail - [x] WS connect/disconnect lifecycle in AppLayout - [x] Composable API strictly additive (existing tests still pass) - [x] 42 new tests, 100% coverage maintained Reviewed-by: adversarial-review (PASS) Issue: #1 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * docs(knowledge): capture learnings from WebSocket feature Extract 6 high-value learnings from the WebSocket infrastructure implementation (issue #1): Gotchas: - Vue computed reading a plain `let` returns stale data — use ref - @fastify/* plugin majors must match Fastify major (v10=4, v11=5) - Importing from 'ws' needs @types/ws; prefer @fastify/websocket's types - Bruno `run -r` is alphabetical, not dependency-ordered Decisions: - Sender exclusion via x-ws-client-id header (REST/WS are separate TCP connections, so attaching socket to request doesn't work) - /ws endpoint is exempt from the Bruno gate (HTTP-only CLI limitation) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(ci): add typecheck script and resolve HomePage TS2532 (#25) * fix(ci): add typecheck script and resolve HomePage TS2532 CI was failing on `npm run typecheck` because no such script existed in the root package.json. Add a per-workspace `typecheck` script that runs `tsc --noEmit` (vue-tsc for client) and aggregate them via the root script. While verifying the new typecheck step locally, fix the only standing TS2532 in `packages/client/src/pages/HomePage.vue:57`: `posts.value[0]` is `T | undefined` under `noUncheckedIndexedAccess`, and the `length > 0` guard does not narrow array indexing. Pull the first element into a const and gate on its truthiness. Verified locally: - npm run typecheck → exit 0 across shared/server/client - npm run lint → 0 errors (130 pre-existing warnings unchanged) - 1458 tests / 89 files pass - Coverage 100% (lines / branches / functions / statements) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(ci): build shared before typecheck CI failed because the client typecheck (vue-tsc) cannot resolve '@forge/shared' until that workspace's dist/index.d.ts exists. Locally we got lucky — prior test runs had already produced the dist. Add a `pretypecheck` hook on the root package.json that builds the shared workspace first, mirroring the existing `pretest` pattern. Verified locally from a fresh state (rm -rf packages/shared/dist): - npm run typecheck → exit 0 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: search (PostgreSQL full-text + Cmd+K modal) (#3) (#26) * docs(search): add implementation plan for issue #3 Plan approved after 3 iterations of adversarial review gate (Feasibility / Completeness / Scope & Alignment). Decomposes the PostgreSQL full-text + Cmd+K modal feature into 11 work units. Notes that migration 001 already ships the search_vector column, forge_search config, weighted A/B/C trigger, and GIN indexes — so WU-1 verifies rather than re-implements that infrastructure. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * test(search): add skipped integration test for search_vector trigger (WU-1) Migration 001 already ships the forge_search config, search_vector column, weighted trigger (A/B/C), and refresh triggers. This suite will verify them end-to-end, but no integration-test harness exists yet in this repo so the suite is intentionally `.skip`-ed with a TODO. A follow-up will add Testcontainers-Postgres (or similar) and remove the `.skip`. DoD items verified: - [x] Integration file lives at the planned path - [x] Test body captures the weighted A/B/C token assertions + tsquery match - [x] `.skip` gate with a clear TODO - [x] No regressions: 434 pass, 1 skip, typecheck clean Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(shared): add search types + Zod schema (WU-2) Adds SearchQuery / SearchSnippet / AiAction / UserSummary / SearchResponse plus searchQuerySchema for validating GET /api/search query strings on both server and client. Replaces the plan's z.coerce.boolean() for `fuzzy` with a z.preprocess() guard — z.coerce.boolean() converts the string "false" to true (any non-empty string is truthy), which would silently mis-handle the intended query-string semantics. DoD items verified: - [x] searchQuerySchema matches spec shape - [x] 5 interfaces with exact plan shapes (including matchedBy and excerpt on SearchSnippet) - [x] Re-exports via types/index.ts - [x] 22 tests covering round-trip, rejections, boundaries, coercion - [x] typecheck clean - [x] 100% coverage on search.ts - [x] No `any`, no non-null assertions Reviewed-by: adversarial-review (PASS) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(server): add search query layer (WU-3) Three raw-SQL query functions in db/queries/search.ts: - searchPostsByTsvector — primary full-text path using plainto_tsquery('forge_search', $1) + ts_rank - searchPostsByTrigram — fuzzy fallback using similarity(p.title, $1) > 0.3 AND title % $1 - searchUsers — display_name match (trigram + ILIKE) with post_count via COUNT FILTER on public non-draft posts Both post queries LATERAL-join the latest revision for an excerpt and left-truncate to 200 chars. Filters (contentType, tag) are parameterised via EXISTS for tag to avoid duplicates. Similarity score is aliased as `rank` in both post queries so WU-4 can treat them as a uniform row shape. DoD verified: - [x] All 3 functions export SearchPostRow / SearchUserRow - [x] Soft-delete + visibility filters present on both post queries - [x] 0.3 similarity threshold literal - [x] 15 tests: every filter branch + single-quote parameterisation + users - [x] typecheck clean, 100% coverage on search.ts Reviewed-by: adversarial-review (PASS) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(server): add /api/search route + service (WU-4) - services/search.ts: pure transforms (toSearchSnippet, toUserSummary) + buildAiActions stub that returns 2 hardcoded actions ("Generate a {q} tutorial" / "Explain {q}") when q.length >= 2, else []. - routes/search.ts: GET /search (public, no auth). Validates with searchQuerySchema; empty q short-circuits to empty response; tsvector primary; trigram fallback when <5 hits OR fuzzy=true; tsvector wins on id collision; users + ai actions in Promise.all; bare-catch DB errors -> 500 { error: 'internal_error' } (no leak). - app.ts: register searchRoutes with prefix /api so the path is GET /api/search. DoD verified: - [x] Public endpoint, no preHandler - [x] Empty q -> 200 with empty shape, no DB calls - [x] tsvector then trigram fallback at threshold 5 - [x] fuzzy=true skips tsvector - [x] Filters (type, tag) pass through - [x] 400 on bad type, 500 on DB throw with sanitized body - [x] 23 tests (11 service + 12 route) - [x] 100% coverage on services/search.ts and routes/search.ts - [x] typecheck clean Reviewed-by: adversarial-review (PASS) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * test(bruno): add /api/search collection (WU-5) Six requests under bruno/search/ covering basic, type filter, tag filter, fuzzy, empty query (asserts totalResults=0), and people match. All public — no bearer auth. Gate verification (server on :3002 to avoid the dev :3001): bruno search collection: 6/6 requests, 31/31 asserts PASS full bruno regression: 41/41 requests, 31/31 asserts PASS DoD verified: - [x] All 6 .bru files use {{baseUrl}}, no hardcoded URLs - [x] auth: none on every request (public endpoint) - [x] empty-query asserts totalResults: eq 0 - [x] basic-query captures firstSnippetId for follow-ups - [x] BLOCKING gate (live server) passed - [x] No regression in full collection Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(client): add debounce util + Pinia search store (WU-6) - lib/debounce.ts: 15-line generic debounce with cancel(), no @vueuse dependency. - stores/search.ts: composition-API Pinia store. State = query, results, isLoading, isOpen, recentQueries (cap 10, case-insensitive dedupe, latest-first), activeIndex. - localStorage persistence is best-effort: getItem/setItem failures are swallowed so the store works in SSR / test environments where localStorage is undefined. Coder typed the debounce return as `((...args) => void) & { cancel }` rather than the plan's `T & { cancel }` because debounced calls always return void — the original signature in the plan was technically unsound TypeScript. Adversarial review iteration 1 caught a missing SSR-guard test path; added two tests that stub globalThis.localStorage to undefined and verify init + pushRecent behave correctly. Re-review PASSED. DoD verified: - [x] debounce + cancel + re-invoke - [x] All 6 refs + 8 mutations - [x] localStorage init: missing / invalid JSON / non-array → [] - [x] setItem throw swallowed; in-memory still updates - [x] SSR (localStorage undefined) path covered by test - [x] 30 tests, 100% coverage on both files - [x] typecheck clean Reviewed-by: adversarial-review (PASS, iter 2) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(client): add useSearch composable with debounced search Implements the useSearch composable (WU-7) that wraps the search Pinia store with a 300ms debounced apiFetch call. Returns stable query, results, isLoading refs via storeToRefs, plus search() and clearResults() functions. Empty/whitespace queries short-circuit without fetching. Non-2xx and network errors are handled gracefully with console.warn. 17 tests, 100% coverage on all metrics. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(client): add useKeyboard composable with cross-platform mod+k (WU-8) useKeyboard().register('mod+k', handler) attaches a window keydown listener once and dispatches by parsed shortcut. On macOS `mod` resolves to event.metaKey; elsewhere ctrlKey. SSR-safe (typeof navigator guard, falls back to ctrlKey). When a registered shortcut matches, preventDefault() fires (so browser Cmd+K is intercepted). The window listener is attached lazily on first register and detached when the last handler is unregistered, then re-attached on the next register — verified via vi.spyOn(addEventListener) tests. Also supports single-key tokens (escape, arrowup, arrowdown, enter) for the modal's keyboard navigation. Auto-cleanup via onScopeDispose when called inside a Vue component setup (uses getCurrentScope to detect context; silently no-ops when called outside a scope). A `_resetForTesting()` helper resets the module singleton between test cases — prefixed with `_` to signal internal, not exposed via useKeyboard()'s return value. DoD verified: - [x] mod+k → metaKey on Mac, ctrlKey elsewhere - [x] Single-key shortcuts (escape, arrows, enter) - [x] preventDefault only on match - [x] Listener lifecycle (attach/detach/re-attach) - [x] Auto-cleanup via onScopeDispose - [x] Shortcut name case-normalised - [x] 22 tests, 100% coverage - [x] No `any`, no `!`; typecheck clean Reviewed-by: adversarial-review (PASS) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(client): add SearchResultItem + SearchResultGroup (WU-9) SearchResultItem renders one of three variants — snippet, person, or aiAction — bound to the same SearchSnippet | UserSummary | AiAction discriminated-union prop. role="option", aria-selected mirrors active. Click emits select (no payload — parent decides navigation per variant). Active state uses bg-primary/10 (consistent with existing components; the plan's bg-primary-50 isn't defined in this project's Tailwind v4 config). SearchResultGroup renders a heading + v-for of items. Empty items produce no DOM. Per-item active = activeGlobalIndex === startIndex + i. Bubbles select with the global index payload, giving the modal a single integer to address any item across groups. Initials fallback: split on spaces, take first char of each word, cap at 2, uppercase. Singular/plural agreement on the "X post(s)" line. DoD verified: - [x] 3 variants render the right fields - [x] role="option" + aria-selected on root - [x] Active class only when active=true - [x] Empty group renders nothing - [x] Group bubbles correct global index - [x] 29 tests, 100% coverage on both .vue files - [x] vue-tsc clean Reviewed-by: adversarial-review (PASS); pre-commit eslint caught test-file `!` non-null assertions, replaced with `?.` optional chaining (project rule per memory note). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(client): add TheSearchModal — first modal in the codebase (WU-10) The Cmd+K modal: dialog with role="dialog" + aria-modal, auto-focus on open, debounced typeahead via useSearch, Esc to close, ArrowUp/Down with wrap-around through the combined snippets+aiActions+people list, Enter on the active item, Tab focus trap between input and close button, backdrop click closes (dialog click .stop'd), focus restored on close, "See all results" footer that deep-links to /search?q=… Per-variant select handlers (all push the *search query*, not the item label, into recents BEFORE close): snippet → pushRecent(q) → /posts/<id> → close person → pushRecent(q) → close → /search?q=<displayName> aiAction → pushRecent(q) → close → console.info (Phase 3 will replace this with real action dispatch) Empty input shows the recent-search list (clicking a recent populates input + triggers search). Loading shows a "Searching..." line while in flight. DoD verified: - [x] Dialog ARIA, autofocus, Esc/backdrop close - [x] Arrow nav with wrap-around - [x] Tab focus trap forward + backward - [x] Per-variant select (pushRecent before close, exact navigation) - [x] "See all results" hidden when empty input - [x] Recent searches in empty state - [x] Loading state - [x] 31 tests, 100% coverage, vue-tsc clean Reviewed-by: adversarial-review (PASS); pre-commit eslint caught missing browser-globals declaration in <script> and an unused test import — added /* global ... */ comment per the existing pattern in CodeViewer.vue, and dropped the flushPromises import. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(client): add /search page + shell wiring (WU-11, final) Wires the search feature into the app: - pages/SearchPage.vue — /search?q&type&tag&fuzzy page with route-query-driven search on mount + on change; filter chips (type, tag) with X-to-remove; "Try fuzzy search" link when fuzzy=false and no results; empty-state CTA opens the Cmd+K modal; Snippets / AI Actions / People sections via SearchResultGroup. - plugins/router.ts — registers /search as a child of the AppLayout parent with meta.requiresAuth=false so unauthenticated users can deep-link. Vue Router 4 merges meta child-wins, overriding the parent's requiresAuth=true for this specific path. - layouts/AppLayout.vue — mounts <TheSearchModal /> once at layout root so Cmd+K works from any authenticated route. - components/shell/TheTopBar.vue — replaces the readonly placeholder input with a click-to-open button (visual affordance: "Search…" placeholder + Cmd+K kbd badge); registers mod+k via useKeyboard at mount. Auto-cleanup handled by useKeyboard's onScopeDispose hook. Tests (91 across 4 files, all passing): - SearchPage.test.ts (31 new): mount search, route-query change re-search, filter-chip removal w/ cross-filter preservation, fuzzy-link visibility rules, loading and empty and no-results states. - TheTopBar.test.ts (new file, 11 tests): preserves the 7 previously-unreferenced behaviors + 4 new — search-button click opens modal; Cmd+K on Mac; Ctrl+K on non-Mac; handler cleaned up on unmount. - AppLayout.test.ts: +1 smoke that TheSearchModal is rendered at layout root. - router.test.ts: +4 for the /search route — existence, meta.requiresAuth=false, unauthenticated access without redirect, authenticated access. Pre-commit hook caught: - File named Search.vue violated vue/multi-word-component- names — renamed to SearchPage.vue to match the project's *Page.vue convention (HomePage, LoginPage, etc.). - Unused `nextTick` and `mountPage` in the test — removed. DoD verified: - [x] /search reads q/type/tag/fuzzy and re-searches on change - [x] Filter chips remove via router.push preserving others - [x] Fuzzy link hidden when already fuzzy - [x] Empty/loading/no-results states - [x] Cmd+K globally registered on mount, cleaned on unmount - [x] TheSearchModal mounted once in AppLayout - [x] /search public (meta override verified) - [x] 100% coverage on SearchPage.vue, TheTopBar.vue, AppLayout.vue, router.ts - [x] vue-tsc clean, no any, no `!` Reviewed-by: adversarial-review (PASS, pre-rename) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * docs(knowledge): capture 6 learnings from search implementation From the 11-WU orchestrated execution of /api/search + Cmd+K modal (Issue #3), extracted the highest-value insights that will save future agents a debugging cycle: gotchas.jsonl: - gotcha-zod-coerce-boolean-querystring: z.coerce.boolean() decodes "false" as true; use z.preprocess instead. - gotcha-vue-multi-word-page-name: vue/multi-word-component- names rejects Search.vue; follow the *Page.vue convention. - gotcha-non-null-assertion-in-tests: ESLint enforces the no-non-null-assertion rule in test files too; use ?.[0] optional chaining, not ![0]. codebase-facts.jsonl: - fact-migration-001-search-infrastructure: migration 001 pre-ships pg_trgm, unaccent, forge_search config, search_vector column, weighted A/B/C trigger, GIN indexes, and refresh triggers; future search work should verify, not re-create. decisions.jsonl: - decision-tailwind-v4-primary-opacity-palette: the project uses a single --color-primary with opacity modifiers (bg-primary/10, bg-primary/20), not numbered palette variants. patterns.jsonl: - pattern-worktree-setup-forge-monorepo: npm install + cp .env + unique PORT are required to use a new worktree for running tests and the server. Deduplicated against existing /* global ... */ and Bruno alphabetical-order facts (already captured on prior issues). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: ignore .worktrees/ for isolated worktree development Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore(wu-001): add LangChain server deps and promote @codemirror/view to direct DoD items verified: - [x] @langchain/community ^0.3.14 (installed ^0.3.59) - [x] @langchain/core ^0.3.18 (installed ^0.3.80) - [x] @langchain/google-vertexai ^0.1.3 (installed ^0.1.8) - [x] @langchain/openai ^0.3.14 (installed ^0.3.17) - [x] @codemirror/view ^6.27.0 (installed ^6.41.0) — direct client dep - [x] .env.example already contains LLM_PROVIDER, LLM_MODEL, OLLAMA_BASE_URL - [x] docker-compose.yml already has ollama service - [x] npm install + typecheck clean; 1694 tests passing (no regression) Reviewed-by: adversarial-review (PASS, iteration 2 — iter 1 misinterpreted phase ordering) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(wu-002): add aiCompleteRequestSchema to @forge/shared DoD items verified: - [x] AI_CONTEXT_MAX = 8000 exported - [x] aiCompleteRequestSchema: before/after (max 8000), language (1-32) - [x] AiCompleteRequest = z.infer<...> type exported - [x] 5 tests (exact names), all green via TDD - [x] Barrel export appended - [x] typecheck clean; 1699 tests pass Reviewed-by: adversarial-review (PASS) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(wu-003): LangChain provider factory (ollama/openai/vertex) DoD items verified: - [x] createChatModel() reads LLM_PROVIDER (default ollama), LLM_MODEL (default gemma4) - [x] Ollama arm: OLLAMA_BASE_URL default http://ollama:11434 - [x] OpenAI arm: modelName + OPENAI_API_KEY + streaming:true - [x] Vertex arm: ChatVertexAI({ model }) - [x] Unknown provider throws Error - [x] 5 tests (exact names), all green via TDD; process.env restored per test - [x] vi.mock on all 3 LangChain modules; lint clean Reviewed-by: adversarial-review (PASS) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(wu-005): in-memory per-user AI rate limiter DoD items verified: - [x] AiRateLimiter: acquire(userId) -> AiSlot | null; release() idempotent - [x] Default timeoutMs = 60_000; injectable now() for deterministic tests - [x] Strict > boundary: 59_999ms still-live, 60_001ms reclaimed+aborted - [x] Users tracked independently via Map - [x] 7 tests (exact names), all green via TDD; lint clean (no !) Reviewed-by: adversarial-review (PASS) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(wu-004): autocomplete prompt template and chain DoD items verified: - [x] autocompletePrompt: ChatPromptTemplate with system (few-shot) + human (language/before/after) - [x] createAutocompleteChain(model) pipes prompt -> model -> StringOutputParser - [x] streamAutocomplete(chain, input, {signal}) yields chunks; aborts cleanly - [x] 3 tests green via TDD; lint + typecheck clean Deviation: tests use FakeListChatModel + hand-rolled chain mock (the plan's original fake-pipe helper couldn't work with LangChain RunnableSequence). Adversarial reviewer accepted the deviation. Reviewed-by: adversarial-review (PASS) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(wu-006): LangChain Fastify plugin (aiProvider, aiRateLimit, aiGate) DoD items verified: - [x] langchainPlugin via fp(...) with name 'langchain-plugin' and auth-plugin dep - [x] app.aiProvider: lazy createChatModel(), cached - [x] app.aiRateLimit: preHandler (401 if unauth, 429 + Retry-After:5 on contention, attaches request.aiSlot) - [x] app.aiGate = [authenticate, aiRateLimit] - [x] Module augmentation: FastifyInstance + FastifyRequest.aiSlot - [x] onResponse + onError hooks release slot - [x] app.ts: registered after authPlugin, before websocketPlugin - [x] 2 tests green; 1716 full-suite tests pass (no regressions); lint + typecheck clean Reviewed-by: adversarial-review (PASS) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(wu-007): POST /api/ai/complete with SSE streaming DoD items verified: - [x] POST /api/ai/complete with preHandler: app.aiGate - [x] createAbortHandlers(request, slot, timeoutMs) helper exported for unit tests - [x] SSE headers: text/event-stream + no-cache + keep-alive + X-Accel-Buffering:no - [x] Token events: event: token\ndata: {"text":"..."}\n\n - [x] done event on success; error event on model failure - [x] finally: cleanupAborts + slot.release + reply.raw.end - [x] 8 tests green (3 createAbortHandlers + 5 route); 1724 full-suite pass; lint + typecheck clean - [x] app.ts registers aiRoutes with prefix '/api/ai' Reviewed-by: adversarial-review (PASS) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * test(wu-008): Bruno request for POST /api/ai/complete (SSE) DoD items verified: - [x] bruno/ai/complete.bru with meta/post/auth:bearer/body:json - [x] post-response script checks for 'event: done' in SSE body - [x] tests assert status 200 + content-type text/event-stream - [x] Follows collection convention (matches bruno/comments/create-comment.bru format) End-to-end execution against a running server happens in WU-014. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(wu-009): CodeMirror ghost-text extension DoD items verified: - [x] setGhostText StateEffect + ghostTextField StateField + ghostTextExtension bundle - [x] acceptGhostText(view): inserts at cursor, clears, returns true; false when empty - [x] currentGhostText(state): read current text - [x] Widget: class cm-ghost-text, opacity 0.4, color var(--text-muted, #999) - [x] docChanged clears ghost inside StateField.update - [x] 6 tests green via TDD; lint + typecheck clean Reviewed-by: adversarial-review (PASS) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(wu-010): client-side SSE stream parser DoD items verified: - [x] parseSseStream(ReadableStream<Uint8Array>) async generator yields {event, data} - [x] TextDecoder streaming mode; splits on \n\n; frames across chunks handled - [x] Default event='message' when no event: line; malformed JSON frames skipped - [x] Reader released in finally - [x] 5 tests green via TDD; lint + typecheck clean Reviewed-by: adversarial-review (PASS) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(wu-011): useAiComplete composable DoD items verified: - [x] suggestion, isLoading refs; requestCompletion/acceptSuggestion/dismissSuggestion/cancel - [x] 300ms debounce; new call cancels previous debounce + aborts in-flight - [x] Fetches POST /api/ai/complete with AbortController signal - [x] parseSseStream accumulates token.text; breaks on done; errors clear suggestion - [x] acceptSuggestion returns text + clears; dismissSuggestion clears + cancels - [x] 8 tests green via TDD; lint + typecheck clean Test adjustments: hanging ReadableStream for abort tests (empty-closed stream completes synchronously and clears controller before the abort assertion). Reviewed-by: adversarial-review (PASS) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(wu-012): AiSuggestion.vue wires useAiComplete to CodeMirror EditorView DoD items verified: - [x] props.editorView: EditorView (required) - [x] watch(suggestion) -> dispatch setGhostText.of(val) - [x] keydown listener on contentDOM: Tab accepts + preventDefault (when ghost present); other keys dismiss; no-op when empty - [x] onMounted adds, onBeforeUnmount removes + calls cancel() - [x] defineExpose({ requestCompletion }) - [x] Template: hidden span wrapper - [x] 5 tests green (real EditorView + ghostTextExtension); lint + typecheck clean Reviewed-by: adversarial-review (PASS) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(wu-013): wire AI autocomplete into CodeEditor + PostEditor DoD items verified: - [x] CodeEditor.vue: @ready handler captures EditorView; ghostTextExtension added to base + language extensions - [x] CodeEditor.vue: defineExpose({ view }) (shallowRef auto-unwraps via template ref) - [x] PostEditor.vue: editorRef + editorView computed + aiRef; watcher on props.modelValue/language/editorView - [x] PostEditor.vue: <AiSuggestion v-if="editorView" ref="aiRef"/> adjacent to CodeEditor - [x] CodeEditor.test.ts: 21 -> 22 tests (added "exposes EditorView via defineExpose after ready event"); extension counts updated for ghostTextExtension - [x] PostEditor.test.ts: 21 -> 23 tests (mounts AiSuggestion / forwards requestCompletion on modelValue change) - [x] 1751 full-suite tests pass; lint + typecheck clean (only 2 pre-existing warnings on untouched lines) Reviewed-by: adversarial-review (PASS) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * test(wu-014): close remaining branch-coverage gaps to hit 100% Added tests for defensive / fallback branches that weren't reachable via the main tests: - plugin.test.ts: aiRateLimit 401 when request.user missing; onError hook releases the slot so subsequent requests succeed - ai.test.ts: 500 defensive guard when aiSlot missing (minimal app without aiRateLimit preHandler); non-Error throw falls back to 'stream_error' - ghost-text.test.ts: GhostWidget.eq() and ignoreEvent() unit-tested directly (GhostWidget now exported) - sse-stream.test.ts: frame with only event: line (no data:) is skipped - useAiComplete.test.ts: cancel() clears a pending debounce before it fires; cancel() no-op path; non-ok fetch response clears suggestion - PostEditor.vue: removed dead `?? 'plaintext'` fallback (language is required by the prop type) Coverage: 100% lines/branches/functions/statements (was 99.79/99.54/99.54/99.79 after WU-013). Full suite: 1761 passing, 1 skipped. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * docs(kb): capture 8 learnings from issue #9 LangChain AI autocomplete Gotchas (6): - Fastify onRequest fires before preHandler; request.user undefined there - Vue 3 defineExpose auto-unwraps shallowRef one level (editorRef.value.view) - LangChain ChatPromptTemplate requires {{ }} to escape literal braces - Fake-pipe pattern doesn't work for LangChain chains; use FakeListChatModel - Vitest v8 counts ?? branches; remove dead fallbacks on required props - Husky post-failure staging carryover can combine logical commits Patterns (2): - Fastify plugins that reference app.authenticate must declare dependencies: ["auth-plugin"] via fp() config - Extract testable helpers (createAbortHandlers) for SSE route branches that light-my-request cannot reach (client-disconnect + setTimeout) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * test(bruno): add pre-request login to ai/complete for -r regression In `bruno -r` recursive runs, directories execute alphabetically so `ai/` fires before `auth/` — the `accessToken` collection variable is empty and `ai/complete` returns 401. The pre-request script logs in if the token is not yet set, making the request self-contained. Verified: full `-r` regression now reports 42/42 passed, 31/31 assertions, 2/2 tests; ai/complete returns 200 with streamed tokens. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
multiandrewlab
added a commit
that referenced
this pull request
Apr 13, 2026
The cherry-pick of PR #27 with -X ours preserved main's versions of packages/server/src/app.ts and packages/shared/src/validators/index.ts, which lacked the LangChain plugin registration, AI route registration, and the ai.ts validator re-export. Adds them back so AI autocomplete works end-to-end on main. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
multiandrewlab
added a commit
that referenced
this pull request
Apr 28, 2026
#57) Hotfix for the broken Bruno API Regression workflow on main. The `fork-post.bru` and `refresh-link-preview.bru` requests reference {{forkablePostId}} and {{createdLinkPostId}} variables that were added to bruno/environments/local.bru by recent PRs (#27 forking, #?? link sharing) but never added to bruno/environments/ci.bru. CI runs use --env ci, so the substitution failed and Bruno sent the literal string "{{forkablePostId}}" in the URL. The server returned 500 from `invalid input syntax for type uuid: "{{forkablePostId}}"`. Fix: add both variables to ci.bru so the two envs are identical. `forkablePostId` points to the seeded public post owned by a different user (so the testuser can fork it). `createdLinkPostId` is empty by default — set at runtime by create-link-post.bru's post-response and consumed by refresh-link-preview.bru. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Implements issue #9 — LangChain.js provider-factory abstraction, SSE-streaming autocomplete endpoint, and CodeMirror 6 ghost text overlay.
Closes #9
What's in this PR
ollamadefaultgemma4,openai,vertex) driven byLLM_PROVIDER/LLM_MODELenv varsStringOutputParserAiRateLimiterclass — 1 in-flight request per user, 60s timeout, injectablenow()for deterministic testslangchainPluginFastify plugin exposingapp.aiProvider,app.aiRateLimit,app.aiGatedecorators;onResponse/onErrorrelease hooksPOST /api/ai/completeSSE streaming route with exportedcreateAbortHandlershelperghost-textextension (StateField + WidgetType)parseSseStreamasync-iterable SSE parseruseAiCompletecomposable: 300ms debounce + AbortController cancellationAiSuggestion.vuewires composable to the editor'sEditorViewCodeEditor.vueexposes itsEditorViewref and includesghostTextExtensionPostEditor.vuemounts<AiSuggestion>and triggersrequestCompletionon content/language changesbruno/ai/complete.bruwith SSE assertions.beads/knowledge/(patterns + gotchas)Key design decision —
preHandlerinstead ofonRequestThe issue's example puts the rate limiter in a Fastify
onRequesthook, butonRequestfires beforeapp.authenticate(which runs inpreHandler), sorequest.userwould be undefined there. This PR composesapp.aiGate = [app.authenticate, app.aiRateLimit]as apreHandler, matching the existing pattern atpackages/server/src/routes/posts.ts:35. The adversarial reviewers accepted this deviation as a necessary correctness fix.Verification
.coverage-thresholds.jsonai/complete.bruagainst live server (OpenAI providergpt-5.4) — 9 token events + done event + suggestionreturn a + b;ai/collection tests (2/2 assertions pass)Notes for reviewers
gemma4stays as the declared default in.env.exampleper the issue spec. Live verification on this PR used OpenAI provider because the Apple M5 + macOS Tahoe + ollama 0.20.5/0.20.6 stack has a Metal4 shader compile bug (upstream ollama#15487, fix merged as commitec29ce4, awaiting a release). Once that release ships, swappingLLM_PROVIDER=openai→LLM_PROVIDER=ollamain local.envis enough to verify the happy path with gemma4.docs/superpowers/plans/2026-04-13-langchain-ai-autocomplete.md(uncommitted — a previously-established convention for this repo).-rregression has pre-existing failures unrelated to this branch (500s on bookmarks/comments/revisions, andai/completegets 401 becauseauth/logout.bruruns before it alphabetically). None of those endpoints were touched.Test plan
npm test— 1761/1762npm run test:coverage— 100% all metricsnpm run typecheck— cleannpx eslint— clean (2 pre-existing warnings on untouched lines)bruno/ai/complete.bru— PASSfunction add(a, b) {, wait 300ms, verify translucent ghost text, press Tab to accept, press any other key to dismiss🤖 Generated with Claude Code