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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/server/infra/process/feature-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string>
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<string>
private readonly transport: ITransportServer

public constructor(deps: AnalyticsDisclosureHandlerDeps) {
this.loadDisclosure = deps.loadDisclosure ?? loadAnalyticsDisclosureText
this.transport = deps.transport
}

public setup(): void {
this.transport.onRequest<void, AnalyticsDisclosureResponse>(AnalyticsEvents.GET_DISCLOSURE, async () => {
const markdown = await this.loadDisclosure()
return {markdown}
})
}
Comment on lines +30 to +35
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick (non-blocking): AnalyticsDisclosureResponseSchema is declared in analytics-events.ts with markdown: z.string().min(1) but is only used here for type inference, never safeParse'd on the response. Sister handler AnalyticsListHandler validates inbound payloads with AnalyticsListRequestSchema.safeParse; this one has no inbound payload to validate. If loadDisclosure() ever returns "" (truncated read, bad bundle), the wire response will technically violate the .min(1) schema and the webui will silently render an empty <MarkdownView/>. Cheap defense: if (!markdown) throw new Error('disclosure missing') — keeps the failure mode visible (the existing error-state UI in DisclosureDetails) instead of "blank panel, no toast." Not blocking since the bundled asset is checked into git; flagging because the test on line 27-42 of the handler test already establishes the propagate-errors contract you'd be leaning on.

}
2 changes: 2 additions & 0 deletions src/server/infra/transport/handlers/index.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down
7 changes: 1 addition & 6 deletions src/shared/constants/privacy.ts
Original file line number Diff line number Diff line change
@@ -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'
13 changes: 13 additions & 0 deletions src/shared/transport/events/analytics-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof AnalyticsDisclosureResponseSchema>

/**
* M4.6 `analytics:status` response. Surfaces operational metrics for
* `brv settings get analytics.status`: enabled flag (from GlobalConfig), client
Expand Down
31 changes: 31 additions & 0 deletions src/webui/features/analytics/api/get-analytics-disclosure.ts
Original file line number Diff line number Diff line change
@@ -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<AnalyticsDisclosureResponse> => {
const {apiClient} = useTransportStore.getState()
if (!apiClient) return Promise.reject(new Error('Not connected'))
return apiClient.request<AnalyticsDisclosureResponse, void>(AnalyticsEvents.GET_DISCLOSURE)
}

export const getAnalyticsDisclosureQueryOptions = () =>
queryOptions({
queryFn: getAnalyticsDisclosure,
queryKey: ['analyticsDisclosure'],
})
Comment on lines +17 to +21
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick (non-blocking): the PR description claims the webui will refetch the disclosure on tab refocus ("TanStack Query default + override on the globalConfig query"), but the global queryConfig in src/webui/lib/react-query.ts has refetchOnWindowFocus: false, and this hook doesn't override it (unlike useGetGlobalConfig, which sets refetchOnWindowFocus: true). So in practice the disclosure won't auto-refresh on focus — which is actually fine since the markdown is bundled with the CLI binary, but the PR description's test-plan bullet is misleading. Consider either:

  1. Adding staleTime: Infinity here to make the bundled-asset semantics explicit (and avoid a re-request every time the component remounts), or
  2. Just updating the PR description so it doesn't claim a refetch behavior the code doesn't implement.


type UseGetAnalyticsDisclosureOptions = {
queryConfig?: QueryConfig<typeof getAnalyticsDisclosureQueryOptions>
}

export const useGetAnalyticsDisclosure = ({queryConfig}: UseGetAnalyticsDisclosureOptions = {}) =>
useQuery({
...getAnalyticsDisclosureQueryOptions(),
...queryConfig,
})
6 changes: 3 additions & 3 deletions src/webui/features/analytics/components/analytics-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -103,12 +103,12 @@ export function AnalyticsPanel() {

<a
className="text-foreground/80 hover:text-foreground inline-flex items-center gap-2 border-t px-5 py-3 text-sm transition-colors"
href={ANALYTICS_PRIVACY_URL}
href={PRIVACY_POLICY_URL}
rel="noopener noreferrer"
target="_blank"
>
<ExternalLink className="size-3.5 text-primary" />
<span className="text-primary">docs.byterover.dev/privacy</span>
<span className="text-primary">byterover.dev/services/privacy</span>
</a>
Comment on lines 104 to 112
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (non-blocking): the visible label byterover.dev/services/privacy is hardcoded next to href={PRIVACY_POLICY_URL}. The whole point of e8de448 is that there is now one canonical URL constant — but the label here can still drift independently. Consider deriving the label from PRIVACY_POLICY_URL (e.g. PRIVACY_POLICY_URL.replace(/^https?:\/\/(www\.)?/, '')) so a future URL change updates both the link and the text in one place.

</div>
)}
Expand Down
54 changes: 35 additions & 19 deletions src/webui/features/analytics/components/disclosure-details.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="grid grid-cols-1 gap-x-6 gap-y-5 md:grid-cols-2">
{ANALYTICS_DISCLOSURE_SECTIONS.map((section) => {
const Icon = section.icon
return (
<div className="flex flex-col gap-2" key={section.label}>
<Icon className="size-4 text-muted-foreground" strokeWidth={1.75} />
<div className="flex flex-col gap-1">
<span className="text-foreground text-[0.6875rem] font-semibold tracking-wider">
{section.label}
</span>
<p className="text-muted-foreground text-[0.8125rem] leading-relaxed">{section.body}</p>
</div>
</div>
)
})}
</div>
)
const {data, error, isError, isLoading, refetch} = useGetAnalyticsDisclosure()

if (isLoading) {
return (
<div className="flex flex-col gap-2">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
</div>
)
}

if (isError) {
return (
<p className="text-destructive text-sm">
✗ {formatError(error, 'Failed to load disclosure')}
{' · '}
<button
className="underline underline-offset-2"
onClick={() => refetch().catch(noop)}
type="button"
>
retry
</button>
</p>
)
}

return <MarkdownView content={data?.markdown ?? ''} />
}
39 changes: 0 additions & 39 deletions src/webui/features/analytics/constants.ts

This file was deleted.

Original file line number Diff line number Diff line change
@@ -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),
)
})
})
Original file line number Diff line number Diff line change
@@ -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'),
)
})
})
44 changes: 0 additions & 44 deletions test/unit/webui/features/analytics/constants.test.ts

This file was deleted.

Loading