Skip to content

Commit 78d5d91

Browse files
authored
feat(browser): spin up browser cells from the command center (#3182)
1 parent a1f1814 commit 78d5d91

6 files changed

Lines changed: 226 additions & 28 deletions

File tree

packages/core/src/command-center/cells.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import type { AgentSession, WorkspaceMode } from "@posthog/shared";
22
import type { Task } from "@posthog/shared/domain-types";
33
import {
4+
getBrowserCellUrl,
45
getTerminalCellCwd,
56
getTerminalCellId,
67
isBrainrotCell,
8+
isBrowserCell,
79
isTerminalCell,
810
} from "./grid";
911
import { type CellStatus, deriveStatus, getRepoName } from "./status";
@@ -21,6 +23,7 @@ export interface CommandCenterCellData {
2123
// Standalone terminal slot, independent of any agent run.
2224
terminalId: string | null;
2325
terminalCwd: string | null;
26+
browserUrl: string | null;
2427
}
2528

2629
export interface BuildCellsInput {
@@ -39,6 +42,7 @@ const EMPTY_CELL_DATA = {
3942
isBrainrot: false,
4043
terminalId: null,
4144
terminalCwd: null,
45+
browserUrl: null,
4246
};
4347

4448
export function buildCommandCenterCells(
@@ -60,6 +64,14 @@ export function buildCommandCenterCells(
6064
};
6165
}
6266

67+
if (isBrowserCell(cellValue)) {
68+
return {
69+
...EMPTY_CELL_DATA,
70+
cellIndex,
71+
browserUrl: getBrowserCellUrl(cellValue),
72+
};
73+
}
74+
6375
const taskId = cellValue;
6476
const task = taskId ? taskById.get(taskId) : undefined;
6577
const session = taskId ? sessionByTaskId.get(taskId) : undefined;

packages/core/src/command-center/grid.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,16 @@ import { describe, expect, it } from "vitest";
22
import {
33
BRAINROT_CELL,
44
clampZoom,
5+
getBrowserCellUrl,
56
getCellCount,
67
getCellSessionId,
78
getGridDimensions,
89
getTerminalCellCwd,
910
getTerminalCellId,
1011
isBrainrotCell,
12+
isBrowserCell,
1113
isTerminalCell,
14+
makeBrowserCellValue,
1215
makeTerminalCellValue,
1316
resizeCells,
1417
} from "./grid";
@@ -92,6 +95,35 @@ describe("terminal cells", () => {
9295
});
9396
});
9497

