From 8fbc4c0595cadd700df0d60a923d9689812e3d42 Mon Sep 17 00:00:00 2001 From: DaniAkash Date: Wed, 5 Aug 2026 16:36:03 +0530 Subject: [PATCH 1/2] feat(claw-app): drive the cockpit video rail from PostHog remote config The Learn BrowserClaw rail lineup now comes from a PostHog remote-config JSON payload (flag key cockpit-videos) instead of the app bundle. Videos are given as YouTube URLs; the client derives each thumbnail (i.ytimg.com, hq fallback) and tiles link out to YouTube in a new tab, since a YouTube iframe cannot play from the extension origin. The rail scrolls horizontally and its collapsed state persists to chrome.storage via WXT, so it stays collapsed until reopened. Hidden when the config lists no videos. Enables PostHog remote-config loading in the cockpit posthog wrapper (the one flag read is a global 100% rollout payload with no person targeting); the distinct id stays the anonymous install uuid. Adds zod-validated parsing of the untrusted payload. --- .../components/cockpit/CockpitVideoRail.tsx | 154 ++++++++++++++++++ .../cockpit/cockpit-video-config.hooks.ts | 38 +++++ .../cockpit/cockpit-video-config.test.ts | 91 +++++++++++ .../cockpit/cockpit-video-config.ts | 116 +++++++++++++ .../cockpit/cockpit-videos.storage.ts | 28 ++++ .../claw-app/modules/analytics/posthog.ts | 33 +++- .../apps/claw-app/package.json | 1 + .../apps/claw-app/screens/cockpit/Cockpit.tsx | 2 + packages/browseros-agent/bun.lock | 1 + 9 files changed, 461 insertions(+), 3 deletions(-) create mode 100644 packages/browseros-agent/apps/claw-app/components/cockpit/CockpitVideoRail.tsx create mode 100644 packages/browseros-agent/apps/claw-app/components/cockpit/cockpit-video-config.hooks.ts create mode 100644 packages/browseros-agent/apps/claw-app/components/cockpit/cockpit-video-config.test.ts create mode 100644 packages/browseros-agent/apps/claw-app/components/cockpit/cockpit-video-config.ts create mode 100644 packages/browseros-agent/apps/claw-app/components/cockpit/cockpit-videos.storage.ts diff --git a/packages/browseros-agent/apps/claw-app/components/cockpit/CockpitVideoRail.tsx b/packages/browseros-agent/apps/claw-app/components/cockpit/CockpitVideoRail.tsx new file mode 100644 index 0000000000..41a94a8b8d --- /dev/null +++ b/packages/browseros-agent/apps/claw-app/components/cockpit/CockpitVideoRail.tsx @@ -0,0 +1,154 @@ +/** + * @license + * Copyright 2026 BrowserOS + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * "Learn BrowserClaw" rail on the cockpit new tab. The lineup comes from PostHog + * remote config; tiles are posters that open the video on YouTube in a new tab + * (a YouTube iframe cannot play from the extension origin). Horizontally + * scrollable, and foldable to a one-line handle whose state persists in + * chrome.storage so it stays collapsed until the reader expands it again. Hidden + * entirely when the config lists no videos. + */ + +import { ChevronDown, Play } from 'lucide-react' +import { useEffect, useState } from 'react' +import { cn } from '@/lib/utils' +import { + type CockpitVideo, + youtubeThumbnailFallback, +} from './cockpit-video-config' +import { useCockpitVideos } from './cockpit-video-config.hooks' +import { cockpitVideosCollapsedStorage } from './cockpit-videos.storage' + +function useCollapsed(): [boolean, (value: boolean) => void] { + const [collapsed, setCollapsedState] = useState(false) + useEffect(() => { + let active = true + const store = cockpitVideosCollapsedStorage() + // External system: chrome.storage is async, so the initial value and any + // cross-tab change arrive here rather than in a synchronous initializer. + void store.getValue().then((value) => { + if (active) setCollapsedState(value) + }) + const unwatch = store.watch((value) => setCollapsedState(value ?? false)) + return () => { + active = false + unwatch() + } + }, []) + const setCollapsed = (value: boolean) => { + setCollapsedState(value) + void cockpitVideosCollapsedStorage().setValue(value) + } + return [collapsed, setCollapsed] +} + +export function CockpitVideoRail() { + const { heading, videos } = useCockpitVideos() + if (videos.length === 0) return null + return +} + +// Split so the chrome.storage-backed collapse hook only runs once there is a +// rail to show (and never in a video-less render, e.g. under test). +function VideoRail({ + heading, + videos, +}: { + heading: string + videos: CockpitVideo[] +}) { + const [collapsed, setCollapsed] = useCollapsed() + return ( +
+ + +
+
+
+ {videos.map((video) => ( + + ))} +
+
+
+
+ ) +} + +function VideoCard({ video }: { video: CockpitVideo }) { + return ( + + +
+ + + + {(video.title || video.channel) && ( +
+ {video.title && ( + + {video.title} + + )} + {video.channel && ( + + {video.channel} + + )} +
+ )} +
+ ) +} + +function Poster({ video }: { video: CockpitVideo }) { + const [src, setSrc] = useState(video.poster) + return ( + { + const fallback = youtubeThumbnailFallback(video.id) + if (src !== fallback) setSrc(fallback) + }} + className="absolute inset-0 h-full w-full object-cover transition duration-300 group-hover:scale-[1.03]" + /> + ) +} diff --git a/packages/browseros-agent/apps/claw-app/components/cockpit/cockpit-video-config.hooks.ts b/packages/browseros-agent/apps/claw-app/components/cockpit/cockpit-video-config.hooks.ts new file mode 100644 index 0000000000..733e9b8422 --- /dev/null +++ b/packages/browseros-agent/apps/claw-app/components/cockpit/cockpit-video-config.hooks.ts @@ -0,0 +1,38 @@ +/** + * @license + * Copyright 2026 BrowserOS + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { useEffect, useState } from 'react' +import { + getRemoteConfigPayload, + onRemoteConfig, +} from '@/modules/analytics/posthog' +import { + COCKPIT_VIDEOS_FLAG, + type CockpitVideoLineup, + parseCockpitVideoLineup, +} from './cockpit-video-config' + +/** + * The cockpit video lineup from PostHog remote config. Empty until the config + * loads, and stays empty when telemetry is off or no PostHog key is set, so + * callers hide the section on `videos.length === 0`. + */ +export function useCockpitVideos(): CockpitVideoLineup { + const [lineup, setLineup] = useState(() => + parseCockpitVideoLineup(getRemoteConfigPayload(COCKPIT_VIDEOS_FLAG)), + ) + useEffect(() => { + const read = () => + setLineup( + parseCockpitVideoLineup(getRemoteConfigPayload(COCKPIT_VIDEOS_FLAG)), + ) + read() + // External system: PostHog loads remote config asynchronously after init; + // re-read when it arrives. No-op unsubscribe when posthog is not ready. + return onRemoteConfig(read) + }, []) + return lineup +} diff --git a/packages/browseros-agent/apps/claw-app/components/cockpit/cockpit-video-config.test.ts b/packages/browseros-agent/apps/claw-app/components/cockpit/cockpit-video-config.test.ts new file mode 100644 index 0000000000..ea45b802c9 --- /dev/null +++ b/packages/browseros-agent/apps/claw-app/components/cockpit/cockpit-video-config.test.ts @@ -0,0 +1,91 @@ +/** + * @license + * Copyright 2026 BrowserOS + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { describe, expect, it } from 'bun:test' +import { + DEFAULT_HEADING, + parseCockpitVideoLineup, + parseYouTubeId, + youtubeThumbnail, +} from './cockpit-video-config' + +describe('parseYouTubeId', () => { + it('parses the common YouTube URL forms', () => { + for (const url of [ + 'https://www.youtube.com/watch?v=AF05B1O9nq8', + 'https://youtu.be/AF05B1O9nq8', + 'https://www.youtube.com/embed/AF05B1O9nq8', + 'https://www.youtube.com/shorts/AF05B1O9nq8', + 'https://youtube.com/watch?v=AF05B1O9nq8&t=30s', + 'https://m.youtube.com/watch?v=AF05B1O9nq8', + ]) { + expect(parseYouTubeId(url)).toBe('AF05B1O9nq8') + } + }) + + it('rejects non-YouTube, malformed, and wrong-length ids', () => { + expect(parseYouTubeId('https://vimeo.com/12345')).toBeNull() + expect(parseYouTubeId('not a url')).toBeNull() + expect(parseYouTubeId('https://www.youtube.com/watch?v=short')).toBeNull() + expect(parseYouTubeId('https://www.youtube.com/')).toBeNull() + }) +}) + +describe('parseCockpitVideoLineup', () => { + it('validates a payload and derives thumbnails', () => { + const lineup = parseCockpitVideoLineup({ + heading: 'Watch these', + videos: [ + { + url: 'https://youtu.be/AF05B1O9nq8', + title: 'One', + channel: 'BrowserOS', + }, + { url: 'https://www.youtube.com/watch?v=rIZ8OBHL7Zo' }, + ], + }) + expect(lineup.heading).toBe('Watch these') + expect(lineup.videos).toHaveLength(2) + expect(lineup.videos[0]).toMatchObject({ + id: 'AF05B1O9nq8', + url: 'https://youtu.be/AF05B1O9nq8', + title: 'One', + channel: 'BrowserOS', + poster: youtubeThumbnail('AF05B1O9nq8'), + }) + expect(lineup.videos[1].id).toBe('rIZ8OBHL7Zo') + }) + + it('honors an explicit thumbnail override', () => { + const lineup = parseCockpitVideoLineup({ + videos: [ + { + url: 'https://youtu.be/AF05B1O9nq8', + thumbnail: 'https://cdn.example.com/p.jpg', + }, + ], + }) + expect(lineup.videos[0].poster).toBe('https://cdn.example.com/p.jpg') + }) + + it('drops videos whose url is not a recognizable YouTube link', () => { + const lineup = parseCockpitVideoLineup({ + videos: [ + { url: 'https://vimeo.com/1' }, + { url: 'https://youtu.be/AF05B1O9nq8' }, + ], + }) + expect(lineup.videos.map((video) => video.id)).toEqual(['AF05B1O9nq8']) + }) + + it('defaults the heading and returns empty on an unusable payload', () => { + const empty = { heading: DEFAULT_HEADING, videos: [] } + expect(parseCockpitVideoLineup({ videos: [] })).toEqual(empty) + expect(parseCockpitVideoLineup(null)).toEqual(empty) + expect(parseCockpitVideoLineup({})).toEqual(empty) + expect(parseCockpitVideoLineup({ videos: 'nope' })).toEqual(empty) + }) +}) diff --git a/packages/browseros-agent/apps/claw-app/components/cockpit/cockpit-video-config.ts b/packages/browseros-agent/apps/claw-app/components/cockpit/cockpit-video-config.ts new file mode 100644 index 0000000000..b31993e05b --- /dev/null +++ b/packages/browseros-agent/apps/claw-app/components/cockpit/cockpit-video-config.ts @@ -0,0 +1,116 @@ +/** + * @license + * Copyright 2026 BrowserOS + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * The cockpit "Learn BrowserClaw" video lineup is controlled from PostHog remote + * config (flag key `cockpit-videos`), never the app bundle. The payload is an + * untrusted JSON object; the client validates it, derives each YouTube thumbnail + * on the fly, and links out to YouTube (a YouTube iframe cannot play from the + * extension's chrome-extension origin). + * + * Payload shape: + * { + * "heading": "Learn BrowserClaw", // optional section title + * "videos": [ + * { "url": "https://youtu.be/AF05B1O9nq8", // required YouTube url + * "title": "...", // optional + * "channel": "...", // optional + * "thumbnail": "https://..." } // optional poster override + * ] + * } + */ + +import { z } from 'zod' + +/** PostHog remote-config flag whose JSON payload holds the video lineup. */ +export const COCKPIT_VIDEOS_FLAG = 'cockpit-videos' + +export const DEFAULT_HEADING = 'Learn BrowserClaw' + +const rawVideoSchema = z.object({ + url: z.string(), + title: z.string().optional(), + channel: z.string().optional(), + thumbnail: z.string().optional(), +}) + +export const cockpitVideoConfigSchema = z.object({ + heading: z.string().optional(), + videos: z.array(rawVideoSchema), +}) + +export type CockpitVideoConfigInput = z.infer + +/** A validated, render-ready video (its URL yielded a real YouTube id). */ +export interface CockpitVideo { + id: string + url: string + title?: string + channel?: string + poster: string +} + +export interface CockpitVideoLineup { + heading: string + videos: CockpitVideo[] +} + +const YOUTUBE_ID = /^[A-Za-z0-9_-]{11}$/ + +/** Extracts the 11-char video id from any common YouTube URL form, else null. */ +export function parseYouTubeId(url: string): string | null { + let parsed: URL + try { + parsed = new URL(url) + } catch { + return null + } + const host = parsed.hostname.replace(/^www\./, '') + let id: string | null = null + if (host === 'youtu.be') { + id = parsed.pathname.split('/').filter(Boolean)[0] ?? null + } else if (host === 'youtube.com' || host === 'm.youtube.com') { + id = + parsed.pathname === '/watch' + ? parsed.searchParams.get('v') + : (parsed.pathname.match(/^\/(?:embed|shorts|v|live)\/([^/]+)/)?.[1] ?? + null) + } + return id && YOUTUBE_ID.test(id) ? id : null +} + +/** Max-res YouTube thumbnail; the tile falls back to hq on error. */ +export function youtubeThumbnail(id: string): string { + return `https://i.ytimg.com/vi/${id}/maxresdefault.jpg` +} + +/** Standard fallback thumbnail; maxres does not exist for every video. */ +export function youtubeThumbnailFallback(id: string): string { + return `https://i.ytimg.com/vi/${id}/hqdefault.jpg` +} + +/** + * Validates an untrusted PostHog payload into a render-ready lineup. A malformed + * payload, or a video whose URL is not a recognizable YouTube link, is dropped; + * returns an empty lineup when the payload is unusable. + */ +export function parseCockpitVideoLineup(payload: unknown): CockpitVideoLineup { + const result = cockpitVideoConfigSchema.safeParse(payload) + if (!result.success) { + return { heading: DEFAULT_HEADING, videos: [] } + } + const videos: CockpitVideo[] = [] + for (const raw of result.data.videos) { + const id = parseYouTubeId(raw.url) + if (!id) continue + videos.push({ + id, + url: raw.url, + title: raw.title, + channel: raw.channel, + poster: raw.thumbnail ?? youtubeThumbnail(id), + }) + } + return { heading: result.data.heading?.trim() || DEFAULT_HEADING, videos } +} diff --git a/packages/browseros-agent/apps/claw-app/components/cockpit/cockpit-videos.storage.ts b/packages/browseros-agent/apps/claw-app/components/cockpit/cockpit-videos.storage.ts new file mode 100644 index 0000000000..2e2b96cfd4 --- /dev/null +++ b/packages/browseros-agent/apps/claw-app/components/cockpit/cockpit-videos.storage.ts @@ -0,0 +1,28 @@ +/** + * @license + * Copyright 2026 BrowserOS + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * Persisted collapse state for the cockpit video rail, in chrome.storage.local + * via WXT. Defaults to expanded; once the reader collapses it, it stays + * collapsed across new tabs until they expand it again. + * + * Defined lazily: `defineItem` touches chrome.storage on first use, so building + * it on demand keeps importing this module safe outside the extension (e.g. + * under test) where `browser` is undefined. + */ + +import { storage } from '@wxt-dev/storage' + +let item: ReturnType | undefined + +function define() { + return storage.defineItem('local:cockpit.videos.collapsed', { + fallback: false, + }) +} + +export function cockpitVideosCollapsedStorage() { + if (!item) item = define() + return item +} diff --git a/packages/browseros-agent/apps/claw-app/modules/analytics/posthog.ts b/packages/browseros-agent/apps/claw-app/modules/analytics/posthog.ts index 27ab9fe530..86a6e15719 100644 --- a/packages/browseros-agent/apps/claw-app/modules/analytics/posthog.ts +++ b/packages/browseros-agent/apps/claw-app/modules/analytics/posthog.ts @@ -77,9 +77,10 @@ export function createPostHogConfig( // Reconciled explicitly after effective consent is known. disable_session_recording: true, disable_surveys: true, - // Session replay needs PostHog's remote config, but BrowserClaw does not use - // feature flags. Fetch config once, without evaluating or polling flags. - advanced_disable_feature_flags_on_first_load: true, + // Session replay config and the cockpit's video lineup both come from + // PostHog remote config, so load it once on init. The only flag read is a + // single global remote-config payload (100% rollout, no person targeting); + // we never evaluate user-targeted flags. No polling. remote_config_refresh_interval_ms: 0, // Do not persist browser location metadata outside the event sanitizer. save_campaign_params: false, @@ -170,3 +171,29 @@ export function capture( if (!isCapturing()) return posthog.capture(event, properties) } + +/** + * Reads a PostHog remote-config JSON payload (a 100%-rollout flag used purely as + * config, not for user targeting). Returns null before init / without a matched + * payload, and never throws. Callers validate the shape before using it. + */ +export function getRemoteConfigPayload(key: string): unknown { + try { + return posthog.getFeatureFlagPayload(key) ?? null + } catch { + return null + } +} + +/** + * Subscribes to remote-config load so a reader can re-read a payload once it + * arrives. Returns an unsubscribe function (a no-op if posthog is not ready). + */ +export function onRemoteConfig(callback: () => void): () => void { + try { + const unsubscribe = posthog.onFeatureFlags(() => callback()) + return typeof unsubscribe === 'function' ? unsubscribe : () => {} + } catch { + return () => {} + } +} diff --git a/packages/browseros-agent/apps/claw-app/package.json b/packages/browseros-agent/apps/claw-app/package.json index 05fa80e031..44d8b87c7c 100644 --- a/packages/browseros-agent/apps/claw-app/package.json +++ b/packages/browseros-agent/apps/claw-app/package.json @@ -36,6 +36,7 @@ "@tabler/icons-react": "^3.45.0", "@tanstack/react-query": "^5.101.4", "@tanstack/react-table": "^8.21.3", + "@wxt-dev/storage": "^1.2.8", "@xyflow/react": "^12.11.2", "ai": "6.0.230", "ansi-to-react": "^6.2.6", diff --git a/packages/browseros-agent/apps/claw-app/screens/cockpit/Cockpit.tsx b/packages/browseros-agent/apps/claw-app/screens/cockpit/Cockpit.tsx index 7c4d89059b..6dfe69462f 100644 --- a/packages/browseros-agent/apps/claw-app/screens/cockpit/Cockpit.tsx +++ b/packages/browseros-agent/apps/claw-app/screens/cockpit/Cockpit.tsx @@ -1,5 +1,6 @@ import { CockpitHero } from '@/components/cockpit/CockpitHero' import { CockpitOnboarding } from '@/components/cockpit/CockpitOnboarding' +import { CockpitVideoRail } from '@/components/cockpit/CockpitVideoRail' import { RecentActivity } from '@/components/cockpit/RecentActivity' import { RunningGrid } from '@/components/cockpit/RunningGrid' import { SavedStatsBand } from '@/components/cockpit/SavedStatsBand' @@ -90,6 +91,7 @@ export function Cockpit() { )} +
) } diff --git a/packages/browseros-agent/bun.lock b/packages/browseros-agent/bun.lock index 4f1295f79f..0de9ba9354 100644 --- a/packages/browseros-agent/bun.lock +++ b/packages/browseros-agent/bun.lock @@ -140,6 +140,7 @@ "@tabler/icons-react": "^3.45.0", "@tanstack/react-query": "^5.101.4", "@tanstack/react-table": "^8.21.3", + "@wxt-dev/storage": "^1.2.8", "@xyflow/react": "^12.11.2", "ai": "6.0.230", "ansi-to-react": "^6.2.6", From bec25563f589747632f5cee2aa86bf423c17cf9a Mon Sep 17 00:00:00 2001 From: DaniAkash Date: Wed, 5 Aug 2026 16:48:38 +0530 Subject: [PATCH 2/2] test(claw-app): align posthog privacy assertion with remote-config load --- .../apps/claw-app/modules/analytics/posthog.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/browseros-agent/apps/claw-app/modules/analytics/posthog.test.ts b/packages/browseros-agent/apps/claw-app/modules/analytics/posthog.test.ts index bd5bb662e5..6293dadfc3 100644 --- a/packages/browseros-agent/apps/claw-app/modules/analytics/posthog.test.ts +++ b/packages/browseros-agent/apps/claw-app/modules/analytics/posthog.test.ts @@ -14,7 +14,10 @@ describe('BrowserClaw PostHog privacy', () => { distinctID: 'anonymous-install-id', }) expect(config.advanced_disable_decide).toBeUndefined() - expect(config.advanced_disable_feature_flags_on_first_load).toBe(true) + // Remote config (session-replay settings + the cockpit video lineup) must + // load once on init, so first-load flag loading is intentionally not + // disabled. Polling stays off via remote_config_refresh_interval_ms: 0. + expect(config.advanced_disable_feature_flags_on_first_load).toBeUndefined() expect(config.remote_config_refresh_interval_ms).toBe(0) expect(config.save_campaign_params).toBe(false) expect(config.save_referrer).toBe(false)