From c960f2571c12722c2c717aee466129ab6225ba57 Mon Sep 17 00:00:00 2001 From: James Morton Date: Wed, 9 Sep 2026 22:34:12 +0100 Subject: [PATCH 01/13] fix(widget): honour documented open() deep-links The SDK already sent view/title/board/body and post/article/changelog targets; the iframe dropped new-post after the composer merge and never applied the other fields. Co-authored-by: Cursor --- .../widget/__tests__/widget-compose.test.ts | 137 ++++++++++++++++++ .../src/components/widget/widget-compose.ts | 127 ++++++++++++++++ .../widget/widget-home-animated.tsx | 37 +++-- apps/web/src/lib/shared/widget/types.ts | 7 +- apps/web/src/routes/widget/index.tsx | 99 +++++++++---- packages/widget/README.md | 2 +- packages/widget/__tests__/sdk.test.ts | 13 ++ packages/widget/src/types.ts | 6 +- 8 files changed, 385 insertions(+), 43 deletions(-) create mode 100644 apps/web/src/components/widget/__tests__/widget-compose.test.ts create mode 100644 apps/web/src/components/widget/widget-compose.ts diff --git a/apps/web/src/components/widget/__tests__/widget-compose.test.ts b/apps/web/src/components/widget/__tests__/widget-compose.test.ts new file mode 100644 index 0000000000..7eb2a576a7 --- /dev/null +++ b/apps/web/src/components/widget/__tests__/widget-compose.test.ts @@ -0,0 +1,137 @@ +import { describe, it, expect } from 'vitest' +import { + composeBodyFromPlainText, + resolveComposeBoardId, + resolveOpenCommand, +} from '../widget-compose' +import type { EnabledTabs } from '../widget-nav' + +const boards = [ + { id: 'board_ideas', slug: 'ideas' }, + { id: 'board_bugs', slug: 'bug-reports' }, +] + +const allTabs: EnabledTabs = { + feedback: true, + changelog: true, + help: true, + messages: true, + tickets: true, + home: true, +} + +describe('resolveComposeBoardId', () => { + it('selects the requested slug when it is on the visible list', () => { + expect(resolveComposeBoardId(boards, 'bug-reports', 'ideas')).toBe('board_bugs') + }) + + it('falls back to the configured default when no slug is given', () => { + expect(resolveComposeBoardId(boards, undefined, 'ideas')).toBe('board_ideas') + }) + + it('falls back to the configured default when the slug is unknown', () => { + expect(resolveComposeBoardId(boards, 'secret-board', 'ideas')).toBe('board_ideas') + }) + + it('does not select a board that is not on the visible list', () => { + expect(resolveComposeBoardId(boards, 'secret-board', undefined)).toBe('') + }) + + it('auto-selects the only board when no slug or default applies', () => { + expect(resolveComposeBoardId([boards[1]], undefined, undefined)).toBe('board_bugs') + expect(resolveComposeBoardId([boards[1]], 'missing', 'also-missing')).toBe('board_bugs') + }) + + it('leaves the picker empty when several boards have no default', () => { + expect(resolveComposeBoardId(boards, undefined, undefined)).toBe('') + }) +}) + +describe('resolveOpenCommand', () => { + it('opens a new-post compose with title, body, and board', () => { + expect( + resolveOpenCommand( + { view: 'new-post', title: 'Bug:', body: 'steps', board: 'bug-reports' }, + allTabs + ) + ).toEqual({ + type: 'new-post', + title: 'Bug:', + body: 'steps', + boardSlug: 'bug-reports', + }) + }) + + it('opens new-post with default-board behaviour when board is omitted', () => { + expect(resolveOpenCommand({ view: 'new-post' }, allTabs)).toEqual({ + type: 'new-post', + title: undefined, + body: undefined, + boardSlug: undefined, + }) + }) + + it('does not open new-post when Feedback is off', () => { + expect(resolveOpenCommand({ view: 'new-post', board: 'bugs' }, { help: true })).toBeNull() + }) + + it('deep-links a post when Feedback is on', () => { + expect(resolveOpenCommand({ postId: 'post_01h' }, allTabs)).toEqual({ + type: 'post', + postId: 'post_01h', + }) + }) + + it('does not deep-link a post when Feedback is off', () => { + expect(resolveOpenCommand({ postId: 'post_01h' }, { changelog: true })).toBeNull() + }) + + it('deep-links a help article when Help is on', () => { + expect(resolveOpenCommand({ articleId: 'pricing' }, allTabs)).toEqual({ + type: 'article', + articleId: 'pricing', + }) + }) + + it('prefills help search and opens a changelog entry', () => { + expect(resolveOpenCommand({ view: 'help', query: 'pricing' }, allTabs)).toEqual({ + type: 'help', + query: 'pricing', + }) + expect(resolveOpenCommand({ view: 'changelog', entryId: 'chg_01h' }, allTabs)).toEqual({ + type: 'changelog', + entryId: 'chg_01h', + }) + }) + + it('opens chat aliases on the messenger and tickets with a messages fallback', () => { + expect(resolveOpenCommand({ view: 'chat' }, allTabs)).toEqual({ type: 'messenger' }) + expect(resolveOpenCommand({ view: 'live-chat' }, allTabs)).toEqual({ type: 'messenger' }) + expect(resolveOpenCommand({ view: 'tickets' }, { tickets: true })).toEqual({ type: 'tickets' }) + expect(resolveOpenCommand({ view: 'tickets' }, { messages: true })).toEqual({ + type: 'messages', + }) + expect(resolveOpenCommand({ view: 'chat' }, { feedback: true })).toBeNull() + }) + + it('opens home for an empty payload when Home is enabled', () => { + expect(resolveOpenCommand({}, allTabs)).toEqual({ type: 'home' }) + expect(resolveOpenCommand({ view: 'home' }, allTabs)).toEqual({ type: 'home' }) + expect(resolveOpenCommand({}, { feedback: true })).toBeNull() + }) +}) + +describe('composeBodyFromPlainText', () => { + it('turns each line into a paragraph', () => { + const { json, html } = composeBodyFromPlainText('one\ntwo') + expect(json).toEqual({ + type: 'doc', + content: [ + { type: 'paragraph', content: [{ type: 'text', text: 'one' }] }, + { type: 'paragraph', content: [{ type: 'text', text: 'two' }] }, + ], + }) + expect(html).toContain('one') + expect(html).toContain('two') + }) +}) diff --git a/apps/web/src/components/widget/widget-compose.ts b/apps/web/src/components/widget/widget-compose.ts new file mode 100644 index 0000000000..d84ce05d6b --- /dev/null +++ b/apps/web/src/components/widget/widget-compose.ts @@ -0,0 +1,127 @@ +import type { JSONContent } from '@tiptap/core' +import { generateContentHTML } from '@/lib/shared/content-html' +import { homeEnabled, type EnabledTabs } from './widget-nav' + +export interface WidgetComposeRequest { + /** Bumped on every programmatic open so the same title/board can re-apply. */ + nonce: number + title?: string + body?: string + boardSlug?: string +} + +export type WidgetOpenPayload = { + view?: string + title?: string + body?: string + board?: string + query?: string + entryId?: string + postId?: string + articleId?: string +} + +export type WidgetOpenCommand = + | { type: 'new-post'; title?: string; body?: string; boardSlug?: string } + | { type: 'post'; postId: string } + | { type: 'article'; articleId: string } + | { type: 'changelog'; entryId?: string } + | { type: 'help'; query?: string } + | { type: 'messenger' } + | { type: 'tickets' } + | { type: 'messages' } + | { type: 'home' } + +/** + * Map an SDK `open(...)` payload to an iframe command. Unknown or unauthorized + * targets return null — the panel is already open; do not invent a surface. + */ +export function resolveOpenCommand( + opts: WidgetOpenPayload, + tabs: EnabledTabs +): WidgetOpenCommand | null { + if (nonEmpty(opts.postId)) { + return tabs.feedback ? { type: 'post', postId: opts.postId } : null + } + if (nonEmpty(opts.articleId)) { + return tabs.help ? { type: 'article', articleId: opts.articleId } : null + } + + switch (opts.view) { + case 'new-post': + if (!tabs.feedback) return null + return { + type: 'new-post', + title: emptyToUndef(opts.title), + body: emptyToUndef(opts.body), + boardSlug: emptyToUndef(opts.board), + } + case 'changelog': + if (!tabs.changelog) return null + return { type: 'changelog', entryId: emptyToUndef(opts.entryId) } + case 'help': + if (!tabs.help) return null + return { type: 'help', query: emptyToUndef(opts.query) } + case 'messages': + case 'chat': + case 'live-chat': + return tabs.messages ? { type: 'messenger' } : null + case 'tickets': + if (tabs.tickets) return { type: 'tickets' } + if (tabs.messages) return { type: 'messages' } + return null + case 'home': + case 'overview': + case undefined: + return homeEnabled(tabs) ? { type: 'home' } : null + default: + return null + } +} + +/** + * Resolve a compose-form board. Only slugs already on the visitor-visible + * `boards` list (boardViewFilter) can win — never invent access. An unknown + * or omitted slug uses the same fallback as a normal form mount: configured + * default, else the only board, else empty (picker). + */ +export function resolveComposeBoardId( + boards: ReadonlyArray<{ id: string; slug: string }>, + requestedSlug: string | undefined, + defaultBoardSlug: string | undefined +): string { + if (requestedSlug) { + const requested = boards.find((b) => b.slug === requestedSlug) + if (requested) return requested.id + } + if (defaultBoardSlug) { + const fallback = boards.find((b) => b.slug === defaultBoardSlug) + if (fallback) return fallback.id + } + if (boards.length === 1) return boards[0].id + return '' +} + +/** Plain-text `body` from the host → a one-paragraph-per-line TipTap doc. */ +export function composeBodyFromPlainText(body: string): { json: JSONContent; html: string } { + const lines = body.replace(/\r\n/g, '\n').split('\n') + const json: JSONContent = { + type: 'doc', + content: + lines.length === 0 + ? [{ type: 'paragraph' }] + : lines.map((line) => ({ + type: 'paragraph', + ...(line ? { content: [{ type: 'text', text: line }] } : {}), + })), + } + return { json, html: generateContentHTML(json) } +} + +function nonEmpty(value: string | undefined): value is string { + return typeof value === 'string' && value.length > 0 +} + +function emptyToUndef(value: string | undefined): string | undefined { + return nonEmpty(value) ? value : undefined +} diff --git a/apps/web/src/components/widget/widget-home-animated.tsx b/apps/web/src/components/widget/widget-home-animated.tsx index 809bff2a8c..cad5107a61 100644 --- a/apps/web/src/components/widget/widget-home-animated.tsx +++ b/apps/web/src/components/widget/widget-home-animated.tsx @@ -31,6 +31,11 @@ import { RichTextEditor } from '@/components/ui/rich-text-editor' import { useWidgetImageUpload, WidgetSessionError } from './use-widget-image-upload' import type { JSONContent } from '@tiptap/react' import type { TiptapContent } from '@/lib/shared/schemas/posts' +import { + composeBodyFromPlainText, + resolveComposeBoardId, + type WidgetComposeRequest, +} from './widget-compose' interface WidgetPost { id: string @@ -79,6 +84,8 @@ export interface WidgetHomeProps { */ boardPermissions?: Record defaultBoard?: string + /** Programmatic `open({ view: 'new-post' })` — expand and prefill. */ + composeRequest?: WidgetComposeRequest | null onPostSelect?: (postId: string) => void onPostCreated?: (post: { id: string @@ -218,6 +225,7 @@ export function WidgetHomeAnimated({ boards, boardPermissions, defaultBoard, + composeRequest, onPostSelect, onPostCreated, }: WidgetHomeProps) { @@ -237,16 +245,9 @@ export function WidgetHomeAnimated({ const [title, setTitle] = useState('') const [expanded, setExpanded] = useState(false) - const [selectedBoardId, setSelectedBoardId] = useState(() => { - if (defaultBoard) { - const match = boards.find((b) => b.slug === defaultBoard) - if (match) return match.id - } - // Single board: auto-select (selector is hidden anyway). Multiple boards with no - // default: leave empty so the user is prompted to pick one. - if (boards.length === 1) return boards[0].id - return '' - }) + const [selectedBoardId, setSelectedBoardId] = useState(() => + resolveComposeBoardId(boards, undefined, defaultBoard) + ) const [contentJson, setContentJson] = useState(null) const [contentHtml, setContentHtml] = useState('') const handleEditorChange = useCallback((json: JSONContent, html: string) => { @@ -254,6 +255,22 @@ export function WidgetHomeAnimated({ setContentHtml(html) }, []) + // Host `open({ view: 'new-post' })` lands here. Nonce (not title/board) is + // the trigger so a second identical command still expands and reapplies. + useEffect(() => { + if (!composeRequest) return + setExpanded(true) + if (composeRequest.title) setTitle(composeRequest.title) + if (composeRequest.body) { + const next = composeBodyFromPlainText(composeRequest.body) + setContentJson(next.json) + setContentHtml(next.html) + } + setSelectedBoardId(resolveComposeBoardId(boards, composeRequest.boardSlug, defaultBoard)) + inputRef.current?.focus({ preventScroll: true }) + // oxlint-disable-next-line react-hooks/exhaustive-deps -- nonce is the command identity + }, [composeRequest?.nonce]) + // Per-board capability, server-computed for the request actor. The widget // route refetches boardPermissions with the Bearer identity (keyed on // sessionVersion), so for an identified viewer this already reflects the real diff --git a/apps/web/src/lib/shared/widget/types.ts b/apps/web/src/lib/shared/widget/types.ts index 1b6370402f..ff6a2c2558 100644 --- a/apps/web/src/lib/shared/widget/types.ts +++ b/apps/web/src/lib/shared/widget/types.ts @@ -43,9 +43,14 @@ export interface WidgetInboundMessages { 'quackback:locale': string 'quackback:open': | { - view?: 'home' | 'new-post' + view?: 'home' | 'new-post' | 'changelog' | 'help' | 'chat' | 'messages' | 'tickets' title?: string + body?: string board?: string + query?: string + entryId?: string + postId?: string + articleId?: string } | undefined } diff --git a/apps/web/src/routes/widget/index.tsx b/apps/web/src/routes/widget/index.tsx index 420f542ce1..e6cbe3ca61 100644 --- a/apps/web/src/routes/widget/index.tsx +++ b/apps/web/src/routes/widget/index.tsx @@ -23,11 +23,11 @@ import { type WidgetView, resolveInitialTab, resolveInitialView, - homeEnabled, contentSurfaceCount, isExpandedView, visibleTabsForVisitor, } from '@/components/widget/widget-nav' +import { resolveOpenCommand, type WidgetComposeRequest } from '@/components/widget/widget-compose' import { WidgetHome } from '@/components/widget/widget-home' import { WidgetOverview } from '@/components/widget/widget-overview' import { WidgetHeroBackdrop } from '@/components/widget/widget-hero-backdrop' @@ -504,6 +504,8 @@ function WidgetPage() { name: string icon: string | null } | null>(null) + const [composeRequest, setComposeRequest] = useState(null) + const composeNonceRef = useRef(0) const [createdPosts, setCreatedPosts] = useState([]) const allPosts = useMemo(() => { @@ -570,41 +572,81 @@ function WidgetPage() { setHostIsMobile(!!msg.data) return } - if (msg.type !== 'quackback:open' || !msg.data) return + if (msg.type !== 'quackback:open') return + + const opts = (msg.data ?? {}) as { + view?: string + title?: string + body?: string + board?: string + query?: string + entryId?: string + postId?: string + articleId?: string + } + const command = resolveOpenCommand(opts, tabs) + if (!command) return - const opts = msg.data as { view?: string } // SDK-driven opens are tab-level landings: no back-chevron origin. setBackTarget(null) lastNavRef.current = 'tab' - if (opts.view === 'changelog' && tabs.changelog) { - setActiveTab('changelog') - setView('changelog') - } else if (opts.view === 'help' && tabs.help) { - // Same fresh start as navigateToTab('help'): the lifted search - // would otherwise resurface an old query on a programmatic open. - setSelectedHelpSlug(null) - setSelectedCategory(null) - setHelpSearch('') - setActiveTab('help') - setView('help') - } else if ( - (opts.view === 'messages' || opts.view === 'chat' || opts.view === 'live-chat') && - tabs.messages - ) { - openMessenger() - } else if (opts.view === 'tickets') { - // The requester's own-tickets list lives on the Tickets tab; on - // workspaces without it, ticket threads are listed in Messages. - if (tabs.tickets) { + switch (command.type) { + case 'new-post': + composeNonceRef.current += 1 + setComposeRequest({ + nonce: composeNonceRef.current, + title: command.title, + body: command.body, + boardSlug: command.boardSlug, + }) + setSelectedPostId(null) + setActiveTab('feedback') + setView('feedback') + break + case 'post': + setActiveTab('feedback') + setSelectedPostId(command.postId) + setView('post-detail') + break + case 'article': + setSelectedCategory(null) + setHelpSearch('') + setSelectedHelpSlug(command.articleId) + setActiveTab('help') + setView('help-detail') + break + case 'changelog': + setActiveTab('changelog') + if (command.entryId) { + setSelectedChangelogId(command.entryId) + setView('changelog-detail') + } else { + setSelectedChangelogId(null) + setView('changelog') + } + break + case 'help': + setSelectedHelpSlug(null) + setSelectedCategory(null) + setHelpSearch(command.query ?? '') + setActiveTab('help') + setView('help') + break + case 'messenger': + openMessenger() + break + case 'tickets': setActiveTab('tickets') setView('tickets') - } else if (tabs.messages) { + break + case 'messages': setActiveTab('messages') setView('messages') - } - } else if ((opts.view === 'home' || opts.view === 'overview') && homeEnabled(tabs)) { - setActiveTab('home') - setView('overview') + break + case 'home': + setActiveTab('home') + setView('overview') + break } } window.addEventListener('message', handleMessage) @@ -1046,6 +1088,7 @@ function WidgetPage() { boards={boards} boardPermissions={livePermissions} defaultBoard={defaultBoard} + composeRequest={composeRequest} onPostSelect={handlePostSelect} onPostCreated={handlePostCreated} /> diff --git a/packages/widget/README.md b/packages/widget/README.md index c80b686be2..88bbe27bfd 100644 --- a/packages/widget/README.md +++ b/packages/widget/README.md @@ -120,7 +120,7 @@ Quackback.open({ postId: 'post_01h...' }) // specific post Quackback.open({ articleId: 'art_01h...' }) // help article ``` -`view`, `title`, and `board` are live. `body`, `query`, `postId`, `articleId`, `entryId` pass through today and render in a follow-up release. +Every `open` field is live. A disabled surface or an unseen board fails closed — the panel still opens, but the widget does not invent access. ### Events diff --git a/packages/widget/__tests__/sdk.test.ts b/packages/widget/__tests__/sdk.test.ts index acaee3d7f0..ae3feec08f 100644 --- a/packages/widget/__tests__/sdk.test.ts +++ b/packages/widget/__tests__/sdk.test.ts @@ -199,6 +199,19 @@ describe('sdk', () => { { type: 'quackback:open', data: { postId: 'post_01h' } }, ORIGIN ) + sdk.dispatch('open', { + view: 'new-post', + title: 'Bug:', + body: 'steps', + board: 'bug-reports', + }) + expect(postMessage).toHaveBeenLastCalledWith( + { + type: 'quackback:open', + data: { view: 'new-post', title: 'Bug:', body: 'steps', board: 'bug-reports' }, + }, + ORIGIN + ) spy.mockRestore() }) diff --git a/packages/widget/src/types.ts b/packages/widget/src/types.ts index e21f4b3c96..da89d2f73b 100644 --- a/packages/widget/src/types.ts +++ b/packages/widget/src/types.ts @@ -61,9 +61,9 @@ export type Identity = * - `{ postId }` deep-links to a specific post * - `{ articleId }` deep-links to a help article * - * Fields `view` / `title` / `board` are handled by the iframe today. - * `body`, `query`, `postId`, `articleId`, `entryId` pass through the postMessage - * protocol; full iframe-side handling lands in follow-up iframe work. + * The iframe handles every field on this type. A target whose surface is + * disabled (or a board the visitor cannot see) fails closed — the panel + * still opens, but the widget does not invent access. */ export type OpenOptions = | undefined From abe2c025c172697b99120dc9bb35098c9167c6df Mon Sep 17 00:00:00 2001 From: James Morton Date: Wed, 9 Sep 2026 22:47:15 +0100 Subject: [PATCH 02/13] fix(widget): resolve boards after identify and article TypeIDs Identify now refetches the visitor-visible board list so open({ board }) can select members-only slugs, and articleId accepts a kb_article TypeID. Co-authored-by: Cursor --- .../widget/__tests__/widget-compose.test.ts | 13 ++++ .../src/components/widget/widget-compose.ts | 8 ++- .../widget/widget-home-animated.tsx | 10 ++++ .../__tests__/portal-gate-extended.test.ts | 20 ++++--- .../src/lib/server/functions/help-center.ts | 26 ++++++++ apps/web/src/lib/server/functions/portal.ts | 15 ++++- .../lib/server/policy/authz-matrix/MATRIX.md | 3 +- apps/web/src/lib/shared/widget/types.ts | 2 +- apps/web/src/routes/widget/index.tsx | 59 +++++++++++++++---- packages/widget/README.md | 2 +- packages/widget/src/types.ts | 2 +- 11 files changed, 133 insertions(+), 27 deletions(-) diff --git a/apps/web/src/components/widget/__tests__/widget-compose.test.ts b/apps/web/src/components/widget/__tests__/widget-compose.test.ts index 7eb2a576a7..b648757eeb 100644 --- a/apps/web/src/components/widget/__tests__/widget-compose.test.ts +++ b/apps/web/src/components/widget/__tests__/widget-compose.test.ts @@ -1,6 +1,8 @@ import { describe, it, expect } from 'vitest' +import { generateId } from '@quackback/ids' import { composeBodyFromPlainText, + isKbArticleTypeId, resolveComposeBoardId, resolveOpenCommand, } from '../widget-compose' @@ -93,6 +95,17 @@ describe('resolveOpenCommand', () => { }) }) + it('forwards a kb_article TypeID for the iframe to resolve to a slug', () => { + const articleId = generateId('kb_article') + expect(resolveOpenCommand({ articleId }, allTabs)).toEqual({ + type: 'article', + articleId, + }) + expect(isKbArticleTypeId(articleId)).toBe(true) + expect(isKbArticleTypeId('art_01h...')).toBe(false) + expect(isKbArticleTypeId('pricing')).toBe(false) + }) + it('prefills help search and opens a changelog entry', () => { expect(resolveOpenCommand({ view: 'help', query: 'pricing' }, allTabs)).toEqual({ type: 'help', diff --git a/apps/web/src/components/widget/widget-compose.ts b/apps/web/src/components/widget/widget-compose.ts index d84ce05d6b..d1ab042b3e 100644 --- a/apps/web/src/components/widget/widget-compose.ts +++ b/apps/web/src/components/widget/widget-compose.ts @@ -1,7 +1,13 @@ import type { JSONContent } from '@tiptap/core' +import { isTypeId } from '@quackback/ids' import { generateContentHTML } from '@/lib/shared/content-html' import { homeEnabled, type EnabledTabs } from './widget-nav' +/** True for a `kb_article_…` TypeID. Slugs and the old `art_` prefix are not. */ +export function isKbArticleTypeId(ref: string): boolean { + return isTypeId(ref, 'kb_article') +} + export interface WidgetComposeRequest { /** Bumped on every programmatic open so the same title/board can re-apply. */ nonce: number @@ -24,7 +30,7 @@ export type WidgetOpenPayload = { export type WidgetOpenCommand = | { type: 'new-post'; title?: string; body?: string; boardSlug?: string } | { type: 'post'; postId: string } - | { type: 'article'; articleId: string } + | { type: 'article'; articleId: string } // slug or `kb_article_…` TypeID | { type: 'changelog'; entryId?: string } | { type: 'help'; query?: string } | { type: 'messenger' } diff --git a/apps/web/src/components/widget/widget-home-animated.tsx b/apps/web/src/components/widget/widget-home-animated.tsx index cad5107a61..4ef2c2deb8 100644 --- a/apps/web/src/components/widget/widget-home-animated.tsx +++ b/apps/web/src/components/widget/widget-home-animated.tsx @@ -271,6 +271,16 @@ export function WidgetHomeAnimated({ // oxlint-disable-next-line react-hooks/exhaustive-deps -- nonce is the command identity }, [composeRequest?.nonce]) + // Identify can grow the visitor-visible list (members-only slugs). Re-apply + // a requested slug when it appears; do not reset a user-chosen board when + // open() did not name one. + useEffect(() => { + const slug = composeRequest?.boardSlug + if (!slug) return + const match = boards.find((b) => b.slug === slug) + if (match) setSelectedBoardId(match.id) + }, [boards, composeRequest?.boardSlug, composeRequest?.nonce]) + // Per-board capability, server-computed for the request actor. The widget // route refetches boardPermissions with the Bearer identity (keyed on // sessionVersion), so for an identified viewer this already reflects the real diff --git a/apps/web/src/lib/server/functions/__tests__/portal-gate-extended.test.ts b/apps/web/src/lib/server/functions/__tests__/portal-gate-extended.test.ts index 3f55368ea6..24bb4096a3 100644 --- a/apps/web/src/lib/server/functions/__tests__/portal-gate-extended.test.ts +++ b/apps/web/src/lib/server/functions/__tests__/portal-gate-extended.test.ts @@ -373,27 +373,31 @@ describe('portal.ts fetchBoardCapabilitiesFn — per-board capability map', () = mockResolvePortalAccess.mockResolvedValue({ granted: false, reason: 'unauthorized' }) const handler = await loadExportedHandler(PORTAL, 'fetchBoardCapabilitiesFn') const result = await handler({ data: {} }) - expect(result).toEqual({}) + expect(result).toEqual({ permissions: {}, boards: [] }) expect(mockListPublicBoardsWithStats).not.toHaveBeenCalled() }) it('maps each visible board to its submit/vote capability for the actor', async () => { mockResolvePortalAccess.mockResolvedValue({ granted: true, reason: 'public' }) mockListPublicBoardsWithStats.mockResolvedValue([ - { id: 'board_pub', access: anonAccess }, - { id: 'board_auth', access: authAccess }, + { id: 'board_pub', name: 'Public', slug: 'public', access: anonAccess }, + { id: 'board_auth', name: 'Auth', slug: 'auth', access: authAccess }, ]) const handler = await loadExportedHandler(PORTAL, 'fetchBoardCapabilitiesFn') - const result = (await handler({ data: {} })) as Record< - string, - { canSubmit: boolean; canVote: boolean } - > + const result = (await handler({ data: {} })) as { + permissions: Record + boards: { id: string; name: string; slug: string }[] + } // Anonymous actor (mocked) + workspace allowAnonymous=true: the all-anonymous // board is actionable, the sign-in-required board is not. - expect(result).toEqual({ + expect(result.permissions).toEqual({ board_pub: { canSubmit: true, canVote: true }, board_auth: { canSubmit: false, canVote: false }, }) + expect(result.boards).toEqual([ + { id: 'board_pub', name: 'Public', slug: 'public' }, + { id: 'board_auth', name: 'Auth', slug: 'auth' }, + ]) }) }) diff --git a/apps/web/src/lib/server/functions/help-center.ts b/apps/web/src/lib/server/functions/help-center.ts index 1093b309c7..49200a6f10 100644 --- a/apps/web/src/lib/server/functions/help-center.ts +++ b/apps/web/src/lib/server/functions/help-center.ts @@ -583,3 +583,29 @@ export const searchPublicArticlesFn = createServerFn({ method: 'GET' }) ) return hybridSearchForLocale(data.query, locale, data.limit ?? 10, await publicViewer()) }) + +/** + * Resolve a widget `open({ articleId })` ref to the public article slug. + * TypeIDs (`kb_article_…`) look up by id; anything else is treated as a slug. + * Missing or gated articles return null (fail closed — do not invent access). + * Appended so existing help-center handler indices stay put. + */ +export const resolvePublicArticleRefFn = createServerFn({ method: 'GET' }) + .validator(z.object({ ref: z.string().min(1) })) + .handler(async ({ data }) => { + const { isTypeId } = await import('@quackback/ids') + const { getPublicArticleByIdForLocale, getPublicArticleBySlugForLocale } = + await import('@/lib/server/domains/help-center/help-center-locale.query') + const { DEFAULT_LOCALE } = await import('@/lib/shared/i18n') + const { NotFoundError } = await import('@/lib/shared/errors') + const viewer = await publicViewer() + try { + const article = isTypeId(data.ref, 'kb_article') + ? await getPublicArticleByIdForLocale(data.ref as KbArticleId, DEFAULT_LOCALE, viewer) + : await getPublicArticleBySlugForLocale(data.ref, DEFAULT_LOCALE, viewer) + return { slug: article.slug } + } catch (err) { + if (err instanceof NotFoundError) return null + throw err + } + }) diff --git a/apps/web/src/lib/server/functions/portal.ts b/apps/web/src/lib/server/functions/portal.ts index ee7fb7d181..b6529ce3e6 100644 --- a/apps/web/src/lib/server/functions/portal.ts +++ b/apps/web/src/lib/server/functions/portal.ts @@ -745,7 +745,7 @@ export const fetchBoardCapabilitiesFn = createServerFn({ method: 'GET' }).handle // Same portal-visibility + per-board gates as fetchPortalData. const access = await resolvePortalAccessForRequest() - if (!access.granted) return empty + if (!access.granted) return { permissions: empty, boards: [] as WidgetVisibleBoard[] } const auth = await getOptionalAuth() const actor = await policyActorFromAuth(auth) @@ -756,5 +756,16 @@ export const fetchBoardCapabilitiesFn = createServerFn({ method: 'GET' }).handle listPublicBoardsWithStats(actor), loadAllowAnonymous(), ]) - return buildBoardPermissions(actor, boards, allowAnonymous) + return { + permissions: await buildBoardPermissions(actor, boards, allowAnonymous), + // Same visitor-visible list as the permissions map, so identify can + // surface segment/members boards the anonymous SSR seed omitted. + boards: boards.map((board): WidgetVisibleBoard => ({ + id: String(board.id), + name: board.name, + slug: board.slug, + })), + } }) + +export type WidgetVisibleBoard = { id: string; name: string; slug: string } diff --git a/apps/web/src/lib/server/policy/authz-matrix/MATRIX.md b/apps/web/src/lib/server/policy/authz-matrix/MATRIX.md index 38a3aca1f9..b1e3e6d1ef 100644 --- a/apps/web/src/lib/server/policy/authz-matrix/MATRIX.md +++ b/apps/web/src/lib/server/policy/authz-matrix/MATRIX.md @@ -993,7 +993,7 @@ Key scopes are enforced: an API key holds exactly its stored scopes (owner permi ## 4. Entry points without a requireAuth/key gate -193 of 987 entry points hold no `requireAuth` / `withApiKeyAuth` / `requireTeamAuth` gate. +194 of 988 entry points hold no `requireAuth` / `withApiKeyAuth` / `requireTeamAuth` gate. Each is expected to be intentionally public, a pre-auth flow, a signature-verified webhook, or a handler that delegates auth (e.g. the MCP route). **Adding a row here is an access-control change** — confirm the new entry point is meant to be reachable without a gate. @@ -1034,6 +1034,7 @@ Each is expected to be intentionally public, a pre-auth flow, a signature-verifi | `lib/server/functions/help-center.ts`::listPublicCategoriesFn | server-fn | | `lib/server/functions/help-center.ts`::listPublicCategoryEditorsFn | server-fn | | `lib/server/functions/help-center.ts`::recordArticleFeedbackFn | server-fn | +| `lib/server/functions/help-center.ts`::resolvePublicArticleRefFn | server-fn | | `lib/server/functions/help-center.ts`::searchPublicArticlesFn | server-fn | | `lib/server/functions/help-center.ts`::submitArticleFeedbackReasonFn | server-fn | | `lib/server/functions/instant-sso.ts`::resolveInstantSsoRedirectFn | server-fn | diff --git a/apps/web/src/lib/shared/widget/types.ts b/apps/web/src/lib/shared/widget/types.ts index ff6a2c2558..fe1828c530 100644 --- a/apps/web/src/lib/shared/widget/types.ts +++ b/apps/web/src/lib/shared/widget/types.ts @@ -50,7 +50,7 @@ export interface WidgetInboundMessages { query?: string entryId?: string postId?: string - articleId?: string + articleId?: string // `kb_article_…` TypeID or public slug } | undefined } diff --git a/apps/web/src/routes/widget/index.tsx b/apps/web/src/routes/widget/index.tsx index e6cbe3ca61..f431d145ec 100644 --- a/apps/web/src/routes/widget/index.tsx +++ b/apps/web/src/routes/widget/index.tsx @@ -27,7 +27,11 @@ import { isExpandedView, visibleTabsForVisitor, } from '@/components/widget/widget-nav' -import { resolveOpenCommand, type WidgetComposeRequest } from '@/components/widget/widget-compose' +import { + isKbArticleTypeId, + resolveOpenCommand, + type WidgetComposeRequest, +} from '@/components/widget/widget-compose' import { WidgetHome } from '@/components/widget/widget-home' import { WidgetOverview } from '@/components/widget/widget-overview' import { WidgetHeroBackdrop } from '@/components/widget/widget-hero-backdrop' @@ -38,7 +42,7 @@ import { publicChangelogQueries } from '@/lib/client/queries/changelog' import { publicHelpCenterQueries } from '@/lib/client/queries/help-center' import { fetchBoardCapabilitiesFn } from '@/lib/server/functions/portal' import { getShowPoweredByFn } from '@/lib/server/functions/powered-by' -import { listPublicArticlesFn } from '@/lib/server/functions/help-center' +import { listPublicArticlesFn, resolvePublicArticleRefFn } from '@/lib/server/functions/help-center' import { getWidgetAuthHeaders } from '@/lib/client/widget-auth' import { sendToHost } from '@/lib/client/widget-bridge' import { widgetQueryKeys, INITIAL_SESSION_VERSION } from '@/lib/client/hooks/use-widget-vote' @@ -407,8 +411,8 @@ function WidgetPage() { // feed gates votes/submission per the actual actor instead of OR-ing in a // blanket isIdentified (which advertised CTAs on segments/team boards the // actor cannot act on). Seeded with the loader map so SSR + first paint match. - const { data: livePermissions } = useQuery({ - queryKey: ['widget', 'boardPermissions', sessionVersion], + const { data: liveCapabilities } = useQuery({ + queryKey: ['widget', 'boardCapabilities', sessionVersion], queryFn: () => fetchBoardCapabilitiesFn({ headers: getWidgetAuthHeaders() }), // Seed ONLY the initial (anonymous, SSR) key from the loader. initialData // stamps an entry fresh as of now, so seeding it on every key would also @@ -416,11 +420,16 @@ function WidgetPage() { // staleTime — leaving an identified viewer stuck on the anonymous baseline. // After identify the key changes, carries no initialData, and refetches with // the Bearer while keepPreviousData shows the prior map meanwhile. - initialData: sessionVersion === INITIAL_SESSION_VERSION ? boardPermissions : undefined, + initialData: + sessionVersion === INITIAL_SESSION_VERSION + ? { permissions: boardPermissions, boards } + : undefined, placeholderData: keepPreviousData, staleTime: 30 * 1000, enabled: !!tabs.feedback, }) + const livePermissions = liveCapabilities?.permissions + const liveBoards = liveCapabilities?.boards ?? boards const { c: resumeConversationId } = Route.useSearch() const { hasTickets } = useTicketStageBadge(!!tabs.tickets) @@ -608,13 +617,39 @@ function WidgetPage() { setSelectedPostId(command.postId) setView('post-detail') break - case 'article': - setSelectedCategory(null) - setHelpSearch('') - setSelectedHelpSlug(command.articleId) - setActiveTab('help') - setView('help-detail') + case 'article': { + const openArticle = (slug: string) => { + setSelectedCategory(null) + setHelpSearch('') + setSelectedHelpSlug(slug) + setActiveTab('help') + setView('help-detail') + } + // WidgetHelpDetail loads by slug. A `kb_article_…` TypeID must + // resolve first; slugs pass through for in-widget navigation. + if (isKbArticleTypeId(command.articleId)) { + void resolvePublicArticleRefFn({ + data: { ref: command.articleId }, + headers: getWidgetAuthHeaders(), + }) + .then((resolved) => { + if (resolved?.slug) openArticle(resolved.slug) + else { + setSelectedHelpSlug(null) + setActiveTab('help') + setView('help') + } + }) + .catch(() => { + setSelectedHelpSlug(null) + setActiveTab('help') + setView('help') + }) + } else { + openArticle(command.articleId) + } break + } case 'changelog': setActiveTab('changelog') if (command.entryId) { @@ -1085,7 +1120,7 @@ function WidgetPage() { initialPosts={allPosts} initialHasMore={postsHasMore} statuses={statuses} - boards={boards} + boards={liveBoards} boardPermissions={livePermissions} defaultBoard={defaultBoard} composeRequest={composeRequest} diff --git a/packages/widget/README.md b/packages/widget/README.md index 88bbe27bfd..171b374080 100644 --- a/packages/widget/README.md +++ b/packages/widget/README.md @@ -117,7 +117,7 @@ Quackback.open({ view: 'new-post', title: 'Bug:', body: '...' }) // pre-filled f Quackback.open({ view: 'changelog' }) // changelog feed Quackback.open({ view: 'help', query: 'pricing' }) // help search Quackback.open({ postId: 'post_01h...' }) // specific post -Quackback.open({ articleId: 'art_01h...' }) // help article +Quackback.open({ articleId: 'kb_article_01h...' }) // help article TypeID or slug ``` Every `open` field is live. A disabled surface or an unseen board fails closed — the panel still opens, but the widget does not invent access. diff --git a/packages/widget/src/types.ts b/packages/widget/src/types.ts index da89d2f73b..41037aa7d3 100644 --- a/packages/widget/src/types.ts +++ b/packages/widget/src/types.ts @@ -59,7 +59,7 @@ export type Identity = * - `{ view: 'help', query? }` opens help, optionally with search prefilled * - `{ view: 'chat' }` opens the live chat view * - `{ postId }` deep-links to a specific post - * - `{ articleId }` deep-links to a help article + * - `{ articleId }` deep-links to a help article (`kb_article_…` TypeID or slug) * * The iframe handles every field on this type. A target whose surface is * disabled (or a board the visitor cannot see) fails closed — the panel From f167710e35c52865822ea28bef52a107598f76de Mon Sep 17 00:00:00 2001 From: James Morton Date: Wed, 9 Sep 2026 23:19:17 +0100 Subject: [PATCH 03/13] fix(widget): load articles like posts and auth identity feeds open({ articleId }) now stores the TypeID or slug and lets help-detail fetch with Bearer + sessionVersion, accepting article_ and kb_article_. Popular board feeds pass the widget identity so members-only pills are not empty after identify. Co-authored-by: Cursor --- .../widget/__tests__/widget-compose.test.ts | 16 +++--- .../src/components/widget/widget-compose.ts | 9 ++-- .../components/widget/widget-help-detail.tsx | 23 +++++++-- .../widget/widget-home-animated.tsx | 27 +++++++--- .../hooks/__tests__/widget-query-keys.test.ts | 32 ++++++++++++ .../src/lib/client/hooks/use-widget-vote.ts | 12 +++++ .../src/lib/server/functions/help-center.ts | 21 ++++---- .../widget/__tests__/article-ref.test.ts | 20 ++++++++ apps/web/src/lib/shared/widget/article-ref.ts | 31 +++++++++++ apps/web/src/lib/shared/widget/types.ts | 2 +- apps/web/src/routes/widget/index.tsx | 51 +++++-------------- packages/widget/README.md | 2 +- packages/widget/src/types.ts | 3 +- 13 files changed, 174 insertions(+), 75 deletions(-) create mode 100644 apps/web/src/lib/shared/widget/__tests__/article-ref.test.ts create mode 100644 apps/web/src/lib/shared/widget/article-ref.ts diff --git a/apps/web/src/components/widget/__tests__/widget-compose.test.ts b/apps/web/src/components/widget/__tests__/widget-compose.test.ts index b648757eeb..3a385b346e 100644 --- a/apps/web/src/components/widget/__tests__/widget-compose.test.ts +++ b/apps/web/src/components/widget/__tests__/widget-compose.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest' import { generateId } from '@quackback/ids' import { composeBodyFromPlainText, - isKbArticleTypeId, + isArticleTypeId, resolveComposeBoardId, resolveOpenCommand, } from '../widget-compose' @@ -95,15 +95,17 @@ describe('resolveOpenCommand', () => { }) }) - it('forwards a kb_article TypeID for the iframe to resolve to a slug', () => { + it('forwards an article TypeID the same way as a post TypeID', () => { const articleId = generateId('kb_article') - expect(resolveOpenCommand({ articleId }, allTabs)).toEqual({ + const publicId = `article_${articleId.slice('kb_article_'.length)}` + expect(resolveOpenCommand({ articleId: publicId }, allTabs)).toEqual({ type: 'article', - articleId, + articleId: publicId, }) - expect(isKbArticleTypeId(articleId)).toBe(true) - expect(isKbArticleTypeId('art_01h...')).toBe(false) - expect(isKbArticleTypeId('pricing')).toBe(false) + expect(isArticleTypeId(publicId)).toBe(true) + expect(isArticleTypeId(articleId)).toBe(true) + expect(isArticleTypeId('art_01h...')).toBe(false) + expect(isArticleTypeId('pricing')).toBe(false) }) it('prefills help search and opens a changelog entry', () => { diff --git a/apps/web/src/components/widget/widget-compose.ts b/apps/web/src/components/widget/widget-compose.ts index d1ab042b3e..1b57ff20a1 100644 --- a/apps/web/src/components/widget/widget-compose.ts +++ b/apps/web/src/components/widget/widget-compose.ts @@ -1,12 +1,9 @@ import type { JSONContent } from '@tiptap/core' -import { isTypeId } from '@quackback/ids' import { generateContentHTML } from '@/lib/shared/content-html' +import { isArticleTypeId } from '@/lib/shared/widget/article-ref' import { homeEnabled, type EnabledTabs } from './widget-nav' -/** True for a `kb_article_…` TypeID. Slugs and the old `art_` prefix are not. */ -export function isKbArticleTypeId(ref: string): boolean { - return isTypeId(ref, 'kb_article') -} +export { isArticleTypeId } export interface WidgetComposeRequest { /** Bumped on every programmatic open so the same title/board can re-apply. */ @@ -30,7 +27,7 @@ export type WidgetOpenPayload = { export type WidgetOpenCommand = | { type: 'new-post'; title?: string; body?: string; boardSlug?: string } | { type: 'post'; postId: string } - | { type: 'article'; articleId: string } // slug or `kb_article_…` TypeID + | { type: 'article'; articleId: string } // slug or `article_` / `kb_article_` TypeID | { type: 'changelog'; entryId?: string } | { type: 'help'; query?: string } | { type: 'messenger' } diff --git a/apps/web/src/components/widget/widget-help-detail.tsx b/apps/web/src/components/widget/widget-help-detail.tsx index 4a3b2dceb3..acfd4b5531 100644 --- a/apps/web/src/components/widget/widget-help-detail.tsx +++ b/apps/web/src/components/widget/widget-help-detail.tsx @@ -3,16 +3,20 @@ import { useQuery } from '@tanstack/react-query' import { FormattedMessage } from 'react-intl' import { ChevronRightIcon } from '@heroicons/react/24/outline' import { ScrollArea } from '@/components/ui/scroll-area' -import { publicHelpCenterQueries } from '@/lib/client/queries/help-center' +import { resolvePublicArticleRefFn } from '@/lib/server/functions/help-center' +import { getWidgetAuthHeaders } from '@/lib/client/widget-auth' +import { widgetQueryKeys } from '@/lib/client/hooks/use-widget-vote' import { RichTextContent, isRichTextContent } from '@/components/ui/rich-text-content' import type { JSONContent } from '@tiptap/react' import { WidgetPortalTitle } from './widget-portal-title' import { WidgetArticleFooter } from './widget-article-footer' import { sendToHost } from '@/lib/client/widget-bridge' import { WidgetArticleSkeleton } from './widget-skeletons' +import { useWidgetAuth } from './widget-auth-provider' interface WidgetHelpDetailProps { - articleSlug: string + /** `article_` / `kb_article_` TypeID or public slug — same as `open({ articleId })`. */ + articleRef: string /** Tapping the category eyebrow browses the rest of that collection. */ onCategorySelect?: (categoryId: string, categoryName: string) => void /** "Still stuck?" exit ramp — opens a new conversation. Omitted when the @@ -21,11 +25,22 @@ interface WidgetHelpDetailProps { } export function WidgetHelpDetail({ - articleSlug, + articleRef, onCategorySelect, onAskQuestion, }: WidgetHelpDetailProps) { - const { data: article, isLoading } = useQuery(publicHelpCenterQueries.articleBySlug(articleSlug)) + const { sessionVersion } = useWidgetAuth() + const { data: article, isLoading } = useQuery({ + queryKey: widgetQueryKeys.articleDetail.byRef(articleRef, sessionVersion), + queryFn: () => + resolvePublicArticleRefFn({ + data: { ref: articleRef }, + headers: getWidgetAuthHeaders(), + }), + placeholderData: (prev, prevQuery) => + prevQuery?.queryKey[2] === articleRef ? prev : undefined, + staleTime: 30 * 1000, + }) const handleViewOnPortal = useCallback(() => { if (!article) return diff --git a/apps/web/src/components/widget/widget-home-animated.tsx b/apps/web/src/components/widget/widget-home-animated.tsx index 4ef2c2deb8..fd5f504e18 100644 --- a/apps/web/src/components/widget/widget-home-animated.tsx +++ b/apps/web/src/components/widget/widget-home-animated.tsx @@ -22,7 +22,8 @@ import { listPublicPostsFn } from '@/lib/server/functions/public-posts' import { useInfiniteScroll } from '@/lib/client/hooks/use-infinite-scroll' import { WidgetVoteButton } from './widget-vote-button' import { WidgetPostListSkeleton } from './widget-skeletons' -import { widgetQueryKeys } from '@/lib/client/hooks/use-widget-vote' +import { widgetQueryKeys, INITIAL_SESSION_VERSION } from '@/lib/client/hooks/use-widget-vote' +import { getWidgetAuthHeaders } from '@/lib/client/widget-auth' import { cn } from '@/lib/shared/utils' import { useWidgetAuth } from './widget-auth-provider' import { sendToHost } from '@/lib/client/widget-bridge' @@ -239,6 +240,7 @@ export function WidgetHomeAnimated({ emitEvent, metadata, getSessionVersion, + sessionVersion, } = useWidgetAuth() const queryClient = useQueryClient() const inputRef = useRef(null) @@ -344,7 +346,7 @@ export function WidgetHomeAnimated({ isFetchingNextPage, isFetching: isFetchingPosts, } = useInfiniteQuery({ - queryKey: ['widget', 'posts', 'popular', 'top', activeBoardSlug ?? 'all'], + queryKey: widgetQueryKeys.popularPosts.list(activeBoardSlug, sessionVersion), queryFn: async ({ pageParam }) => { const page = await listPublicPostsFn({ data: { @@ -353,14 +355,16 @@ export function WidgetHomeAnimated({ limit: 20, boardSlug: activeBoardSlug ?? undefined, }, + headers: getWidgetAuthHeaders(), }) return { ...page, items: page.items.map(toWidgetPost) } }, initialPageParam: 1, getNextPageParam: (lastPage, allPages) => (lastPage.hasMore ? allPages.length + 1 : undefined), - // Only seed from SSR data on the initial unfiltered view + // Seed from SSR only on the anonymous first paint. Identify re-keys + // this query so members-only boards refetch with the Bearer actor. initialData: - activeBoardSlug === null + activeBoardSlug === null && sessionVersion === INITIAL_SESSION_VERSION ? { pages: [{ items: initialPosts, total: undefined, hasMore: initialHasMore }], pageParams: [1], @@ -385,11 +389,17 @@ export function WidgetHomeAnimated({ isFetching: isPopularSearchFetching, isPlaceholderData: isPopularSearchStale, } = useQuery({ - queryKey: ['widget', 'search', 'popular', debouncedPopularSearch, activeBoardSlug ?? 'all'], + queryKey: widgetQueryKeys.popularSearch.query( + debouncedPopularSearch, + activeBoardSlug, + sessionVersion + ), queryFn: async () => { const params = new URLSearchParams({ q: debouncedPopularSearch, limit: '20' }) if (activeBoardSlug) params.set('board', activeBoardSlug) - const res = await fetch(`/api/widget/search?${params}`) + const res = await fetch(`/api/widget/search?${params}`, { + headers: getWidgetAuthHeaders(), + }) const json = await res.json() return { posts: (json.data?.posts ?? []) as WidgetPost[] } }, @@ -445,7 +455,10 @@ export function WidgetHomeAnimated({ similarDebounceRef.current = setTimeout(async () => { try { const params = new URLSearchParams({ q, limit: '5' }) - const res = await fetch(`/api/widget/search?${params}`, { signal: controller.signal }) + const res = await fetch(`/api/widget/search?${params}`, { + signal: controller.signal, + headers: getWidgetAuthHeaders(), + }) const json = await res.json() const result: SearchResult = { posts: json.data?.posts ?? [] } similarSearchCache.set(q, result) diff --git a/apps/web/src/lib/client/hooks/__tests__/widget-query-keys.test.ts b/apps/web/src/lib/client/hooks/__tests__/widget-query-keys.test.ts index a3513dfc59..248343c703 100644 --- a/apps/web/src/lib/client/hooks/__tests__/widget-query-keys.test.ts +++ b/apps/web/src/lib/client/hooks/__tests__/widget-query-keys.test.ts @@ -46,6 +46,38 @@ describe('widgetQueryKeys', () => { }) }) + describe('articleDetail', () => { + it('byRef includes ref and version', () => { + expect(widgetQueryKeys.articleDetail.byRef('article_1', 0)).toEqual([ + 'widget', + 'article', + 'article_1', + 0, + ]) + }) + }) + + describe('popularPosts', () => { + it('list includes board slug and version', () => { + expect(widgetQueryKeys.popularPosts.list(null, 0)).toEqual([ + 'widget', + 'posts', + 'popular', + 'top', + 'all', + 0, + ]) + expect(widgetQueryKeys.popularPosts.list('bugs', 1)).toEqual([ + 'widget', + 'posts', + 'popular', + 'top', + 'bugs', + 1, + ]) + }) + }) + it('INITIAL_SESSION_VERSION is 0', () => { expect(INITIAL_SESSION_VERSION).toBe(0) }) diff --git a/apps/web/src/lib/client/hooks/use-widget-vote.ts b/apps/web/src/lib/client/hooks/use-widget-vote.ts index b20f4330f6..f0f379254d 100644 --- a/apps/web/src/lib/client/hooks/use-widget-vote.ts +++ b/apps/web/src/lib/client/hooks/use-widget-vote.ts @@ -27,6 +27,18 @@ export const widgetQueryKeys = { all: ['widget', 'post'] as const, byId: (postId: string, version: number) => ['widget', 'post', postId, version] as const, }, + articleDetail: { + all: ['widget', 'article'] as const, + byRef: (ref: string, version: number) => ['widget', 'article', ref, version] as const, + }, + popularPosts: { + list: (boardSlug: string | null, version: number) => + ['widget', 'posts', 'popular', 'top', boardSlug ?? 'all', version] as const, + }, + popularSearch: { + query: (q: string, boardSlug: string | null, version: number) => + ['widget', 'search', 'popular', q, boardSlug ?? 'all', version] as const, + }, } interface UseWidgetVoteOptions { diff --git a/apps/web/src/lib/server/functions/help-center.ts b/apps/web/src/lib/server/functions/help-center.ts index 49200a6f10..9fe5d5550d 100644 --- a/apps/web/src/lib/server/functions/help-center.ts +++ b/apps/web/src/lib/server/functions/help-center.ts @@ -585,25 +585,28 @@ export const searchPublicArticlesFn = createServerFn({ method: 'GET' }) }) /** - * Resolve a widget `open({ articleId })` ref to the public article slug. - * TypeIDs (`kb_article_…`) look up by id; anything else is treated as a slug. - * Missing or gated articles return null (fail closed — do not invent access). + * Public article by widget `open({ articleId })` ref — same shape as + * getPublicArticleBySlugFn. `article_` and `kb_article_` TypeIDs look up by + * id (same UUID); anything else is a slug. Missing or gated → null. * Appended so existing help-center handler indices stay put. */ export const resolvePublicArticleRefFn = createServerFn({ method: 'GET' }) - .validator(z.object({ ref: z.string().min(1) })) + .validator(z.object({ ref: z.string().min(1), locale: z.string().optional() })) .handler(async ({ data }) => { - const { isTypeId } = await import('@quackback/ids') + const { articleTypeIdToKbArticleId } = await import('@/lib/shared/widget/article-ref') const { getPublicArticleByIdForLocale, getPublicArticleBySlugForLocale } = await import('@/lib/server/domains/help-center/help-center-locale.query') const { DEFAULT_LOCALE } = await import('@/lib/shared/i18n') const { NotFoundError } = await import('@/lib/shared/errors') const viewer = await publicViewer() + const locale = data.locale ?? DEFAULT_LOCALE try { - const article = isTypeId(data.ref, 'kb_article') - ? await getPublicArticleByIdForLocale(data.ref as KbArticleId, DEFAULT_LOCALE, viewer) - : await getPublicArticleBySlugForLocale(data.ref, DEFAULT_LOCALE, viewer) - return { slug: article.slug } + const kbId = articleTypeIdToKbArticleId(data.ref) + const article = kbId + ? await getPublicArticleByIdForLocale(kbId, locale, viewer) + : await getPublicArticleBySlugForLocale(data.ref, locale, viewer) + const { helpfulCount: _h, notHelpfulCount: _n, ...publicArticle } = serializeArticle(article) + return publicArticle } catch (err) { if (err instanceof NotFoundError) return null throw err diff --git a/apps/web/src/lib/shared/widget/__tests__/article-ref.test.ts b/apps/web/src/lib/shared/widget/__tests__/article-ref.test.ts new file mode 100644 index 0000000000..4670413956 --- /dev/null +++ b/apps/web/src/lib/shared/widget/__tests__/article-ref.test.ts @@ -0,0 +1,20 @@ +import { describe, it, expect } from 'vitest' +import { generateId } from '@quackback/ids' +import { articleTypeIdToKbArticleId, isArticleTypeId } from '../article-ref' + +describe('article TypeID refs', () => { + it('treats article_ and kb_article_ as the same row', () => { + const stored = generateId('kb_article') + const published = `article_${stored.slice('kb_article_'.length)}` + expect(isArticleTypeId(stored)).toBe(true) + expect(isArticleTypeId(published)).toBe(true) + expect(articleTypeIdToKbArticleId(published)).toBe(stored) + expect(articleTypeIdToKbArticleId(stored)).toBe(stored) + }) + + it('rejects slugs and the old art_ prefix', () => { + expect(isArticleTypeId('pricing')).toBe(false) + expect(isArticleTypeId('art_01h...')).toBe(false) + expect(articleTypeIdToKbArticleId('pricing')).toBeNull() + }) +}) diff --git a/apps/web/src/lib/shared/widget/article-ref.ts b/apps/web/src/lib/shared/widget/article-ref.ts new file mode 100644 index 0000000000..dd205cbaf5 --- /dev/null +++ b/apps/web/src/lib/shared/widget/article-ref.ts @@ -0,0 +1,31 @@ +import { fromUuid, isValidTypeId, parseTypeId, type KbArticleId } from '@quackback/ids' + +/** Public `article_` plus the stored help-center prefix. */ +const ARTICLE_TYPEID_PREFIXES = new Set(['article', 'kb_article']) + +/** + * True for an article TypeID (`article_…` or `kb_article_…`). + * Slugs and the old undocumented `art_` prefix are not. + */ +export function isArticleTypeId(ref: string): boolean { + if (!isValidTypeId(ref)) return false + try { + return ARTICLE_TYPEID_PREFIXES.has(parseTypeId(ref).prefix) + } catch { + return false + } +} + +/** + * Map an `article_` / `kb_article_` TypeID onto the stored `KbArticleId`. + * Same UUID, canonical prefix — so a public `article_` id looks up the row. + */ +export function articleTypeIdToKbArticleId(ref: string): KbArticleId | null { + try { + const { prefix, uuid } = parseTypeId(ref) + if (!ARTICLE_TYPEID_PREFIXES.has(prefix)) return null + return fromUuid('kb_article', uuid) + } catch { + return null + } +} diff --git a/apps/web/src/lib/shared/widget/types.ts b/apps/web/src/lib/shared/widget/types.ts index fe1828c530..06239e7078 100644 --- a/apps/web/src/lib/shared/widget/types.ts +++ b/apps/web/src/lib/shared/widget/types.ts @@ -50,7 +50,7 @@ export interface WidgetInboundMessages { query?: string entryId?: string postId?: string - articleId?: string // `kb_article_…` TypeID or public slug + articleId?: string // `article_…` TypeID or public slug } | undefined } diff --git a/apps/web/src/routes/widget/index.tsx b/apps/web/src/routes/widget/index.tsx index f431d145ec..afad10daa6 100644 --- a/apps/web/src/routes/widget/index.tsx +++ b/apps/web/src/routes/widget/index.tsx @@ -27,11 +27,7 @@ import { isExpandedView, visibleTabsForVisitor, } from '@/components/widget/widget-nav' -import { - isKbArticleTypeId, - resolveOpenCommand, - type WidgetComposeRequest, -} from '@/components/widget/widget-compose' +import { resolveOpenCommand, type WidgetComposeRequest } from '@/components/widget/widget-compose' import { WidgetHome } from '@/components/widget/widget-home' import { WidgetOverview } from '@/components/widget/widget-overview' import { WidgetHeroBackdrop } from '@/components/widget/widget-hero-backdrop' @@ -42,7 +38,7 @@ import { publicChangelogQueries } from '@/lib/client/queries/changelog' import { publicHelpCenterQueries } from '@/lib/client/queries/help-center' import { fetchBoardCapabilitiesFn } from '@/lib/server/functions/portal' import { getShowPoweredByFn } from '@/lib/server/functions/powered-by' -import { listPublicArticlesFn, resolvePublicArticleRefFn } from '@/lib/server/functions/help-center' +import { listPublicArticlesFn } from '@/lib/server/functions/help-center' import { getWidgetAuthHeaders } from '@/lib/client/widget-auth' import { sendToHost } from '@/lib/client/widget-bridge' import { widgetQueryKeys, INITIAL_SESSION_VERSION } from '@/lib/client/hooks/use-widget-vote' @@ -617,39 +613,16 @@ function WidgetPage() { setSelectedPostId(command.postId) setView('post-detail') break - case 'article': { - const openArticle = (slug: string) => { - setSelectedCategory(null) - setHelpSearch('') - setSelectedHelpSlug(slug) - setActiveTab('help') - setView('help-detail') - } - // WidgetHelpDetail loads by slug. A `kb_article_…` TypeID must - // resolve first; slugs pass through for in-widget navigation. - if (isKbArticleTypeId(command.articleId)) { - void resolvePublicArticleRefFn({ - data: { ref: command.articleId }, - headers: getWidgetAuthHeaders(), - }) - .then((resolved) => { - if (resolved?.slug) openArticle(resolved.slug) - else { - setSelectedHelpSlug(null) - setActiveTab('help') - setView('help') - } - }) - .catch(() => { - setSelectedHelpSlug(null) - setActiveTab('help') - setView('help') - }) - } else { - openArticle(command.articleId) - } + case 'article': + // Same as postId: store the ref and let the detail view fetch it + // with Bearer + sessionVersion. TypeIDs (`article_` / `kb_article_`) + // and slugs both resolve server-side; no client hop. + setSelectedCategory(null) + setHelpSearch('') + setSelectedHelpSlug(command.articleId) + setActiveTab('help') + setView('help-detail') break - } case 'changelog': setActiveTab('changelog') if (command.entryId) { @@ -1081,7 +1054,7 @@ function WidgetPage() { fallback={} > handleHelpCategorySelect(id, name, null)} onAskQuestion={ messengerEnabled diff --git a/packages/widget/README.md b/packages/widget/README.md index 171b374080..842adfbcda 100644 --- a/packages/widget/README.md +++ b/packages/widget/README.md @@ -117,7 +117,7 @@ Quackback.open({ view: 'new-post', title: 'Bug:', body: '...' }) // pre-filled f Quackback.open({ view: 'changelog' }) // changelog feed Quackback.open({ view: 'help', query: 'pricing' }) // help search Quackback.open({ postId: 'post_01h...' }) // specific post -Quackback.open({ articleId: 'kb_article_01h...' }) // help article TypeID or slug +Quackback.open({ articleId: 'article_01h...' }) // help article TypeID or slug ``` Every `open` field is live. A disabled surface or an unseen board fails closed — the panel still opens, but the widget does not invent access. diff --git a/packages/widget/src/types.ts b/packages/widget/src/types.ts index 41037aa7d3..3dc666ea3c 100644 --- a/packages/widget/src/types.ts +++ b/packages/widget/src/types.ts @@ -59,7 +59,8 @@ export type Identity = * - `{ view: 'help', query? }` opens help, optionally with search prefilled * - `{ view: 'chat' }` opens the live chat view * - `{ postId }` deep-links to a specific post - * - `{ articleId }` deep-links to a help article (`kb_article_…` TypeID or slug) + * - `{ articleId }` deep-links to a help article (`article_…` TypeID or slug; + * stored `kb_article_…` ids also resolve) * * The iframe handles every field on this type. A target whose surface is * disabled (or a board the visitor cannot see) fails closed — the panel From 9b914d5e7fc2748fa702b9aab20b913b584f97ca Mon Sep 17 00:00:00 2001 From: James Morton Date: Thu, 10 Sep 2026 00:28:46 +0100 Subject: [PATCH 04/13] fix(widget): isolate open() feeds from identity and board filter drift Similar-post hits are cached per session so a later identify cannot keep showing another visitor's private titles. Popular Ideas keeps the SDK ?board= filter across session re-keys, and article detail no longer reuses another actor's content as placeholder data. Co-authored-by: Cursor --- .../components/widget/widget-help-detail.tsx | 4 +++- .../widget/widget-home-animated.tsx | 23 +++++++++++++------ apps/web/src/routes/widget/index.tsx | 2 ++ 3 files changed, 21 insertions(+), 8 deletions(-) diff --git a/apps/web/src/components/widget/widget-help-detail.tsx b/apps/web/src/components/widget/widget-help-detail.tsx index acfd4b5531..6fc09bc55b 100644 --- a/apps/web/src/components/widget/widget-help-detail.tsx +++ b/apps/web/src/components/widget/widget-help-detail.tsx @@ -38,7 +38,9 @@ export function WidgetHelpDetail({ headers: getWidgetAuthHeaders(), }), placeholderData: (prev, prevQuery) => - prevQuery?.queryKey[2] === articleRef ? prev : undefined, + prevQuery?.queryKey[2] === articleRef && prevQuery?.queryKey[3] === sessionVersion + ? prev + : undefined, staleTime: 30 * 1000, }) diff --git a/apps/web/src/components/widget/widget-home-animated.tsx b/apps/web/src/components/widget/widget-home-animated.tsx index fd5f504e18..5ef1c7521f 100644 --- a/apps/web/src/components/widget/widget-home-animated.tsx +++ b/apps/web/src/components/widget/widget-home-animated.tsx @@ -85,6 +85,8 @@ export interface WidgetHomeProps { */ boardPermissions?: Record defaultBoard?: string + /** SDK `?board=` / `defaultBoard` — seed the Popular Ideas filter. */ + initialBoardSlug?: string /** Programmatic `open({ view: 'new-post' })` — expand and prefill. */ composeRequest?: WidgetComposeRequest | null onPostSelect?: (postId: string) => void @@ -226,6 +228,7 @@ export function WidgetHomeAnimated({ boards, boardPermissions, defaultBoard, + initialBoardSlug, composeRequest, onPostSelect, onPostCreated, @@ -328,7 +331,9 @@ export function WidgetHomeAnimated({ const [similarPostResults, setSimilarPostResults] = useState(null) const [isSimilarSearching, setIsSimilarSearching] = useState(false) const similarDebounceRef = useRef>(null) - const [activeBoardSlug, setActiveBoardSlug] = useState(null) + const [activeBoardSlug, setActiveBoardSlug] = useState( + () => initialBoardSlug ?? null + ) const pills = usePillsScroll() const [popularSearch, setPopularSearch] = useState('') const [debouncedPopularSearch, setDebouncedPopularSearch] = useState('') @@ -361,10 +366,11 @@ export function WidgetHomeAnimated({ }, initialPageParam: 1, getNextPageParam: (lastPage, allPages) => (lastPage.hasMore ? allPages.length + 1 : undefined), - // Seed from SSR only on the anonymous first paint. Identify re-keys - // this query so members-only boards refetch with the Bearer actor. + // Seed from SSR only on the anonymous first paint for the same board + // filter the loader used (`?board=` or All). Identify re-keys this + // query so members-only boards refetch with the Bearer actor. initialData: - activeBoardSlug === null && sessionVersion === INITIAL_SESSION_VERSION + activeBoardSlug === (initialBoardSlug ?? null) && sessionVersion === INITIAL_SESSION_VERSION ? { pages: [{ items: initialPosts, total: undefined, hasMore: initialHasMore }], pageParams: [1], @@ -444,12 +450,15 @@ export function WidgetHomeAnimated({ setIsSimilarSearching(false) return } - const cached = similarSearchCache.get(q) + const cacheKey = `${sessionVersion}:${q}` + const cached = similarSearchCache.get(cacheKey) if (cached) { setSimilarPostResults(cached) setIsSimilarSearching(false) return } + // Drop the previous identity's hits before the new request lands. + setSimilarPostResults(null) setIsSimilarSearching(true) const controller = new AbortController() similarDebounceRef.current = setTimeout(async () => { @@ -461,7 +470,7 @@ export function WidgetHomeAnimated({ }) const json = await res.json() const result: SearchResult = { posts: json.data?.posts ?? [] } - similarSearchCache.set(q, result) + similarSearchCache.set(cacheKey, result) setSimilarPostResults(result) } catch (err) { if (err instanceof Error && err.name === 'AbortError') return @@ -474,7 +483,7 @@ export function WidgetHomeAnimated({ if (similarDebounceRef.current) clearTimeout(similarDebounceRef.current) controller.abort() } - }, [title]) + }, [title, sessionVersion]) // Debounce popular ideas search useEffect(() => { diff --git a/apps/web/src/routes/widget/index.tsx b/apps/web/src/routes/widget/index.tsx index afad10daa6..79f8b7face 100644 --- a/apps/web/src/routes/widget/index.tsx +++ b/apps/web/src/routes/widget/index.tsx @@ -398,6 +398,7 @@ function WidgetPage() { messengerEnabled, showPoweredBy, } = Route.useLoaderData() + const { board: initialBoardSlug } = Route.useSearch() const { ensureSession, sessionVersion } = useWidgetAuth() const intl = useIntl() @@ -1096,6 +1097,7 @@ function WidgetPage() { boards={liveBoards} boardPermissions={livePermissions} defaultBoard={defaultBoard} + initialBoardSlug={initialBoardSlug} composeRequest={composeRequest} onPostSelect={handlePostSelect} onPostCreated={handlePostCreated} From f2648c1cc7d10e1d8c2658f429cdcad0447ac9ab Mon Sep 17 00:00:00 2001 From: James Morton Date: Thu, 10 Sep 2026 00:30:10 +0100 Subject: [PATCH 05/13] fix(widget): honour identity and locale on open() detail views Changelog entryId fetches with the widget Bearer and sessionVersion so an authenticated-audience entry is not treated as anonymous. Article detail passes the active widget locale through the same query key as help search, so a translated article is not swapped for the default. Co-authored-by: Cursor --- .../widget/widget-changelog-detail.tsx | 20 +++++++++++++++-- .../components/widget/widget-help-detail.tsx | 11 ++++++---- .../hooks/__tests__/widget-query-keys.test.ts | 22 +++++++++++++++++-- .../src/lib/client/hooks/use-widget-vote.ts | 7 +++++- 4 files changed, 51 insertions(+), 9 deletions(-) diff --git a/apps/web/src/components/widget/widget-changelog-detail.tsx b/apps/web/src/components/widget/widget-changelog-detail.tsx index 2c7d7ba563..73e4bbaae3 100644 --- a/apps/web/src/components/widget/widget-changelog-detail.tsx +++ b/apps/web/src/components/widget/widget-changelog-detail.tsx @@ -2,7 +2,9 @@ import { useCallback } from 'react' import { useQuery } from '@tanstack/react-query' import { FormattedMessage } from 'react-intl' import { ScrollArea } from '@/components/ui/scroll-area' -import { publicChangelogQueries } from '@/lib/client/queries/changelog' +import { getPublicChangelogFn } from '@/lib/server/functions/changelog' +import { getWidgetAuthHeaders } from '@/lib/client/widget-auth' +import { widgetQueryKeys } from '@/lib/client/hooks/use-widget-vote' import { RichTextContent, isRichTextContent } from '@/components/ui/rich-text-content' import { EmbedHydration } from '@/components/shared/embed-hydration' import type { ChangelogId } from '@quackback/ids' @@ -11,13 +13,27 @@ import { WidgetPortalTitle } from './widget-portal-title' import { sendToHost } from '@/lib/client/widget-bridge' import { WidgetArticleSkeleton } from './widget-skeletons' import { ChangelogMetaRow } from './widget-changelog-meta' +import { useWidgetAuth } from './widget-auth-provider' interface WidgetChangelogDetailProps { entryId: string } export function WidgetChangelogDetail({ entryId }: WidgetChangelogDetailProps) { - const { data: entry, isLoading } = useQuery(publicChangelogQueries.detail(entryId as ChangelogId)) + const { sessionVersion } = useWidgetAuth() + const { data: entry, isLoading } = useQuery({ + queryKey: widgetQueryKeys.changelogDetail.byId(entryId, sessionVersion), + queryFn: () => + getPublicChangelogFn({ + data: { id: entryId as ChangelogId }, + headers: getWidgetAuthHeaders(), + }), + placeholderData: (prev, prevQuery) => + prevQuery?.queryKey[2] === entryId && prevQuery?.queryKey[3] === sessionVersion + ? prev + : undefined, + staleTime: 30 * 1000, + }) const changelogEntryId = entry?.id const handleViewOnPortal = useCallback(() => { diff --git a/apps/web/src/components/widget/widget-help-detail.tsx b/apps/web/src/components/widget/widget-help-detail.tsx index 6fc09bc55b..119986301f 100644 --- a/apps/web/src/components/widget/widget-help-detail.tsx +++ b/apps/web/src/components/widget/widget-help-detail.tsx @@ -1,6 +1,6 @@ import { useCallback } from 'react' import { useQuery } from '@tanstack/react-query' -import { FormattedMessage } from 'react-intl' +import { FormattedMessage, useIntl } from 'react-intl' import { ChevronRightIcon } from '@heroicons/react/24/outline' import { ScrollArea } from '@/components/ui/scroll-area' import { resolvePublicArticleRefFn } from '@/lib/server/functions/help-center' @@ -30,15 +30,18 @@ export function WidgetHelpDetail({ onAskQuestion, }: WidgetHelpDetailProps) { const { sessionVersion } = useWidgetAuth() + const { locale } = useIntl() const { data: article, isLoading } = useQuery({ - queryKey: widgetQueryKeys.articleDetail.byRef(articleRef, sessionVersion), + queryKey: widgetQueryKeys.articleDetail.byRef(articleRef, sessionVersion, locale), queryFn: () => resolvePublicArticleRefFn({ - data: { ref: articleRef }, + data: { ref: articleRef, locale }, headers: getWidgetAuthHeaders(), }), placeholderData: (prev, prevQuery) => - prevQuery?.queryKey[2] === articleRef && prevQuery?.queryKey[3] === sessionVersion + prevQuery?.queryKey[2] === articleRef && + prevQuery?.queryKey[3] === locale && + prevQuery?.queryKey[4] === sessionVersion ? prev : undefined, staleTime: 30 * 1000, diff --git a/apps/web/src/lib/client/hooks/__tests__/widget-query-keys.test.ts b/apps/web/src/lib/client/hooks/__tests__/widget-query-keys.test.ts index 248343c703..3fda55a316 100644 --- a/apps/web/src/lib/client/hooks/__tests__/widget-query-keys.test.ts +++ b/apps/web/src/lib/client/hooks/__tests__/widget-query-keys.test.ts @@ -47,14 +47,32 @@ describe('widgetQueryKeys', () => { }) describe('articleDetail', () => { - it('byRef includes ref and version', () => { - expect(widgetQueryKeys.articleDetail.byRef('article_1', 0)).toEqual([ + it('byRef includes ref, locale, and version', () => { + expect(widgetQueryKeys.articleDetail.byRef('article_1', 0, 'en')).toEqual([ 'widget', 'article', 'article_1', + 'en', 0, ]) }) + + it('same ref with different locales produce different keys', () => { + const en = widgetQueryKeys.articleDetail.byRef('article_1', 0, 'en') + const de = widgetQueryKeys.articleDetail.byRef('article_1', 0, 'de') + expect(en).not.toEqual(de) + }) + }) + + describe('changelogDetail', () => { + it('byId includes entryId and version', () => { + expect(widgetQueryKeys.changelogDetail.byId('changelog_1', 2)).toEqual([ + 'widget', + 'changelog', + 'changelog_1', + 2, + ]) + }) }) describe('popularPosts', () => { diff --git a/apps/web/src/lib/client/hooks/use-widget-vote.ts b/apps/web/src/lib/client/hooks/use-widget-vote.ts index f0f379254d..694e3ddde0 100644 --- a/apps/web/src/lib/client/hooks/use-widget-vote.ts +++ b/apps/web/src/lib/client/hooks/use-widget-vote.ts @@ -29,7 +29,12 @@ export const widgetQueryKeys = { }, articleDetail: { all: ['widget', 'article'] as const, - byRef: (ref: string, version: number) => ['widget', 'article', ref, version] as const, + byRef: (ref: string, version: number, locale: string) => + ['widget', 'article', ref, locale, version] as const, + }, + changelogDetail: { + all: ['widget', 'changelog'] as const, + byId: (entryId: string, version: number) => ['widget', 'changelog', entryId, version] as const, }, popularPosts: { list: (boardSlug: string | null, version: number) => From fccbeef1d72978d7e25247307da62bc40e3f6477 Mon Sep 17 00:00:00 2001 From: James Morton Date: Thu, 10 Sep 2026 00:56:25 +0100 Subject: [PATCH 06/13] fix(widget): isolate open() cache, auth changelog list, and article locale fallback Keep similar-search hits session-scoped and bounded, send Bearer on the changelog feed, fall back to the default-locale article, and stop Home from advertising a board filter or overwriting a visitor board pick. Co-authored-by: Cursor --- .../widget/__tests__/widget-compose.test.ts | 26 ++++++++++ .../components/widget/use-changelog-unread.ts | 6 ++- .../widget/widget-changelog-detail.tsx | 7 ++- .../widget/widget-changelog-query.ts | 24 ++++++++++ .../widget/widget-changelog-teaser.tsx | 6 ++- .../components/widget/widget-changelog.tsx | 7 ++- .../src/components/widget/widget-compose.ts | 13 +++++ .../components/widget/widget-help-detail.tsx | 9 ++-- .../widget/widget-home-animated.tsx | 47 ++++++++++++++++--- .../hooks/__tests__/widget-query-keys.test.ts | 23 ++++++++- .../src/lib/client/hooks/use-widget-vote.ts | 14 ++++++ .../src/lib/server/functions/help-center.ts | 14 ++++-- .../widget/__tests__/article-locale.test.ts | 45 ++++++++++++++++++ .../src/lib/shared/widget/article-locale.ts | 20 ++++++++ apps/web/src/routes/widget/index.tsx | 6 ++- packages/widget/README.md | 4 +- packages/widget/src/types.ts | 7 ++- 17 files changed, 250 insertions(+), 28 deletions(-) create mode 100644 apps/web/src/components/widget/widget-changelog-query.ts create mode 100644 apps/web/src/lib/shared/widget/__tests__/article-locale.test.ts create mode 100644 apps/web/src/lib/shared/widget/article-locale.ts diff --git a/apps/web/src/components/widget/__tests__/widget-compose.test.ts b/apps/web/src/components/widget/__tests__/widget-compose.test.ts index 3a385b346e..469ccb4ca0 100644 --- a/apps/web/src/components/widget/__tests__/widget-compose.test.ts +++ b/apps/web/src/components/widget/__tests__/widget-compose.test.ts @@ -5,6 +5,7 @@ import { isArticleTypeId, resolveComposeBoardId, resolveOpenCommand, + shouldReapplyComposeBoard, } from '../widget-compose' import type { EnabledTabs } from '../widget-nav' @@ -134,6 +135,31 @@ describe('resolveOpenCommand', () => { expect(resolveOpenCommand({ view: 'home' }, allTabs)).toEqual({ type: 'home' }) expect(resolveOpenCommand({}, { feedback: true })).toBeNull() }) + + it('lets postId and articleId win over view', () => { + expect( + resolveOpenCommand({ view: 'new-post', postId: 'post_01h', title: 'Bug:' }, allTabs) + ).toEqual({ type: 'post', postId: 'post_01h' }) + expect( + resolveOpenCommand({ view: 'new-post', articleId: 'pricing', title: 'Bug:' }, allTabs) + ).toEqual({ type: 'article', articleId: 'pricing' }) + expect( + resolveOpenCommand({ view: 'help', postId: 'post_01h', articleId: 'pricing' }, allTabs) + ).toEqual({ type: 'post', postId: 'post_01h' }) + }) +}) + +describe('shouldReapplyComposeBoard', () => { + it('re-applies only when identify newly grants the requested slug', () => { + expect(shouldReapplyComposeBoard('bugs', new Set(), new Set(['bugs', 'ideas']))).toBe(true) + expect(shouldReapplyComposeBoard('bugs', new Set(['bugs']), new Set(['bugs', 'ideas']))).toBe( + false + ) + expect(shouldReapplyComposeBoard('secret', new Set(['bugs']), new Set(['bugs', 'ideas']))).toBe( + false + ) + expect(shouldReapplyComposeBoard(undefined, new Set(), new Set(['bugs']))).toBe(false) + }) }) describe('composeBodyFromPlainText', () => { diff --git a/apps/web/src/components/widget/use-changelog-unread.ts b/apps/web/src/components/widget/use-changelog-unread.ts index 1114f231f3..24086061bf 100644 --- a/apps/web/src/components/widget/use-changelog-unread.ts +++ b/apps/web/src/components/widget/use-changelog-unread.ts @@ -1,6 +1,7 @@ import { useCallback, useEffect, useState } from 'react' import { useInfiniteQuery } from '@tanstack/react-query' -import { publicChangelogQueries } from '@/lib/client/queries/changelog' +import { widgetChangelogListQuery } from './widget-changelog-query' +import { useWidgetAuth } from './widget-auth-provider' import { countUnreadChangelogs, getChangelogSeenAt, @@ -18,8 +19,9 @@ export function useChangelogUnread(enabled: boolean): { unread: number markSeen: (publishedAt: string) => void } { + const { sessionVersion } = useWidgetAuth() const { data } = useInfiniteQuery({ - ...publicChangelogQueries.list(), + ...widgetChangelogListQuery(sessionVersion), enabled, refetchInterval: 60_000, }) diff --git a/apps/web/src/components/widget/widget-changelog-detail.tsx b/apps/web/src/components/widget/widget-changelog-detail.tsx index 73e4bbaae3..d647785bb4 100644 --- a/apps/web/src/components/widget/widget-changelog-detail.tsx +++ b/apps/web/src/components/widget/widget-changelog-detail.tsx @@ -4,7 +4,7 @@ import { FormattedMessage } from 'react-intl' import { ScrollArea } from '@/components/ui/scroll-area' import { getPublicChangelogFn } from '@/lib/server/functions/changelog' import { getWidgetAuthHeaders } from '@/lib/client/widget-auth' -import { widgetQueryKeys } from '@/lib/client/hooks/use-widget-vote' +import { widgetQueryKeys, widgetQueryKeyEquals } from '@/lib/client/hooks/use-widget-vote' import { RichTextContent, isRichTextContent } from '@/components/ui/rich-text-content' import { EmbedHydration } from '@/components/shared/embed-hydration' import type { ChangelogId } from '@quackback/ids' @@ -29,7 +29,10 @@ export function WidgetChangelogDetail({ entryId }: WidgetChangelogDetailProps) { headers: getWidgetAuthHeaders(), }), placeholderData: (prev, prevQuery) => - prevQuery?.queryKey[2] === entryId && prevQuery?.queryKey[3] === sessionVersion + widgetQueryKeyEquals( + widgetQueryKeys.changelogDetail.byId(entryId, sessionVersion), + prevQuery?.queryKey + ) ? prev : undefined, staleTime: 30 * 1000, diff --git a/apps/web/src/components/widget/widget-changelog-query.ts b/apps/web/src/components/widget/widget-changelog-query.ts new file mode 100644 index 0000000000..e4251048ae --- /dev/null +++ b/apps/web/src/components/widget/widget-changelog-query.ts @@ -0,0 +1,24 @@ +import { infiniteQueryOptions } from '@tanstack/react-query' +import { listPublicChangelogsFn } from '@/lib/server/functions/changelog' +import { getWidgetAuthHeaders } from '@/lib/client/widget-auth' +import { widgetQueryKeys } from '@/lib/client/hooks/use-widget-vote' + +const STALE_TIME_MEDIUM = 60 * 1000 + +/** Identity-aware changelog feed for the widget (Bearer + sessionVersion). */ +export function widgetChangelogListQuery(sessionVersion: number) { + return infiniteQueryOptions({ + queryKey: widgetQueryKeys.changelogList.bySession(sessionVersion), + queryFn: ({ pageParam }) => + listPublicChangelogsFn({ + data: { + cursor: pageParam, + limit: 10, + }, + headers: getWidgetAuthHeaders(), + }), + initialPageParam: undefined as string | undefined, + getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined, + staleTime: STALE_TIME_MEDIUM, + }) +} diff --git a/apps/web/src/components/widget/widget-changelog-teaser.tsx b/apps/web/src/components/widget/widget-changelog-teaser.tsx index b096d5c7a3..4c94222743 100644 --- a/apps/web/src/components/widget/widget-changelog-teaser.tsx +++ b/apps/web/src/components/widget/widget-changelog-teaser.tsx @@ -1,7 +1,8 @@ import { useInfiniteQuery } from '@tanstack/react-query' import { FormattedMessage } from 'react-intl' import { contentPreview } from '@/lib/shared/utils/string' -import { publicChangelogQueries } from '@/lib/client/queries/changelog' +import { widgetChangelogListQuery } from './widget-changelog-query' +import { useWidgetAuth } from './widget-auth-provider' function formatDate(iso: string) { return new Date(iso).toLocaleDateString('en-US', { @@ -26,7 +27,8 @@ interface WidgetChangelogTeaserProps { * they never disagree about whether content exists. */ export function WidgetChangelogTeaser({ onOpenEntry, onSeeAll }: WidgetChangelogTeaserProps) { - const { data } = useInfiniteQuery(publicChangelogQueries.list()) + const { sessionVersion } = useWidgetAuth() + const { data } = useInfiniteQuery(widgetChangelogListQuery(sessionVersion)) const latest = data?.pages[0]?.items[0] if (!latest) return null diff --git a/apps/web/src/components/widget/widget-changelog.tsx b/apps/web/src/components/widget/widget-changelog.tsx index 4f23dcd76d..2b733164cc 100644 --- a/apps/web/src/components/widget/widget-changelog.tsx +++ b/apps/web/src/components/widget/widget-changelog.tsx @@ -4,7 +4,9 @@ import { FormattedMessage } from 'react-intl' import { ScrollArea } from '@/components/ui/scroll-area' import { contentPreview } from '@/lib/shared/utils/string' import { cn } from '@/lib/shared/utils' -import { publicChangelogQueries, changelogCategoryQueries } from '@/lib/client/queries/changelog' +import { changelogCategoryQueries } from '@/lib/client/queries/changelog' +import { widgetChangelogListQuery } from './widget-changelog-query' +import { useWidgetAuth } from './widget-auth-provider' import { useInfiniteScroll } from '@/lib/client/hooks/use-infinite-scroll' import { getChangelogSeenAt, markChangelogSeen } from './changelog-unread' import { NewspaperIcon } from '@heroicons/react/24/outline' @@ -42,8 +44,9 @@ const lastVisit: { const FILTER_LOOKAHEAD_MIN_ROWS = 3 export function WidgetChangelog({ teamName, onEntrySelect }: WidgetChangelogProps) { + const { sessionVersion } = useWidgetAuth() const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isFetchNextPageError, isLoading } = - useInfiniteQuery(publicChangelogQueries.list()) + useInfiniteQuery(widgetChangelogListQuery(sessionVersion)) const { data: categories = [] } = useQuery(changelogCategoryQueries.list()) const [activeCategoryId, setActiveCategoryId] = useState( lastVisit.categoryId diff --git a/apps/web/src/components/widget/widget-compose.ts b/apps/web/src/components/widget/widget-compose.ts index 1b57ff20a1..a17808618f 100644 --- a/apps/web/src/components/widget/widget-compose.ts +++ b/apps/web/src/components/widget/widget-compose.ts @@ -38,6 +38,9 @@ export type WidgetOpenCommand = /** * Map an SDK `open(...)` payload to an iframe command. Unknown or unauthorized * targets return null — the panel is already open; do not invent a surface. + * + * `postId` and `articleId` win over `view` so a deep-link is never swallowed + * by a leftover compose/home view on the same payload. */ export function resolveOpenCommand( opts: WidgetOpenPayload, @@ -105,6 +108,16 @@ export function resolveComposeBoardId( return '' } +/** Re-apply `open({ board })` only when identify just granted that slug. */ +export function shouldReapplyComposeBoard( + requestedSlug: string | undefined, + previousSlugs: ReadonlySet, + nextSlugs: ReadonlySet +): boolean { + if (!requestedSlug) return false + return nextSlugs.has(requestedSlug) && !previousSlugs.has(requestedSlug) +} + /** Plain-text `body` from the host → a one-paragraph-per-line TipTap doc. */ export function composeBodyFromPlainText(body: string): { json: JSONContent; html: string } { const lines = body.replace(/\r\n/g, '\n').split('\n') diff --git a/apps/web/src/components/widget/widget-help-detail.tsx b/apps/web/src/components/widget/widget-help-detail.tsx index 119986301f..fa23127ac1 100644 --- a/apps/web/src/components/widget/widget-help-detail.tsx +++ b/apps/web/src/components/widget/widget-help-detail.tsx @@ -5,7 +5,7 @@ import { ChevronRightIcon } from '@heroicons/react/24/outline' import { ScrollArea } from '@/components/ui/scroll-area' import { resolvePublicArticleRefFn } from '@/lib/server/functions/help-center' import { getWidgetAuthHeaders } from '@/lib/client/widget-auth' -import { widgetQueryKeys } from '@/lib/client/hooks/use-widget-vote' +import { widgetQueryKeys, widgetQueryKeyEquals } from '@/lib/client/hooks/use-widget-vote' import { RichTextContent, isRichTextContent } from '@/components/ui/rich-text-content' import type { JSONContent } from '@tiptap/react' import { WidgetPortalTitle } from './widget-portal-title' @@ -39,9 +39,10 @@ export function WidgetHelpDetail({ headers: getWidgetAuthHeaders(), }), placeholderData: (prev, prevQuery) => - prevQuery?.queryKey[2] === articleRef && - prevQuery?.queryKey[3] === locale && - prevQuery?.queryKey[4] === sessionVersion + widgetQueryKeyEquals( + widgetQueryKeys.articleDetail.byRef(articleRef, sessionVersion, locale), + prevQuery?.queryKey + ) ? prev : undefined, staleTime: 30 * 1000, diff --git a/apps/web/src/components/widget/widget-home-animated.tsx b/apps/web/src/components/widget/widget-home-animated.tsx index 5ef1c7521f..24a4970158 100644 --- a/apps/web/src/components/widget/widget-home-animated.tsx +++ b/apps/web/src/components/widget/widget-home-animated.tsx @@ -35,6 +35,7 @@ import type { TiptapContent } from '@/lib/shared/schemas/posts' import { composeBodyFromPlainText, resolveComposeBoardId, + shouldReapplyComposeBoard, type WidgetComposeRequest, } from './widget-compose' @@ -103,8 +104,32 @@ interface SearchResult { posts: WidgetPost[] } +const SIMILAR_SEARCH_CACHE_LIMIT = 40 +let similarSearchCacheVersion = INITIAL_SESSION_VERSION const similarSearchCache = new Map() +function similarSearchCacheGet(sessionVersion: number, q: string): SearchResult | undefined { + if (similarSearchCacheVersion !== sessionVersion) { + similarSearchCache.clear() + similarSearchCacheVersion = sessionVersion + } + return similarSearchCache.get(q) +} + +function similarSearchCacheSet(sessionVersion: number, q: string, result: SearchResult) { + if (similarSearchCacheVersion !== sessionVersion) { + similarSearchCache.clear() + similarSearchCacheVersion = sessionVersion + } + if (similarSearchCache.has(q)) similarSearchCache.delete(q) + similarSearchCache.set(q, result) + while (similarSearchCache.size > SIMILAR_SEARCH_CACHE_LIMIT) { + const oldest = similarSearchCache.keys().next().value + if (oldest === undefined) break + similarSearchCache.delete(oldest) + } +} + // ── Shared post row used in both similar-posts and popular-ideas lists ── const WidgetPostRow = memo( @@ -277,14 +302,19 @@ export function WidgetHomeAnimated({ }, [composeRequest?.nonce]) // Identify can grow the visitor-visible list (members-only slugs). Re-apply - // a requested slug when it appears; do not reset a user-chosen board when - // open() did not name one. + // a requested slug only when it just appeared — not when the visitor already + // picked another board after open(). + const visibleBoardSlugs = useMemo(() => new Set(boards.map((b) => b.slug)), [boards]) + const prevVisibleBoardSlugsRef = useRef>(new Set()) useEffect(() => { + const next = visibleBoardSlugs + const prev = prevVisibleBoardSlugsRef.current + prevVisibleBoardSlugsRef.current = next const slug = composeRequest?.boardSlug - if (!slug) return + if (!shouldReapplyComposeBoard(slug, prev, next)) return const match = boards.find((b) => b.slug === slug) if (match) setSelectedBoardId(match.id) - }, [boards, composeRequest?.boardSlug, composeRequest?.nonce]) + }, [visibleBoardSlugs, boards, composeRequest?.boardSlug, composeRequest?.nonce]) // Per-board capability, server-computed for the request actor. The widget // route refetches boardPermissions with the Bearer identity (keyed on @@ -450,8 +480,7 @@ export function WidgetHomeAnimated({ setIsSimilarSearching(false) return } - const cacheKey = `${sessionVersion}:${q}` - const cached = similarSearchCache.get(cacheKey) + const cached = similarSearchCacheGet(sessionVersion, q) if (cached) { setSimilarPostResults(cached) setIsSimilarSearching(false) @@ -468,9 +497,13 @@ export function WidgetHomeAnimated({ signal: controller.signal, headers: getWidgetAuthHeaders(), }) + if (!res.ok) { + setSimilarPostResults({ posts: [] }) + return + } const json = await res.json() const result: SearchResult = { posts: json.data?.posts ?? [] } - similarSearchCache.set(cacheKey, result) + similarSearchCacheSet(sessionVersion, q, result) setSimilarPostResults(result) } catch (err) { if (err instanceof Error && err.name === 'AbortError') return diff --git a/apps/web/src/lib/client/hooks/__tests__/widget-query-keys.test.ts b/apps/web/src/lib/client/hooks/__tests__/widget-query-keys.test.ts index 3fda55a316..e56ea632fe 100644 --- a/apps/web/src/lib/client/hooks/__tests__/widget-query-keys.test.ts +++ b/apps/web/src/lib/client/hooks/__tests__/widget-query-keys.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { widgetQueryKeys, INITIAL_SESSION_VERSION } from '../use-widget-vote' +import { widgetQueryKeys, widgetQueryKeyEquals, INITIAL_SESSION_VERSION } from '../use-widget-vote' describe('widgetQueryKeys', () => { describe('votedPosts', () => { @@ -75,6 +75,27 @@ describe('widgetQueryKeys', () => { }) }) + describe('changelogList', () => { + it('bySession includes version', () => { + expect(widgetQueryKeys.changelogList.all).toEqual(['widget', 'changelogs']) + expect(widgetQueryKeys.changelogList.bySession(0)).toEqual(['widget', 'changelogs', 0]) + expect(widgetQueryKeys.changelogList.bySession(2)).toEqual(['widget', 'changelogs', 2]) + }) + }) + + describe('widgetQueryKeyEquals', () => { + it('matches a factory key without depending on slot indexes', () => { + const key = widgetQueryKeys.articleDetail.byRef('article_1', 3, 'de') + expect( + widgetQueryKeyEquals(widgetQueryKeys.articleDetail.byRef('article_1', 3, 'de'), key) + ).toBe(true) + expect( + widgetQueryKeyEquals(widgetQueryKeys.articleDetail.byRef('article_1', 4, 'de'), key) + ).toBe(false) + expect(widgetQueryKeyEquals(key, undefined)).toBe(false) + }) + }) + describe('popularPosts', () => { it('list includes board slug and version', () => { expect(widgetQueryKeys.popularPosts.list(null, 0)).toEqual([ diff --git a/apps/web/src/lib/client/hooks/use-widget-vote.ts b/apps/web/src/lib/client/hooks/use-widget-vote.ts index 694e3ddde0..46fb9f82ff 100644 --- a/apps/web/src/lib/client/hooks/use-widget-vote.ts +++ b/apps/web/src/lib/client/hooks/use-widget-vote.ts @@ -36,6 +36,10 @@ export const widgetQueryKeys = { all: ['widget', 'changelog'] as const, byId: (entryId: string, version: number) => ['widget', 'changelog', entryId, version] as const, }, + changelogList: { + all: ['widget', 'changelogs'] as const, + bySession: (version: number) => ['widget', 'changelogs', version] as const, + }, popularPosts: { list: (boardSlug: string | null, version: number) => ['widget', 'posts', 'popular', 'top', boardSlug ?? 'all', version] as const, @@ -46,6 +50,16 @@ export const widgetQueryKeys = { }, } +/** True when `actual` is the same factory key (avoids placeholder index coupling). */ +export function widgetQueryKeyEquals( + expected: readonly unknown[], + actual: readonly unknown[] | undefined +): boolean { + return ( + !!actual && expected.length === actual.length && expected.every((part, i) => part === actual[i]) + ) +} + interface UseWidgetVoteOptions { postId: PostId voteCount: number diff --git a/apps/web/src/lib/server/functions/help-center.ts b/apps/web/src/lib/server/functions/help-center.ts index 9fe5d5550d..d9b9e38e69 100644 --- a/apps/web/src/lib/server/functions/help-center.ts +++ b/apps/web/src/lib/server/functions/help-center.ts @@ -598,13 +598,21 @@ export const resolvePublicArticleRefFn = createServerFn({ method: 'GET' }) await import('@/lib/server/domains/help-center/help-center-locale.query') const { DEFAULT_LOCALE } = await import('@/lib/shared/i18n') const { NotFoundError } = await import('@/lib/shared/errors') + const { withDefaultLocaleFallback } = await import('@/lib/shared/widget/article-locale') const viewer = await publicViewer() const locale = data.locale ?? DEFAULT_LOCALE try { const kbId = articleTypeIdToKbArticleId(data.ref) - const article = kbId - ? await getPublicArticleByIdForLocale(kbId, locale, viewer) - : await getPublicArticleBySlugForLocale(data.ref, locale, viewer) + const load = (loc: string) => + kbId + ? getPublicArticleByIdForLocale(kbId, loc, viewer) + : getPublicArticleBySlugForLocale(data.ref, loc, viewer) + const article = await withDefaultLocaleFallback( + locale, + DEFAULT_LOCALE, + load, + (err) => err instanceof NotFoundError + ) const { helpfulCount: _h, notHelpfulCount: _n, ...publicArticle } = serializeArticle(article) return publicArticle } catch (err) { diff --git a/apps/web/src/lib/shared/widget/__tests__/article-locale.test.ts b/apps/web/src/lib/shared/widget/__tests__/article-locale.test.ts new file mode 100644 index 0000000000..37e831048a --- /dev/null +++ b/apps/web/src/lib/shared/widget/__tests__/article-locale.test.ts @@ -0,0 +1,45 @@ +import { describe, it, expect, vi } from 'vitest' +import { NotFoundError } from '@/lib/shared/errors' +import { withDefaultLocaleFallback } from '../article-locale' + +describe('withDefaultLocaleFallback', () => { + it('returns the requested locale when present', async () => { + const load = vi.fn(async (locale: string) => locale) + await expect(withDefaultLocaleFallback('de', 'en', load, () => false)).resolves.toBe('de') + expect(load).toHaveBeenCalledTimes(1) + }) + + it('falls back to the default locale when the requested translation is missing', async () => { + const load = vi.fn(async (locale: string) => { + if (locale === 'de') throw new NotFoundError('ARTICLE_NOT_FOUND', 'missing') + return 'en-article' + }) + await expect( + withDefaultLocaleFallback('de', 'en', load, (err) => err instanceof NotFoundError) + ).resolves.toBe('en-article') + expect(load).toHaveBeenCalledWith('de') + expect(load).toHaveBeenCalledWith('en') + }) + + it('does not retry when already on the default locale', async () => { + const err = new NotFoundError('ARTICLE_NOT_FOUND', 'missing') + const load = vi.fn(async () => { + throw err + }) + await expect( + withDefaultLocaleFallback('en', 'en', load, (e) => e instanceof NotFoundError) + ).rejects.toBe(err) + expect(load).toHaveBeenCalledTimes(1) + }) + + it('rethrows errors that are not a missing translation', async () => { + const err = new Error('db down') + const load = vi.fn(async () => { + throw err + }) + await expect( + withDefaultLocaleFallback('de', 'en', load, (e) => e instanceof NotFoundError) + ).rejects.toBe(err) + expect(load).toHaveBeenCalledTimes(1) + }) +}) diff --git a/apps/web/src/lib/shared/widget/article-locale.ts b/apps/web/src/lib/shared/widget/article-locale.ts new file mode 100644 index 0000000000..ea5a224861 --- /dev/null +++ b/apps/web/src/lib/shared/widget/article-locale.ts @@ -0,0 +1,20 @@ +/** + * Widget `open({ articleId })` should show the requested locale when a + * translation exists, and the default-locale article otherwise — same + * fallback as help search. Portal `/hc/{locale}/…` URLs stay strict 404. + */ +export async function withDefaultLocaleFallback( + locale: string, + defaultLocale: string, + load: (locale: string) => Promise, + isMissing: (err: unknown) => boolean +): Promise { + try { + return await load(locale) + } catch (err) { + if (isMissing(err) && locale !== defaultLocale) { + return load(defaultLocale) + } + throw err + } +} diff --git a/apps/web/src/routes/widget/index.tsx b/apps/web/src/routes/widget/index.tsx index 79f8b7face..40488ad400 100644 --- a/apps/web/src/routes/widget/index.tsx +++ b/apps/web/src/routes/widget/index.tsx @@ -34,8 +34,8 @@ import { WidgetHeroBackdrop } from '@/components/widget/widget-hero-backdrop' import type { ConversationId } from '@quackback/ids' import { useWidgetAuth } from '@/components/widget/widget-auth-provider' import { portalQueries } from '@/lib/client/queries/portal' -import { publicChangelogQueries } from '@/lib/client/queries/changelog' import { publicHelpCenterQueries } from '@/lib/client/queries/help-center' +import { widgetChangelogListQuery } from '@/components/widget/widget-changelog-query' import { fetchBoardCapabilitiesFn } from '@/lib/server/functions/portal' import { getShowPoweredByFn } from '@/lib/server/functions/powered-by' import { listPublicArticlesFn } from '@/lib/server/functions/help-center' @@ -189,7 +189,9 @@ export const Route = createFileRoute('/widget/')({ .catch(() => {}) : Promise.resolve(), changelogTabEnabled - ? queryClient.ensureInfiniteQueryData(publicChangelogQueries.list()).catch(() => {}) + ? queryClient + .ensureInfiniteQueryData(widgetChangelogListQuery(INITIAL_SESSION_VERSION)) + .catch(() => {}) : Promise.resolve(), helpTabEnabled ? queryClient.ensureQueryData(publicHelpCenterQueries.categories()).catch(() => {}) diff --git a/packages/widget/README.md b/packages/widget/README.md index 842adfbcda..eb48a0d46d 100644 --- a/packages/widget/README.md +++ b/packages/widget/README.md @@ -113,13 +113,15 @@ See the [Identify users guide](https://quackback.io/docs/widget/identify-users) ```ts Quackback.open() // home -Quackback.open({ view: 'new-post', title: 'Bug:', body: '...' }) // pre-filled form +Quackback.open({ view: 'new-post', title: 'Bug:', body: '...', board: 'bugs' }) // pre-filled form Quackback.open({ view: 'changelog' }) // changelog feed Quackback.open({ view: 'help', query: 'pricing' }) // help search Quackback.open({ postId: 'post_01h...' }) // specific post Quackback.open({ articleId: 'article_01h...' }) // help article TypeID or slug ``` +`postId` and `articleId` win over `view` if both are set. `board` on `open` only applies to `view: 'new-post'`. Home / Popular Ideas filtering uses `init({ defaultBoard })` or the iframe `?board=` param. + Every `open` field is live. A disabled surface or an unseen board fails closed — the panel still opens, but the widget does not invent access. ### Events diff --git a/packages/widget/src/types.ts b/packages/widget/src/types.ts index 3dc666ea3c..d08b400c7f 100644 --- a/packages/widget/src/types.ts +++ b/packages/widget/src/types.ts @@ -53,7 +53,7 @@ export type Identity = /** * Arguments to `Quackback.open(...)`. Discriminated on the target: - * - omit the payload to open the home view + * - omit the payload or `{ view: 'home' }` to open the home view * - `{ view: 'new-post', title?, body?, board? }` pre-fills the new-post form * - `{ view: 'changelog', entryId? }` opens the changelog, optionally to one entry * - `{ view: 'help', query? }` opens help, optionally with search prefilled @@ -62,13 +62,16 @@ export type Identity = * - `{ articleId }` deep-links to a help article (`article_…` TypeID or slug; * stored `kb_article_…` ids also resolve) * + * `postId` and `articleId` win over `view` when both are set. `board` applies + * only to `new-post` — Home filtering uses `init({ defaultBoard })` or `?board=`. + * * The iframe handles every field on this type. A target whose surface is * disabled (or a board the visitor cannot see) fails closed — the panel * still opens, but the widget does not invent access. */ export type OpenOptions = | undefined - | { view?: 'home'; board?: string } + | { view?: 'home' } | { view: 'new-post'; title?: string; body?: string; board?: string } | { view: 'changelog'; entryId?: string } | { view: 'help'; query?: string } From 3d668c5ecbfd0284ef7f7128d8944fb8b42af8fa Mon Sep 17 00:00:00 2001 From: James Morton Date: Thu, 10 Sep 2026 01:03:54 +0100 Subject: [PATCH 07/13] refactor(widget): share query-key and similar-search helpers Deduplicate session-cache reset and match placeholders by key prefix instead of queryKey slot indexes. Co-authored-by: Cursor --- .../widget/widget-home-animated.tsx | 23 ++++++++++--------- .../components/widget/widget-post-detail.tsx | 7 ++++-- .../hooks/__tests__/widget-query-keys.test.ts | 17 +++++++++++++- .../src/lib/client/hooks/use-widget-vote.ts | 10 +++++++- 4 files changed, 42 insertions(+), 15 deletions(-) diff --git a/apps/web/src/components/widget/widget-home-animated.tsx b/apps/web/src/components/widget/widget-home-animated.tsx index 24a4970158..fa0fd08f32 100644 --- a/apps/web/src/components/widget/widget-home-animated.tsx +++ b/apps/web/src/components/widget/widget-home-animated.tsx @@ -108,25 +108,26 @@ const SIMILAR_SEARCH_CACHE_LIMIT = 40 let similarSearchCacheVersion = INITIAL_SESSION_VERSION const similarSearchCache = new Map() -function similarSearchCacheGet(sessionVersion: number, q: string): SearchResult | undefined { +function similarSearchCacheFor(sessionVersion: number) { if (similarSearchCacheVersion !== sessionVersion) { similarSearchCache.clear() similarSearchCacheVersion = sessionVersion } - return similarSearchCache.get(q) + return similarSearchCache +} + +function similarSearchCacheGet(sessionVersion: number, q: string): SearchResult | undefined { + return similarSearchCacheFor(sessionVersion).get(q) } function similarSearchCacheSet(sessionVersion: number, q: string, result: SearchResult) { - if (similarSearchCacheVersion !== sessionVersion) { - similarSearchCache.clear() - similarSearchCacheVersion = sessionVersion - } - if (similarSearchCache.has(q)) similarSearchCache.delete(q) - similarSearchCache.set(q, result) - while (similarSearchCache.size > SIMILAR_SEARCH_CACHE_LIMIT) { - const oldest = similarSearchCache.keys().next().value + const cache = similarSearchCacheFor(sessionVersion) + if (cache.has(q)) cache.delete(q) + cache.set(q, result) + while (cache.size > SIMILAR_SEARCH_CACHE_LIMIT) { + const oldest = cache.keys().next().value if (oldest === undefined) break - similarSearchCache.delete(oldest) + cache.delete(oldest) } } diff --git a/apps/web/src/components/widget/widget-post-detail.tsx b/apps/web/src/components/widget/widget-post-detail.tsx index 3be5429435..9864849a28 100644 --- a/apps/web/src/components/widget/widget-post-detail.tsx +++ b/apps/web/src/components/widget/widget-post-detail.tsx @@ -9,7 +9,7 @@ import { fetchPublicPostDetail } from '@/lib/server/functions/portal' import { createCommentFn } from '@/lib/server/functions/comments' import { getWidgetAuthHeaders, generateOneTimeToken } from '@/lib/client/widget-auth' import { buildPortalUrl } from './build-portal-url' -import { widgetQueryKeys } from '@/lib/client/hooks/use-widget-vote' +import { widgetQueryKeys, widgetQueryKeyPrefixEquals } from '@/lib/client/hooks/use-widget-vote' import type { PublicPostDetailView } from '@/lib/client/queries/portal-detail' import { WidgetVoteButton } from './widget-vote-button' import { WidgetCommentList } from './widget-comment-list' @@ -70,7 +70,10 @@ export function WidgetPostDetail({ postId, statuses }: WidgetPostDetailProps) { // chips stay mounted for the in-flight request to land in — a skeleton // here would tear them down. Only for the same post: switching posts // still shows the skeleton rather than the previous post. - placeholderData: (prev, prevQuery) => (prevQuery?.queryKey[2] === postId ? prev : undefined), + placeholderData: (prev, prevQuery) => + widgetQueryKeyPrefixEquals([...widgetQueryKeys.postDetail.all, postId], prevQuery?.queryKey) + ? prev + : undefined, staleTime: 30 * 1000, }) diff --git a/apps/web/src/lib/client/hooks/__tests__/widget-query-keys.test.ts b/apps/web/src/lib/client/hooks/__tests__/widget-query-keys.test.ts index e56ea632fe..5017885732 100644 --- a/apps/web/src/lib/client/hooks/__tests__/widget-query-keys.test.ts +++ b/apps/web/src/lib/client/hooks/__tests__/widget-query-keys.test.ts @@ -1,5 +1,10 @@ import { describe, it, expect } from 'vitest' -import { widgetQueryKeys, widgetQueryKeyEquals, INITIAL_SESSION_VERSION } from '../use-widget-vote' +import { + widgetQueryKeys, + widgetQueryKeyEquals, + widgetQueryKeyPrefixEquals, + INITIAL_SESSION_VERSION, +} from '../use-widget-vote' describe('widgetQueryKeys', () => { describe('votedPosts', () => { @@ -94,6 +99,16 @@ describe('widgetQueryKeys', () => { ).toBe(false) expect(widgetQueryKeyEquals(key, undefined)).toBe(false) }) + + it('prefix match keeps the same entity across trailing key slots', () => { + const key = widgetQueryKeys.postDetail.byId('post_1', 4) + expect(widgetQueryKeyPrefixEquals([...widgetQueryKeys.postDetail.all, 'post_1'], key)).toBe( + true + ) + expect(widgetQueryKeyPrefixEquals([...widgetQueryKeys.postDetail.all, 'post_2'], key)).toBe( + false + ) + }) }) describe('popularPosts', () => { diff --git a/apps/web/src/lib/client/hooks/use-widget-vote.ts b/apps/web/src/lib/client/hooks/use-widget-vote.ts index 46fb9f82ff..025669aec2 100644 --- a/apps/web/src/lib/client/hooks/use-widget-vote.ts +++ b/apps/web/src/lib/client/hooks/use-widget-vote.ts @@ -56,10 +56,18 @@ export function widgetQueryKeyEquals( actual: readonly unknown[] | undefined ): boolean { return ( - !!actual && expected.length === actual.length && expected.every((part, i) => part === actual[i]) + !!actual && actual.length === expected.length && widgetQueryKeyPrefixEquals(expected, actual) ) } +/** True when `actual` starts with `prefix` — same entity, any trailing key slots. */ +export function widgetQueryKeyPrefixEquals( + prefix: readonly unknown[], + actual: readonly unknown[] | undefined +): boolean { + return !!actual && prefix.every((part, i) => actual[i] === part) +} + interface UseWidgetVoteOptions { postId: PostId voteCount: number From b02c8d9483489c0b3458a4e00994c2cd9d5c05fe Mon Sep 17 00:00:00 2001 From: James Morton Date: Thu, 10 Sep 2026 01:19:56 +0100 Subject: [PATCH 08/13] fix(widget): clear identity placeholders and attach help/changelog OTT Logout no longer keeps the previous visitor's boards or search hits, help collections refetch with Bearer + sessionVersion, and View on portal transfers an identified OTT for articles and changelog entries. Co-authored-by: Cursor --- .../widget/__tests__/build-portal-url.test.ts | 22 +++++++++- .../src/components/widget/build-portal-url.ts | 8 ++++ .../widget/widget-changelog-detail.tsx | 16 ++++--- .../widget/widget-help-category.tsx | 13 ++++-- .../components/widget/widget-help-detail.tsx | 16 ++++--- .../components/widget/widget-help-query.ts | 39 +++++++++++++++++ .../web/src/components/widget/widget-help.tsx | 6 ++- .../widget/widget-home-animated.tsx | 15 ++++--- .../hooks/__tests__/widget-query-keys.test.ts | 42 +++++++++++++++++++ .../src/lib/client/hooks/use-widget-vote.ts | 16 +++++++ apps/web/src/routes/widget/index.tsx | 16 ++++--- 11 files changed, 181 insertions(+), 28 deletions(-) create mode 100644 apps/web/src/components/widget/widget-help-query.ts diff --git a/apps/web/src/components/widget/__tests__/build-portal-url.test.ts b/apps/web/src/components/widget/__tests__/build-portal-url.test.ts index fb141f0c0b..88fbf4d1d8 100644 --- a/apps/web/src/components/widget/__tests__/build-portal-url.test.ts +++ b/apps/web/src/components/widget/__tests__/build-portal-url.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { buildPortalUrl } from '../build-portal-url' +import { appendWidgetOtt, buildPortalUrl } from '../build-portal-url' describe('buildPortalUrl', () => { const baseUrl = 'https://feedback.example.com' @@ -63,3 +63,23 @@ describe('buildPortalUrl', () => { expect(url).toContain('?ott=token%2Bwith%2Fspecial%3Dchars') }) }) + +describe('appendWidgetOtt', () => { + it('appends ott when identified', () => { + expect( + appendWidgetOtt('https://feedback.example.com/hc/articles/getting-started/faq', true, 'ott-1') + ).toBe('https://feedback.example.com/hc/articles/getting-started/faq?ott=ott-1') + }) + + it('leaves the URL unchanged for anonymous visitors', () => { + expect( + appendWidgetOtt('https://feedback.example.com/changelog/changelog_1', false, 'ott-1') + ).toBe('https://feedback.example.com/changelog/changelog_1') + }) + + it('leaves the URL unchanged when OTT generation returned null', () => { + expect(appendWidgetOtt('https://feedback.example.com/changelog/changelog_1', true, null)).toBe( + 'https://feedback.example.com/changelog/changelog_1' + ) + }) +}) diff --git a/apps/web/src/components/widget/build-portal-url.ts b/apps/web/src/components/widget/build-portal-url.ts index 85ea88579e..1fb4444a26 100644 --- a/apps/web/src/components/widget/build-portal-url.ts +++ b/apps/web/src/components/widget/build-portal-url.ts @@ -19,3 +19,11 @@ export function buildPortalUrl(params: { } return url } + +/** Append an OTT to a portal URL when the widget visitor is identified. */ +export function appendWidgetOtt(url: string, isIdentified: boolean, ott: string | null): string { + if (!isIdentified || !ott) return url + const next = new URL(url) + next.searchParams.set('ott', ott) + return next.toString() +} diff --git a/apps/web/src/components/widget/widget-changelog-detail.tsx b/apps/web/src/components/widget/widget-changelog-detail.tsx index d647785bb4..9a2fb1457d 100644 --- a/apps/web/src/components/widget/widget-changelog-detail.tsx +++ b/apps/web/src/components/widget/widget-changelog-detail.tsx @@ -3,7 +3,8 @@ import { useQuery } from '@tanstack/react-query' import { FormattedMessage } from 'react-intl' import { ScrollArea } from '@/components/ui/scroll-area' import { getPublicChangelogFn } from '@/lib/server/functions/changelog' -import { getWidgetAuthHeaders } from '@/lib/client/widget-auth' +import { generateOneTimeToken, getWidgetAuthHeaders } from '@/lib/client/widget-auth' +import { appendWidgetOtt } from './build-portal-url' import { widgetQueryKeys, widgetQueryKeyEquals } from '@/lib/client/hooks/use-widget-vote' import { RichTextContent, isRichTextContent } from '@/components/ui/rich-text-content' import { EmbedHydration } from '@/components/shared/embed-hydration' @@ -20,7 +21,7 @@ interface WidgetChangelogDetailProps { } export function WidgetChangelogDetail({ entryId }: WidgetChangelogDetailProps) { - const { sessionVersion } = useWidgetAuth() + const { isIdentified, sessionVersion } = useWidgetAuth() const { data: entry, isLoading } = useQuery({ queryKey: widgetQueryKeys.changelogDetail.byId(entryId, sessionVersion), queryFn: () => @@ -39,11 +40,16 @@ export function WidgetChangelogDetail({ entryId }: WidgetChangelogDetailProps) { }) const changelogEntryId = entry?.id - const handleViewOnPortal = useCallback(() => { + const handleViewOnPortal = useCallback(async () => { if (!changelogEntryId) return - const url = `${window.location.origin}/changelog/${changelogEntryId}` + const ott = isIdentified ? await generateOneTimeToken() : null + const url = appendWidgetOtt( + `${window.location.origin}/changelog/${changelogEntryId}`, + isIdentified, + ott + ) sendToHost({ type: 'quackback:navigate', url }) - }, [changelogEntryId]) + }, [changelogEntryId, isIdentified]) if (isLoading) { return diff --git a/apps/web/src/components/widget/widget-help-category.tsx b/apps/web/src/components/widget/widget-help-category.tsx index bf2b293312..203d6e6176 100644 --- a/apps/web/src/components/widget/widget-help-category.tsx +++ b/apps/web/src/components/widget/widget-help-category.tsx @@ -1,10 +1,11 @@ import { useQuery } from '@tanstack/react-query' -import { FormattedMessage } from 'react-intl' +import { FormattedMessage, useIntl } from 'react-intl' import { ScrollArea } from '@/components/ui/scroll-area' import { ChevronRightIcon } from '@heroicons/react/24/solid' -import { publicHelpCenterQueries } from '@/lib/client/queries/help-center' import { CategoryIcon } from '@/components/help-center/category-icon' import { WidgetHelpArticleListSkeleton } from './widget-skeletons' +import { widgetHelpCategoriesQuery, widgetHelpCategoryArticlesQuery } from './widget-help-query' +import { useWidgetAuth } from './widget-auth-provider' interface WidgetHelpCategoryProps { categoryId: string @@ -19,11 +20,15 @@ export function WidgetHelpCategory({ categoryIcon, onArticleSelect, }: WidgetHelpCategoryProps) { - const articlesQuery = useQuery(publicHelpCenterQueries.articlesForCategory(categoryId)) + const { locale } = useIntl() + const { sessionVersion } = useWidgetAuth() + const articlesQuery = useQuery( + widgetHelpCategoryArticlesQuery(categoryId, sessionVersion, locale) + ) // The collection list already has every category's description and icon; // read them from that cache so the header carries context (and an icon even // when we arrived from an article's eyebrow, which only knows id + name). - const categoriesQuery = useQuery(publicHelpCenterQueries.categories()) + const categoriesQuery = useQuery(widgetHelpCategoriesQuery(sessionVersion, locale)) const category = categoriesQuery.data?.find((c) => c.id === categoryId) const icon = categoryIcon ?? category?.icon ?? null const articleCount = articlesQuery.data?.length ?? category?.articleCount diff --git a/apps/web/src/components/widget/widget-help-detail.tsx b/apps/web/src/components/widget/widget-help-detail.tsx index fa23127ac1..a1bcedfaaa 100644 --- a/apps/web/src/components/widget/widget-help-detail.tsx +++ b/apps/web/src/components/widget/widget-help-detail.tsx @@ -4,7 +4,8 @@ import { FormattedMessage, useIntl } from 'react-intl' import { ChevronRightIcon } from '@heroicons/react/24/outline' import { ScrollArea } from '@/components/ui/scroll-area' import { resolvePublicArticleRefFn } from '@/lib/server/functions/help-center' -import { getWidgetAuthHeaders } from '@/lib/client/widget-auth' +import { generateOneTimeToken, getWidgetAuthHeaders } from '@/lib/client/widget-auth' +import { appendWidgetOtt } from './build-portal-url' import { widgetQueryKeys, widgetQueryKeyEquals } from '@/lib/client/hooks/use-widget-vote' import { RichTextContent, isRichTextContent } from '@/components/ui/rich-text-content' import type { JSONContent } from '@tiptap/react' @@ -29,7 +30,7 @@ export function WidgetHelpDetail({ onCategorySelect, onAskQuestion, }: WidgetHelpDetailProps) { - const { sessionVersion } = useWidgetAuth() + const { isIdentified, sessionVersion } = useWidgetAuth() const { locale } = useIntl() const { data: article, isLoading } = useQuery({ queryKey: widgetQueryKeys.articleDetail.byRef(articleRef, sessionVersion, locale), @@ -48,11 +49,16 @@ export function WidgetHelpDetail({ staleTime: 30 * 1000, }) - const handleViewOnPortal = useCallback(() => { + const handleViewOnPortal = useCallback(async () => { if (!article) return - const url = `${window.location.origin}/hc/articles/${article.category.slug}/${article.slug}` + const ott = isIdentified ? await generateOneTimeToken() : null + const url = appendWidgetOtt( + `${window.location.origin}/hc/articles/${article.category.slug}/${article.slug}`, + isIdentified, + ott + ) sendToHost({ type: 'quackback:navigate', url }) - }, [article]) + }, [article, isIdentified]) if (isLoading) { return diff --git a/apps/web/src/components/widget/widget-help-query.ts b/apps/web/src/components/widget/widget-help-query.ts new file mode 100644 index 0000000000..65b78673f4 --- /dev/null +++ b/apps/web/src/components/widget/widget-help-query.ts @@ -0,0 +1,39 @@ +import { queryOptions } from '@tanstack/react-query' +import { + listPublicArticlesForCategoryFn, + listPublicCategoriesFn, +} from '@/lib/server/functions/help-center' +import { getWidgetAuthHeaders } from '@/lib/client/widget-auth' +import { widgetQueryKeys } from '@/lib/client/hooks/use-widget-vote' + +const STALE_TIME_MEDIUM = 60 * 1000 + +/** Identity-aware help collections for the widget (Bearer + sessionVersion). */ +export function widgetHelpCategoriesQuery(sessionVersion: number, locale: string) { + return queryOptions({ + queryKey: widgetQueryKeys.helpCategories.bySession(sessionVersion, locale), + queryFn: () => + listPublicCategoriesFn({ + data: { locale }, + headers: getWidgetAuthHeaders(), + }), + staleTime: STALE_TIME_MEDIUM, + }) +} + +/** Identity-aware articles in one collection (Bearer + sessionVersion). */ +export function widgetHelpCategoryArticlesQuery( + categoryId: string, + sessionVersion: number, + locale: string +) { + return queryOptions({ + queryKey: widgetQueryKeys.helpCategoryArticles.byCategory(categoryId, sessionVersion, locale), + queryFn: () => + listPublicArticlesForCategoryFn({ + data: { categoryId, locale }, + headers: getWidgetAuthHeaders(), + }), + staleTime: STALE_TIME_MEDIUM, + }) +} diff --git a/apps/web/src/components/widget/widget-help.tsx b/apps/web/src/components/widget/widget-help.tsx index 1cc546b166..41ff0062ff 100644 --- a/apps/web/src/components/widget/widget-help.tsx +++ b/apps/web/src/components/widget/widget-help.tsx @@ -10,7 +10,8 @@ import { ChevronRightIcon, XMarkIcon, } from '@heroicons/react/24/outline' -import { publicHelpCenterQueries } from '@/lib/client/queries/help-center' +import { widgetHelpCategoriesQuery } from './widget-help-query' +import { useWidgetAuth } from './widget-auth-provider' import { getTopLevelCategories } from '@/components/help-center/help-center-utils' import { CategoryIcon } from '@/components/help-center/category-icon' import { @@ -44,11 +45,12 @@ export function WidgetHelp({ onSearchChange, }: WidgetHelpProps) { const intl = useIntl() + const { sessionVersion } = useWidgetAuth() const [localSearch, setLocalSearch] = useState('') const search = controlledSearch ?? localSearch const setSearch = onSearchChange ?? setLocalSearch - const categoriesQuery = useQuery(publicHelpCenterQueries.categories()) + const categoriesQuery = useQuery(widgetHelpCategoriesQuery(sessionVersion, intl.locale)) const topLevelCategories = categoriesQuery.data ? getTopLevelCategories(categoriesQuery.data) : [] const askAiAvailable = useAskAiAvailable() diff --git a/apps/web/src/components/widget/widget-home-animated.tsx b/apps/web/src/components/widget/widget-home-animated.tsx index fa0fd08f32..0665604dd7 100644 --- a/apps/web/src/components/widget/widget-home-animated.tsx +++ b/apps/web/src/components/widget/widget-home-animated.tsx @@ -9,7 +9,7 @@ import { ChevronRightIcon, } from '@heroicons/react/24/outline' import { motion, AnimatePresence } from 'framer-motion' -import { useInfiniteQuery, useQuery, useQueryClient, keepPreviousData } from '@tanstack/react-query' +import { useInfiniteQuery, useQuery, useQueryClient } from '@tanstack/react-query' import { useIntl, FormattedMessage } from 'react-intl' import { Select, @@ -22,7 +22,11 @@ import { listPublicPostsFn } from '@/lib/server/functions/public-posts' import { useInfiniteScroll } from '@/lib/client/hooks/use-infinite-scroll' import { WidgetVoteButton } from './widget-vote-button' import { WidgetPostListSkeleton } from './widget-skeletons' -import { widgetQueryKeys, INITIAL_SESSION_VERSION } from '@/lib/client/hooks/use-widget-vote' +import { + widgetQueryKeys, + widgetQueryKeySameSession, + INITIAL_SESSION_VERSION, +} from '@/lib/client/hooks/use-widget-vote' import { getWidgetAuthHeaders } from '@/lib/client/widget-auth' import { cn } from '@/lib/shared/utils' import { useWidgetAuth } from './widget-auth-provider' @@ -442,9 +446,10 @@ export function WidgetHomeAnimated({ }, enabled: debouncedPopularSearch.length > 0, // Refining a query keeps the previous hits on screen (dimmed) instead of - // blinking the list empty between keystrokes; only the very first search - // has nothing to hold and shows the row skeleton. - placeholderData: keepPreviousData, + // blinking the list empty between keystrokes. Drop them when the session + // changes so a later identity never sees the previous visitor's titles. + placeholderData: (prev, prevQuery) => + widgetQueryKeySameSession(prevQuery?.queryKey, sessionVersion) ? prev : undefined, }) // Typed-but-unsettled (debounce window), or fetching, or showing hits that // belong to the previous query. diff --git a/apps/web/src/lib/client/hooks/__tests__/widget-query-keys.test.ts b/apps/web/src/lib/client/hooks/__tests__/widget-query-keys.test.ts index 5017885732..9e7f13d207 100644 --- a/apps/web/src/lib/client/hooks/__tests__/widget-query-keys.test.ts +++ b/apps/web/src/lib/client/hooks/__tests__/widget-query-keys.test.ts @@ -3,6 +3,7 @@ import { widgetQueryKeys, widgetQueryKeyEquals, widgetQueryKeyPrefixEquals, + widgetQueryKeySameSession, INITIAL_SESSION_VERSION, } from '../use-widget-vote' @@ -111,6 +112,47 @@ describe('widgetQueryKeys', () => { }) }) + describe('helpCategories', () => { + it('bySession includes locale and version', () => { + expect(widgetQueryKeys.helpCategories.bySession(0, 'en')).toEqual([ + 'widget', + 'help', + 'categories', + 'en', + 0, + ]) + expect(widgetQueryKeys.helpCategories.bySession(2, 'de')).toEqual([ + 'widget', + 'help', + 'categories', + 'de', + 2, + ]) + }) + }) + + describe('helpCategoryArticles', () => { + it('byCategory includes category, locale, and version', () => { + expect(widgetQueryKeys.helpCategoryArticles.byCategory('cat_1', 1, 'en')).toEqual([ + 'widget', + 'help', + 'category-articles', + 'cat_1', + 'en', + 1, + ]) + }) + }) + + describe('widgetQueryKeySameSession', () => { + it('matches when the last key slot is the current session', () => { + const key = widgetQueryKeys.popularSearch.query('bugs', null, 3) + expect(widgetQueryKeySameSession(key, 3)).toBe(true) + expect(widgetQueryKeySameSession(key, 4)).toBe(false) + expect(widgetQueryKeySameSession(undefined, 3)).toBe(false) + }) + }) + describe('popularPosts', () => { it('list includes board slug and version', () => { expect(widgetQueryKeys.popularPosts.list(null, 0)).toEqual([ diff --git a/apps/web/src/lib/client/hooks/use-widget-vote.ts b/apps/web/src/lib/client/hooks/use-widget-vote.ts index 025669aec2..ebb9042506 100644 --- a/apps/web/src/lib/client/hooks/use-widget-vote.ts +++ b/apps/web/src/lib/client/hooks/use-widget-vote.ts @@ -48,6 +48,22 @@ export const widgetQueryKeys = { query: (q: string, boardSlug: string | null, version: number) => ['widget', 'search', 'popular', q, boardSlug ?? 'all', version] as const, }, + helpCategories: { + bySession: (version: number, locale: string) => + ['widget', 'help', 'categories', locale, version] as const, + }, + helpCategoryArticles: { + byCategory: (categoryId: string, version: number, locale: string) => + ['widget', 'help', 'category-articles', categoryId, locale, version] as const, + }, +} + +/** True when the last key slot is this session (popular search dim-hold). */ +export function widgetQueryKeySameSession( + actual: readonly unknown[] | undefined, + sessionVersion: number +): boolean { + return !!actual && actual[actual.length - 1] === sessionVersion } /** True when `actual` is the same factory key (avoids placeholder index coupling). */ diff --git a/apps/web/src/routes/widget/index.tsx b/apps/web/src/routes/widget/index.tsx index 40488ad400..dbbc75b221 100644 --- a/apps/web/src/routes/widget/index.tsx +++ b/apps/web/src/routes/widget/index.tsx @@ -1,5 +1,5 @@ import { createFileRoute } from '@tanstack/react-router' -import { useQuery, keepPreviousData } from '@tanstack/react-query' +import { useQuery } from '@tanstack/react-query' import { z } from 'zod' import { lazy, @@ -34,14 +34,15 @@ import { WidgetHeroBackdrop } from '@/components/widget/widget-hero-backdrop' import type { ConversationId } from '@quackback/ids' import { useWidgetAuth } from '@/components/widget/widget-auth-provider' import { portalQueries } from '@/lib/client/queries/portal' -import { publicHelpCenterQueries } from '@/lib/client/queries/help-center' import { widgetChangelogListQuery } from '@/components/widget/widget-changelog-query' +import { widgetHelpCategoriesQuery } from '@/components/widget/widget-help-query' import { fetchBoardCapabilitiesFn } from '@/lib/server/functions/portal' import { getShowPoweredByFn } from '@/lib/server/functions/powered-by' import { listPublicArticlesFn } from '@/lib/server/functions/help-center' import { getWidgetAuthHeaders } from '@/lib/client/widget-auth' import { sendToHost } from '@/lib/client/widget-bridge' import { widgetQueryKeys, INITIAL_SESSION_VERSION } from '@/lib/client/hooks/use-widget-vote' +import { DEFAULT_LOCALE } from '@/lib/shared/i18n' import { CONVERSATION_PRESENCE_QUERY_KEY, useConversationPresence, @@ -194,7 +195,9 @@ export const Route = createFileRoute('/widget/')({ .catch(() => {}) : Promise.resolve(), helpTabEnabled - ? queryClient.ensureQueryData(publicHelpCenterQueries.categories()).catch(() => {}) + ? queryClient + .ensureQueryData(widgetHelpCategoriesQuery(INITIAL_SESSION_VERSION, DEFAULT_LOCALE)) + .catch(() => {}) : Promise.resolve(), helpTabEnabled ? listPublicArticlesFn({ data: { limit: 4 } }) @@ -417,13 +420,14 @@ function WidgetPage() { // stamps an entry fresh as of now, so seeding it on every key would also // mark the post-identify key fresh and suppress the Bearer refetch within // staleTime — leaving an identified viewer stuck on the anonymous baseline. - // After identify the key changes, carries no initialData, and refetches with - // the Bearer while keepPreviousData shows the prior map meanwhile. + // After identify the key changes, carries no initialData, and refetches + // with the Bearer. Do not keepPreviousData — logout/switch would otherwise + // show the prior visitor's members-only boards until the new fetch lands. + // Missing data falls back to the anonymous SSR `boards` list. initialData: sessionVersion === INITIAL_SESSION_VERSION ? { permissions: boardPermissions, boards } : undefined, - placeholderData: keepPreviousData, staleTime: 30 * 1000, enabled: !!tabs.feedback, }) From 54416d9db8a37a28ef01389aece7c459ee2a7674 Mon Sep 17 00:00:00 2001 From: James Morton Date: Thu, 10 Sep 2026 01:58:17 +0100 Subject: [PATCH 09/13] fix(widget): auth help search, localize portal article URLs, drop lost board filters Help search sends the widget Bearer and is session-scoped; article "View on portal" uses hcArticlePath with the resolved locale; Popular Ideas clears a filter the current session can no longer see. Co-authored-by: Cursor --- .../components/help-center/use-kb-search.ts | 23 +++++++++++++++---- .../widget/__tests__/widget-compose.test.ts | 10 ++++++++ .../src/components/widget/widget-compose.ts | 10 ++++++++ .../components/widget/widget-help-detail.tsx | 7 +++++- .../web/src/components/widget/widget-help.tsx | 9 +++++++- .../widget/widget-home-animated.tsx | 14 +++++++++++ .../components/widget/widget-messenger.tsx | 1 + .../src/lib/server/functions/help-center.ts | 4 ++-- .../widget/__tests__/article-locale.test.ts | 7 ++++-- .../src/lib/shared/widget/article-locale.ts | 6 ++--- apps/web/src/routes/widget/index.tsx | 1 + 11 files changed, 79 insertions(+), 13 deletions(-) diff --git a/apps/web/src/components/help-center/use-kb-search.ts b/apps/web/src/components/help-center/use-kb-search.ts index 6eb73c91c5..78ebc2b2c2 100644 --- a/apps/web/src/components/help-center/use-kb-search.ts +++ b/apps/web/src/components/help-center/use-kb-search.ts @@ -21,6 +21,8 @@ export function useKbSearch({ query, limit, locale, + sessionVersion, + getHeaders, onResults, }: { query: string @@ -29,6 +31,10 @@ export function useKbSearch({ * (domains/languages §2); an unrecognized/not-enabled locale falls back * to default server-side. */ locale?: string + /** Widget session — included in the cache key and clears hits on change. */ + sessionVersion?: number + /** Widget Bearer (or empty on the portal, which uses cookies). */ + getHeaders?: () => HeadersInit /** Fired with the articles of each completed search (cache hits included); * not fired when the query is blank. */ onResults?: (articles: KbSearchArticle[]) => void @@ -40,15 +46,17 @@ export function useKbSearch({ // Latest callback without retriggering the debounce effect. const onResultsRef = useRef(onResults) onResultsRef.current = onResults + const getHeadersRef = useRef(getHeaders) + getHeadersRef.current = getHeaders const doSearch = useCallback( - async (q: string, loc: string | undefined) => { + async (q: string, loc: string | undefined, version: number | undefined) => { if (!q.trim()) { setResults([]) return } - const cacheKey = `${loc ?? ''}:${q}` + const cacheKey = `${version ?? ''}:${loc ?? ''}:${q}` const cached = cacheRef.current.get(cacheKey) if (cached) { setResults(cached) @@ -71,7 +79,9 @@ export function useKbSearch({ if (loc) params.set('locale', loc) const res = await fetch(`/api/widget/kb-search?${params.toString()}`, { signal: controller.signal, + headers: getHeadersRef.current?.(), }) + if (!res.ok) return const data = await res.json() const articles: KbSearchArticle[] = data.data?.articles ?? [] cacheRef.current.set(cacheKey, articles) @@ -87,9 +97,14 @@ export function useKbSearch({ ) useEffect(() => { - const timer = setTimeout(() => void doSearch(query, locale), DEBOUNCE_MS) + cacheRef.current.clear() + setResults([]) + }, [sessionVersion]) + + useEffect(() => { + const timer = setTimeout(() => void doSearch(query, locale, sessionVersion), DEBOUNCE_MS) return () => clearTimeout(timer) - }, [query, locale, doSearch]) + }, [query, locale, sessionVersion, doSearch]) return { results, isSearching } } diff --git a/apps/web/src/components/widget/__tests__/widget-compose.test.ts b/apps/web/src/components/widget/__tests__/widget-compose.test.ts index 469ccb4ca0..06a754d79b 100644 --- a/apps/web/src/components/widget/__tests__/widget-compose.test.ts +++ b/apps/web/src/components/widget/__tests__/widget-compose.test.ts @@ -5,6 +5,7 @@ import { isArticleTypeId, resolveComposeBoardId, resolveOpenCommand, + shouldClearInvisibleBoardFilter, shouldReapplyComposeBoard, } from '../widget-compose' import type { EnabledTabs } from '../widget-nav' @@ -149,6 +150,15 @@ describe('resolveOpenCommand', () => { }) }) +describe('shouldClearInvisibleBoardFilter', () => { + it('clears a selected slug that the live session list no longer contains', () => { + expect(shouldClearInvisibleBoardFilter('secret', ['ideas', 'bugs'])).toBe(true) + expect(shouldClearInvisibleBoardFilter('ideas', ['ideas', 'bugs'])).toBe(false) + expect(shouldClearInvisibleBoardFilter('secret', null)).toBe(false) + expect(shouldClearInvisibleBoardFilter(null, ['ideas'])).toBe(false) + }) +}) + describe('shouldReapplyComposeBoard', () => { it('re-applies only when identify newly grants the requested slug', () => { expect(shouldReapplyComposeBoard('bugs', new Set(), new Set(['bugs', 'ideas']))).toBe(true) diff --git a/apps/web/src/components/widget/widget-compose.ts b/apps/web/src/components/widget/widget-compose.ts index a17808618f..f1fbb76d95 100644 --- a/apps/web/src/components/widget/widget-compose.ts +++ b/apps/web/src/components/widget/widget-compose.ts @@ -108,6 +108,16 @@ export function resolveComposeBoardId( return '' } +/** Drop a Popular Ideas filter the current session can no longer see. */ +export function shouldClearInvisibleBoardFilter( + activeBoardSlug: string | null, + confirmedBoardSlugs: readonly string[] | null | undefined +): boolean { + return ( + !!activeBoardSlug && !!confirmedBoardSlugs && !confirmedBoardSlugs.includes(activeBoardSlug) + ) +} + /** Re-apply `open({ board })` only when identify just granted that slug. */ export function shouldReapplyComposeBoard( requestedSlug: string | undefined, diff --git a/apps/web/src/components/widget/widget-help-detail.tsx b/apps/web/src/components/widget/widget-help-detail.tsx index a1bcedfaaa..18ffe9b40d 100644 --- a/apps/web/src/components/widget/widget-help-detail.tsx +++ b/apps/web/src/components/widget/widget-help-detail.tsx @@ -6,6 +6,7 @@ import { ScrollArea } from '@/components/ui/scroll-area' import { resolvePublicArticleRefFn } from '@/lib/server/functions/help-center' import { generateOneTimeToken, getWidgetAuthHeaders } from '@/lib/client/widget-auth' import { appendWidgetOtt } from './build-portal-url' +import { hcArticlePath } from '@/lib/shared/help-center-url' import { widgetQueryKeys, widgetQueryKeyEquals } from '@/lib/client/hooks/use-widget-vote' import { RichTextContent, isRichTextContent } from '@/components/ui/rich-text-content' import type { JSONContent } from '@tiptap/react' @@ -53,7 +54,11 @@ export function WidgetHelpDetail({ if (!article) return const ott = isIdentified ? await generateOneTimeToken() : null const url = appendWidgetOtt( - `${window.location.origin}/hc/articles/${article.category.slug}/${article.slug}`, + `${window.location.origin}${hcArticlePath({ + locale: article.resolvedLocale, + urlId: article.urlId, + slug: article.slug, + })}`, isIdentified, ott ) diff --git a/apps/web/src/components/widget/widget-help.tsx b/apps/web/src/components/widget/widget-help.tsx index 41ff0062ff..b685efd65f 100644 --- a/apps/web/src/components/widget/widget-help.tsx +++ b/apps/web/src/components/widget/widget-help.tsx @@ -12,6 +12,7 @@ import { } from '@heroicons/react/24/outline' import { widgetHelpCategoriesQuery } from './widget-help-query' import { useWidgetAuth } from './widget-auth-provider' +import { getWidgetAuthHeaders } from '@/lib/client/widget-auth' import { getTopLevelCategories } from '@/components/help-center/help-center-utils' import { CategoryIcon } from '@/components/help-center/category-icon' import { @@ -56,7 +57,13 @@ export function WidgetHelp({ const askAiAvailable = useAskAiAvailable() // Widget locale passthrough (domains/languages §2): the search API falls // back to the default locale server-side if this locale isn't enabled. - const { results, isSearching } = useKbSearch({ query: search, limit: 10, locale: intl.locale }) + const { results, isSearching } = useKbSearch({ + query: search, + limit: 10, + locale: intl.locale, + sessionVersion, + getHeaders: getWidgetAuthHeaders, + }) // The search hook debounces 300ms before it even starts fetching; during // that window `isSearching` is still false and `results` still belong to // the previous query. Treat "typed but not yet settled" as pending too, so diff --git a/apps/web/src/components/widget/widget-home-animated.tsx b/apps/web/src/components/widget/widget-home-animated.tsx index 0665604dd7..2822bd7bd8 100644 --- a/apps/web/src/components/widget/widget-home-animated.tsx +++ b/apps/web/src/components/widget/widget-home-animated.tsx @@ -39,6 +39,7 @@ import type { TiptapContent } from '@/lib/shared/schemas/posts' import { composeBodyFromPlainText, resolveComposeBoardId, + shouldClearInvisibleBoardFilter, shouldReapplyComposeBoard, type WidgetComposeRequest, } from './widget-compose' @@ -92,6 +93,11 @@ export interface WidgetHomeProps { defaultBoard?: string /** SDK `?board=` / `defaultBoard` — seed the Popular Ideas filter. */ initialBoardSlug?: string + /** + * Board slugs from the current session's capability fetch. `null` while that + * query has no data yet — do not treat the anonymous SSR fallback as final. + */ + confirmedBoardSlugs?: string[] | null /** Programmatic `open({ view: 'new-post' })` — expand and prefill. */ composeRequest?: WidgetComposeRequest | null onPostSelect?: (postId: string) => void @@ -259,6 +265,7 @@ export function WidgetHomeAnimated({ boardPermissions, defaultBoard, initialBoardSlug, + confirmedBoardSlugs, composeRequest, onPostSelect, onPostCreated, @@ -369,6 +376,13 @@ export function WidgetHomeAnimated({ const [activeBoardSlug, setActiveBoardSlug] = useState( () => initialBoardSlug ?? null ) + // After identify/logout the live board list is authoritative. Keep the SDK + // `?board=` filter through the anonymous first paint (identify may grant it). + useEffect(() => { + if (sessionVersion === INITIAL_SESSION_VERSION) return + if (!shouldClearInvisibleBoardFilter(activeBoardSlug, confirmedBoardSlugs)) return + setActiveBoardSlug(null) + }, [sessionVersion, activeBoardSlug, confirmedBoardSlugs]) const pills = usePillsScroll() const [popularSearch, setPopularSearch] = useState('') const [debouncedPopularSearch, setDebouncedPopularSearch] = useState('') diff --git a/apps/web/src/components/widget/widget-messenger.tsx b/apps/web/src/components/widget/widget-messenger.tsx index 673e16c99f..2035bf97e7 100644 --- a/apps/web/src/components/widget/widget-messenger.tsx +++ b/apps/web/src/components/widget/widget-messenger.tsx @@ -49,6 +49,7 @@ export function WidgetMessenger({ search: async (q: string, signal: AbortSignal) => { const res = await fetch(`/api/widget/kb-search?q=${encodeURIComponent(q)}&limit=3`, { signal, + headers: getWidgetAuthHeaders(), }) if (!res.ok) return [] const json = (await res.json()) as { diff --git a/apps/web/src/lib/server/functions/help-center.ts b/apps/web/src/lib/server/functions/help-center.ts index d9b9e38e69..ce4f0e1e7e 100644 --- a/apps/web/src/lib/server/functions/help-center.ts +++ b/apps/web/src/lib/server/functions/help-center.ts @@ -607,14 +607,14 @@ export const resolvePublicArticleRefFn = createServerFn({ method: 'GET' }) kbId ? getPublicArticleByIdForLocale(kbId, loc, viewer) : getPublicArticleBySlugForLocale(data.ref, loc, viewer) - const article = await withDefaultLocaleFallback( + const { value: article, locale: resolvedLocale } = await withDefaultLocaleFallback( locale, DEFAULT_LOCALE, load, (err) => err instanceof NotFoundError ) const { helpfulCount: _h, notHelpfulCount: _n, ...publicArticle } = serializeArticle(article) - return publicArticle + return { ...publicArticle, resolvedLocale } } catch (err) { if (err instanceof NotFoundError) return null throw err diff --git a/apps/web/src/lib/shared/widget/__tests__/article-locale.test.ts b/apps/web/src/lib/shared/widget/__tests__/article-locale.test.ts index 37e831048a..2e2326cb46 100644 --- a/apps/web/src/lib/shared/widget/__tests__/article-locale.test.ts +++ b/apps/web/src/lib/shared/widget/__tests__/article-locale.test.ts @@ -5,7 +5,10 @@ import { withDefaultLocaleFallback } from '../article-locale' describe('withDefaultLocaleFallback', () => { it('returns the requested locale when present', async () => { const load = vi.fn(async (locale: string) => locale) - await expect(withDefaultLocaleFallback('de', 'en', load, () => false)).resolves.toBe('de') + await expect(withDefaultLocaleFallback('de', 'en', load, () => false)).resolves.toEqual({ + value: 'de', + locale: 'de', + }) expect(load).toHaveBeenCalledTimes(1) }) @@ -16,7 +19,7 @@ describe('withDefaultLocaleFallback', () => { }) await expect( withDefaultLocaleFallback('de', 'en', load, (err) => err instanceof NotFoundError) - ).resolves.toBe('en-article') + ).resolves.toEqual({ value: 'en-article', locale: 'en' }) expect(load).toHaveBeenCalledWith('de') expect(load).toHaveBeenCalledWith('en') }) diff --git a/apps/web/src/lib/shared/widget/article-locale.ts b/apps/web/src/lib/shared/widget/article-locale.ts index ea5a224861..ad2c7ee593 100644 --- a/apps/web/src/lib/shared/widget/article-locale.ts +++ b/apps/web/src/lib/shared/widget/article-locale.ts @@ -8,12 +8,12 @@ export async function withDefaultLocaleFallback( defaultLocale: string, load: (locale: string) => Promise, isMissing: (err: unknown) => boolean -): Promise { +): Promise<{ value: T; locale: string }> { try { - return await load(locale) + return { value: await load(locale), locale } } catch (err) { if (isMissing(err) && locale !== defaultLocale) { - return load(defaultLocale) + return { value: await load(defaultLocale), locale: defaultLocale } } throw err } diff --git a/apps/web/src/routes/widget/index.tsx b/apps/web/src/routes/widget/index.tsx index dbbc75b221..8c7847ad39 100644 --- a/apps/web/src/routes/widget/index.tsx +++ b/apps/web/src/routes/widget/index.tsx @@ -1104,6 +1104,7 @@ function WidgetPage() { boardPermissions={livePermissions} defaultBoard={defaultBoard} initialBoardSlug={initialBoardSlug} + confirmedBoardSlugs={liveCapabilities?.boards.map((b) => b.slug) ?? null} composeRequest={composeRequest} onPostSelect={handlePostSelect} onPostCreated={handlePostCreated} From cf211e03efe96a5e1d19200e1b9cb846e20cf744 Mon Sep 17 00:00:00 2001 From: James Morton Date: Thu, 10 Sep 2026 02:26:45 +0100 Subject: [PATCH 10/13] fix(widget): abort stale help searches and reset compose after logout In-flight KB search is aborted when sessionVersion changes, messenger suggestions re-key with identity, and a compose board the live list no longer contains falls back to the default. Co-authored-by: Cursor --- apps/web/src/components/help-center/use-kb-search.ts | 6 ++++++ .../conversation/visitor-conversation-thread.tsx | 3 +++ .../widget/__tests__/widget-compose.test.ts | 10 ++++++++++ apps/web/src/components/widget/widget-compose.ts | 11 +++++++++++ .../src/components/widget/widget-home-animated.tsx | 10 ++++++++++ apps/web/src/components/widget/widget-messenger.tsx | 2 +- 6 files changed, 41 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/help-center/use-kb-search.ts b/apps/web/src/components/help-center/use-kb-search.ts index 78ebc2b2c2..2cd8a4d399 100644 --- a/apps/web/src/components/help-center/use-kb-search.ts +++ b/apps/web/src/components/help-center/use-kb-search.ts @@ -48,6 +48,8 @@ export function useKbSearch({ onResultsRef.current = onResults const getHeadersRef = useRef(getHeaders) getHeadersRef.current = getHeaders + const sessionVersionRef = useRef(sessionVersion) + sessionVersionRef.current = sessionVersion const doSearch = useCallback( async (q: string, loc: string | undefined, version: number | undefined) => { @@ -82,6 +84,7 @@ export function useKbSearch({ headers: getHeadersRef.current?.(), }) if (!res.ok) return + if (version !== sessionVersionRef.current) return const data = await res.json() const articles: KbSearchArticle[] = data.data?.articles ?? [] cacheRef.current.set(cacheKey, articles) @@ -97,8 +100,11 @@ export function useKbSearch({ ) useEffect(() => { + abortRef.current?.abort() + abortRef.current = null cacheRef.current.clear() setResults([]) + setIsSearching(false) }, [sessionVersion]) useEffect(() => { diff --git a/apps/web/src/components/shared/conversation/visitor-conversation-thread.tsx b/apps/web/src/components/shared/conversation/visitor-conversation-thread.tsx index c41d640467..47433afd8c 100644 --- a/apps/web/src/components/shared/conversation/visitor-conversation-thread.tsx +++ b/apps/web/src/components/shared/conversation/visitor-conversation-thread.tsx @@ -670,6 +670,9 @@ export function VisitorConversationThread({ const [helpResults, setHelpResults] = useState>([]) const helpSearchFn = helpSearch?.search const messageText = composer.text + useEffect(() => { + setHelpResults([]) + }, [helpSearchFn, sessionVersion]) useEffect(() => { if (!helpSearchFn || conversationId || messages.length > 0) { setHelpResults([]) diff --git a/apps/web/src/components/widget/__tests__/widget-compose.test.ts b/apps/web/src/components/widget/__tests__/widget-compose.test.ts index 06a754d79b..9c5c8f0de5 100644 --- a/apps/web/src/components/widget/__tests__/widget-compose.test.ts +++ b/apps/web/src/components/widget/__tests__/widget-compose.test.ts @@ -6,6 +6,7 @@ import { resolveComposeBoardId, resolveOpenCommand, shouldClearInvisibleBoardFilter, + shouldResetComposeBoard, shouldReapplyComposeBoard, } from '../widget-compose' import type { EnabledTabs } from '../widget-nav' @@ -150,6 +151,15 @@ describe('resolveOpenCommand', () => { }) }) +describe('shouldResetComposeBoard', () => { + it('resets when the selected board is missing from the confirmed list', () => { + expect(shouldResetComposeBoard('board_secret', boards, ['ideas', 'bug-reports'])).toBe(true) + expect(shouldResetComposeBoard('board_ideas', boards, ['ideas', 'bug-reports'])).toBe(false) + expect(shouldResetComposeBoard('board_ideas', boards, null)).toBe(false) + expect(shouldResetComposeBoard('', boards, ['ideas'])).toBe(false) + }) +}) + describe('shouldClearInvisibleBoardFilter', () => { it('clears a selected slug that the live session list no longer contains', () => { expect(shouldClearInvisibleBoardFilter('secret', ['ideas', 'bugs'])).toBe(true) diff --git a/apps/web/src/components/widget/widget-compose.ts b/apps/web/src/components/widget/widget-compose.ts index f1fbb76d95..a8c0275c7e 100644 --- a/apps/web/src/components/widget/widget-compose.ts +++ b/apps/web/src/components/widget/widget-compose.ts @@ -108,6 +108,17 @@ export function resolveComposeBoardId( return '' } +/** Drop a compose selection the current session can no longer see. */ +export function shouldResetComposeBoard( + selectedBoardId: string, + boards: ReadonlyArray<{ id: string; slug: string }>, + confirmedBoardSlugs: readonly string[] | null | undefined +): boolean { + if (!selectedBoardId || !confirmedBoardSlugs) return false + const selected = boards.find((b) => b.id === selectedBoardId) + return !selected || !confirmedBoardSlugs.includes(selected.slug) +} + /** Drop a Popular Ideas filter the current session can no longer see. */ export function shouldClearInvisibleBoardFilter( activeBoardSlug: string | null, diff --git a/apps/web/src/components/widget/widget-home-animated.tsx b/apps/web/src/components/widget/widget-home-animated.tsx index 2822bd7bd8..7ad9caf11e 100644 --- a/apps/web/src/components/widget/widget-home-animated.tsx +++ b/apps/web/src/components/widget/widget-home-animated.tsx @@ -40,6 +40,7 @@ import { composeBodyFromPlainText, resolveComposeBoardId, shouldClearInvisibleBoardFilter, + shouldResetComposeBoard, shouldReapplyComposeBoard, type WidgetComposeRequest, } from './widget-compose' @@ -328,6 +329,15 @@ export function WidgetHomeAnimated({ if (match) setSelectedBoardId(match.id) }, [visibleBoardSlugs, boards, composeRequest?.boardSlug, composeRequest?.nonce]) + // After identify/logout the live list is authoritative. Keep a stale + // members-only selection through the anonymous first paint (identify may + // grant it); once this session's fetch lands, fall back to the default. + useEffect(() => { + if (sessionVersion === INITIAL_SESSION_VERSION) return + if (!shouldResetComposeBoard(selectedBoardId, boards, confirmedBoardSlugs)) return + setSelectedBoardId(resolveComposeBoardId(boards, undefined, defaultBoard)) + }, [sessionVersion, selectedBoardId, boards, confirmedBoardSlugs, defaultBoard]) + // Per-board capability, server-computed for the request actor. The widget // route refetches boardPermissions with the Bearer identity (keyed on // sessionVersion), so for an identified viewer this already reflects the real diff --git a/apps/web/src/components/widget/widget-messenger.tsx b/apps/web/src/components/widget/widget-messenger.tsx index 2035bf97e7..8b5f87f384 100644 --- a/apps/web/src/components/widget/widget-messenger.tsx +++ b/apps/web/src/components/widget/widget-messenger.tsx @@ -59,7 +59,7 @@ export function WidgetMessenger({ }, onSelect: onArticleSelect, } - }, [helpEnabled, onArticleSelect]) + }, [helpEnabled, onArticleSelect, sessionVersion]) return ( Date: Thu, 10 Sep 2026 07:56:11 +0100 Subject: [PATCH 11/13] fix(widget): auth Ask AI and leave gated help collections after logout SDK-only visitors can retrieve the same gated articles they can browse, and a replacement visitor is not left on a members-only category. Co-authored-by: Cursor --- .../__tests__/ask-ai-stream.test.ts | 15 +++++++ .../web/src/components/help-center/ask-ai.tsx | 39 +++++++++++++------ .../__tests__/widget-help-query.test.ts | 20 ++++++++++ .../widget/widget-help-category.tsx | 24 +++++++++++- .../components/widget/widget-help-query.ts | 16 +++++++- .../web/src/components/widget/widget-help.tsx | 7 +++- apps/web/src/lib/client/utils/agui-fetch.ts | 11 +++++- apps/web/src/routes/widget/index.tsx | 7 ++++ 8 files changed, 123 insertions(+), 16 deletions(-) create mode 100644 apps/web/src/components/widget/__tests__/widget-help-query.test.ts diff --git a/apps/web/src/components/help-center/__tests__/ask-ai-stream.test.ts b/apps/web/src/components/help-center/__tests__/ask-ai-stream.test.ts index 8ac452e0e9..8d3f005273 100644 --- a/apps/web/src/components/help-center/__tests__/ask-ai-stream.test.ts +++ b/apps/web/src/components/help-center/__tests__/ask-ai-stream.test.ts @@ -133,6 +133,21 @@ describe('useAskAi', () => { expect(result.current.state.status).toBe('error') }) + it('sends getHeaders on the AG-UI request', async () => { + const answer = { kind: 'grounded', answer: 'A.', sources: [] } + const fetchMock = stubAguiFetch(aguiRun({ middle: structuredDeltas(answer), result: answer })) + + const { result } = renderHook(() => + useAskAi({ getHeaders: () => ({ Authorization: 'Bearer widget-token' }) }) + ) + await act(async () => { + await result.current.ask('q') + }) + + const init = fetchMock.mock.calls[0]?.[1] as RequestInit | undefined + expect(new Headers(init?.headers).get('Authorization')).toBe('Bearer widget-token') + }) + it('reset returns the hook to idle', async () => { const answer = { kind: 'grounded', answer: 'A.', sources: [{ articleId: 'kb_article_1' }] } stubAguiFetch( diff --git a/apps/web/src/components/help-center/ask-ai.tsx b/apps/web/src/components/help-center/ask-ai.tsx index ffa1bd5890..99323ead4c 100644 --- a/apps/web/src/components/help-center/ask-ai.tsx +++ b/apps/web/src/components/help-center/ask-ai.tsx @@ -39,11 +39,16 @@ export type AskAiSourceMeta = KbAskSourceMeta * Whether Ask AI can be offered: the flag is on AND a model is configured. * Backed by the kb-ask capability probe (404 when flags are off). */ -export function useAskAiAvailable(enabled = true): boolean { +export function useAskAiAvailable( + enabled = true, + options?: { getHeaders?: () => HeadersInit; sessionVersion?: number } +): boolean { const query = useQuery({ - queryKey: ['kb-ask', 'capability'], + queryKey: ['kb-ask', 'capability', options?.sessionVersion ?? 'anon'] as const, queryFn: async () => { - const res = await fetch('/api/widget/kb-ask') + const res = await fetch('/api/widget/kb-ask', { + headers: options?.getHeaders?.(), + }) if (!res.ok) return false const json = (await res.json()) as { data?: { enabled?: boolean } } return json.data?.enabled === true @@ -100,13 +105,10 @@ function toCitations(sources: AskAiSourceMeta[]): ConversationMessageCitation[] const KB_ASK_URL = '/api/widget/kb-ask' -/** Non-2xx widget envelopes (rate limits, flag gates, budget) become a - * synthetic RUN_ERROR SSE frame. Throwing from fetchClient is wrong on AI - * 0.52+: the adapter wraps any rejection as StreamReadError. */ -const askAiFetch = aguiFetchClient() - /** Drive one Ask AI question at a time; re-asking aborts the previous run. */ -export function useAskAi() { +export function useAskAi(options?: { getHeaders?: () => HeadersInit }) { + const getHeadersRef = useRef(options?.getHeaders) + getHeadersRef.current = options?.getHeaders const [state, setState] = useState(IDLE_STATE) const clientRef = useRef(null) @@ -179,7 +181,10 @@ export function useAskAi() { } const client = new ChatClient({ - connection: fetchServerSentEvents(KB_ASK_URL, () => ({ fetchClient: askAiFetch })), + connection: fetchServerSentEvents(KB_ASK_URL, () => ({ + // Non-2xx widget envelopes become a synthetic RUN_ERROR SSE frame. + fetchClient: aguiFetchClient(() => getHeadersRef.current?.()), + })), onChunk: (rawChunk: StreamChunk) => { const chunk = rawChunk as { type: string @@ -269,6 +274,10 @@ export interface AskAiSearchControllerOptions { /** Surface hook fired when the answer panel is dismissed (e.g. reopen the * dropdown for the current query). */ onDismiss?: () => void + /** Widget Bearer (or empty on the portal, which uses cookies). */ + getHeaders?: () => HeadersInit + /** Widget session — reset an open answer when identity changes. */ + sessionVersion?: number } /** @@ -287,8 +296,10 @@ export function useAskAiSearchController({ onClearQuery, onAsk, onDismiss, + getHeaders, + sessionVersion, }: AskAiSearchControllerOptions) { - const { state: askAiState, ask: askAi, reset: resetAskAi } = useAskAi() + const { state: askAiState, ask: askAi, reset: resetAskAi } = useAskAi({ getHeaders }) // Keyboard selection over [ask-ai row, ...results]; -1 = nothing selected. const [selectedIndex, setSelectedIndex] = useState(-1) @@ -303,6 +314,12 @@ export function useAskAiSearchController({ setSelectedIndex(-1) }, [query, resetAskAi]) + // Logout / identify must not leave the previous visitor's cited titles up. + useEffect(() => { + resetAskAi() + setSelectedIndex(-1) + }, [sessionVersion, resetAskAi]) + const triggerAsk = useCallback(() => { if (!hasAskRow) return setSelectedIndex(-1) diff --git a/apps/web/src/components/widget/__tests__/widget-help-query.test.ts b/apps/web/src/components/widget/__tests__/widget-help-query.test.ts new file mode 100644 index 0000000000..30909e8ee0 --- /dev/null +++ b/apps/web/src/components/widget/__tests__/widget-help-query.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest' +import { INITIAL_SESSION_VERSION } from '@/lib/client/hooks/use-widget-vote' +import { shouldLeaveUnavailableHelpCategory } from '../widget-help-query' + +describe('shouldLeaveUnavailableHelpCategory', () => { + it('leaves once the live session list no longer contains the id', () => { + expect(shouldLeaveUnavailableHelpCategory('secret', [{ id: 'public' }], 1)).toBe(true) + expect(shouldLeaveUnavailableHelpCategory('public', [{ id: 'public' }], 1)).toBe(false) + }) + + it('waits until the current session list is in', () => { + expect(shouldLeaveUnavailableHelpCategory('secret', undefined, 1)).toBe(false) + }) + + it('does not bounce on the anonymous first paint', () => { + expect( + shouldLeaveUnavailableHelpCategory('secret', [{ id: 'public' }], INITIAL_SESSION_VERSION) + ).toBe(false) + }) +}) diff --git a/apps/web/src/components/widget/widget-help-category.tsx b/apps/web/src/components/widget/widget-help-category.tsx index 203d6e6176..d9301b7534 100644 --- a/apps/web/src/components/widget/widget-help-category.tsx +++ b/apps/web/src/components/widget/widget-help-category.tsx @@ -1,10 +1,15 @@ +import { useEffect } from 'react' import { useQuery } from '@tanstack/react-query' import { FormattedMessage, useIntl } from 'react-intl' import { ScrollArea } from '@/components/ui/scroll-area' import { ChevronRightIcon } from '@heroicons/react/24/solid' import { CategoryIcon } from '@/components/help-center/category-icon' import { WidgetHelpArticleListSkeleton } from './widget-skeletons' -import { widgetHelpCategoriesQuery, widgetHelpCategoryArticlesQuery } from './widget-help-query' +import { + shouldLeaveUnavailableHelpCategory, + widgetHelpCategoriesQuery, + widgetHelpCategoryArticlesQuery, +} from './widget-help-query' import { useWidgetAuth } from './widget-auth-provider' interface WidgetHelpCategoryProps { @@ -12,6 +17,8 @@ interface WidgetHelpCategoryProps { categoryName: string categoryIcon: string | null onArticleSelect: (articleSlug: string) => void + /** Identity change dropped this collection — return to Help. */ + onCategoryUnavailable?: () => void } export function WidgetHelpCategory({ @@ -19,6 +26,7 @@ export function WidgetHelpCategory({ categoryName, categoryIcon, onArticleSelect, + onCategoryUnavailable, }: WidgetHelpCategoryProps) { const { locale } = useIntl() const { sessionVersion } = useWidgetAuth() @@ -30,6 +38,20 @@ export function WidgetHelpCategory({ // when we arrived from an article's eyebrow, which only knows id + name). const categoriesQuery = useQuery(widgetHelpCategoriesQuery(sessionVersion, locale)) const category = categoriesQuery.data?.find((c) => c.id === categoryId) + + useEffect(() => { + if (!onCategoryUnavailable || !categoriesQuery.isSuccess) return + if (!shouldLeaveUnavailableHelpCategory(categoryId, categoriesQuery.data, sessionVersion)) { + return + } + onCategoryUnavailable() + }, [ + categoriesQuery.data, + categoriesQuery.isSuccess, + categoryId, + onCategoryUnavailable, + sessionVersion, + ]) const icon = categoryIcon ?? category?.icon ?? null const articleCount = articlesQuery.data?.length ?? category?.articleCount diff --git a/apps/web/src/components/widget/widget-help-query.ts b/apps/web/src/components/widget/widget-help-query.ts index 65b78673f4..3491257fc0 100644 --- a/apps/web/src/components/widget/widget-help-query.ts +++ b/apps/web/src/components/widget/widget-help-query.ts @@ -4,7 +4,7 @@ import { listPublicCategoriesFn, } from '@/lib/server/functions/help-center' import { getWidgetAuthHeaders } from '@/lib/client/widget-auth' -import { widgetQueryKeys } from '@/lib/client/hooks/use-widget-vote' +import { INITIAL_SESSION_VERSION, widgetQueryKeys } from '@/lib/client/hooks/use-widget-vote' const STALE_TIME_MEDIUM = 60 * 1000 @@ -37,3 +37,17 @@ export function widgetHelpCategoryArticlesQuery( staleTime: STALE_TIME_MEDIUM, }) } + +/** + * Leave a stored collection once this session's list is in and no longer + * contains it. Skip the anonymous first paint so identify can still grant + * a members-only category the visitor arrived on. + */ +export function shouldLeaveUnavailableHelpCategory( + categoryId: string, + categories: ReadonlyArray<{ id: string }> | undefined, + sessionVersion: number +): boolean { + if (sessionVersion === INITIAL_SESSION_VERSION) return false + return !!categories && !categories.some((c) => c.id === categoryId) +} diff --git a/apps/web/src/components/widget/widget-help.tsx b/apps/web/src/components/widget/widget-help.tsx index b685efd65f..a349e2ecba 100644 --- a/apps/web/src/components/widget/widget-help.tsx +++ b/apps/web/src/components/widget/widget-help.tsx @@ -54,7 +54,10 @@ export function WidgetHelp({ const categoriesQuery = useQuery(widgetHelpCategoriesQuery(sessionVersion, intl.locale)) const topLevelCategories = categoriesQuery.data ? getTopLevelCategories(categoriesQuery.data) : [] - const askAiAvailable = useAskAiAvailable() + const askAiAvailable = useAskAiAvailable(true, { + getHeaders: getWidgetAuthHeaders, + sessionVersion, + }) // Widget locale passthrough (domains/languages §2): the search API falls // back to the default locale server-side if this locale isn't enabled. const { results, isSearching } = useKbSearch({ @@ -88,6 +91,8 @@ export function WidgetHelp({ if (article) onArticleSelect?.(article.slug) }, onClearQuery: () => setSearch(''), + getHeaders: getWidgetAuthHeaders, + sessionVersion, }) const showCategories = !search && !isSearching diff --git a/apps/web/src/lib/client/utils/agui-fetch.ts b/apps/web/src/lib/client/utils/agui-fetch.ts index ce9feb0f93..5a177573f8 100644 --- a/apps/web/src/lib/client/utils/agui-fetch.ts +++ b/apps/web/src/lib/client/utils/agui-fetch.ts @@ -16,9 +16,16 @@ function aguiErrorFrame(code: string, message: string): string { return `data: ${JSON.stringify({ type: 'RUN_ERROR', code, message })}\n\n` } -export function aguiFetchClient(): typeof fetch { +export function aguiFetchClient(getHeaders?: () => HeadersInit | undefined): typeof fetch { const wrapped = async (input: RequestInfo | URL, init?: RequestInit): Promise => { - const res = await fetch(input, init) + const extra = getHeaders?.() + const headers = extra ? new Headers(init?.headers) : init?.headers + if (extra) { + new Headers(extra).forEach((value, key) => { + ;(headers as Headers).set(key, value) + }) + } + const res = await fetch(input, extra ? { ...init, headers } : init) if (res.ok) return res const message = await extractHttpErrorMessage(res) return new Response(aguiErrorFrame(`http_${res.status}`, message), { diff --git a/apps/web/src/routes/widget/index.tsx b/apps/web/src/routes/widget/index.tsx index 8c7847ad39..8b5fb3c5d6 100644 --- a/apps/web/src/routes/widget/index.tsx +++ b/apps/web/src/routes/widget/index.tsx @@ -819,6 +819,12 @@ function WidgetPage() { setView('help-detail') }, []) + const handleHelpCategoryUnavailable = useCallback(() => { + lastNavRef.current = 'move' + setSelectedCategory(null) + setView('help') + }, []) + // The feedback view stays mounted (form state survives a detail round-trip), // so it can't take focus via ViewTransition's mount hook; do it when it // becomes visible again after a back/cross navigation. @@ -1049,6 +1055,7 @@ function WidgetPage() { categoryName={selectedCategory.name} categoryIcon={selectedCategory.icon} onArticleSelect={handleHelpCategoryArticleSelect} + onCategoryUnavailable={handleHelpCategoryUnavailable} /> )} From 9a1a76896789d0daf5f982acca46c8de50ffe8ce Mon Sep 17 00:00:00 2001 From: James Morton Date: Thu, 10 Sep 2026 08:01:33 +0100 Subject: [PATCH 12/13] fix(widget): type the Ask AI fetch stub so header assertions typecheck The mock was inferred as taking no arguments, so reading the request init from mock.calls failed CI typecheck. Co-authored-by: Cursor --- apps/web/src/test/agui.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/web/src/test/agui.ts b/apps/web/src/test/agui.ts index c9dc84900e..1a644a625c 100644 --- a/apps/web/src/test/agui.ts +++ b/apps/web/src/test/agui.ts @@ -77,7 +77,9 @@ export function structuredDeltas(object: unknown, pieces = 3): Chunk[] { * Returns the mock for request-body assertions; undo via * `vi.unstubAllGlobals()`. */ export function stubAguiFetch(frames: string) { - const fetchMock = vi.fn(() => Promise.resolve(mockStreamingResponse(frames))) + const fetchMock = vi.fn((_input: RequestInfo | URL, _init?: RequestInit): Promise => + Promise.resolve(mockStreamingResponse(frames)) + ) vi.stubGlobal('fetch', fetchMock) return fetchMock } From 2683dae61e522983babedba2c25de5d37a5a29ed Mon Sep 17 00:00:00 2001 From: James Morton Date: Thu, 10 Sep 2026 08:25:37 +0100 Subject: [PATCH 13/13] fix(widget): keep manual compose boards and drop stale changelog filters A replacement visitor who picked another board after open() is no longer overwritten when a restricted slug reappears, and a gated changelog category clears once the new session feed does not contain it. Co-authored-by: Cursor --- .../__tests__/widget-changelog-query.test.ts | 37 +++++++++++++++++++ .../widget/__tests__/widget-compose.test.ts | 9 +++++ .../widget/widget-changelog-query.ts | 18 ++++++++- .../components/widget/widget-changelog.tsx | 26 ++++++++++++- .../src/components/widget/widget-compose.ts | 10 +++-- .../widget/widget-home-animated.tsx | 10 ++++- 6 files changed, 103 insertions(+), 7 deletions(-) create mode 100644 apps/web/src/components/widget/__tests__/widget-changelog-query.test.ts diff --git a/apps/web/src/components/widget/__tests__/widget-changelog-query.test.ts b/apps/web/src/components/widget/__tests__/widget-changelog-query.test.ts new file mode 100644 index 0000000000..84074dcfb8 --- /dev/null +++ b/apps/web/src/components/widget/__tests__/widget-changelog-query.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest' +import { INITIAL_SESSION_VERSION } from '@/lib/client/hooks/use-widget-vote' +import { shouldClearUnavailableChangelogCategory } from '../widget-changelog-query' + +describe('shouldClearUnavailableChangelogCategory', () => { + const ready = { sessionVersion: 1, listReady: true, stillLooking: false } + + it('clears a filter the current session feed no longer contains', () => { + expect(shouldClearUnavailableChangelogCategory('secret', [{ id: 'public' }], ready)).toBe(true) + expect(shouldClearUnavailableChangelogCategory('public', [{ id: 'public' }], ready)).toBe(false) + expect(shouldClearUnavailableChangelogCategory(null, [{ id: 'public' }], ready)).toBe(false) + }) + + it('waits until the feed is in and lookahead has finished', () => { + expect( + shouldClearUnavailableChangelogCategory('secret', [], { + ...ready, + listReady: false, + }) + ).toBe(false) + expect( + shouldClearUnavailableChangelogCategory('secret', [], { + ...ready, + stillLooking: true, + }) + ).toBe(false) + }) + + it('does not bounce on the anonymous first paint', () => { + expect( + shouldClearUnavailableChangelogCategory('secret', [], { + ...ready, + sessionVersion: INITIAL_SESSION_VERSION, + }) + ).toBe(false) + }) +}) diff --git a/apps/web/src/components/widget/__tests__/widget-compose.test.ts b/apps/web/src/components/widget/__tests__/widget-compose.test.ts index 9c5c8f0de5..822a5445e9 100644 --- a/apps/web/src/components/widget/__tests__/widget-compose.test.ts +++ b/apps/web/src/components/widget/__tests__/widget-compose.test.ts @@ -180,6 +180,15 @@ describe('shouldReapplyComposeBoard', () => { ) expect(shouldReapplyComposeBoard(undefined, new Set(), new Set(['bugs']))).toBe(false) }) + + it('does not overwrite a board the visitor picked after open()', () => { + expect( + shouldReapplyComposeBoard('secret', new Set(['ideas']), new Set(['ideas', 'secret']), true) + ).toBe(false) + expect( + shouldReapplyComposeBoard('secret', new Set(['ideas']), new Set(['ideas', 'secret']), false) + ).toBe(true) + }) }) describe('composeBodyFromPlainText', () => { diff --git a/apps/web/src/components/widget/widget-changelog-query.ts b/apps/web/src/components/widget/widget-changelog-query.ts index e4251048ae..b111a61f7f 100644 --- a/apps/web/src/components/widget/widget-changelog-query.ts +++ b/apps/web/src/components/widget/widget-changelog-query.ts @@ -1,7 +1,7 @@ import { infiniteQueryOptions } from '@tanstack/react-query' import { listPublicChangelogsFn } from '@/lib/server/functions/changelog' import { getWidgetAuthHeaders } from '@/lib/client/widget-auth' -import { widgetQueryKeys } from '@/lib/client/hooks/use-widget-vote' +import { INITIAL_SESSION_VERSION, widgetQueryKeys } from '@/lib/client/hooks/use-widget-vote' const STALE_TIME_MEDIUM = 60 * 1000 @@ -22,3 +22,19 @@ export function widgetChangelogListQuery(sessionVersion: number) { staleTime: STALE_TIME_MEDIUM, }) } + +/** + * Drop a changelog category the current session's visible entries no longer + * contain. Wait until the feed is in and lookahead has finished, and skip + * the anonymous first paint so identify can still grant a gated segment. + */ +export function shouldClearUnavailableChangelogCategory( + activeCategoryId: string | null, + categoriesInUse: ReadonlyArray<{ id: string }>, + options: { sessionVersion: number; listReady: boolean; stillLooking: boolean } +): boolean { + if (options.sessionVersion === INITIAL_SESSION_VERSION) return false + if (!options.listReady || options.stillLooking) return false + if (!activeCategoryId) return false + return !categoriesInUse.some((c) => c.id === activeCategoryId) +} diff --git a/apps/web/src/components/widget/widget-changelog.tsx b/apps/web/src/components/widget/widget-changelog.tsx index 2b733164cc..685b36ac87 100644 --- a/apps/web/src/components/widget/widget-changelog.tsx +++ b/apps/web/src/components/widget/widget-changelog.tsx @@ -5,7 +5,10 @@ import { ScrollArea } from '@/components/ui/scroll-area' import { contentPreview } from '@/lib/shared/utils/string' import { cn } from '@/lib/shared/utils' import { changelogCategoryQueries } from '@/lib/client/queries/changelog' -import { widgetChangelogListQuery } from './widget-changelog-query' +import { + shouldClearUnavailableChangelogCategory, + widgetChangelogListQuery, +} from './widget-changelog-query' import { useWidgetAuth } from './widget-auth-provider' import { useInfiniteScroll } from '@/lib/client/hooks/use-infinite-scroll' import { getChangelogSeenAt, markChangelogSeen } from './changelog-unread' @@ -106,6 +109,27 @@ export function WidgetChangelog({ teamName, onEntrySelect }: WidgetChangelogProp if (filteredLookahead && !isFetchingNextPage) void fetchNextPage() }, [filteredLookahead, isFetchingNextPage, fetchNextPage]) + useEffect(() => { + if ( + !shouldClearUnavailableChangelogCategory(activeCategoryId, categoriesInUse, { + sessionVersion, + listReady: !isLoading && data !== undefined, + stillLooking: filteredLookahead || isFetchingNextPage, + }) + ) { + return + } + setActiveCategoryId(null) + }, [ + activeCategoryId, + categoriesInUse, + data, + filteredLookahead, + isFetchingNextPage, + isLoading, + sessionVersion, + ]) + // Scroll restore: put the viewport back where it was once the (cached) list // has painted, then track every scroll so the next visit can do the same. const viewportRef = useRef(null) diff --git a/apps/web/src/components/widget/widget-compose.ts b/apps/web/src/components/widget/widget-compose.ts index a8c0275c7e..da4d66928e 100644 --- a/apps/web/src/components/widget/widget-compose.ts +++ b/apps/web/src/components/widget/widget-compose.ts @@ -129,13 +129,17 @@ export function shouldClearInvisibleBoardFilter( ) } -/** Re-apply `open({ board })` only when identify just granted that slug. */ +/** + * Re-apply `open({ board })` only when identify just granted that slug + * and the visitor has not picked another board since the compose request. + */ export function shouldReapplyComposeBoard( requestedSlug: string | undefined, previousSlugs: ReadonlySet, - nextSlugs: ReadonlySet + nextSlugs: ReadonlySet, + selectionDirty = false ): boolean { - if (!requestedSlug) return false + if (selectionDirty || !requestedSlug) return false return nextSlugs.has(requestedSlug) && !previousSlugs.has(requestedSlug) } diff --git a/apps/web/src/components/widget/widget-home-animated.tsx b/apps/web/src/components/widget/widget-home-animated.tsx index 7ad9caf11e..64981af118 100644 --- a/apps/web/src/components/widget/widget-home-animated.tsx +++ b/apps/web/src/components/widget/widget-home-animated.tsx @@ -291,6 +291,11 @@ export function WidgetHomeAnimated({ const [selectedBoardId, setSelectedBoardId] = useState(() => resolveComposeBoardId(boards, undefined, defaultBoard) ) + const composeBoardDirtyRef = useRef(false) + const handleComposeBoardChange = useCallback((id: string) => { + composeBoardDirtyRef.current = true + setSelectedBoardId(id) + }, []) const [contentJson, setContentJson] = useState(null) const [contentHtml, setContentHtml] = useState('') const handleEditorChange = useCallback((json: JSONContent, html: string) => { @@ -302,6 +307,7 @@ export function WidgetHomeAnimated({ // the trigger so a second identical command still expands and reapplies. useEffect(() => { if (!composeRequest) return + composeBoardDirtyRef.current = false setExpanded(true) if (composeRequest.title) setTitle(composeRequest.title) if (composeRequest.body) { @@ -324,7 +330,7 @@ export function WidgetHomeAnimated({ const prev = prevVisibleBoardSlugsRef.current prevVisibleBoardSlugsRef.current = next const slug = composeRequest?.boardSlug - if (!shouldReapplyComposeBoard(slug, prev, next)) return + if (!shouldReapplyComposeBoard(slug, prev, next, composeBoardDirtyRef.current)) return const match = boards.find((b) => b.slug === slug) if (match) setSelectedBoardId(match.id) }, [visibleBoardSlugs, boards, composeRequest?.boardSlug, composeRequest?.nonce]) @@ -713,7 +719,7 @@ export function WidgetHomeAnimated({ defaultMessage="Posting to" /> -