Skip to content

Commit e343a42

Browse files
committed
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__:<url> 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
1 parent 851fac6 commit e343a42

6 files changed

Lines changed: 196 additions & 23 deletions

File tree

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

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
import type { AgentSession, WorkspaceMode } from "@posthog/shared";
22
import type { Task } from "@posthog/shared/domain-types";
3-
import { getTerminalCellId, isBrainrotCell, isTerminalCell } from "./grid";
3+
import {
4+
getBrowserCellUrl,
5+
getTerminalCellId,
6+
isBrainrotCell,
7+
isBrowserCell,
8+
isTerminalCell,
9+
} from "./grid";
410
import { type CellStatus, deriveStatus, getRepoName } from "./status";
511

612
export interface CommandCenterCellData {
@@ -15,6 +21,9 @@ export interface CommandCenterCellData {
1521
isBrainrot: boolean;
1622
// Standalone terminal slot, independent of any agent run.
1723
terminalId: string | null;
24+
// Standalone browser slot. Empty string is a valid browser cell, so callers
25+
// must check `!== null`, not truthiness.
26+
browserUrl: string | null;
1827
}
1928

2029
export interface BuildCellsInput {
@@ -32,6 +41,7 @@ const EMPTY_CELL_DATA = {
3241
workspaceMode: null,
3342
isBrainrot: false,
3443
terminalId: null,
44+
browserUrl: null,
3545
};
3646

3747
export function buildCommandCenterCells(
@@ -52,6 +62,14 @@ export function buildCommandCenterCells(
5262
};
5363
}
5464

65+
if (isBrowserCell(cellValue)) {
66+
return {
67+
...EMPTY_CELL_DATA,
68+
cellIndex,
69+
browserUrl: getBrowserCellUrl(cellValue),
70+
};
71+
}
72+
5573
const taskId = cellValue;
5674
const task = taskId ? taskById.get(taskId) : undefined;
5775
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,12 +2,15 @@ import { describe, expect, it } from "vitest";
22
import {
33
BRAINROT_CELL,
44
clampZoom,
5+
getBrowserCellUrl,
56
getCellCount,
67
getCellSessionId,
78
getGridDimensions,
89
getTerminalCellId,
910
isBrainrotCell,
11+
isBrowserCell,
1012
isTerminalCell,
13+
makeBrowserCellValue,
1114
makeTerminalCellValue,
1215
resizeCells,
1316
} from "./grid";
@@ -83,6 +86,35 @@ describe("terminal cells", () => {
8386
});
8487
});
8588