98+
describe("browser cells", () => {
99+
it.each([
100+
"about:blank",
101+
"https://posthog.com",
102+
// A url containing the delimiter and prefix-like text must survive intact.
103+
"https://example.com/x?to=__browser__:https://evil.com",
104+
])("round-trips %j through the cell value", (url) => {
105+
const value = makeBrowserCellValue(url);
106+
expect(isBrowserCell(value)).toBe(true);
107+
expect(getBrowserCellUrl(value)).toBe(url);
108+
});
109+
110+
it("round-trips an empty url (blank browser cell)", () => {
111+
const value = makeBrowserCellValue("");
112+
expect(isBrowserCell(value)).toBe(true);
113+
expect(getBrowserCellUrl(value)).toBe("");
114+
});
115+
116+
it.each([
117+
{ value: "some-task-uuid", expected: false },
118+
{ value: BRAINROT_CELL, expected: false },
119+
{ value: makeTerminalCellValue("t1"), expected: false },
120+
{ value: null, expected: false },
121+
])("isBrowserCell($value) -> $expected", ({ value, expected }) => {
122+
expect(isBrowserCell(value)).toBe(expected);
123+
expect(getBrowserCellUrl(value)).toBeNull();
124+
});
125+
});
126+
95127
describe("getCellSessionId", () => {
96128
it("formats the cell session id", () => {
97129
expect(getCellSessionId(2)).toBe("cc-cell-2");

packages/core/src/command-center/grid.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,23 @@ export function getTerminalCellCwd(value: string | null): string | null {
4949
return colon === -1 ? null : decodeURIComponent(rest.slice(colon + 1));
5050
}
5151

52+
// Reserved prefix for standalone browser cells; the whole remainder is the
53+
// url, so urls containing ":" or the prefix text are safe. Never collides with
54+
// task ids (uuids), BRAINROT_CELL, or terminal cells.
55+
export const BROWSER_CELL_PREFIX = "__browser__:";
56+
57+
export function isBrowserCell(value: string | null): value is string {
58+
return value?.startsWith(BROWSER_CELL_PREFIX) ?? false;
59+
}
60+
61+
export function makeBrowserCellValue(url: string): string {
62+
return `${BROWSER_CELL_PREFIX}${url}`;
63+
}
64+
65+
export function getBrowserCellUrl(value: string | null): string | null {
66+
return isBrowserCell(value) ? value.slice(BROWSER_CELL_PREFIX.length) : null;
67+
}
68+
5269
export function getGridDimensions(preset: LayoutPreset): GridDimensions {
5370
const [cols, rows] = preset.split("x").map(Number);
5471
return { cols, rows };

packages/ui/src/features/command-center/commandCenterStore.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@ import {
22
BRAINROT_CELL,
33
clampZoom,
44
getCellCount,
5+
isBrowserCell,
56
type LayoutPreset,
7+
makeBrowserCellValue,
68
makeTerminalCellValue,
79
resizeCells,
810
ZOOM_STEP,
@@ -39,6 +41,8 @@ interface CommandCenterStoreActions {
3941
terminalId: string,
4042
cwd?: string,
4143
) => void;
44+
setBrowserCell: (cellIndex: number, url: string) => void;
45+
updateBrowserCellUrl: (cellIndex: number, url: string) => void;
4246
autofillCells: (taskIds: string[]) => void;
4347
clearCell: (cellIndex: number) => void;
4448
removeTaskById: (taskId: string) => void;
@@ -136,6 +140,28 @@ export const useCommandCenterStore = create<CommandCenterStore>()(
136140
};
137141
}),
138142

143+
setBrowserCell: (cellIndex, url) =>
144+
set((state) => {
145+
if (cellIndex < 0 || cellIndex >= state.cells.length) return state;
146+
const cells = [...state.cells];
147+
cells[cellIndex] = makeBrowserCellValue(url);
148+
return {
149+
cells,
150+
activeTaskId: null,
151+
activeCellIndex: cellIndex,
152+
creatingCells: state.creatingCells.filter((i) => i !== cellIndex),
153+
hasAutofilled: true,
154+
};
155+
}),
156+
157+
updateBrowserCellUrl: (cellIndex, url) =>
158+
set((state) => {
159+
if (!isBrowserCell(state.cells[cellIndex] ?? null)) return state;
160+
const cells = [...state.cells];
161+
cells[cellIndex] = makeBrowserCellValue(url);
162+
return { cells };
163+
}),
164+
139165
autofillCells: (taskIds) =>
140166
set((state) => {
141167
// Grid already full: nothing to place, but the bootstrap is done.

packages/ui/src/features/command-center/components/CommandCenterPanel.tsx

Lines changed: 121 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
Desktop,
55
Folder,
66
GitFork,
7+
Globe,
78
Lightning,
89
Plus,
910
Terminal,
@@ -12,14 +13,24 @@ import {
1213
import { isBrainrotCell } from "@posthog/core/command-center/grid";
1314
import { ANALYTICS_EVENTS, type WorkspaceMode } from "@posthog/shared";
1415
import type { Task } from "@posthog/shared/domain-types";
16+
import {
17+
BrowserPanel,
18+
useBrowserEnabled,
19+
} from "@posthog/ui/features/browser/BrowserPanel";
1520
import { useSettingsStore } from "@posthog/ui/features/settings/settingsStore";
1621
import { destroyShellTerminal } from "@posthog/ui/features/terminal/destroyShellTerminal";
1722
import { ShellTerminal } from "@posthog/ui/features/terminal/ShellTerminal";
1823
import { openTask } from "@posthog/ui/router/useOpenTask";
1924
import { track } from "@posthog/ui/shell/analytics";
2025
import { secureRandomString } from "@posthog/ui/utils/random";
2126
import { Flex, Spinner, Text } from "@radix-ui/themes";
22-
import { useCallback, useEffect, useRef, useState } from "react";
27+
import {
28+
type ReactNode,
29+
useCallback,
30+
useEffect,
31+
useRef,
32+
useState,
33+
} from "react";
2334
import { useFolders } from "../../folders/useFolders";
2435
import { useCloudPrUrl } from "../../git-interaction/useCloudPrUrl";
2536
import { useDraftStore } from "../../message-editor/draftStore";
@@ -116,11 +127,13 @@ function EmptyCell({ cellIndex }: { cellIndex: number }) {
116127
const assignTask = useCommandCenterStore((s) => s.assignTask);
117128
const setBrainrotCell = useCommandCenterStore((s) => s.setBrainrotCell);
118129
const setTerminalCell = useCommandCenterStore((s) => s.setTerminalCell);
130+
const setBrowserCell = useCommandCenterStore((s) => s.setBrowserCell);
119131
const startCreating = useCommandCenterStore((s) => s.startCreating);
120132
const stopCreating = useCommandCenterStore((s) => s.stopCreating);
121133
const layout = useCommandCenterStore((s) => s.layout);
122134
const cells = useCommandCenterStore((s) => s.cells);
123135
const brainrotMode = useSettingsStore((s) => s.brainrotMode);
136+
const browserEnabled = useBrowserEnabled();
124137
const clearDraft = useDraftStore((s) => s.actions.setDraft);
125138

126139
const sessionId = getCellSessionId(cellIndex);
@@ -140,6 +153,10 @@ function EmptyCell({ cellIndex }: { cellIndex: number }) {
140153
[setTerminalCell, cellIndex],
141154
);
142155

156+
const handleNewBrowser = useCallback(() => {
157+
setBrowserCell(cellIndex, "about:blank");
158+
}, [setBrowserCell, cellIndex]);
159+
143160
const handleTaskCreated = useCallback(
144161
(task: Task) => {
145162
assignTask(cellIndex, task.id);
@@ -199,6 +216,7 @@ function EmptyCell({ cellIndex }: { cellIndex: number }) {
199216
onOpenChange={setSelectorOpen}
200217
onNewTask={() => startCreating(cellIndex)}
201218
onNewTerminal={handleNewTerminal}
219+
onNewBrowser={browserEnabled ? handleNewBrowser : undefined}
202220
onBrainrot={brainrotMode ? handleBrainrot : undefined}
203221
>
204222
<button
@@ -279,6 +297,54 @@ function BrainrotCell({ cellIndex }: { cellIndex: number }) {
279297
);
280298
}
281299

300+
// Shared chrome for every occupied command-center cell: a titled header with a
301+
// type icon, an optional badge slot, a remove button, and the cell body below.
302+
function CellFrame({
303+
icon,
304+
title,
305+
headerExtra,
306+
onRemove,
307+
children,
308+
}: {
309+
icon: ReactNode;
310+
title: string;
311+
headerExtra?: ReactNode;
312+
onRemove: () => void;
313+
children: ReactNode;
314+
}) {
315+
return (
316+
<Flex direction="column" height="100%">
317+
<Flex
318+
align="center"
319+
gap="2"
320+
px="2"
321+
py="1"
322+
className="shrink-0 border-gray-6 border-b"
323+
>
324+
{icon}
325+
<Text
326+
className="min-w-0 flex-1 truncate font-medium text-[12px]"
327+
title={title}
328+
>
329+
{title}
330+
</Text>
331+
{headerExtra}
332+
<button
333+
type="button"
334+
onClick={onRemove}
335+
className="flex h-5 w-5 shrink-0 items-center justify-center rounded text-gray-10 transition-colors hover:bg-gray-4 hover:text-gray-12"
336+
title="Remove from grid"
337+
>
338+
<X size={12} />
339+
</button>
340+
</Flex>
341+
<Flex direction="column" className="min-h-0 flex-1">
342+
{children}
343+
</Flex>
344+
</Flex>
345+
);
346+
}
347+
282348
function TerminalCell({
283349
cellIndex,
284350
terminalId,
@@ -300,37 +366,59 @@ function TerminalCell({
300366
}, [stateKey, clearCell, cellIndex]);
301367

302368
return (
303-
<Flex direction="column" height="100%">
304-
<Flex
305-
align="center"
306-
gap="2"
307-
px="2"
308-
py="1"
309-
className="shrink-0 border-gray-6 border-b"
310-
>
311-
<Terminal size={12} className="shrink-0 text-gray-10" />
312-
<Text className="min-w-0 flex-1 truncate font-medium text-[12px]">
313-
Terminal
314-
</Text>
315-
{folderName && (
369+
<CellFrame
370+
icon={<Terminal size={12} className="shrink-0 text-gray-10" />}
371+
title="Terminal"
372+
headerExtra={
373+
folderName ? (
316374
<span className="inline-flex items-center gap-0.5 rounded bg-gray-3 px-1 py-0.5 text-[10px] text-gray-10">
317375
<Folder size={10} />
318376
{folderName}
319377
</span>
320-
)}
321-
<button
322-
type="button"
323-
onClick={handleRemove}
324-
className="flex h-5 w-5 shrink-0 items-center justify-center rounded text-gray-10 transition-colors hover:bg-gray-4 hover:text-gray-12"
325-
title="Remove from grid"
326-
>
327-
<X size={12} />
328-
</button>
329-
</Flex>
330-
<Flex direction="column" className="min-h-0 flex-1">
331-
<ShellTerminal cwd={cwd} stateKey={stateKey} />
332-
</Flex>
333-
</Flex>
378+
) : undefined
379+
}
380+
onRemove={handleRemove}
381+
>
382+
<ShellTerminal cwd={cwd} stateKey={stateKey} />
383+
</CellFrame>
384+
);
385+
}
386+
387+
function hostnameOf(url: string): string | null {
388+
try {
389+
return new URL(url).hostname || null;
390+
} catch {
391+
return null;
392+
}
393+
}
394+
395+
function BrowserCell({ cellIndex, url }: { cellIndex: number; url: string }) {
396+
const clearCell = useCommandCenterStore((s) => s.clearCell);
397+
const updateBrowserCellUrl = useCommandCenterStore(
398+
(s) => s.updateBrowserCellUrl,
399+
);
400+
// Page title once loaded; before that (e.g. a just-restored cell) the
401+
// persisted url's hostname beats a bare "Browser".
402+
const [title, setTitle] = useState<string | null>(null);
403+
const label = title ?? hostnameOf(url) ?? "Browser";
404+
405+
const onUrlChange = useCallback(
406+
(next: string) => updateBrowserCellUrl(cellIndex, next),
407+
[updateBrowserCellUrl, cellIndex],
408+
);
409+
410+
return (
411+
<CellFrame
412+
icon={<Globe size={12} className="shrink-0 text-gray-10" />}
413+
title={label}
414+
onRemove={() => clearCell(cellIndex)}
415+
>
416+
<BrowserPanel
417+
url={url}
418+
onUrlChange={onUrlChange}
419+
onTitleChange={setTitle}
420+
/>
421+
</CellFrame>
334422
);
335423
}
336424

@@ -426,6 +514,11 @@ export function CommandCenterPanel({
426514
);
427515
}
428516

517+
// Empty-string url is a valid (blank) browser cell, so check against null.
518+
if (cell.browserUrl !== null) {
519+
return <BrowserCell cellIndex={cell.cellIndex} url={cell.browserUrl} />;
520+
}
521+
429522
if (!cell.taskId || !cell.task) {
430523
return <EmptyCell cellIndex={cell.cellIndex} />;
431524
}

0 commit comments

Comments
 (0)