From e8de448a1d484284c0e4e9c891de7eaabdbc977c Mon Sep 17 00:00:00 2001 From: ncnthien Date: Mon, 1 Jun 2026 18:01:10 +0700 Subject: [PATCH 1/7] feat: [ENG-2621] point PRIVACY_POLICY_URL at the canonical services page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the docs.byterover.dev placeholder with the canonical https://www.byterover.dev/services/privacy URL now that the public page has been published. Drops the stale "placeholder until M1.5" comment — the URL is no longer a placeholder. --- src/shared/constants/privacy.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/shared/constants/privacy.ts b/src/shared/constants/privacy.ts index d070455da..cc939b86c 100644 --- a/src/shared/constants/privacy.ts +++ b/src/shared/constants/privacy.ts @@ -1,6 +1 @@ -/** - * Public privacy policy URL for ByteRover CLI analytics. - * Placeholder until M1.5 lands the canonical docs page; reviewers should - * update this constant when the byterover-docs URL is finalized. - */ -export const PRIVACY_POLICY_URL = 'https://byterover.dev/privacy' +export const PRIVACY_POLICY_URL = 'https://www.byterover.dev/services/privacy' From 94b1cae711ab2c9db44ee6a5958fa4f34bec7f94 Mon Sep 17 00:00:00 2001 From: ncnthien Date: Mon, 1 Jun 2026 18:01:31 +0700 Subject: [PATCH 2/7] feat: [ENG-2621] serve analytics disclosure markdown via daemon and render it in the webui Single source of truth for the disclosure copy lives in src/shared/assets/analytics-disclosure.md and was already consumed by the CLI consent prompt via loadAnalyticsDisclosureText(). The webui's Privacy panel had a separate hardcoded copy that drifted out of sync once the real PM/legal copy landed. - Add `analytics:getDisclosure` transport event + Zod response schema. - New AnalyticsDisclosureHandler wraps loadAnalyticsDisclosureText() and exposes it over the transport; wired into feature-handlers.ts alongside the other AnalyticsHandler family. - Webui `useGetAnalyticsDisclosure` query hook + render via the existing MarkdownView from the contexts feature so lists, code blocks, and links in the markdown render properly. - Delete the now-obsolete ANALYTICS_DISCLOSURE_SECTIONS / icon grid and the ANALYTICS_PRIVACY_URL duplicate; analytics-panel.tsx now consumes PRIVACY_POLICY_URL directly from src/shared/constants/privacy.ts. - Drop the constants test file along with the hardcoded data it covered. --- src/server/infra/process/feature-handlers.ts | 5 ++ .../handlers/analytics-disclosure-handler.ts | 36 +++++++++++++ src/server/infra/transport/handlers/index.ts | 2 + .../transport/events/analytics-events.ts | 13 +++++ .../analytics/api/get-analytics-disclosure.ts | 31 +++++++++++ .../analytics/components/analytics-panel.tsx | 6 +-- .../components/disclosure-details.tsx | 54 ++++++++++++------- src/webui/features/analytics/constants.ts | 39 -------------- .../analytics-disclosure-handler.test.ts | 43 +++++++++++++++ .../api/get-analytics-disclosure.test.ts | 46 ++++++++++++++++ .../features/analytics/constants.test.ts | 44 --------------- 11 files changed, 214 insertions(+), 105 deletions(-) create mode 100644 src/server/infra/transport/handlers/analytics-disclosure-handler.ts create mode 100644 src/webui/features/analytics/api/get-analytics-disclosure.ts delete mode 100644 src/webui/features/analytics/constants.ts create mode 100644 test/unit/server/infra/transport/handlers/analytics-disclosure-handler.test.ts create mode 100644 test/unit/webui/features/analytics/api/get-analytics-disclosure.test.ts delete mode 100644 test/unit/webui/features/analytics/constants.test.ts diff --git a/src/server/infra/process/feature-handlers.ts b/src/server/infra/process/feature-handlers.ts index ea387eb39..bf3f94792 100644 --- a/src/server/infra/process/feature-handlers.ts +++ b/src/server/infra/process/feature-handlers.ts @@ -68,6 +68,7 @@ import {createTokenStore} from '../storage/token-store.js' import {HttpTeamService} from '../team/http-team-service.js' import {FsTemplateLoader} from '../template/fs-template-loader.js' import { + AnalyticsDisclosureHandler, AnalyticsHandler, AnalyticsListHandler, AnalyticsStatusHandler, @@ -300,6 +301,10 @@ export async function setupFeatureHandlers({ // (TUI, oclif, MCP, webui) to the same singleton. new AnalyticsHandler({analyticsClient, transport}).setup() + // Serves the canonical analytics disclosure markdown to the local + // web UI so it renders the same text as the CLI consent prompt. + new AnalyticsDisclosureHandler({transport}).setup() + // Global SettingsHandler (no project context). Deferred from line 180 so // analyticsClient is in scope for M15.4 `setting_changed` / `setting_reset` // emits. M16.3 wires the `analytics.status` readonly-info provider so diff --git a/src/server/infra/transport/handlers/analytics-disclosure-handler.ts b/src/server/infra/transport/handlers/analytics-disclosure-handler.ts new file mode 100644 index 000000000..ec9bbc7a5 --- /dev/null +++ b/src/server/infra/transport/handlers/analytics-disclosure-handler.ts @@ -0,0 +1,36 @@ +import type {ITransportServer} from '../../../core/interfaces/transport/i-transport-server.js' + +import { + type AnalyticsDisclosureResponse, + AnalyticsEvents, +} from '../../../../shared/transport/events/analytics-events.js' +import {loadAnalyticsDisclosureText} from '../../../../shared/utils/load-analytics-disclosure.js' + +export interface AnalyticsDisclosureHandlerDeps { + readonly loadDisclosure?: () => Promise + readonly transport: ITransportServer +} + +/** + * Serves `analytics:getDisclosure` so the local web UI can render the same + * canonical disclosure markdown shown by the CLI consent prompt + * (`brv settings set analytics.share true`). Single source of truth is + * `src/shared/assets/analytics-disclosure.md`, read via + * `loadAnalyticsDisclosureText()`. + */ +export class AnalyticsDisclosureHandler { + private readonly loadDisclosure: () => Promise + private readonly transport: ITransportServer + + public constructor(deps: AnalyticsDisclosureHandlerDeps) { + this.loadDisclosure = deps.loadDisclosure ?? loadAnalyticsDisclosureText + this.transport = deps.transport + } + + public setup(): void { + this.transport.onRequest(AnalyticsEvents.GET_DISCLOSURE, async () => { + const markdown = await this.loadDisclosure() + return {markdown} + }) + } +} diff --git a/src/server/infra/transport/handlers/index.ts b/src/server/infra/transport/handlers/index.ts index c842b5192..a6103f4fa 100644 --- a/src/server/infra/transport/handlers/index.ts +++ b/src/server/infra/transport/handlers/index.ts @@ -1,3 +1,5 @@ +export {AnalyticsDisclosureHandler} from './analytics-disclosure-handler.js' +export type {AnalyticsDisclosureHandlerDeps} from './analytics-disclosure-handler.js' export {AnalyticsHandler} from './analytics-handler.js' export type {AnalyticsHandlerDeps} from './analytics-handler.js' export {AnalyticsListHandler} from './analytics-list-handler.js' diff --git a/src/shared/transport/events/analytics-events.ts b/src/shared/transport/events/analytics-events.ts index 141e6c413..04d6e46b0 100644 --- a/src/shared/transport/events/analytics-events.ts +++ b/src/shared/transport/events/analytics-events.ts @@ -4,11 +4,24 @@ import {CliRequestBaseSchema} from '../../analytics/cli-metadata-schema.js' import {StoredAnalyticsRecordSchema} from '../../analytics/stored-record.js' export const AnalyticsEvents = { + GET_DISCLOSURE: 'analytics:getDisclosure', LIST: 'analytics:list', STATUS: 'analytics:status', TRACK: 'analytics:track', } as const +/** + * Response schema for `analytics:getDisclosure`. Exposes the canonical + * disclosure markdown shipped at `src/shared/assets/analytics-disclosure.md` + * so the local web UI can render the same text the CLI consent prompt + * (`brv settings set analytics.share true`) shows. + */ +export const AnalyticsDisclosureResponseSchema = z.object({ + markdown: z.string().min(1), +}) + +export type AnalyticsDisclosureResponse = z.infer + /** * M4.6 `analytics:status` response. Surfaces operational metrics for * `brv settings get analytics.status`: enabled flag (from GlobalConfig), client diff --git a/src/webui/features/analytics/api/get-analytics-disclosure.ts b/src/webui/features/analytics/api/get-analytics-disclosure.ts new file mode 100644 index 000000000..4ecfd7bd4 --- /dev/null +++ b/src/webui/features/analytics/api/get-analytics-disclosure.ts @@ -0,0 +1,31 @@ +import {queryOptions, useQuery} from '@tanstack/react-query' + +import type {QueryConfig} from '../../../lib/react-query' + +import { + type AnalyticsDisclosureResponse, + AnalyticsEvents, +} from '../../../../shared/transport/events/analytics-events.js' +import {useTransportStore} from '../../../stores/transport-store' + +export const getAnalyticsDisclosure = (): Promise => { + const {apiClient} = useTransportStore.getState() + if (!apiClient) return Promise.reject(new Error('Not connected')) + return apiClient.request(AnalyticsEvents.GET_DISCLOSURE) +} + +export const getAnalyticsDisclosureQueryOptions = () => + queryOptions({ + queryFn: getAnalyticsDisclosure, + queryKey: ['analyticsDisclosure'], + }) + +type UseGetAnalyticsDisclosureOptions = { + queryConfig?: QueryConfig +} + +export const useGetAnalyticsDisclosure = ({queryConfig}: UseGetAnalyticsDisclosureOptions = {}) => + useQuery({ + ...getAnalyticsDisclosureQueryOptions(), + ...queryConfig, + }) diff --git a/src/webui/features/analytics/components/analytics-panel.tsx b/src/webui/features/analytics/components/analytics-panel.tsx index 16add8131..429fe9a50 100644 --- a/src/webui/features/analytics/components/analytics-panel.tsx +++ b/src/webui/features/analytics/components/analytics-panel.tsx @@ -5,11 +5,11 @@ import {ChevronDown, ExternalLink, ShieldCheck} from 'lucide-react' import {useState} from 'react' import {toast} from 'sonner' +import {PRIVACY_POLICY_URL} from '../../../../shared/constants/privacy.js' import {formatError} from '../../../lib/error-messages' import {noop} from '../../../lib/noop' import {useGetGlobalConfig} from '../api/get-global-config' import {useSetAnalytics} from '../api/set-analytics' -import {ANALYTICS_PRIVACY_URL} from '../constants' import {DisableConfirmDialog} from './disable-confirm-dialog' import {DisclosureDetails} from './disclosure-details' import {EnableConfirmDialog} from './enable-confirm-dialog' @@ -103,12 +103,12 @@ export function AnalyticsPanel() { - docs.byterover.dev/privacy + byterover.dev/services/privacy )} diff --git a/src/webui/features/analytics/components/disclosure-details.tsx b/src/webui/features/analytics/components/disclosure-details.tsx index 38414d4f4..2bbc4ff2e 100644 --- a/src/webui/features/analytics/components/disclosure-details.tsx +++ b/src/webui/features/analytics/components/disclosure-details.tsx @@ -1,22 +1,38 @@ -import {ANALYTICS_DISCLOSURE_SECTIONS} from '../constants' +import {Skeleton} from '@campfirein/byterover-packages/components/skeleton' + +import {formatError} from '../../../lib/error-messages' +import {noop} from '../../../lib/noop' +import {MarkdownView} from '../../context/components/markdown-view' +import {useGetAnalyticsDisclosure} from '../api/get-analytics-disclosure' export function DisclosureDetails() { - return ( -
- {ANALYTICS_DISCLOSURE_SECTIONS.map((section) => { - const Icon = section.icon - return ( -
- -
- - {section.label} - -

{section.body}

-
-
- ) - })} -
- ) + const {data, error, isError, isLoading, refetch} = useGetAnalyticsDisclosure() + + if (isLoading) { + return ( +
+ + + +
+ ) + } + + if (isError) { + return ( +

+ ✗ {formatError(error, 'Failed to load disclosure')} + {' · '} + +

+ ) + } + + return } diff --git a/src/webui/features/analytics/constants.ts b/src/webui/features/analytics/constants.ts deleted file mode 100644 index 0e5d2af7c..000000000 --- a/src/webui/features/analytics/constants.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type {LucideIcon} from 'lucide-react' - -import {Database, Eye, Link2, PowerOff, Server} from 'lucide-react' - -export type AnalyticsDisclosureSection = { - body: string - icon: LucideIcon - label: string -} - -export const ANALYTICS_PRIVACY_URL = 'https://docs.byterover.dev/privacy' - -export const ANALYTICS_DISCLOSURE_SECTIONS: readonly AnalyticsDisclosureSection[] = [ - { - body: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore.', - icon: Database, - label: 'WHAT IS COLLECTED', - }, - { - body: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore.', - icon: Eye, - label: 'WHICH SURFACES ARE TRACKED', - }, - { - body: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore.', - icon: Server, - label: 'WHERE IT GOES', - }, - { - body: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore.', - icon: Link2, - label: 'CROSS-DEVICE ALIAS', - }, - { - body: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore.', - icon: PowerOff, - label: 'HOW TO DISABLE', - }, -] as const diff --git a/test/unit/server/infra/transport/handlers/analytics-disclosure-handler.test.ts b/test/unit/server/infra/transport/handlers/analytics-disclosure-handler.test.ts new file mode 100644 index 000000000..d401dfab7 --- /dev/null +++ b/test/unit/server/infra/transport/handlers/analytics-disclosure-handler.test.ts @@ -0,0 +1,43 @@ +import {expect} from 'chai' + +import {AnalyticsDisclosureHandler} from '../../../../../../src/server/infra/transport/handlers/analytics-disclosure-handler.js' +import {AnalyticsEvents} from '../../../../../../src/shared/transport/events/analytics-events.js' +import {createMockTransportServer} from '../../../../../helpers/mock-factories.js' + +type DisclosureHandler = (data: unknown, clientId: string) => Promise<{markdown: string}> + +describe('AnalyticsDisclosureHandler', () => { + it('registers a handler for analytics:getDisclosure on setup()', () => { + const transport = createMockTransportServer() + new AnalyticsDisclosureHandler({loadDisclosure: async () => 'noop', transport}).setup() + expect(transport._handlers.has(AnalyticsEvents.GET_DISCLOSURE)).to.equal(true) + }) + + it('returns the markdown loaded from the injected loader', async () => { + const transport = createMockTransportServer() + const markdown = '# Disclosure\n\nLorem.' + new AnalyticsDisclosureHandler({loadDisclosure: async () => markdown, transport}).setup() + + const handler = transport._handlers.get(AnalyticsEvents.GET_DISCLOSURE) as DisclosureHandler + const result = await handler(undefined, 'client-1') + + expect(result).to.deep.equal({markdown}) + }) + + it('propagates loader errors so the daemon does not silently serve empty disclosure', async () => { + const transport = createMockTransportServer() + const boom = new Error('ENOENT') + new AnalyticsDisclosureHandler({ + async loadDisclosure() { + throw boom + }, + transport, + }).setup() + + const handler = transport._handlers.get(AnalyticsEvents.GET_DISCLOSURE) as DisclosureHandler + await handler(undefined, 'client-1').then( + () => expect.fail('expected promise to reject'), + (error: Error) => expect(error).to.equal(boom), + ) + }) +}) diff --git a/test/unit/webui/features/analytics/api/get-analytics-disclosure.test.ts b/test/unit/webui/features/analytics/api/get-analytics-disclosure.test.ts new file mode 100644 index 000000000..c77b42f82 --- /dev/null +++ b/test/unit/webui/features/analytics/api/get-analytics-disclosure.test.ts @@ -0,0 +1,46 @@ +import {expect} from 'chai' +import {createSandbox, type SinonSandbox, type SinonStub} from 'sinon' + +import type {BrvApiClient} from '../../../../../../src/webui/lib/api-client.js' + +import {AnalyticsEvents} from '../../../../../../src/shared/transport/events/analytics-events.js' +import {getAnalyticsDisclosure} from '../../../../../../src/webui/features/analytics/api/get-analytics-disclosure.js' +import {useTransportStore} from '../../../../../../src/webui/stores/transport-store.js' + +describe('getAnalyticsDisclosure', () => { + let sandbox: SinonSandbox + let request: SinonStub + + beforeEach(() => { + sandbox = createSandbox() + request = sandbox.stub() + useTransportStore.setState({ + apiClient: {on: sandbox.stub(), request} as unknown as BrvApiClient, + }) + }) + + afterEach(() => { + sandbox.restore() + useTransportStore.setState({apiClient: null}) + }) + + it('emits analytics:getDisclosure with no payload', async () => { + request.resolves({markdown: '# Disclosure'}) + await getAnalyticsDisclosure() + expect(request.firstCall.args[0]).to.equal(AnalyticsEvents.GET_DISCLOSURE) + }) + + it('returns the markdown body from the daemon response', async () => { + request.resolves({markdown: '# Title\n\nBody.'}) + const result = await getAnalyticsDisclosure() + expect(result).to.deep.equal({markdown: '# Title\n\nBody.'}) + }) + + it('rejects when the transport is not connected', async () => { + useTransportStore.setState({apiClient: null}) + await getAnalyticsDisclosure().then( + () => expect.fail('expected promise to reject'), + (error: Error) => expect(error.message).to.equal('Not connected'), + ) + }) +}) diff --git a/test/unit/webui/features/analytics/constants.test.ts b/test/unit/webui/features/analytics/constants.test.ts deleted file mode 100644 index 2e03bc3a0..000000000 --- a/test/unit/webui/features/analytics/constants.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import {expect} from 'chai' - -import { - ANALYTICS_DISCLOSURE_SECTIONS, - ANALYTICS_PRIVACY_URL, -} from '../../../../../src/webui/features/analytics/constants.js' - -describe('analytics constants', () => { - describe('ANALYTICS_DISCLOSURE_SECTIONS', () => { - it('contains the five required sections in ticket-spec order', () => { - const labels = ANALYTICS_DISCLOSURE_SECTIONS.map((s) => s.label) - expect(labels).to.deep.equal([ - 'WHAT IS COLLECTED', - 'WHICH SURFACES ARE TRACKED', - 'WHERE IT GOES', - 'CROSS-DEVICE ALIAS', - 'HOW TO DISABLE', - ]) - }) - - it('every section has a non-empty body', () => { - for (const section of ANALYTICS_DISCLOSURE_SECTIONS) { - expect(section.body.length).to.be.greaterThan(0) - } - }) - - it('every section has an icon component reference', () => { - for (const section of ANALYTICS_DISCLOSURE_SECTIONS) { - expect(section.icon).to.exist - expect(['function', 'object']).to.include(typeof section.icon) - } - }) - }) - - describe('ANALYTICS_PRIVACY_URL', () => { - it('is a https URL', () => { - expect(ANALYTICS_PRIVACY_URL).to.match(/^https:\/\//) - }) - - it('points at the byterover privacy docs', () => { - expect(ANALYTICS_PRIVACY_URL).to.include('byterover.dev/privacy') - }) - }) -}) From 1275ff0c8a925bdc6b55cebcb5189b7866ef41f3 Mon Sep 17 00:00:00 2001 From: ncnthien Date: Tue, 2 Jun 2026 08:56:42 +0700 Subject: [PATCH 3/7] feat: [ENG-2621] address PR review on analytics disclosure wiring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Derive the visible privacy link label from PRIVACY_POLICY_URL so the URL is the single source of truth — the label can no longer drift on a future URL change. - Mark the disclosure query as staleTime: Infinity. The markdown is a bundled asset that never changes at runtime, so refetching on focus is pointless; making the cache-forever semantic explicit avoids confusion. - Guard against an empty disclosure body in AnalyticsDisclosureHandler: throw a visible error instead of returning an empty string the webui would silently render as a blank panel. Test added for the empty case. --- .../handlers/analytics-disclosure-handler.ts | 1 + .../analytics/api/get-analytics-disclosure.ts | 1 + .../features/analytics/components/analytics-panel.tsx | 2 +- .../handlers/analytics-disclosure-handler.test.ts | 11 +++++++++++ 4 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/server/infra/transport/handlers/analytics-disclosure-handler.ts b/src/server/infra/transport/handlers/analytics-disclosure-handler.ts index ec9bbc7a5..b4b905a46 100644 --- a/src/server/infra/transport/handlers/analytics-disclosure-handler.ts +++ b/src/server/infra/transport/handlers/analytics-disclosure-handler.ts @@ -30,6 +30,7 @@ export class AnalyticsDisclosureHandler { public setup(): void { this.transport.onRequest(AnalyticsEvents.GET_DISCLOSURE, async () => { const markdown = await this.loadDisclosure() + if (!markdown) throw new Error('Analytics disclosure markdown is missing or empty.') return {markdown} }) } diff --git a/src/webui/features/analytics/api/get-analytics-disclosure.ts b/src/webui/features/analytics/api/get-analytics-disclosure.ts index 4ecfd7bd4..0acc42f3b 100644 --- a/src/webui/features/analytics/api/get-analytics-disclosure.ts +++ b/src/webui/features/analytics/api/get-analytics-disclosure.ts @@ -18,6 +18,7 @@ export const getAnalyticsDisclosureQueryOptions = () => queryOptions({ queryFn: getAnalyticsDisclosure, queryKey: ['analyticsDisclosure'], + staleTime: Number.POSITIVE_INFINITY, }) type UseGetAnalyticsDisclosureOptions = { diff --git a/src/webui/features/analytics/components/analytics-panel.tsx b/src/webui/features/analytics/components/analytics-panel.tsx index 429fe9a50..279f01053 100644 --- a/src/webui/features/analytics/components/analytics-panel.tsx +++ b/src/webui/features/analytics/components/analytics-panel.tsx @@ -108,7 +108,7 @@ export function AnalyticsPanel() { target="_blank" > - byterover.dev/services/privacy + {PRIVACY_POLICY_URL.replace(/^https?:\/\/(www\.)?/, '')} )} diff --git a/test/unit/server/infra/transport/handlers/analytics-disclosure-handler.test.ts b/test/unit/server/infra/transport/handlers/analytics-disclosure-handler.test.ts index d401dfab7..47d36393e 100644 --- a/test/unit/server/infra/transport/handlers/analytics-disclosure-handler.test.ts +++ b/test/unit/server/infra/transport/handlers/analytics-disclosure-handler.test.ts @@ -24,6 +24,17 @@ describe('AnalyticsDisclosureHandler', () => { expect(result).to.deep.equal({markdown}) }) + it('throws when the loader returns an empty string so the webui surfaces an error state', async () => { + const transport = createMockTransportServer() + new AnalyticsDisclosureHandler({loadDisclosure: async () => '', transport}).setup() + + const handler = transport._handlers.get(AnalyticsEvents.GET_DISCLOSURE) as DisclosureHandler + await handler(undefined, 'client-1').then( + () => expect.fail('expected promise to reject'), + (error: Error) => expect(error.message).to.include('missing'), + ) + }) + it('propagates loader errors so the daemon does not silently serve empty disclosure', async () => { const transport = createMockTransportServer() const boom = new Error('ENOENT') From e4f8b10eaef54a709dbdcab4f81960b0fb315439 Mon Sep 17 00:00:00 2001 From: ncnthien Date: Tue, 2 Jun 2026 09:10:11 +0700 Subject: [PATCH 4/7] feat: [ENG-2621] parse disclosure markdown into sections + restore icon grid layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reviewer rejected dumping the full markdown body into the Privacy panel — the original M1.6 icon-grid layout should be preserved. Move the markdown -> sections conversion daemon-side so the webui still gets structured data without owning a copy of the section labels. - New parseAnalyticsDisclosure() helper splits the markdown on ## H2 headings into {label, body}[]. Intro paragraphs above the first H2 and any H3+ nested headings stay inside their parent section verbatim. - Updated AnalyticsDisclosureResponseSchema to return sections instead of the raw markdown string. Handler throws if the markdown has zero H2s (single-source-of-truth invariant still surfaced as an error toast). - DisclosureDetails reverts to the 2-column icon grid (Database / Eye / Server / Link2 / PowerOff mapped by section index), filters out the "Privacy policy" section (already shown as the footer link), and uses the parsed label + body. Labels render uppercased to match the design. - Hook + handler tests updated for the new shape; new parser test covers multi-paragraph bodies, code blocks, no-H2 input, and nested headings. --- .../handlers/analytics-disclosure-handler.ts | 19 ++++-- .../transport/events/analytics-events.ts | 16 +++-- .../utils/parse-analytics-disclosure.ts | 33 +++++++++ .../components/disclosure-details.tsx | 53 ++++++++++++--- .../analytics-disclosure-handler.test.ts | 27 +++++--- .../utils/parse-analytics-disclosure.test.ts | 68 +++++++++++++++++++ .../api/get-analytics-disclosure.test.ts | 12 ++-- 7 files changed, 192 insertions(+), 36 deletions(-) create mode 100644 src/shared/utils/parse-analytics-disclosure.ts create mode 100644 test/unit/shared/utils/parse-analytics-disclosure.test.ts diff --git a/src/server/infra/transport/handlers/analytics-disclosure-handler.ts b/src/server/infra/transport/handlers/analytics-disclosure-handler.ts index b4b905a46..1647fce36 100644 --- a/src/server/infra/transport/handlers/analytics-disclosure-handler.ts +++ b/src/server/infra/transport/handlers/analytics-disclosure-handler.ts @@ -5,6 +5,7 @@ import { AnalyticsEvents, } from '../../../../shared/transport/events/analytics-events.js' import {loadAnalyticsDisclosureText} from '../../../../shared/utils/load-analytics-disclosure.js' +import {parseAnalyticsDisclosure} from '../../../../shared/utils/parse-analytics-disclosure.js' export interface AnalyticsDisclosureHandlerDeps { readonly loadDisclosure?: () => Promise @@ -12,11 +13,11 @@ export interface AnalyticsDisclosureHandlerDeps { } /** - * Serves `analytics:getDisclosure` so the local web UI can render the same - * canonical disclosure markdown shown by the CLI consent prompt - * (`brv settings set analytics.share true`). Single source of truth is - * `src/shared/assets/analytics-disclosure.md`, read via - * `loadAnalyticsDisclosureText()`. + * Serves `analytics:getDisclosure` so the local web UI can render the + * canonical disclosure in its icon-grid layout. The daemon parses + * `src/shared/assets/analytics-disclosure.md` into one section per + * `## H2` heading and ships the structured array. Single source of truth + * stays the markdown file — PM/legal edits propagate without code changes. */ export class AnalyticsDisclosureHandler { private readonly loadDisclosure: () => Promise @@ -30,8 +31,12 @@ export class AnalyticsDisclosureHandler { public setup(): void { this.transport.onRequest(AnalyticsEvents.GET_DISCLOSURE, async () => { const markdown = await this.loadDisclosure() - if (!markdown) throw new Error('Analytics disclosure markdown is missing or empty.') - return {markdown} + const sections = parseAnalyticsDisclosure(markdown) + if (sections.length === 0) { + throw new Error('Analytics disclosure is missing or contains no sections.') + } + + return {sections} }) } } diff --git a/src/shared/transport/events/analytics-events.ts b/src/shared/transport/events/analytics-events.ts index 04d6e46b0..c516a2cb5 100644 --- a/src/shared/transport/events/analytics-events.ts +++ b/src/shared/transport/events/analytics-events.ts @@ -11,15 +11,21 @@ export const AnalyticsEvents = { } as const /** - * Response schema for `analytics:getDisclosure`. Exposes the canonical - * disclosure markdown shipped at `src/shared/assets/analytics-disclosure.md` - * so the local web UI can render the same text the CLI consent prompt - * (`brv settings set analytics.share true`) shows. + * Response schema for `analytics:getDisclosure`. Sections are parsed + * daemon-side from `src/shared/assets/analytics-disclosure.md` (one entry per + * `## H2` heading) so the local web UI can render them in its icon-grid + * layout. Single source of truth stays the markdown file. */ +export const AnalyticsDisclosureSectionSchema = z.object({ + body: z.string().min(1), + label: z.string().min(1), +}) + export const AnalyticsDisclosureResponseSchema = z.object({ - markdown: z.string().min(1), + sections: z.array(AnalyticsDisclosureSectionSchema).min(1), }) +export type AnalyticsDisclosureSection = z.infer export type AnalyticsDisclosureResponse = z.infer /** diff --git a/src/shared/utils/parse-analytics-disclosure.ts b/src/shared/utils/parse-analytics-disclosure.ts new file mode 100644 index 000000000..6f9ac2a91 --- /dev/null +++ b/src/shared/utils/parse-analytics-disclosure.ts @@ -0,0 +1,33 @@ +export type AnalyticsDisclosureSection = { + body: string + label: string +} + +const H2_PATTERN = /^##\s+(.+?)\s*$/ + +export function parseAnalyticsDisclosure(markdown: string): AnalyticsDisclosureSection[] { + const sections: AnalyticsDisclosureSection[] = [] + let currentLabel: string | undefined + let currentBodyLines: string[] = [] + + for (const line of markdown.split('\n')) { + const match = H2_PATTERN.exec(line) + if (match) { + if (currentLabel !== undefined) { + sections.push({body: currentBodyLines.join('\n').trim(), label: currentLabel}) + } + + currentLabel = match[1] + currentBodyLines = [] + continue + } + + if (currentLabel !== undefined) currentBodyLines.push(line) + } + + if (currentLabel !== undefined) { + sections.push({body: currentBodyLines.join('\n').trim(), label: currentLabel}) + } + + return sections +} diff --git a/src/webui/features/analytics/components/disclosure-details.tsx b/src/webui/features/analytics/components/disclosure-details.tsx index 2bbc4ff2e..5f1d3a2d7 100644 --- a/src/webui/features/analytics/components/disclosure-details.tsx +++ b/src/webui/features/analytics/components/disclosure-details.tsx @@ -1,19 +1,33 @@ import {Skeleton} from '@campfirein/byterover-packages/components/skeleton' +import {Database, Eye, Link2, type LucideIcon, PowerOff, Server} from 'lucide-react' + +import type {AnalyticsDisclosureSection} from '../../../../shared/transport/events/analytics-events.js' import {formatError} from '../../../lib/error-messages' import {noop} from '../../../lib/noop' -import {MarkdownView} from '../../context/components/markdown-view' import {useGetAnalyticsDisclosure} from '../api/get-analytics-disclosure' +const SECTION_ICONS: readonly LucideIcon[] = [Database, Eye, Server, Link2, PowerOff] + +const PRIVACY_POLICY_LABEL = 'privacy policy' + +function isVisibleSection(section: AnalyticsDisclosureSection): boolean { + return section.label.trim().toLowerCase() !== PRIVACY_POLICY_LABEL +} + export function DisclosureDetails() { const {data, error, isError, isLoading, refetch} = useGetAnalyticsDisclosure() if (isLoading) { return ( -
- - - +
+ {['a', 'b', 'c', 'd'].map((slot) => ( +
+ + + +
+ ))}
) } @@ -23,16 +37,33 @@ export function DisclosureDetails() {

✗ {formatError(error, 'Failed to load disclosure')} {' · '} -

) } - return + const sections = (data?.sections ?? []).filter((section) => isVisibleSection(section)) + + return ( +
+ {sections.map((section, index) => { + const Icon = SECTION_ICONS[index] + return ( +
+ {Icon && } +
+ + {section.label} + +

+ {section.body} +

+
+
+ ) + })} +
+ ) } diff --git a/test/unit/server/infra/transport/handlers/analytics-disclosure-handler.test.ts b/test/unit/server/infra/transport/handlers/analytics-disclosure-handler.test.ts index 47d36393e..402248cf5 100644 --- a/test/unit/server/infra/transport/handlers/analytics-disclosure-handler.test.ts +++ b/test/unit/server/infra/transport/handlers/analytics-disclosure-handler.test.ts @@ -4,34 +4,43 @@ import {AnalyticsDisclosureHandler} from '../../../../../../src/server/infra/tra import {AnalyticsEvents} from '../../../../../../src/shared/transport/events/analytics-events.js' import {createMockTransportServer} from '../../../../../helpers/mock-factories.js' -type DisclosureHandler = (data: unknown, clientId: string) => Promise<{markdown: string}> +type DisclosureHandler = ( + data: unknown, + clientId: string, +) => Promise<{sections: Array<{body: string; label: string}>}> + +const FIXTURE = ['## What is collected', 'Event names.', '', '## How to disable', 'Toggle off.'].join('\n') describe('AnalyticsDisclosureHandler', () => { it('registers a handler for analytics:getDisclosure on setup()', () => { const transport = createMockTransportServer() - new AnalyticsDisclosureHandler({loadDisclosure: async () => 'noop', transport}).setup() + new AnalyticsDisclosureHandler({loadDisclosure: async () => FIXTURE, transport}).setup() expect(transport._handlers.has(AnalyticsEvents.GET_DISCLOSURE)).to.equal(true) }) - it('returns the markdown loaded from the injected loader', async () => { + it('returns parsed sections from the markdown loaded by the injected loader', async () => { const transport = createMockTransportServer() - const markdown = '# Disclosure\n\nLorem.' - new AnalyticsDisclosureHandler({loadDisclosure: async () => markdown, transport}).setup() + new AnalyticsDisclosureHandler({loadDisclosure: async () => FIXTURE, transport}).setup() const handler = transport._handlers.get(AnalyticsEvents.GET_DISCLOSURE) as DisclosureHandler const result = await handler(undefined, 'client-1') - expect(result).to.deep.equal({markdown}) + expect(result).to.deep.equal({ + sections: [ + {body: 'Event names.', label: 'What is collected'}, + {body: 'Toggle off.', label: 'How to disable'}, + ], + }) }) - it('throws when the loader returns an empty string so the webui surfaces an error state', async () => { + it('throws when the markdown has no H2 sections so the webui surfaces an error state', async () => { const transport = createMockTransportServer() - new AnalyticsDisclosureHandler({loadDisclosure: async () => '', transport}).setup() + new AnalyticsDisclosureHandler({loadDisclosure: async () => '# Only H1\n\nIntro.', transport}).setup() const handler = transport._handlers.get(AnalyticsEvents.GET_DISCLOSURE) as DisclosureHandler await handler(undefined, 'client-1').then( () => expect.fail('expected promise to reject'), - (error: Error) => expect(error.message).to.include('missing'), + (error: Error) => expect(error.message).to.include('no sections'), ) }) diff --git a/test/unit/shared/utils/parse-analytics-disclosure.test.ts b/test/unit/shared/utils/parse-analytics-disclosure.test.ts new file mode 100644 index 000000000..a15ebb2e6 --- /dev/null +++ b/test/unit/shared/utils/parse-analytics-disclosure.test.ts @@ -0,0 +1,68 @@ +import {expect} from 'chai' + +import {parseAnalyticsDisclosure} from '../../../../src/shared/utils/parse-analytics-disclosure.js' + +describe('parseAnalyticsDisclosure', () => { + it('extracts each H2 heading as a section label and the following text as its body', () => { + const md = [ + '# Title', + '', + 'Intro paragraph the parser must skip.', + '', + '## First', + 'First body.', + '', + '## Second', + 'Second body.', + ].join('\n') + + const sections = parseAnalyticsDisclosure(md) + + expect(sections).to.deep.equal([ + {body: 'First body.', label: 'First'}, + {body: 'Second body.', label: 'Second'}, + ]) + }) + + it('preserves multi-paragraph and list/code-block bodies verbatim', () => { + const md = [ + '## How to disable', + 'You can stop sharing at any time by running:', + '', + '```', + 'brv settings set analytics.share false', + '```', + '', + 'You can also toggle the setting from the Settings page in the TUI.', + ].join('\n') + + const [section] = parseAnalyticsDisclosure(md) + + expect(section.label).to.equal('How to disable') + expect(section.body).to.include('You can stop sharing at any time') + expect(section.body).to.include('```\nbrv settings set analytics.share false\n```') + expect(section.body).to.include('Settings page in the TUI') + }) + + it('returns an empty array when the input has no H2 headings', () => { + expect(parseAnalyticsDisclosure('# Only H1\n\nBody.')).to.deep.equal([]) + expect(parseAnalyticsDisclosure('')).to.deep.equal([]) + }) + + it('trims leading and trailing whitespace from each body', () => { + const md = '## A\n\n\nBody A.\n\n\n## B\nBody B.' + const sections = parseAnalyticsDisclosure(md) + + expect(sections[0].body).to.equal('Body A.') + expect(sections[1].body).to.equal('Body B.') + }) + + it('ignores H3+ headings inside a section body', () => { + const md = ['## Outer', 'Lead paragraph.', '', '### Nested', 'Nested paragraph.', '', '## Next', 'Next body.'].join('\n') + + const sections = parseAnalyticsDisclosure(md) + + expect(sections.map((s) => s.label)).to.deep.equal(['Outer', 'Next']) + expect(sections[0].body).to.include('### Nested') + }) +}) diff --git a/test/unit/webui/features/analytics/api/get-analytics-disclosure.test.ts b/test/unit/webui/features/analytics/api/get-analytics-disclosure.test.ts index c77b42f82..ac624643b 100644 --- a/test/unit/webui/features/analytics/api/get-analytics-disclosure.test.ts +++ b/test/unit/webui/features/analytics/api/get-analytics-disclosure.test.ts @@ -25,15 +25,19 @@ describe('getAnalyticsDisclosure', () => { }) it('emits analytics:getDisclosure with no payload', async () => { - request.resolves({markdown: '# Disclosure'}) + request.resolves({sections: [{body: 'b', label: 'a'}]}) await getAnalyticsDisclosure() expect(request.firstCall.args[0]).to.equal(AnalyticsEvents.GET_DISCLOSURE) }) - it('returns the markdown body from the daemon response', async () => { - request.resolves({markdown: '# Title\n\nBody.'}) + it('returns the parsed sections from the daemon response', async () => { + const sections = [ + {body: 'Event names.', label: 'What is collected'}, + {body: 'Toggle off.', label: 'How to disable'}, + ] + request.resolves({sections}) const result = await getAnalyticsDisclosure() - expect(result).to.deep.equal({markdown: '# Title\n\nBody.'}) + expect(result).to.deep.equal({sections}) }) it('rejects when the transport is not connected', async () => { From 8cb759900fc59ff752824f178d1b15f118c001c5 Mon Sep 17 00:00:00 2001 From: ncnthien Date: Tue, 2 Jun 2026 09:23:32 +0700 Subject: [PATCH 5/7] feat: [ENG-2621] render disclosure section bodies as markdown in the icon grid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit rendered each section's body as a plain

