diff --git a/packages/core/src/command-center/cells.test.ts b/packages/core/src/command-center/cells.test.ts new file mode 100644 index 0000000000..28a8850a5c --- /dev/null +++ b/packages/core/src/command-center/cells.test.ts @@ -0,0 +1,39 @@ +import type { Task } from "@posthog/shared/domain-types"; +import { describe, expect, it } from "vitest"; +import { buildCommandCenterCells } from "./cells"; +import { BRAINROT_CELL } from "./grid"; + +const EMPTY_INPUT = { + taskById: new Map(), + sessionByTaskId: new Map(), + workspaces: undefined, +}; + +describe("buildCommandCenterCells", () => { + it.each([ + { + name: "brainrot sentinel cell has isBrainrot and no task", + input: BRAINROT_CELL, + expected: { + cellIndex: 0, + isBrainrot: true, + taskId: null, + task: undefined, + status: "idle", + }, + }, + { + name: "empty cell is a non-brainrot empty slot", + input: null, + expected: { isBrainrot: false, taskId: null }, + }, + { + name: "unknown task id is a non-brainrot cell", + input: "missing", + expected: { isBrainrot: false, taskId: "missing" }, + }, + ])("$name", ({ input, expected }) => { + const [cell] = buildCommandCenterCells([input], EMPTY_INPUT); + expect(cell).toMatchObject(expected); + }); +}); diff --git a/packages/core/src/command-center/cells.ts b/packages/core/src/command-center/cells.ts index 091a8dbaa5..26b8bb03e2 100644 --- a/packages/core/src/command-center/cells.ts +++ b/packages/core/src/command-center/cells.ts @@ -1,5 +1,6 @@ import type { AgentSession, WorkspaceMode } from "@posthog/shared"; import type { Task } from "@posthog/shared/domain-types"; +import { isBrainrotCell } from "./grid"; import { type CellStatus, deriveStatus, getRepoName } from "./status"; export interface CommandCenterCellData { @@ -10,6 +11,8 @@ export interface CommandCenterCellData { status: CellStatus; repoName: string | null; workspaceMode: WorkspaceMode | null; + // Brainrot: a looping video slot rather than a task. + isBrainrot: boolean; } export interface BuildCellsInput { @@ -23,7 +26,21 @@ export function buildCommandCenterCells( input: BuildCellsInput, ): CommandCenterCellData[] { const { taskById, sessionByTaskId, workspaces } = input; - return storeCells.map((taskId, cellIndex) => { + return storeCells.map((cellValue, cellIndex) => { + if (isBrainrotCell(cellValue)) { + return { + cellIndex, + taskId: null, + task: undefined, + session: undefined, + status: "idle" as const, + repoName: null, + workspaceMode: null, + isBrainrot: true, + }; + } + + const taskId = cellValue; const task = taskId ? taskById.get(taskId) : undefined; const session = taskId ? sessionByTaskId.get(taskId) : undefined; const status = taskId ? deriveStatus(session) : "idle"; @@ -38,6 +55,7 @@ export function buildCommandCenterCells( status, repoName, workspaceMode, + isBrainrot: false, }; }); } diff --git a/packages/core/src/command-center/grid.test.ts b/packages/core/src/command-center/grid.test.ts index f770342df1..4f705aa28d 100644 --- a/packages/core/src/command-center/grid.test.ts +++ b/packages/core/src/command-center/grid.test.ts @@ -1,9 +1,11 @@ import { describe, expect, it } from "vitest"; import { + BRAINROT_CELL, clampZoom, getCellCount, getCellSessionId, getGridDimensions, + isBrainrotCell, resizeCells, } from "./grid"; @@ -51,6 +53,16 @@ describe("clampZoom", () => { }); }); +describe("isBrainrotCell", () => { + it.each([ + { value: BRAINROT_CELL, expected: true }, + { value: "some-task-uuid", expected: false }, + { value: null, expected: false }, + ])("$value -> $expected", ({ value, expected }) => { + expect(isBrainrotCell(value)).toBe(expected); + }); +}); + 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 16e02b17d8..ef024e1164 100644 --- a/packages/core/src/command-center/grid.ts +++ b/packages/core/src/command-center/grid.ts @@ -9,6 +9,14 @@ export const ZOOM_MIN = 0.5; export const ZOOM_MAX = 1.5; export const ZOOM_STEP = 0.1; +// Reserved cell value for the Brainrot video slot instead of a task. Real task +// ids are uuids, so this never collides with one. +export const BRAINROT_CELL = "__brainrot__"; + +export function isBrainrotCell(value: string | null): boolean { + return value === BRAINROT_CELL; +} + export function getGridDimensions(preset: LayoutPreset): GridDimensions { const [cols, rows] = preset.split("x").map(Number); return { cols, rows }; diff --git a/packages/shared/src/analytics-events.ts b/packages/shared/src/analytics-events.ts index 1eaf429c35..10ba508f4a 100644 --- a/packages/shared/src/analytics-events.ts +++ b/packages/shared/src/analytics-events.ts @@ -215,6 +215,13 @@ export interface CommandMenuActionProperties { channel_id?: string; } +export interface BrainrotActivatedProperties { + /** Grid layout preset, e.g. "2x2". */ + layout: string; + /** Cells already holding a task when Brainrot was chosen. */ + filled_cells: number; +} + export interface SkillButtonTriggeredProperties { task_id: string; button_id: SkillButtonId; @@ -961,6 +968,7 @@ export const ANALYTICS_EVENTS = { COMMAND_MENU_OPENED: "Command menu opened", COMMAND_MENU_ACTION: "Command menu action", COMMAND_CENTER_VIEWED: "Command center viewed", + BRAINROT_ACTIVATED: "Brainrot activated", SKILL_BUTTON_TRIGGERED: "Skill button triggered", POSTHOG_WEB_OPENED: "PostHog web opened", @@ -1104,6 +1112,7 @@ export type EventPropertyMap = { [ANALYTICS_EVENTS.COMMAND_MENU_OPENED]: never; [ANALYTICS_EVENTS.COMMAND_MENU_ACTION]: CommandMenuActionProperties; [ANALYTICS_EVENTS.COMMAND_CENTER_VIEWED]: never; + [ANALYTICS_EVENTS.BRAINROT_ACTIVATED]: BrainrotActivatedProperties; [ANALYTICS_EVENTS.SKILL_BUTTON_TRIGGERED]: SkillButtonTriggeredProperties; [ANALYTICS_EVENTS.POSTHOG_WEB_OPENED]: never; diff --git a/packages/ui/src/assets.d.ts b/packages/ui/src/assets.d.ts index e821a9efaf..91a6714c6a 100644 --- a/packages/ui/src/assets.d.ts +++ b/packages/ui/src/assets.d.ts @@ -12,3 +12,8 @@ declare module "*.mp3" { const src: string; export default src; } + +declare module "*.mp4" { + const src: string; + export default src; +} diff --git a/packages/ui/src/assets/videos/brainrot-landscape.mp4 b/packages/ui/src/assets/videos/brainrot-landscape.mp4 new file mode 100644 index 0000000000..9207be4dcb Binary files /dev/null and b/packages/ui/src/assets/videos/brainrot-landscape.mp4 differ diff --git a/packages/ui/src/assets/videos/brainrot-portrait.mp4 b/packages/ui/src/assets/videos/brainrot-portrait.mp4 new file mode 100644 index 0000000000..bbde8be700 Binary files /dev/null and b/packages/ui/src/assets/videos/brainrot-portrait.mp4 differ diff --git a/packages/ui/src/features/command-center/commandCenterStore.test.ts b/packages/ui/src/features/command-center/commandCenterStore.test.ts index be077d7c51..74173d0a7f 100644 --- a/packages/ui/src/features/command-center/commandCenterStore.test.ts +++ b/packages/ui/src/features/command-center/commandCenterStore.test.ts @@ -1,3 +1,4 @@ +import { BRAINROT_CELL } from "@posthog/core/command-center/grid"; import { beforeEach, describe, expect, it, vi } from "vitest"; vi.mock("@posthog/ui/shell/rendererStorage", () => ({ @@ -87,6 +88,50 @@ describe("commandCenterStore", () => { }); }); + describe("setBrainrotCell", () => { + it("marks the target cell as brainrot without disturbing others", () => { + useCommandCenterStore.setState({ cells: ["t1", null, null, null] }); + useCommandCenterStore.getState().setBrainrotCell(2); + expect(useCommandCenterStore.getState().cells).toEqual([ + "t1", + null, + BRAINROT_CELL, + null, + ]); + }); + + it("does not dedupe, so multiple cells can be brainrot", () => { + useCommandCenterStore.getState().setBrainrotCell(0); + useCommandCenterStore.getState().setBrainrotCell(1); + expect(useCommandCenterStore.getState().cells).toEqual([ + BRAINROT_CELL, + BRAINROT_CELL, + null, + null, + ]); + }); + + it("focuses the cell, clears its creating state, and marks the grid curated", () => { + useCommandCenterStore.setState({ creatingCells: [3] }); + useCommandCenterStore.getState().setBrainrotCell(3); + const state = useCommandCenterStore.getState(); + expect(state.activeCellIndex).toBe(3); + expect(state.activeTaskId).toBeNull(); + expect(state.creatingCells).toEqual([]); + expect(state.hasAutofilled).toBe(true); + }); + + it("ignores out-of-range indices", () => { + useCommandCenterStore.getState().setBrainrotCell(9); + expect(useCommandCenterStore.getState().cells).toEqual([ + null, + null, + null, + null, + ]); + }); + }); + describe("hasAutofilled", () => { it("assigning a task marks the grid as curated", () => { useCommandCenterStore.getState().assignTask(0, "t1"); diff --git a/packages/ui/src/features/command-center/commandCenterStore.ts b/packages/ui/src/features/command-center/commandCenterStore.ts index adfad4701a..b478984814 100644 --- a/packages/ui/src/features/command-center/commandCenterStore.ts +++ b/packages/ui/src/features/command-center/commandCenterStore.ts @@ -1,4 +1,5 @@ import { + BRAINROT_CELL, clampZoom, getCellCount, type LayoutPreset, @@ -31,6 +32,7 @@ interface CommandCenterStoreActions { setActiveTask: (taskId: string | null) => void; setActiveCell: (cellIndex: number | null) => void; assignTask: (cellIndex: number, taskId: string) => void; + setBrainrotCell: (cellIndex: number) => void; autofillCells: (taskIds: string[]) => void; removeTask: (cellIndex: number) => void; removeTaskById: (taskId: string) => void; @@ -100,6 +102,20 @@ export const useCommandCenterStore = create()( }; }), + setBrainrotCell: (cellIndex) => + set((state) => { + if (cellIndex < 0 || cellIndex >= state.cells.length) return state; + const cells = [...state.cells]; + cells[cellIndex] = BRAINROT_CELL; + return { + cells, + activeTaskId: null, + activeCellIndex: cellIndex, + creatingCells: state.creatingCells.filter((i) => i !== cellIndex), + hasAutofilled: true, + }; + }), + 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 c397272f6d..15108a4543 100644 --- a/packages/ui/src/features/command-center/components/CommandCenterPanel.tsx +++ b/packages/ui/src/features/command-center/components/CommandCenterPanel.tsx @@ -4,14 +4,19 @@ import { Desktop, Folder, GitFork, + Lightning, Plus, X, } from "@phosphor-icons/react"; -import type { WorkspaceMode } from "@posthog/shared"; +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 { openTask } from "@posthog/ui/router/useOpenTask"; +import { track } from "@posthog/ui/shell/analytics"; import { Flex, Text } from "@radix-ui/themes"; import { useCallback, useEffect, useRef, useState } from "react"; +import brainrotLandscape from "../../../assets/videos/brainrot-landscape.mp4"; +import brainrotPortrait from "../../../assets/videos/brainrot-portrait.mp4"; import { useCloudPrUrl } from "../../git-interaction/useCloudPrUrl"; import { useDraftStore } from "../../message-editor/draftStore"; import { EmbeddedSessionView } from "../../sessions/components/EmbeddedSessionView"; @@ -23,6 +28,7 @@ import type { CellStatus, CommandCenterCellData, } from "../hooks/useCommandCenterData"; +import { useElementOrientation } from "../hooks/useElementOrientation"; import { CommandCenterPRButton } from "./CommandCenterPRButton"; import { TaskSelector } from "./TaskSelector"; @@ -103,12 +109,23 @@ function EmptyCell({ cellIndex }: { cellIndex: number }) { s.creatingCells.includes(cellIndex), ); const assignTask = useCommandCenterStore((s) => s.assignTask); + const setBrainrotCell = useCommandCenterStore((s) => s.setBrainrotCell); 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 clearDraft = useDraftStore((s) => s.actions.setDraft); const sessionId = getCellSessionId(cellIndex); + const handleBrainrot = useCallback(() => { + track(ANALYTICS_EVENTS.BRAINROT_ACTIVATED, { + layout, + filled_cells: cells.filter((c) => c && !isBrainrotCell(c)).length, + }); + setBrainrotCell(cellIndex); + }, [layout, cells, setBrainrotCell, cellIndex]); + const handleTaskCreated = useCallback( (task: Task) => { assignTask(cellIndex, task.id); @@ -167,6 +184,7 @@ function EmptyCell({ cellIndex }: { cellIndex: number }) { open={selectorOpen} onOpenChange={setSelectorOpen} onNewTask={() => startCreating(cellIndex)} + onBrainrot={handleBrainrot} > + +
+
+ + ); +} + function PopulatedCell({ cell, isActiveSession, @@ -263,6 +325,10 @@ export function CommandCenterPanel({ cell, isActiveSession, }: CommandCenterPanelProps) { + if (cell.isBrainrot) { + 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 14d1f7edd9..dc3b9f7eaf 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 { Plus } from "@phosphor-icons/react"; +import { Lightning, Plus } from "@phosphor-icons/react"; import { openTaskInput } from "@posthog/ui/router/useOpenTask"; import { Popover } from "@radix-ui/themes"; import { type ReactNode, useCallback } from "react"; @@ -11,6 +11,7 @@ interface TaskSelectorProps { open: boolean; onOpenChange: (open: boolean) => void; onNewTask?: () => void; + onBrainrot?: () => void; children: ReactNode; } @@ -19,6 +20,7 @@ export function TaskSelector({ open, onOpenChange, onNewTask, + onBrainrot, children, }: TaskSelectorProps) { const availableTasks = useAvailableTasks(); @@ -41,6 +43,11 @@ export function TaskSelector({ } }, [onOpenChange, onNewTask]); + const handleBrainrot = useCallback(() => { + onOpenChange(false); + onBrainrot?.(); + }, [onOpenChange, onBrainrot]); + return ( New task + {onBrainrot && ( + + )} )} diff --git a/packages/ui/src/features/command-center/hooks/useElementOrientation.ts b/packages/ui/src/features/command-center/hooks/useElementOrientation.ts new file mode 100644 index 0000000000..4d0017a774 --- /dev/null +++ b/packages/ui/src/features/command-center/hooks/useElementOrientation.ts @@ -0,0 +1,26 @@ +import { type RefObject, useEffect, useState } from "react"; + +export type Orientation = "landscape" | "portrait"; + +// Reports whether the element is wider than tall, so a matching-orientation +// video can be chosen as the grid resizes. +export function useElementOrientation( + ref: RefObject, +): Orientation { + const [orientation, setOrientation] = useState("landscape"); + + useEffect(() => { + const element = ref.current; + if (!element) return; + + const observer = new ResizeObserver((entries) => { + const box = entries[0]?.contentRect; + if (!box || box.width === 0 || box.height === 0) return; + setOrientation(box.width >= box.height ? "landscape" : "portrait"); + }); + observer.observe(element); + return () => observer.disconnect(); + }, [ref]); + + return orientation; +}