From 2ff3c60fcc8c43e23c70cd98695769898ec1844f Mon Sep 17 00:00:00 2001 From: Matt Pua Date: Mon, 6 Jul 2026 10:48:13 -0400 Subject: [PATCH 1/2] feat(browser): spin up browser cells from the command center - Browser option in the command center empty-cell picker, next to Terminal and Brainrot (same feature flag as the browser tab) - Cells persist as __browser__: and restore their page on reload - Cell header shows page title, falling back to the url hostname - Guarded url persist so stale navigation callbacks can't clobber a replaced cell Generated-By: PostHog Code Task-Id: e40b2af6-d887-4ee5-8b0e-46ea3e1f0b7d --- packages/core/src/command-center/cells.ts | 12 +++ packages/core/src/command-center/grid.test.ts | 32 ++++++++ packages/core/src/command-center/grid.ts | 17 +++++ .../command-center/commandCenterStore.ts | 26 +++++++ .../components/CommandCenterPanel.tsx | 76 +++++++++++++++++++ .../components/TaskSelector.tsx | 18 +++++ 6 files changed, 181 insertions(+) diff --git a/packages/core/src/command-center/cells.ts b/packages/core/src/command-center/cells.ts index d7457526bc..529c615a13 100644 --- a/packages/core/src/command-center/cells.ts +++ b/packages/core/src/command-center/cells.ts @@ -1,9 +1,11 @@ import type { AgentSession, WorkspaceMode } from "@posthog/shared"; import type { Task } from "@posthog/shared/domain-types"; import { + getBrowserCellUrl, getTerminalCellCwd, getTerminalCellId, isBrainrotCell, + isBrowserCell, isTerminalCell, } from "./grid"; import { type CellStatus, deriveStatus, getRepoName } from "./status"; @@ -21,6 +23,7 @@ export interface CommandCenterCellData { // Standalone terminal slot, independent of any agent run. terminalId: string | null; terminalCwd: string | null; + browserUrl: string | null; } export interface BuildCellsInput { @@ -39,6 +42,7 @@ const EMPTY_CELL_DATA = { isBrainrot: false, terminalId: null, terminalCwd: null, + browserUrl: null, }; export function buildCommandCenterCells( @@ -60,6 +64,14 @@ export function buildCommandCenterCells( }; } + if (isBrowserCell(cellValue)) { + return { + ...EMPTY_CELL_DATA, + cellIndex, + browserUrl: getBrowserCellUrl(cellValue), + }; + } + const taskId = cellValue; const task = taskId ? taskById.get(taskId) : undefined; const session = taskId ? sessionByTaskId.get(taskId) : undefined; diff --git a/packages/core/src/command-center/grid.test.ts b/packages/core/src/command-center/grid.test.ts index e1af10334a..a886e5fb2c 100644 --- a/packages/core/src/command-center/grid.test.ts +++ b/packages/core/src/command-center/grid.test.ts @@ -2,13 +2,16 @@ import { describe, expect, it } from "vitest"; import { BRAINROT_CELL, clampZoom, + getBrowserCellUrl, getCellCount, getCellSessionId, getGridDimensions, getTerminalCellCwd, getTerminalCellId, isBrainrotCell, + isBrowserCell, isTerminalCell, + makeBrowserCellValue, makeTerminalCellValue, resizeCells, } from "./grid"; @@ -92,6 +95,35 @@ describe("terminal cells", () => { }); }); +describe("browser cells", () => { + it.each([ + "about:blank", + "https://posthog.com", + // A url containing the delimiter and prefix-like text must survive intact. + "https://example.com/x?to=__browser__:https://evil.com", + ])("round-trips %j through the cell value", (url) => { + const value = makeBrowserCellValue(url); + expect(isBrowserCell(value)).toBe(true); + expect(getBrowserCellUrl(value)).toBe(url); + }); + + it("round-trips an empty url (blank browser cell)", () => { + const value = makeBrowserCellValue(""); + expect(isBrowserCell(value)).toBe(true); + expect(getBrowserCellUrl(value)).toBe(""); + }); + + it.each([ + { value: "some-task-uuid", expected: false }, + { value: BRAINROT_CELL, expected: false }, + { value: makeTerminalCellValue("t1"), expected: false }, + { value: null, expected: false }, + ])("isBrowserCell($value) -> $expected", ({ value, expected }) => { + expect(isBrowserCell(value)).toBe(expected); + expect(getBrowserCellUrl(value)).toBeNull(); + }); +}); + describe("getCellSessionId", () => { it("formats the cell session id", () => { expect(getCellSessionId(2)).toBe("cc-cell-2"); diff --git a/packages/core/src/command-center/grid.ts b/packages/core/src/command-center/grid.ts index 7d2e5f8e16..6a4d8400a5 100644 --- a/packages/core/src/command-center/grid.ts +++ b/packages/core/src/command-center/grid.ts @@ -49,6 +49,23 @@ export function getTerminalCellCwd(value: string | null): string | null { return colon === -1 ? null : decodeURIComponent(rest.slice(colon + 1)); } +// Reserved prefix for standalone browser cells; the whole remainder is the +// url, so urls containing ":" or the prefix text are safe. Never collides with +// task ids (uuids), BRAINROT_CELL, or terminal cells. +export const BROWSER_CELL_PREFIX = "__browser__:"; + +export function isBrowserCell(value: string | null): value is string { + return value?.startsWith(BROWSER_CELL_PREFIX) ?? false; +} + +export function makeBrowserCellValue(url: string): string { + return `${BROWSER_CELL_PREFIX}${url}`; +} + +export function getBrowserCellUrl(value: string | null): string | null { + return isBrowserCell(value) ? value.slice(BROWSER_CELL_PREFIX.length) : null; +} + export function getGridDimensions(preset: LayoutPreset): GridDimensions { const [cols, rows] = preset.split("x").map(Number); return { cols, rows }; diff --git a/packages/ui/src/features/command-center/commandCenterStore.ts b/packages/ui/src/features/command-center/commandCenterStore.ts index 14f32f66ab..e490d76740 100644 --- a/packages/ui/src/features/command-center/commandCenterStore.ts +++ b/packages/ui/src/features/command-center/commandCenterStore.ts @@ -2,7 +2,9 @@ import { BRAINROT_CELL, clampZoom, getCellCount, + isBrowserCell, type LayoutPreset, + makeBrowserCellValue, makeTerminalCellValue, resizeCells, ZOOM_STEP, @@ -39,6 +41,8 @@ interface CommandCenterStoreActions { terminalId: string, cwd?: string, ) => void; + setBrowserCell: (cellIndex: number, url: string) => void; + updateBrowserCellUrl: (cellIndex: number, url: string) => void; autofillCells: (taskIds: string[]) => void; clearCell: (cellIndex: number) => void; removeTaskById: (taskId: string) => void; @@ -136,6 +140,28 @@ export const useCommandCenterStore = create()( }; }), + setBrowserCell: (cellIndex, url) => + set((state) => { + if (cellIndex < 0 || cellIndex >= state.cells.length) return state; + const cells = [...state.cells]; + cells[cellIndex] = makeBrowserCellValue(url); + return { + cells, + activeTaskId: null, + activeCellIndex: cellIndex, + creatingCells: state.creatingCells.filter((i) => i !== cellIndex), + hasAutofilled: true, + }; + }), + + updateBrowserCellUrl: (cellIndex, url) => + set((state) => { + if (!isBrowserCell(state.cells[cellIndex] ?? null)) return state; + const cells = [...state.cells]; + cells[cellIndex] = makeBrowserCellValue(url); + return { cells }; + }), + autofillCells: (taskIds) => set((state) => { // Grid already full: nothing to place, but the bootstrap is done. diff --git a/packages/ui/src/features/command-center/components/CommandCenterPanel.tsx b/packages/ui/src/features/command-center/components/CommandCenterPanel.tsx index db0efcd93a..c79aeb161a 100644 --- a/packages/ui/src/features/command-center/components/CommandCenterPanel.tsx +++ b/packages/ui/src/features/command-center/components/CommandCenterPanel.tsx @@ -4,6 +4,7 @@ import { Desktop, Folder, GitFork, + Globe, Lightning, Plus, Terminal, @@ -12,6 +13,10 @@ import { import { isBrainrotCell } from "@posthog/core/command-center/grid"; import { ANALYTICS_EVENTS, type WorkspaceMode } from "@posthog/shared"; import type { Task } from "@posthog/shared/domain-types"; +import { + BrowserPanel, + useBrowserEnabled, +} from "@posthog/ui/features/browser/BrowserPanel"; import { useSettingsStore } from "@posthog/ui/features/settings/settingsStore"; import { destroyShellTerminal } from "@posthog/ui/features/terminal/destroyShellTerminal"; import { ShellTerminal } from "@posthog/ui/features/terminal/ShellTerminal"; @@ -116,11 +121,13 @@ function EmptyCell({ cellIndex }: { cellIndex: number }) { const assignTask = useCommandCenterStore((s) => s.assignTask); const setBrainrotCell = useCommandCenterStore((s) => s.setBrainrotCell); const setTerminalCell = useCommandCenterStore((s) => s.setTerminalCell); + const setBrowserCell = useCommandCenterStore((s) => s.setBrowserCell); const startCreating = useCommandCenterStore((s) => s.startCreating); const stopCreating = useCommandCenterStore((s) => s.stopCreating); const layout = useCommandCenterStore((s) => s.layout); const cells = useCommandCenterStore((s) => s.cells); const brainrotMode = useSettingsStore((s) => s.brainrotMode); + const browserEnabled = useBrowserEnabled(); const clearDraft = useDraftStore((s) => s.actions.setDraft); const sessionId = getCellSessionId(cellIndex); @@ -140,6 +147,10 @@ function EmptyCell({ cellIndex }: { cellIndex: number }) { [setTerminalCell, cellIndex], ); + const handleNewBrowser = useCallback(() => { + setBrowserCell(cellIndex, "about:blank"); + }, [setBrowserCell, cellIndex]); + const handleTaskCreated = useCallback( (task: Task) => { assignTask(cellIndex, task.id); @@ -199,6 +210,7 @@ function EmptyCell({ cellIndex }: { cellIndex: number }) { onOpenChange={setSelectorOpen} onNewTask={() => startCreating(cellIndex)} onNewTerminal={handleNewTerminal} + onNewBrowser={browserEnabled ? handleNewBrowser : undefined} onBrainrot={brainrotMode ? handleBrainrot : undefined} > + + + + + + ); +} + function PopulatedCell({ cell, isActiveSession, @@ -426,6 +497,11 @@ export function CommandCenterPanel({ ); } + // Empty-string url is a valid (blank) browser cell, so check against null. + if (cell.browserUrl !== null) { + return ; + } + if (!cell.taskId || !cell.task) { return ; } diff --git a/packages/ui/src/features/command-center/components/TaskSelector.tsx b/packages/ui/src/features/command-center/components/TaskSelector.tsx index a3764fd826..02f6ac23b0 100644 --- a/packages/ui/src/features/command-center/components/TaskSelector.tsx +++ b/packages/ui/src/features/command-center/components/TaskSelector.tsx @@ -1,6 +1,7 @@ import { ArrowLeft, Folder, + Globe, Lightning, Plus, Terminal, @@ -19,6 +20,7 @@ interface TaskSelectorProps { onOpenChange: (open: boolean) => void; onNewTask?: () => void; onNewTerminal?: (cwd?: string) => void; + onNewBrowser?: () => void; onBrainrot?: () => void; children: ReactNode; } @@ -29,6 +31,7 @@ export function TaskSelector({ onOpenChange, onNewTask, onNewTerminal, + onNewBrowser, onBrainrot, children, }: TaskSelectorProps) { @@ -81,6 +84,11 @@ export function TaskSelector({ onBrainrot?.(); }, [handleOpenChange, onBrainrot]); + const handleNewBrowser = useCallback(() => { + handleOpenChange(false); + onNewBrowser?.(); + }, [handleOpenChange, onNewBrowser]); + return ( )} + {onNewBrowser && ( + + )} {onBrainrot && ( + + + {children} + + + ); +} + function TerminalCell({ cellIndex, terminalId, @@ -312,37 +366,21 @@ function TerminalCell({ }, [stateKey, clearCell, cellIndex]); return ( - - - - - Terminal - - {folderName && ( + } + title="Terminal" + headerExtra={ + folderName ? ( {folderName} - )} - - - - - - + ) : undefined + } + onRemove={handleRemove} + > + + ); } @@ -370,38 +408,17 @@ function BrowserCell({ cellIndex, url }: { cellIndex: number; url: string }) { ); return ( - - - - - {label} - - - - - - - + } + title={label} + onRemove={() => clearCell(cellIndex)} + > + + ); }