89+
describe("browser cells", () => {
90+
it.each([
91+
"about:blank",
92+
"https://posthog.com",
93+
// A url containing the delimiter and prefix-like text must survive intact.
94+
"https://example.com/x?to=__browser__:https://evil.com",
95+
])("round-trips %j through the cell value", (url) => {
96+
const value = makeBrowserCellValue(url);
97+
expect(isBrowserCell(value)).toBe(true);
98+
expect(getBrowserCellUrl(value)).toBe(url);
99+
});
100+
101+
it("round-trips an empty url (blank browser cell)", () => {
102+
const value = makeBrowserCellValue("");
103+
expect(isBrowserCell(value)).toBe(true);
104+
expect(getBrowserCellUrl(value)).toBe("");
105+
});
106+
107+
it.each([
108+
{ value: "some-task-uuid", expected: false },
109+
{ value: BRAINROT_CELL, expected: false },
110+
{ value: makeTerminalCellValue("t1"), expected: false },
111+
{ value: null, expected: false },
112+
])("isBrowserCell($value) -> $expected", ({ value, expected }) => {
113+
expect(isBrowserCell(value)).toBe(expected);
114+
expect(getBrowserCellUrl(value)).toBeNull();
115+
});
116+
});
117+
86118
describe("getCellSessionId", () => {
87119
it("formats the cell session id", () => {
88120
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
@@ -35,6 +35,23 @@ export function getTerminalCellId(value: string | null): string | null {
3535
: null;
3636
}
3737

38+
// Reserved prefix for standalone browser cells; the whole remainder is the
39+
// url, so urls containing ":" or the prefix text are safe. Never collides with
40+
// task ids (uuids), BRAINROT_CELL, or terminal cells.
41+
export const BROWSER_CELL_PREFIX = "__browser__:";
42+
43+
export function isBrowserCell(value: string | null): value is string {
44+
return value?.startsWith(BROWSER_CELL_PREFIX) ?? false;
45+
}
46+
47+
export function makeBrowserCellValue(url: string): string {
48+
return `${BROWSER_CELL_PREFIX}${url}`;
49+
}
50+
51+
export function getBrowserCellUrl(value: string | null): string | null {
52+
return isBrowserCell(value) ? value.slice(BROWSER_CELL_PREFIX.length) : null;
53+
}
54+
3855
export function getGridDimensions(preset: LayoutPreset): GridDimensions {
3956
const [cols, rows] = preset.split("x").map(Number);
4057
return { cols, rows };

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

Lines changed: 29 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,
@@ -35,6 +37,8 @@ interface CommandCenterStoreActions {
3537
assignTask: (cellIndex: number, taskId: string) => void;
3638
setBrainrotCell: (cellIndex: number) => void;
3739
setTerminalCell: (cellIndex: number, terminalId: string) => void;
40+
setBrowserCell: (cellIndex: number, url: string) => void;
41+
updateBrowserCellUrl: (cellIndex: number, url: string) => void;
3842
autofillCells: (taskIds: string[]) => void;
3943
clearCell: (cellIndex: number) => void;
4044
removeTaskById: (taskId: string) => void;
@@ -132,6 +136,31 @@ export const useCommandCenterStore = create<CommandCenterStore>()(
132136
};
133137
}),
134138

139+
setBrowserCell: (cellIndex, url) =>
140+
set((state) => {
141+
if (cellIndex < 0 || cellIndex >= state.cells.length) return state;
142+
const cells = [...state.cells];
143+
cells[cellIndex] = makeBrowserCellValue(url);
144+
return {
145+
cells,
146+
activeTaskId: null,
147+
activeCellIndex: cellIndex,
148+
creatingCells: state.creatingCells.filter((i) => i !== cellIndex),
149+
hasAutofilled: true,
150+
};
151+
}),
152+
153+
// Guarded so a stale navigation callback firing after the cell was
154+
// replaced can't clobber whatever now occupies it.
155+
updateBrowserCellUrl: (cellIndex, url) =>
156+
set((state) => {
157+
if (cellIndex < 0 || cellIndex >= state.cells.length) return state;
158+
if (!isBrowserCell(state.cells[cellIndex])) return state;
159+
const cells = [...state.cells];
160+
cells[cellIndex] = makeBrowserCellValue(url);
161+
return { cells };
162+
}),
163+
135164
autofillCells: (taskIds) =>
136165
set((state) => {
137166
// Grid already full: nothing to place, but the bootstrap is done.

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

Lines changed: 76 additions & 0 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,6 +13,10 @@ 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";
@@ -116,11 +121,13 @@ function EmptyCell({ cellIndex }: { cellIndex: number }) {
116121
const assignTask = useCommandCenterStore((s) => s.assignTask);
117122
const setBrainrotCell = useCommandCenterStore((s) => s.setBrainrotCell);
118123
const setTerminalCell = useCommandCenterStore((s) => s.setTerminalCell);
124+
const setBrowserCell = useCommandCenterStore((s) => s.setBrowserCell);
119125
const startCreating = useCommandCenterStore((s) => s.startCreating);
120126
const stopCreating = useCommandCenterStore((s) => s.stopCreating);
121127
const layout = useCommandCenterStore((s) => s.layout);
122128
const cells = useCommandCenterStore((s) => s.cells);
123129
const brainrotMode = useSettingsStore((s) => s.brainrotMode);
130+
const browserEnabled = useBrowserEnabled();
124131
const clearDraft = useDraftStore((s) => s.actions.setDraft);
125132

126133
const sessionId = getCellSessionId(cellIndex);
@@ -137,6 +144,10 @@ function EmptyCell({ cellIndex }: { cellIndex: number }) {
137144
setTerminalCell(cellIndex, secureRandomString(8));
138145
}, [setTerminalCell, cellIndex]);
139146

147+
const handleNewBrowser = useCallback(() => {
148+
setBrowserCell(cellIndex, "about:blank");
149+
}, [setBrowserCell, cellIndex]);
150+
140151
const handleTaskCreated = useCallback(
141152
(task: Task) => {
142153
assignTask(cellIndex, task.id);
@@ -196,6 +207,7 @@ function EmptyCell({ cellIndex }: { cellIndex: number }) {
196207
onOpenChange={setSelectorOpen}
197208
onNewTask={() => startCreating(cellIndex)}
198209
onNewTerminal={handleNewTerminal}
210+
onNewBrowser={browserEnabled ? handleNewBrowser : undefined}
199211
onBrainrot={brainrotMode ? handleBrainrot : undefined}
200212
>
201213
<button
@@ -329,6 +341,65 @@ function TerminalCell({
329341
);
330342
}
331343

344+
function hostnameOf(url: string): string | null {
345+
try {
346+
return new URL(url).hostname || null;
347+
} catch {
348+
return null;
349+
}
350+
}
351+
352+
function BrowserCell({ cellIndex, url }: { cellIndex: number; url: string }) {
353+
const clearCell = useCommandCenterStore((s) => s.clearCell);
354+
const updateBrowserCellUrl = useCommandCenterStore(
355+
(s) => s.updateBrowserCellUrl,
356+
);
357+
// Page title once loaded; before that (e.g. a just-restored cell) the
358+
// persisted url's hostname beats a bare "Browser".
359+
const [title, setTitle] = useState<string | null>(null);
360+
const label = title ?? hostnameOf(url) ?? "Browser";
361+
362+
const onUrlChange = useCallback(
363+
(next: string) => updateBrowserCellUrl(cellIndex, next),
364+
[updateBrowserCellUrl, cellIndex],
365+
);
366+
367+
return (
368+
<Flex direction="column" height="100%">
369+
<Flex
370+
align="center"
371+
gap="2"
372+
px="2"
373+
py="1"
374+
className="shrink-0 border-gray-6 border-b"
375+
>
376+
<Globe size={12} className="shrink-0 text-gray-10" />
377+
<Text
378+
className="min-w-0 flex-1 truncate font-medium text-[12px]"
379+
title={label}
380+
>
381+
{label}
382+
</Text>
383+
<button
384+
type="button"
385+
onClick={() => clearCell(cellIndex)}
386+
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"
387+
title="Remove from grid"
388+
>
389+
<X size={12} />
390+
</button>
391+
</Flex>
392+
<Flex direction="column" className="min-h-0 flex-1">
393+
<BrowserPanel
394+
url={url}
395+
onUrlChange={onUrlChange}
396+
onTitleChange={setTitle}
397+
/>
398+
</Flex>
399+
</Flex>
400+
);
401+
}
402+
332403
function PopulatedCell({
333404
cell,
334405
isActiveSession,
@@ -417,6 +488,11 @@ export function CommandCenterPanel({
417488
);
418489
}
419490

491+
// Empty-string url is a valid (blank) browser cell, so check against null.
492+
if (cell.browserUrl !== null) {
493+
return <BrowserCell cellIndex={cell.cellIndex} url={cell.browserUrl} />;
494+
}
495+
420496
if (!cell.taskId || !cell.task) {
421497
return <EmptyCell cellIndex={cell.cellIndex} />;
422498
}

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

Lines changed: 23 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Lightning, Plus, Terminal } from "@phosphor-icons/react";
1+
import { Globe, Lightning, Plus, Terminal } from "@phosphor-icons/react";
22
import { openTaskInput } from "@posthog/ui/router/useOpenTask";
33
import { Popover } from "@radix-ui/themes";
44
import { type ReactNode, useCallback } from "react";
@@ -12,6 +12,7 @@ interface TaskSelectorProps {
1212
onOpenChange: (open: boolean) => void;
1313
onNewTask?: () => void;
1414
onNewTerminal?: () => void;
15+
onNewBrowser?: () => void;
1516
onBrainrot?: () => void;
1617
children: ReactNode;
1718
}
@@ -22,6 +23,7 @@ export function TaskSelector({
2223
onOpenChange,
2324
onNewTask,
2425
onNewTerminal,
26+
onNewBrowser,
2527
onBrainrot,
2628
children,
2729
}: TaskSelectorProps) {
@@ -36,24 +38,13 @@ export function TaskSelector({
3638
[assignTask, cellIndex, onOpenChange],
3739
);
3840

39-
const handleNewTask = useCallback(() => {
40-
onOpenChange(false);
41-
if (onNewTask) {
42-
onNewTask();
43-
} else {
44-
openTaskInput();
45-
}
46-
}, [onOpenChange, onNewTask]);
47-
48-
const handleNewTerminal = useCallback(() => {
49-
onOpenChange(false);
50-
onNewTerminal?.();
51-
}, [onOpenChange, onNewTerminal]);
52-
53-
const handleBrainrot = useCallback(() => {
54-
onOpenChange(false);
55-
onBrainrot?.();
56-
}, [onOpenChange, onBrainrot]);
41+
const closeAnd = useCallback(
42+
(action: () => void) => () => {
43+
onOpenChange(false);
44+
action();
45+
},
46+
[onOpenChange],
47+
);
5748

5849
return (
5950
<Combobox.Root
@@ -95,7 +86,7 @@ export function TaskSelector({
9586
<button
9687
type="button"
9788
className="combobox-footer-button"
98-
onClick={handleNewTask}
89+
onClick={closeAnd(onNewTask ?? openTaskInput)}
9990
>
10091
<Plus size={11} weight="bold" />
10192
New task
@@ -104,17 +95,27 @@ export function TaskSelector({
10495
<button
10596
type="button"
10697
className="combobox-footer-button"
107-
onClick={handleNewTerminal}
98+
onClick={closeAnd(onNewTerminal)}
10899
>
109100
<Terminal size={11} weight="bold" />
110101
Terminal
111102
</button>
112103
)}
104+
{onNewBrowser && (
105+
<button
106+
type="button"
107+
className="combobox-footer-button"
108+
onClick={closeAnd(onNewBrowser)}
109+
>
110+
<Globe size={11} weight="bold" />
111+
Browser
112+
</button>
113+
)}
113114
{onBrainrot && (
114115
<button
115116
type="button"
116117
className="combobox-footer-button"
117-
onClick={handleBrainrot}
118+
onClick={closeAnd(onBrainrot)}
118119
>
119120
<Lightning size={11} weight="bold" />
120121
Brainrot

0 commit comments

Comments
 (0)