From 208aa9d4d760e37afbcbb21d663d46902f3ab8d5 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: 4bc7193a-bc2b-4365-8435-a6b20cd00c08 --- packages/core/src/command-center/cells.ts | 20 ++++- packages/core/src/command-center/grid.test.ts | 32 ++++++++ packages/core/src/command-center/grid.ts | 17 +++++ .../command-center/commandCenterStore.ts | 29 +++++++ .../components/CommandCenterPanel.tsx | 76 +++++++++++++++++++ .../components/TaskSelector.tsx | 45 +++++------ 6 files changed, 196 insertions(+), 23 deletions(-) diff --git a/packages/core/src/command-center/cells.ts b/packages/core/src/command-center/cells.ts index 3d7b7df0c3..fab30543c9 100644 --- a/packages/core/src/command-center/cells.ts +++ b/packages/core/src/command-center/cells.ts @@ -1,6 +1,12 @@ import type { AgentSession, WorkspaceMode } from "@posthog/shared"; import type { Task } from "@posthog/shared/domain-types"; -import { getTerminalCellId, isBrainrotCell, isTerminalCell } from "./grid"; +import { + getBrowserCellUrl, + getTerminalCellId, + isBrainrotCell, + isBrowserCell, + isTerminalCell, +} from "./grid"; import { type CellStatus, deriveStatus, getRepoName } from "./status"; export interface CommandCenterCellData { @@ -15,6 +21,9 @@ export interface CommandCenterCellData { isBrainrot: boolean; // Standalone terminal slot, independent of any agent run. terminalId: string | null; + // Standalone browser slot. Empty string is a valid browser cell, so callers + // must check `!== null`, not truthiness. + browserUrl: string | null; } export interface BuildCellsInput { @@ -32,6 +41,7 @@ const EMPTY_CELL_DATA = { workspaceMode: null, isBrainrot: false, terminalId: null, + browserUrl: null, }; export function buildCommandCenterCells( @@ -52,6 +62,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 a96a56e47d..ceae50ab9d 100644 --- a/packages/core/src/command-center/grid.test.ts +++ b/packages/core/src/command-center/grid.test.ts @@ -2,12 +2,15 @@ import { describe, expect, it } from "vitest"; import { BRAINROT_CELL, clampZoom, + getBrowserCellUrl, getCellCount, getCellSessionId, getGridDimensions, getTerminalCellId, isBrainrotCell, + isBrowserCell, isTerminalCell, + makeBrowserCellValue, makeTerminalCellValue, resizeCells, } from "./grid"; @@ -83,6 +86,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 ce6351544e..5159385a51 100644 --- a/packages/core/src/command-center/grid.ts +++ b/packages/core/src/command-center/grid.ts @@ -35,6 +35,23 @@ export function getTerminalCellId(value: string | null): string | null { : null; } +// 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 00477f013e..8b3d6e901e 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, @@ -35,6 +37,8 @@ interface CommandCenterStoreActions { assignTask: (cellIndex: number, taskId: string) => void; setBrainrotCell: (cellIndex: number) => void; setTerminalCell: (cellIndex: number, terminalId: 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; @@ -132,6 +136,31 @@ 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, + }; + }), + + // Guarded so a stale navigation callback firing after the cell was + // replaced can't clobber whatever now occupies it. + updateBrowserCellUrl: (cellIndex, url) => + set((state) => { + if (cellIndex < 0 || cellIndex >= state.cells.length) return state; + if (!isBrowserCell(state.cells[cellIndex])) 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 a0f6c0af23..453c36a9f3 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); @@ -137,6 +144,10 @@ function EmptyCell({ cellIndex }: { cellIndex: number }) { setTerminalCell(cellIndex, secureRandomString(8)); }, [setTerminalCell, cellIndex]); + const handleNewBrowser = useCallback(() => { + setBrowserCell(cellIndex, "about:blank"); + }, [setBrowserCell, cellIndex]); + const handleTaskCreated = useCallback( (task: Task) => { assignTask(cellIndex, task.id); @@ -196,6 +207,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, @@ -417,6 +488,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 10b95270f6..f2acfd277b 100644 --- a/packages/ui/src/features/command-center/components/TaskSelector.tsx +++ b/packages/ui/src/features/command-center/components/TaskSelector.tsx @@ -1,4 +1,4 @@ -import { Lightning, Plus, Terminal } from "@phosphor-icons/react"; +import { Globe, Lightning, Plus, Terminal } from "@phosphor-icons/react"; import { openTaskInput } from "@posthog/ui/router/useOpenTask"; import { Popover } from "@radix-ui/themes"; import { type ReactNode, useCallback } from "react"; @@ -12,6 +12,7 @@ interface TaskSelectorProps { onOpenChange: (open: boolean) => void; onNewTask?: () => void; onNewTerminal?: () => void; + onNewBrowser?: () => void; onBrainrot?: () => void; children: ReactNode; } @@ -22,6 +23,7 @@ export function TaskSelector({ onOpenChange, onNewTask, onNewTerminal, + onNewBrowser, onBrainrot, children, }: TaskSelectorProps) { @@ -36,24 +38,13 @@ export function TaskSelector({ [assignTask, cellIndex, onOpenChange], ); - const handleNewTask = useCallback(() => { - onOpenChange(false); - if (onNewTask) { - onNewTask(); - } else { - openTaskInput(); - } - }, [onOpenChange, onNewTask]); - - const handleNewTerminal = useCallback(() => { - onOpenChange(false); - onNewTerminal?.(); - }, [onOpenChange, onNewTerminal]); - - const handleBrainrot = useCallback(() => { - onOpenChange(false); - onBrainrot?.(); - }, [onOpenChange, onBrainrot]); + const closeAnd = useCallback( + (action: () => void) => () => { + onOpenChange(false); + action(); + }, + [onOpenChange], + ); return ( New task @@ -104,17 +95,27 @@ export function TaskSelector({ )} + {onNewBrowser && ( + + )} {onBrainrot && ( - + {children} ); } +function TerminalCell({ + cellIndex, + terminalId, +}: { + cellIndex: number; + terminalId: string; +}) { + const clearCell = useCommandCenterStore((s) => s.clearCell); + const { getRecentFolders, getFolderDisplayName } = useFolders(); + const cwd = getRecentFolders(1)[0]?.path; + const folderName = cwd ? getFolderDisplayName(cwd) : null; + const stateKey = getTerminalCellStateKey(terminalId); + + const handleRemove = useCallback(() => { + destroyShellTerminal(stateKey); + clearCell(cellIndex); + }, [stateKey, clearCell, cellIndex]); + + return ( + } + title="Terminal" + headerExtra={ + folderName ? ( + + + {folderName} + + ) : undefined + } + onRemove={handleRemove} + > + + + ); +} + function hostnameOf(url: string): string | null { try { return new URL(url).hostname || null; @@ -365,38 +403,17 @@ function BrowserCell({ cellIndex, url }: { cellIndex: number; url: string }) { ); return ( - - - - - {label} - - - - - - - + } + title={label} + onRemove={() => clearCell(cellIndex)} + > + + ); }