, so real markdown tokens (**bold**, inline `code`, fenced code blocks, bullet lists) leaked into the UI as raw text — see the screenshot in the PR review. Swap the

for the existing MarkdownView (already used by the contexts feature) with a small wrapper class that keeps the cell text styling (muted, 0.8125rem, leading-relaxed). Bold / inline code / lists / fenced code now render correctly; cell heights are still variable but the content reads cleanly. --- .../features/analytics/components/disclosure-details.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/webui/features/analytics/components/disclosure-details.tsx b/src/webui/features/analytics/components/disclosure-details.tsx index 5f1d3a2d7..0a041df2d 100644 --- a/src/webui/features/analytics/components/disclosure-details.tsx +++ b/src/webui/features/analytics/components/disclosure-details.tsx @@ -5,6 +5,7 @@ import type {AnalyticsDisclosureSection} from '../../../../shared/transport/even import {formatError} from '../../../lib/error-messages' import {noop} from '../../../lib/noop' +import {MarkdownView} from '../../context/components/markdown-view' import {useGetAnalyticsDisclosure} from '../api/get-analytics-disclosure' const SECTION_ICONS: readonly LucideIcon[] = [Database, Eye, Server, Link2, PowerOff] @@ -57,9 +58,10 @@ export function DisclosureDetails() { {section.label} -

