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..b87a0b1686 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"; @@ -19,7 +24,13 @@ import { openTask } from "@posthog/ui/router/useOpenTask"; import { track } from "@posthog/ui/shell/analytics"; import { secureRandomString } from "@posthog/ui/utils/random"; import { Flex, Spinner, Text } from "@radix-ui/themes"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { + type ReactNode, + useCallback, + useEffect, + useRef, + useState, +} from "react"; import { useFolders } from "../../folders/useFolders"; import { useCloudPrUrl } from "../../git-interaction/useCloudPrUrl"; import { useDraftStore } from "../../message-editor/draftStore"; @@ -116,11 +127,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 +153,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 +216,7 @@ function EmptyCell({ cellIndex }: { cellIndex: number }) { onOpenChange={setSelectorOpen} onNewTask={() => startCreating(cellIndex)} onNewTerminal={handleNewTerminal} + onNewBrowser={browserEnabled ? handleNewBrowser : undefined} onBrainrot={brainrotMode ? handleBrainrot : undefined} > + + + {children} + + + ); +} + function TerminalCell({ cellIndex, terminalId, @@ -300,37 +366,59 @@ function TerminalCell({ }, [stateKey, clearCell, cellIndex]); return ( - - - - - Terminal - - {folderName && ( + } + title="Terminal" + headerExtra={ + folderName ? ( {folderName} - )} - - - - - - + ) : undefined + } + onRemove={handleRemove} + > + + + ); +} + +function hostnameOf(url: string): string | null { + try { + return new URL(url).hostname || null; + } catch { + return null; + } +} + +function BrowserCell({ cellIndex, url }: { cellIndex: number; url: string }) { + const clearCell = useCommandCenterStore((s) => s.clearCell); + const updateBrowserCellUrl = useCommandCenterStore( + (s) => s.updateBrowserCellUrl, + ); + // Page title once loaded; before that (e.g. a just-restored cell) the + // persisted url's hostname beats a bare "Browser". + const [title, setTitle] = useState(null); + const label = title ?? hostnameOf(url) ?? "Browser"; + + const onUrlChange = useCallback( + (next: string) => updateBrowserCellUrl(cellIndex, next), + [updateBrowserCellUrl, cellIndex], + ); + + return ( + } + title={label} + onRemove={() => clearCell(cellIndex)} + > + + ); } @@ -426,6 +514,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 && (