diff --git a/.dockerignore b/.dockerignore index f544be0ccfdd..012c0b973c8d 100644 --- a/.dockerignore +++ b/.dockerignore @@ -3,7 +3,6 @@ !.devcontainer !.kearc !bin -!common/alerting !common/hogvm !common/esbuilder !common/migration_utils diff --git a/products/desktop/packages/agent/src/server/agent-server.ts b/products/desktop/packages/agent/src/server/agent-server.ts index 0d80b1681fff..fcfe25872df5 100644 --- a/products/desktop/packages/agent/src/server/agent-server.ts +++ b/products/desktop/packages/agent/src/server/agent-server.ts @@ -1575,7 +1575,9 @@ export class AgentServer { return null; }), ]); - this.taskRepositories = preTask?.repository ? [preTask.repository] : []; + this.taskRepositories = + preTask?.repositories ?? + (preTask?.repository ? [preTask.repository] : []); this.prewarmedRun = (preTaskRun?.state as Record | undefined)?.prewarmed === diff --git a/products/desktop/packages/api-client/src/task-normalization.test.ts b/products/desktop/packages/api-client/src/task-normalization.test.ts index 9c9739a51fc1..7c041052534a 100644 --- a/products/desktop/packages/api-client/src/task-normalization.test.ts +++ b/products/desktop/packages/api-client/src/task-normalization.test.ts @@ -112,4 +112,25 @@ describe("task response normalization", () => { }, }); }); + + // Multi-repo handoff and cloud-run instructions read task.repositories, so + // dropping this fallback silently degrades every consumer to single-repo. + it.each([ + [ + "keeps the API's repositories list", + { repository: "posthog/posthog", repositories: ["a/b", "c/d"] }, + ["a/b", "c/d"], + ], + [ + "wraps a lone repository", + { repository: "posthog/posthog" }, + ["posthog/posthog"], + ], + ["defaults to empty", {}, []], + ])("populates repositories (%s)", (_label, dto, expected) => { + expect( + normalizeTaskResponse({ id: "task-1", ...dto }, { teamId: 1 }) + .repositories, + ).toEqual(expected); + }); }); diff --git a/products/desktop/packages/api-client/src/task-normalization.ts b/products/desktop/packages/api-client/src/task-normalization.ts index 164c77701b94..6d1f887f02bf 100644 --- a/products/desktop/packages/api-client/src/task-normalization.ts +++ b/products/desktop/packages/api-client/src/task-normalization.ts @@ -190,6 +190,7 @@ export function normalizeTaskResponse( ...(dto.created_by === undefined ? {} : { created_by: dto.created_by }), origin_product: dto.origin_product ?? "", ...(dto.repository === undefined ? {} : { repository: dto.repository }), + repositories: dto.repositories ?? (dto.repository ? [dto.repository] : []), ...(dto.github_integration === undefined ? {} : { github_integration: dto.github_integration }), diff --git a/products/desktop/packages/core/src/sessions/localHandoffService.test.ts b/products/desktop/packages/core/src/sessions/localHandoffService.test.ts index 50e1a3846455..c36248a20232 100644 --- a/products/desktop/packages/core/src/sessions/localHandoffService.test.ts +++ b/products/desktop/packages/core/src/sessions/localHandoffService.test.ts @@ -194,7 +194,43 @@ describe("LocalHandoffService.start", () => { ); }); - // A task's repository is team-writable via the API, so an unsafe entry must + it("reuses local repositories and clones missing ones before handoff", async () => { + const deps = makeDeps(); + const multiRepoTask = { + repositories: ["posthog/posthog", "posthog/posthog-js"], + } as Task; + deps.host.getRepositoryByRemoteUrl = vi + .fn() + .mockResolvedValueOnce({ path: "/repos/posthog" }) + .mockResolvedValueOnce(null); + deps.sessionService.preflightToLocal.mockResolvedValue({ + canHandoff: true, + }); + + await deps.service.start("task-1", multiRepoTask); + + expect(deps.host.cloneRepository).toHaveBeenCalledWith( + expect.objectContaining({ + repoUrl: "https://github.com/posthog/posthog-js.git", + targetPath: "/worktrees/linked-repositories/posthog/posthog-js", + }), + ); + expect(deps.host.addAdditionalDirectory).toHaveBeenCalledWith({ + taskId: "task-1", + path: "/worktrees/linked-repositories/posthog/posthog-js", + }); + expect(deps.sessionService.handoffToLocal).toHaveBeenCalledWith( + "task-1", + "/repos/posthog", + { + "posthog/posthog": "/repos/posthog", + "posthog/posthog-js": + "/worktrees/linked-repositories/posthog/posthog-js", + }, + ); + }); + + // A task's repositories are team-writable via the API, so an unsafe entry must // never reach `git clone` (RCE via git's remote-ext transport) or escape the // clone root through path traversal. The safe repo alongside it still clones. it.each([ @@ -210,12 +246,44 @@ describe("LocalHandoffService.start", () => { canHandoff: true, }); - await deps.service.start("task-1", { repository: malicious } as Task); + await deps.service.start("task-1", { + repositories: ["posthog/posthog", malicious], + } as Task); - expect(deps.host.cloneRepository).not.toHaveBeenCalled(); + // Only the safe repo is cloned, and always through an explicit https URL. + expect(deps.host.cloneRepository).toHaveBeenCalledTimes(1); + expect(deps.host.cloneRepository).toHaveBeenCalledWith( + expect.objectContaining({ + repoUrl: "https://github.com/posthog/posthog.git", + }), + ); expect(deps.notifier.warn).toHaveBeenCalledWith( expect.stringContaining(malicious), ); - expect(deps.sessionService.handoffToLocal).not.toHaveBeenCalled(); + const [, , repositoryPaths] = + deps.sessionService.handoffToLocal.mock.calls[0]; + expect(repositoryPaths).toEqual({ + "posthog/posthog": "/worktrees/linked-repositories/posthog/posthog", + }); + }); + + it("de-duplicates case-variant repository aliases before cloning", async () => { + const deps = makeDeps(); + deps.host.getRepositoryByRemoteUrl = vi.fn().mockResolvedValue(null); + deps.sessionService.preflightToLocal.mockResolvedValue({ + canHandoff: true, + }); + + await deps.service.start("task-1", { + repositories: ["PostHog/PostHog", "posthog/posthog"], + } as Task); + + // Both collapse to one target, so the racing double-clone can't happen. + expect(deps.host.cloneRepository).toHaveBeenCalledTimes(1); + const [, , repositoryPaths] = + deps.sessionService.handoffToLocal.mock.calls[0]; + expect(repositoryPaths).toEqual({ + "posthog/posthog": "/worktrees/linked-repositories/posthog/posthog", + }); }); }); diff --git a/products/desktop/packages/core/src/sessions/localHandoffService.ts b/products/desktop/packages/core/src/sessions/localHandoffService.ts index 6526e8de4160..d9c59cffcf85 100644 --- a/products/desktop/packages/core/src/sessions/localHandoffService.ts +++ b/products/desktop/packages/core/src/sessions/localHandoffService.ts @@ -132,7 +132,11 @@ export class LocalHandoffService { public async start(taskId: string, task: Task): Promise { try { - const repositories = task.repository ? [task.repository] : []; + const repositories = task.repositories?.length + ? task.repositories + : task.repository + ? [task.repository] + : []; const repositoryPaths = await this.resolveRepositoryPaths(repositories); const paths = Object.values(repositoryPaths); const targetPath = diff --git a/products/desktop/packages/shared/src/domain-types.ts b/products/desktop/packages/shared/src/domain-types.ts index 774f12ab4934..648357416236 100644 --- a/products/desktop/packages/shared/src/domain-types.ts +++ b/products/desktop/packages/shared/src/domain-types.ts @@ -81,6 +81,7 @@ export interface Task { created_by?: UserBasic | null; origin_product: string; repository?: string | null; // Format: "organization/repository" (e.g., "posthog/posthog-js") + repositories?: string[]; github_integration?: number | null; github_user_integration?: string | null; json_schema?: Record | null; diff --git a/products/desktop/packages/ui/src/features/canvas/components/WebsiteChannelArtifacts.tsx b/products/desktop/packages/ui/src/features/canvas/components/WebsiteChannelArtifacts.tsx index e899ba7cbdf6..b2a3af46005c 100644 --- a/products/desktop/packages/ui/src/features/canvas/components/WebsiteChannelArtifacts.tsx +++ b/products/desktop/packages/ui/src/features/canvas/components/WebsiteChannelArtifacts.tsx @@ -1,23 +1,51 @@ -import { CaretRightIcon } from "@phosphor-icons/react"; +import { CaretRightIcon, FilesIcon, TrashIcon } from "@phosphor-icons/react"; import type { ChannelTaskRecord } from "@posthog/core/canvas/channelTaskSchemas"; import type { DashboardRecord } from "@posthog/core/canvas/dashboardSchemas"; +import { + Badge, + Card, + CardContent, + cn, + Empty, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, + Text, +} from "@posthog/quill"; import { formatRelativeTimeShort } from "@posthog/shared"; import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; import { useArchivedTaskIds } from "@posthog/ui/features/archive/useArchivedTaskIds"; +import { ArtifactsViewToggle } from "@posthog/ui/features/canvas/components/ArtifactsViewToggle"; import { ChannelHeader } from "@posthog/ui/features/canvas/components/ChannelHeader"; import { iconForTemplate } from "@posthog/ui/features/canvas/components/canvasTemplateIcon"; import { useChannelsLayout } from "@posthog/ui/features/canvas/hooks/useChannelsLayout"; import { useChannelTasks } from "@posthog/ui/features/canvas/hooks/useChannelTasks"; import { useDashboards } from "@posthog/ui/features/canvas/hooks/useDashboards"; +import { useArtifactsViewStore } from "@posthog/ui/features/canvas/stores/artifactsViewStore"; +import { useIsCanvasPendingDelete } from "@posthog/ui/features/canvas/stores/pendingCanvasDeleteStore"; +import { masonryPreviewHeight } from "@posthog/ui/features/canvas/utils/masonryPreviewHeight"; import { usePrArtifact } from "@posthog/ui/features/git-interaction/usePrArtifact"; import { useTasks } from "@posthog/ui/features/tasks/useTasks"; import { useSetHeaderContent } from "@posthog/ui/hooks/useSetHeaderContent"; +import { + PageHeader, + PageHeaderActions, + PageHeaderChip, + PageHeaderDescription, + PageHeaderHeading, + PageHeaderTitle, + PageHeaderTitleRow, +} from "@posthog/ui/primitives/PageHeader"; import { track } from "@posthog/ui/shell/analytics"; import { openExternalUrl } from "@posthog/ui/shell/openExternal"; -import { Text } from "@radix-ui/themes"; import { useNavigate } from "@tanstack/react-router"; import { type ReactNode, useCallback, useEffect, useMemo } from "react"; +// Uniform media height for the grid: cards line up row to row, and a PR tile +// (which has nothing to preview) fills the same band as a canvas thumbnail. +const GRID_PREVIEW_HEIGHT = 176; + // Artifacts are the durable outputs of a channel's work. Canvases for now; PRs // are surfaced from each filed task's latest run output. More kinds (reports, // files, …) slot into this union later. @@ -40,10 +68,12 @@ type ArtifactItem = // A channel's artifacts: canvases and the pull requests produced by its tasks, // most recent first. Sibling of the History tab, but scoped to outputs rather -// than the full activity stream. +// than the full activity stream. The view toggle switches between a dense row +// list and card layouts. export function WebsiteChannelArtifacts({ channelId }: { channelId: string }) { const spacesLayout = useChannelsLayout(); const navigate = useNavigate(); + const view = useArtifactsViewStore((s) => s.view); useEffect(() => { track(ANALYTICS_EVENTS.CHANNEL_ACTION, { @@ -54,7 +84,10 @@ export function WebsiteChannelArtifacts({ channelId }: { channelId: string }) { }, [channelId]); useSetHeaderContent( - useMemo(() => , [channelId]), + useMemo( + () => , + [channelId], + ), ); const { dashboards } = useDashboards(channelId); @@ -124,50 +157,211 @@ export function WebsiteChannelArtifacts({ channelId }: { channelId: string }) { ); return ( -
-
- {items.length === 0 ? ( -
- - No artifacts yet - - +
+ {/* Full-bleed header over a container-width body — the Inbox shape. Ships + behind the spaces layout like every other space page header; off, + the view switcher rides above the list instead. */} + {spacesLayout ? ( + + + + Artifacts + {items.length > 0 && ( + }> + {items.length} item{items.length === 1 ? "" : "s"} + + )} + + + + + Canvases and pull requests from this{" "} - {spacesLayout ? "space's" : "channel's"} tasks show up here. - -
- ) : ( -
- {items.map((item) => - item.kind === "canvas" ? ( - + + + ) : null} + +
+ {/* Full width, flush with the page header above it — every layout + here (rows and cards alike) is a scannable list, not prose, so a + measure cap just strands whitespace on wide windows. */} +
+ {!spacesLayout && ( +
+ + {items.length === 0 + ? "Artifacts" + : `${items.length} artifact${items.length === 1 ? "" : "s"}`} + + +
+ )} + {items.length === 0 ? ( + + + + + + No artifacts yet + + Canvases and pull requests from this{" "} + {spacesLayout ? "space's" : "channel's"} tasks show up here. + + + + ) : view === "list" ? ( +
+ {items.map((item) => ( + openCanvas(item.dashboardId)} + item={item} + onOpenCanvas={openCanvas} + onOpenPr={openPr} /> - ) : ( - + ) : view === "grid" ? ( + // items-stretch + a full-height card: a PR tile (no preview to + // show) matches the canvas cards in its row instead of ending + // short. +
+ {items.map((item) => ( + - ), - )} -
- )} + ))} +
+ ) : ( + // CSS columns rather than a JS masonry: cards are self-contained and + // never reflow into each other, so break-inside-avoid is enough. The + // trade-off is column-major order — newest runs down column one, not + // across the row — which is fine for a browse-y wall of cards. +
+ {items.map((item) => ( +
+ +
+ ))} +
+ )} +
); } +function ArtifactListItem({ + item, + onOpenCanvas, + onOpenPr, +}: { + item: ArtifactItem; + onOpenCanvas: (dashboardId: string) => void; + onOpenPr: (safeUrl: string) => void; +}) { + return item.kind === "canvas" ? ( + + ) : ( + + ); +} + +function ArtifactCard({ + item, + previewHeight, + fillHeight, + onOpenCanvas, + onOpenPr, +}: { + item: ArtifactItem; + previewHeight: number; + /** Grid only: stretch to the tallest card in the row. */ + fillHeight?: boolean; + onOpenCanvas: (dashboardId: string) => void; + onOpenPr: (safeUrl: string) => void; +}) { + return item.kind === "canvas" ? ( + + ) : ( + + ); +} + +// A canvas artifact row. While the canvas is inside its delete-undo window the +// row stays put — its template icon becomes a pulsing trash can and the row +// stops opening — so undoing puts it back exactly where it was. +function CanvasArtifactRow({ + dashboardId, + templateId, + title, + ts, + onClick, +}: { + dashboardId: string; + templateId: string; + title: string; + ts: number; + onClick: (dashboardId: string) => void; +}) { + const deleting = useIsCanvasPendingDelete(dashboardId); + + return ( + + ) : ( + iconForTemplate(templateId, { size: 15, className: "text-violet-9" }) + ) + } + title={title} + subtitle={ + deleting ? "Deleting…" : `Canvas · ${formatRelativeTimeShort(ts)}` + } + onClick={deleting ? undefined : () => onClick(dashboardId)} + /> + ); +} + // A PR artifact row. The PR's lifecycle state (open / draft / merged / closed) // comes from usePrArtifact, which also gates the URL — PR links come from run // output, so a row must not fetch from whatever host that names. @@ -225,7 +419,7 @@ function ArtifactRow({ type="button" onClick={onClick} disabled={!onClick} - className="group flex w-full items-center gap-3 rounded-lg px-2.5 py-2 text-left transition-colors enabled:hover:bg-gray-3" + className="group flex w-full items-center gap-3 rounded-lg px-2.5 py-2 text-left transition-colors enabled:hover:bg-fill-hover" > {title} - + {subtitle} ); } + +// The card form of a canvas artifact. Canvas records no longer carry source +// code (the rendered output lives behind the build lifecycle), so the media +// slot is a stable placeholder frame until per-card artifact previews are +// wired up, mirroring WebsiteDashboardsIndex. Same delete-undo behaviour as +// the row: the card stays in place, dimmed, until the undo window closes. +function CanvasArtifactCard({ + dashboardId, + templateId, + title, + ts, + previewHeight, + fillHeight, + onClick, +}: { + dashboardId: string; + templateId: string; + title: string; + ts: number; + previewHeight: number; + fillHeight?: boolean; + onClick: (dashboardId: string) => void; +}) { + const deleting = useIsCanvasPendingDelete(dashboardId); + + return ( + +
+
+ + Canvas preview + +
+
+ {deleting && ( +
+ + + Deleting… + +
+ )} + + } + icon={iconForTemplate(templateId, { + size: 14, + className: "text-violet-9", + })} + title={title} + badge="Canvas" + subtitle={ + deleting ? "Deleting…" : `Updated ${formatRelativeTimeShort(ts)}` + } + dimmed={deleting} + fillHeight={fillHeight} + onClick={deleting ? undefined : () => onClick(dashboardId)} + /> + ); +} + +// The card form of a PR artifact. A PR has nothing to preview, so its media +// slot is a short tinted band carrying the lifecycle icon — which also keeps PR +// cards visibly shorter than canvas cards in the masonry layout. +function PrArtifactCard({ + title, + prUrl, + ts, + mediaHeight, + fillHeight, + onClick, +}: { + title: string; + prUrl: string; + ts: number; + /** Grid only: match the canvas cards' band instead of a short strip. */ + mediaHeight?: number; + fillHeight?: boolean; + onClick: (safeUrl: string) => void; +}) { + const { + safeUrl, + title: prTitle, + stateLabel, + Icon, + iconColor, + accentColor, + } = usePrArtifact(prUrl); + + const subtitle = [prTitle, formatRelativeTimeShort(ts)] + .filter(Boolean) + .join(" · "); + + return ( + + +
+ } + icon={} + title={title} + badge={stateLabel || "Pull request"} + subtitle={subtitle} + fillHeight={fillHeight} + onClick={safeUrl ? () => onClick(safeUrl) : undefined} + /> + ); +} + +function ArtifactCardShell({ + media, + icon, + title, + badge, + subtitle, + dimmed, + fillHeight, + onClick, +}: { + media: ReactNode; + icon: ReactNode; + title: string; + badge: string; + subtitle: string; + dimmed?: boolean; + /** Grid only: fill the row so neighbouring cards end at the same line. */ + fillHeight?: boolean; + /** Absent for a card with nowhere safe to go — a non-github PR link. */ + onClick?: () => void; +}) { + return ( + + ); +} diff --git a/products/desktop/packages/ui/src/features/canvas/components/WebsiteContext.tsx b/products/desktop/packages/ui/src/features/canvas/components/WebsiteContext.tsx index c9def3a7ffae..5c8653f5766f 100644 --- a/products/desktop/packages/ui/src/features/canvas/components/WebsiteContext.tsx +++ b/products/desktop/packages/ui/src/features/canvas/components/WebsiteContext.tsx @@ -321,22 +321,41 @@ export function WebsiteContext({ channelId }: WebsiteContextProps) { ) : null} - - - {selectedVersion ? ( - - - Viewing v{selectedVersion.version} metadata. Past content is not - fetched today — switch to "Latest" to read or edit current - content. - - - ) : mode === "rendered" ? ( - hasInstructions ? ( + {!selectedVersion && mode === "edit" ? ( + // The editor sits outside the scroll area so it grows with the window + // instead of scrolling the page around a fixed-height box. + +