- {section.body} -

+
) From ed90d74b959412b5ff1ee91689b2f1a5ca17e430 Mon Sep 17 00:00:00 2001 From: ncnthien Date: Tue, 2 Jun 2026 09:36:10 +0700 Subject: [PATCH 6/7] feat: [ENG-2621] cache parsed disclosure sections + trim handler docstring - The disclosure markdown is a bundled asset that never changes at runtime, but the handler re-loaded and re-parsed it on every analytics:getDisclosure request. Lazy-cache the parsed sections on first success so subsequent requests are a single field read. - Drop the multi-line WHAT docstring on the handler class per the zero-comments codebase convention. Single-source-of-truth context is already covered by the load + parse pipeline reading the markdown asset. - New test pins the caching contract: three back-to-back requests trigger exactly one load. Existing error-propagation tests still cover the load-failure path (errors don't poison the cache). --- .../handlers/analytics-disclosure-handler.ts | 34 ++++++++++--------- .../analytics-disclosure-handler.test.ts | 18 ++++++++++ 2 files changed, 36 insertions(+), 16 deletions(-) diff --git a/src/server/infra/transport/handlers/analytics-disclosure-handler.ts b/src/server/infra/transport/handlers/analytics-disclosure-handler.ts index 1647fce36..0f958990c 100644 --- a/src/server/infra/transport/handlers/analytics-disclosure-handler.ts +++ b/src/server/infra/transport/handlers/analytics-disclosure-handler.ts @@ -2,6 +2,7 @@ import type {ITransportServer} from '../../../core/interfaces/transport/i-transp import { type AnalyticsDisclosureResponse, + type AnalyticsDisclosureSection, AnalyticsEvents, } from '../../../../shared/transport/events/analytics-events.js' import {loadAnalyticsDisclosureText} from '../../../../shared/utils/load-analytics-disclosure.js' @@ -12,14 +13,8 @@ export interface AnalyticsDisclosureHandlerDeps { readonly transport: ITransportServer } -/** - * Serves `analytics:getDisclosure` so the local web UI can render the - * canonical disclosure in its icon-grid layout. The daemon parses - * `src/shared/assets/analytics-disclosure.md` into one section per - * `## H2` heading and ships the structured array. Single source of truth - * stays the markdown file — PM/legal edits propagate without code changes. - */ export class AnalyticsDisclosureHandler { + private cachedSections: AnalyticsDisclosureSection[] | undefined private readonly loadDisclosure: () => Promise private readonly transport: ITransportServer @@ -29,14 +24,21 @@ export class AnalyticsDisclosureHandler { } public setup(): void { - this.transport.onRequest(AnalyticsEvents.GET_DISCLOSURE, async () => { - const markdown = await this.loadDisclosure() - const sections = parseAnalyticsDisclosure(markdown) - if (sections.length === 0) { - throw new Error('Analytics disclosure is missing or contains no sections.') - } - - return {sections} - }) + this.transport.onRequest(AnalyticsEvents.GET_DISCLOSURE, async () => ({ + sections: await this.getSections(), + })) + } + + private async getSections(): Promise { + if (this.cachedSections) return this.cachedSections + + const markdown = await this.loadDisclosure() + const sections = parseAnalyticsDisclosure(markdown) + if (sections.length === 0) { + throw new Error('Analytics disclosure is missing or contains no sections.') + } + + this.cachedSections = sections + return sections } } diff --git a/test/unit/server/infra/transport/handlers/analytics-disclosure-handler.test.ts b/test/unit/server/infra/transport/handlers/analytics-disclosure-handler.test.ts index 402248cf5..071c3e2ff 100644 --- a/test/unit/server/infra/transport/handlers/analytics-disclosure-handler.test.ts +++ b/test/unit/server/infra/transport/handlers/analytics-disclosure-handler.test.ts @@ -33,6 +33,24 @@ describe('AnalyticsDisclosureHandler', () => { }) }) + it('loads and parses the markdown once, then serves the cached sections', async () => { + const transport = createMockTransportServer() + let calls = 0 + const loadDisclosure = async () => { + calls += 1 + return FIXTURE + } + + new AnalyticsDisclosureHandler({loadDisclosure, transport}).setup() + + const handler = transport._handlers.get(AnalyticsEvents.GET_DISCLOSURE) as DisclosureHandler + await handler(undefined, 'client-1') + await handler(undefined, 'client-2') + await handler(undefined, 'client-3') + + expect(calls).to.equal(1) + }) + it('throws when the markdown has no H2 sections so the webui surfaces an error state', async () => { const transport = createMockTransportServer() new AnalyticsDisclosureHandler({loadDisclosure: async () => '# Only H1\n\nIntro.', transport}).setup() From 1aa060bccc381cf58c024984f633b1e1517b357a Mon Sep 17 00:00:00 2001 From: ncnthien Date: Tue, 2 Jun 2026 09:45:36 +0700 Subject: [PATCH 7/7] feat: [ENG-2621] address PR review on disclosure rendering robustness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Switch icon-grid mapping from positional (SECTION_ICONS[index]) to a label-keyed Record with an Info fallback. PM/legal can now reorder or insert sections in the markdown without silently shifting every following icon to the wrong section. - Use a substring "privacy" match for the icon-grid filter instead of an exact-string compare. Survives renames to "Privacy notice", "Privacy Policy & Terms", etc. so the section never accidentally leaks into the grid next to the footer link. - Parser now drops sections with an empty body so the schema's body.min(1) matches the runtime contract — a stray "## Foo\n\n## Bar" can no longer render a blank labelled card. - Tag the analytics-disclosure.md fenced code block with `bash` so the CodeBlock header is no longer empty in the webui (and the CLI prompt gets the language label too). --- src/shared/assets/analytics-disclosure.md | 4 ++-- .../utils/parse-analytics-disclosure.ts | 16 +++++++------- .../components/disclosure-details.tsx | 22 +++++++++++++------ .../utils/parse-analytics-disclosure.test.ts | 7 ++++++ 4 files changed, 32 insertions(+), 17 deletions(-) diff --git a/src/shared/assets/analytics-disclosure.md b/src/shared/assets/analytics-disclosure.md index 9698162ed..0dadc35f7 100644 --- a/src/shared/assets/analytics-disclosure.md +++ b/src/shared/assets/analytics-disclosure.md @@ -43,7 +43,7 @@ is permanently linked to your ByteRover account. You can stop sharing at any time by running: -``` +```bash brv settings set analytics.share false ``` @@ -53,4 +53,4 @@ You can also toggle the `analytics.share` setting from the Settings page in the For full details on how ByteRover handles your data, see: -https://byterover.dev/privacy +https://byterover.dev/services/privacy diff --git a/src/shared/utils/parse-analytics-disclosure.ts b/src/shared/utils/parse-analytics-disclosure.ts index 6f9ac2a91..6dbce363f 100644 --- a/src/shared/utils/parse-analytics-disclosure.ts +++ b/src/shared/utils/parse-analytics-disclosure.ts @@ -10,13 +10,16 @@ export function parseAnalyticsDisclosure(markdown: string): AnalyticsDisclosureS let currentLabel: string | undefined let currentBodyLines: string[] = [] + function pushCurrent() { + if (currentLabel === undefined) return + const body = currentBodyLines.join('\n').trim() + if (body.length > 0) sections.push({body, label: currentLabel}) + } + for (const line of markdown.split('\n')) { const match = H2_PATTERN.exec(line) if (match) { - if (currentLabel !== undefined) { - sections.push({body: currentBodyLines.join('\n').trim(), label: currentLabel}) - } - + pushCurrent() currentLabel = match[1] currentBodyLines = [] continue @@ -25,9 +28,6 @@ export function parseAnalyticsDisclosure(markdown: string): AnalyticsDisclosureS if (currentLabel !== undefined) currentBodyLines.push(line) } - if (currentLabel !== undefined) { - sections.push({body: currentBodyLines.join('\n').trim(), label: currentLabel}) - } - + pushCurrent() return sections } diff --git a/src/webui/features/analytics/components/disclosure-details.tsx b/src/webui/features/analytics/components/disclosure-details.tsx index 0a041df2d..28da9d9f2 100644 --- a/src/webui/features/analytics/components/disclosure-details.tsx +++ b/src/webui/features/analytics/components/disclosure-details.tsx @@ -1,5 +1,5 @@ import {Skeleton} from '@campfirein/byterover-packages/components/skeleton' -import {Database, Eye, Link2, type LucideIcon, PowerOff, Server} from 'lucide-react' +import {Database, Eye, Info, Link2, type LucideIcon, PowerOff, Server} from 'lucide-react' import type {AnalyticsDisclosureSection} from '../../../../shared/transport/events/analytics-events.js' @@ -8,12 +8,20 @@ import {noop} from '../../../lib/noop' import {MarkdownView} from '../../context/components/markdown-view' import {useGetAnalyticsDisclosure} from '../api/get-analytics-disclosure' -const SECTION_ICONS: readonly LucideIcon[] = [Database, Eye, Server, Link2, PowerOff] +const SECTION_ICONS: Record = { + 'cross-device alias': Link2, + 'how to disable': PowerOff, + 'what is collected': Database, + 'where it goes': Server, + 'which surfaces are tracked': Eye, +} -const PRIVACY_POLICY_LABEL = 'privacy policy' +function iconForLabel(label: string): LucideIcon { + return SECTION_ICONS[label.trim().toLowerCase()] ?? Info +} function isVisibleSection(section: AnalyticsDisclosureSection): boolean { - return section.label.trim().toLowerCase() !== PRIVACY_POLICY_LABEL + return !section.label.trim().toLowerCase().includes('privacy') } export function DisclosureDetails() { @@ -49,11 +57,11 @@ export function DisclosureDetails() { return (
- {sections.map((section, index) => { - const Icon = SECTION_ICONS[index] + {sections.map((section) => { + const Icon = iconForLabel(section.label) return (
- {Icon && } +
{section.label} diff --git a/test/unit/shared/utils/parse-analytics-disclosure.test.ts b/test/unit/shared/utils/parse-analytics-disclosure.test.ts index a15ebb2e6..98f614cf0 100644 --- a/test/unit/shared/utils/parse-analytics-disclosure.test.ts +++ b/test/unit/shared/utils/parse-analytics-disclosure.test.ts @@ -57,6 +57,13 @@ describe('parseAnalyticsDisclosure', () => { expect(sections[1].body).to.equal('Body B.') }) + it('drops sections whose body is empty so the webui never renders a blank card', () => { + const md = ['## Empty', '', '## Has body', 'Body text.'].join('\n') + const sections = parseAnalyticsDisclosure(md) + + expect(sections.map((s) => s.label)).to.deep.equal(['Has body']) + }) + it('ignores H3+ headings inside a section body', () => { const md = ['## Outer', 'Lead paragraph.', '', '### Nested', 'Nested paragraph.', '', '## Next', 'Next body.'].join('\n')