diff --git a/apps/code/src/renderer/platform-adapters/auth-side-effects.ts b/apps/code/src/renderer/platform-adapters/auth-side-effects.ts index 2a567d93c5..0e87804a26 100644 --- a/apps/code/src/renderer/platform-adapters/auth-side-effects.ts +++ b/apps/code/src/renderer/platform-adapters/auth-side-effects.ts @@ -6,6 +6,7 @@ import { } from "@posthog/ui/features/auth/authQueries"; import { useAuthUiStateStore } from "@posthog/ui/features/auth/authUiStateStore"; import type { IAuthSideEffects } from "@posthog/ui/features/auth/identifiers"; +import { resetCurrentChannel } from "@posthog/ui/features/canvas/stores/currentChannelStore"; import { useOnboardingStore } from "@posthog/ui/features/onboarding/onboardingStore"; import { resetSessionService } from "@posthog/ui/features/sessions/sessionServiceHost"; import { openTaskInput } from "@posthog/ui/router/useOpenTask"; @@ -30,6 +31,9 @@ export class RendererAuthSideEffects implements IAuthSideEffects { onProjectSelected(): void { clearAuthScopedQueries(); void refreshAuthStateQuery(); + // Before openTaskInput, which files a new task into the scoped channel — + // a channel id from the project we just left. + resetCurrentChannel(); openTaskInput(); } @@ -40,6 +44,7 @@ export class RendererAuthSideEffects implements IAuthSideEffects { if (previousRegion) { useAuthUiStateStore.getState().setStaleRegion(previousRegion); } + resetCurrentChannel(); openTaskInput(); useOnboardingStore.getState().resetSelections(); } diff --git a/apps/web/src/web-auth-side-effects.ts b/apps/web/src/web-auth-side-effects.ts index a22b6081ce..03fed679b4 100644 --- a/apps/web/src/web-auth-side-effects.ts +++ b/apps/web/src/web-auth-side-effects.ts @@ -5,6 +5,7 @@ import { } from "@posthog/ui/features/auth/authQueries"; import { useAuthUiStateStore } from "@posthog/ui/features/auth/authUiStateStore"; import type { IAuthSideEffects } from "@posthog/ui/features/auth/identifiers"; +import { resetCurrentChannel } from "@posthog/ui/features/canvas/stores/currentChannelStore"; import { useOnboardingStore } from "@posthog/ui/features/onboarding/onboardingStore"; import { openTaskInput } from "@posthog/ui/router/useOpenTask"; import { injectable } from "inversify"; @@ -24,6 +25,9 @@ export class WebAuthSideEffects implements IAuthSideEffects { onProjectSelected(): void { clearAuthScopedQueries(); void refreshAuthStateQuery(); + // Before openTaskInput, which files a new task into the scoped channel — + // a channel id from the project we just left. + resetCurrentChannel(); openTaskInput(); } @@ -32,6 +36,7 @@ export class WebAuthSideEffects implements IAuthSideEffects { if (previousRegion) { useAuthUiStateStore.getState().setStaleRegion(previousRegion); } + resetCurrentChannel(); openTaskInput(); useOnboardingStore.getState().resetSelections(); } diff --git a/packages/core/src/canvas/channelItems.test.ts b/packages/core/src/canvas/channelItems.test.ts new file mode 100644 index 0000000000..2cc89a1a5b --- /dev/null +++ b/packages/core/src/canvas/channelItems.test.ts @@ -0,0 +1,211 @@ +import type { Task, UserBasic } from "@posthog/shared/domain-types"; +import { describe, expect, it } from "vitest"; +import { + buildChannelItems, + type ChannelItemModel, + filterChannelItems, +} from "./channelItems"; +import type { DashboardSummary } from "./dashboardSchemas"; + +const ME: UserBasic = { + id: 1, + uuid: "me-uuid", + distinct_id: "me", + first_name: "Ada", + last_name: "Lovelace", + email: "ada@posthog.com", +}; + +const OTHER: UserBasic = { + id: 2, + uuid: "other-uuid", + distinct_id: "other", + first_name: "Grace", + last_name: "Hopper", + email: "grace@posthog.com", +}; + +function canvas(over: Partial = {}): DashboardSummary { + return { + id: "d1", + channelId: "c1", + name: "Canvas", + templateId: "freeform", + createdAt: 0, + updatedAt: 1_000, + ...over, + } as DashboardSummary; +} + +function task(over: Partial = {}): Task { + return { + id: "t1", + title: "Task", + updated_at: new Date(2_000).toISOString(), + created_by: ME, + ...over, + } as Task; +} + +const NONE: ReadonlySet = new Set(); + +function build(options: Partial[0]> = {}) { + return buildChannelItems({ + dashboards: [], + feedTasks: [], + archivedTaskIds: NONE, + pinnedTaskIds: NONE, + ownedBy: null, + ...options, + }); +} + +describe("buildChannelItems", () => { + it("merges canvases and tasks newest-first", () => { + const items = build({ + dashboards: [canvas({ id: "old", updatedAt: 1_000 })], + feedTasks: [ + task({ id: "new", updated_at: new Date(5_000).toISOString() }), + ], + }); + expect(items.map((i) => i.key)).toEqual(["task:new", "canvas:old"]); + }); + + it("drops archived tasks but keeps canvases", () => { + const items = build({ + dashboards: [canvas()], + feedTasks: [task({ id: "gone" })], + archivedTaskIds: new Set(["gone"]), + }); + expect(items.map((i) => i.kind)).toEqual(["canvas"]); + }); + + it("marks pinned state from each source's own signal", () => { + const items = build({ + dashboards: [canvas({ id: "pinned-canvas", pinnedAt: 42 })], + feedTasks: [task({ id: "pinned-task" })], + pinnedTaskIds: new Set(["pinned-task"]), + }); + expect(items.every((i) => i.pinned)).toBe(true); + }); + + it("falls back to a placeholder title for untitled tasks", () => { + const [item] = build({ + feedTasks: [task({ title: "" })], + }); + expect(item.title).toBe("Untitled task"); + }); + + it("treats an unparseable updated_at as epoch rather than NaN", () => { + const [item] = build({ + feedTasks: [task({ updated_at: "not a date" })], + }); + expect(item.ts).toBe(0); + }); + + it("returns everything when the owner is unknown", () => { + const items = build({ + dashboards: [canvas({ createdBy: "Grace Hopper" })], + feedTasks: [task({ created_by: OTHER })], + }); + expect(items).toHaveLength(2); + }); + + it("filters to the owner for the personal channel", () => { + const items = build({ + dashboards: [ + canvas({ id: "mine", createdBy: "Ada Lovelace" }), + canvas({ id: "theirs", createdBy: "Grace Hopper" }), + ], + feedTasks: [ + task({ id: "mine-task", created_by: ME }), + task({ id: "their-task", created_by: OTHER }), + ], + ownedBy: { uuid: ME.uuid, name: "Ada Lovelace" }, + }); + expect(items.map((i) => i.id).sort()).toEqual(["mine", "mine-task"]); + }); + + it("keeps items whose author is unknown", () => { + const items = build({ + dashboards: [canvas({ id: "orphan", createdBy: undefined })], + feedTasks: [task({ id: "orphan-task", created_by: null })], + ownedBy: { uuid: ME.uuid, name: "Ada Lovelace" }, + }); + expect(items).toHaveLength(2); + }); +}); + +function model(over: Partial = {}): ChannelItemModel { + return { + key: "task:t1", + kind: "task", + id: "t1", + title: "Ship the thing", + ts: 0, + pinned: false, + rawStatus: null, + authorUser: ME, + authorName: null, + templateId: null, + ...over, + }; +} + +describe("filterChannelItems", () => { + const me = { uuid: ME.uuid, name: "Ada Lovelace" }; + + it("matches titles case-insensitively", () => { + const items = [model({ title: "Ship IT" }), model({ title: "Other" })]; + const result = filterChannelItems(items, { + query: " ship ", + createdBy: "anyone", + status: null, + me, + }); + expect(result.map((i) => i.title)).toEqual(["Ship IT"]); + }); + + it.each([ + ["me", ["mine"]], + ["others", ["theirs"]], + ["anyone", ["mine", "theirs"]], + ] as const)("filters createdBy=%s", (createdBy, expected) => { + const items = [ + model({ id: "mine", authorUser: ME }), + model({ id: "theirs", authorUser: OTHER }), + ]; + const result = filterChannelItems(items, { + query: "", + createdBy, + status: null, + me, + }); + expect(result.map((i) => i.id)).toEqual(expected); + }); + + it("filters by run status, including not_started", () => { + const items = [ + model({ id: "fresh", rawStatus: "not_started" }), + model({ id: "done", rawStatus: "completed" }), + ]; + const result = filterChannelItems(items, { + query: "", + createdBy: "anyone", + status: "not_started", + me, + }); + expect(result.map((i) => i.id)).toEqual(["fresh"]); + }); + + it("excludes canvases when a run status is selected", () => { + const items = [model({ kind: "canvas", rawStatus: null })]; + const result = filterChannelItems(items, { + query: "", + createdBy: "anyone", + status: "completed", + me, + }); + expect(result).toEqual([]); + }); +}); diff --git a/packages/core/src/canvas/channelItems.ts b/packages/core/src/canvas/channelItems.ts new file mode 100644 index 0000000000..e67a2d0d21 --- /dev/null +++ b/packages/core/src/canvas/channelItems.ts @@ -0,0 +1,115 @@ +import type { + Task, + TaskRunStatus, + UserBasic, +} from "@posthog/shared/domain-types"; +import type { DashboardSummary } from "./dashboardSchemas"; + +export interface ChannelItemModel { + key: string; + kind: "task" | "canvas"; + id: string; + title: string; + ts: number; + pinned: boolean; + rawStatus: TaskRunStatus | null; + authorUser: UserBasic | null; + authorName: string | null; + templateId: string | null; +} + +export interface ChannelItemOwner { + uuid: string | null; + name: string | null; +} + +function isOwnedBy( + item: Pick, + owner: ChannelItemOwner, +): boolean { + if (item.authorUser) return item.authorUser.uuid === owner.uuid; + if (item.authorName && owner.name) return item.authorName === owner.name; + return true; +} + +export function buildChannelItems({ + dashboards, + feedTasks, + archivedTaskIds, + pinnedTaskIds, + ownedBy, +}: { + dashboards: readonly DashboardSummary[]; + feedTasks: readonly Task[]; + archivedTaskIds: ReadonlySet; + pinnedTaskIds: ReadonlySet; + ownedBy: ChannelItemOwner | null; +}): ChannelItemModel[] { + const canvasItems: ChannelItemModel[] = dashboards.map((d) => ({ + key: `canvas:${d.id}`, + kind: "canvas", + id: d.id, + title: d.name, + ts: d.updatedAt, + pinned: d.pinnedAt != null, + rawStatus: null, + authorUser: null, + authorName: d.createdBy ?? null, + templateId: d.templateId, + })); + + const taskItems: ChannelItemModel[] = feedTasks.flatMap((task) => + archivedTaskIds.has(task.id) + ? [] + : [ + { + key: `task:${task.id}`, + kind: "task" as const, + id: task.id, + title: task.title || "Untitled task", + ts: Date.parse(task.updated_at) || 0, + pinned: pinnedTaskIds.has(task.id), + rawStatus: task.latest_run?.status ?? null, + authorUser: task.created_by ?? null, + authorName: null, + templateId: null, + }, + ], + ); + + const all = [...canvasItems, ...taskItems].sort((a, b) => b.ts - a.ts); + return ownedBy ? all.filter((item) => isOwnedBy(item, ownedBy)) : all; +} + +export type CreatedByFilter = "anyone" | "me" | "others"; + +export function filterChannelItems( + items: readonly ChannelItemModel[], + { + query, + createdBy, + status, + me, + }: { + query: string; + createdBy: CreatedByFilter; + status: TaskRunStatus | null; + me: ChannelItemOwner; + }, +): ChannelItemModel[] { + const normalizedQuery = query.trim().toLowerCase(); + return items.filter((item) => { + if ( + normalizedQuery && + !item.title.toLowerCase().includes(normalizedQuery) + ) { + return false; + } + if (createdBy !== "anyone") { + const mine = isOwnedBy(item, me); + if (createdBy === "me" ? !mine : mine) return false; + } + if (status && item.rawStatus !== status) return false; + return true; + }); +} diff --git a/packages/core/src/canvas/runArtifactSchemas.test.ts b/packages/core/src/canvas/runArtifactSchemas.test.ts new file mode 100644 index 0000000000..e2db2fe283 --- /dev/null +++ b/packages/core/src/canvas/runArtifactSchemas.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { parseRunPlans } from "./runArtifactSchemas"; + +describe("parseRunPlans", () => { + it.each([ + { name: "undefined", raw: undefined }, + { name: "null", raw: null }, + { name: "an object", raw: { artifacts: [] } }, + { name: "a string", raw: "plan" }, + ])("reads $name as no artifacts", ({ raw }) => { + expect(parseRunPlans(raw)).toEqual([]); + }); + + it("keeps only plan artifacts", () => { + const plans = parseRunPlans([ + { id: "a", type: "plan", name: "Plan A" }, + { id: "b", type: "upload", name: "internal blob" }, + ]); + expect(plans).toEqual([{ id: "a", type: "plan", name: "Plan A" }]); + }); + + // One bad entry shouldn't take the Artifacts tab down with it. + it("drops entries that don't match the shape", () => { + const plans = parseRunPlans([ + { id: 42, type: "plan" }, + null, + "not an object", + { type: "plan", storage_path: "runs/1/plan.md" }, + ]); + expect(plans).toEqual([{ type: "plan", storage_path: "runs/1/plan.md" }]); + }); + + it("ignores an artifact with no type", () => { + expect(parseRunPlans([{ id: "a", name: "mystery" }])).toEqual([]); + }); +}); diff --git a/packages/core/src/canvas/runArtifactSchemas.ts b/packages/core/src/canvas/runArtifactSchemas.ts new file mode 100644 index 0000000000..8d95e86e32 --- /dev/null +++ b/packages/core/src/canvas/runArtifactSchemas.ts @@ -0,0 +1,17 @@ +import { z } from "zod"; + +export const runArtifactSchema = z.object({ + id: z.string().optional(), + name: z.string().optional(), + type: z.string().optional(), + storage_path: z.string().optional(), +}); +export type RunArtifact = z.infer; + +export function parseRunPlans(raw: unknown): RunArtifact[] { + if (!Array.isArray(raw)) return []; + return raw.flatMap((entry) => { + const parsed = runArtifactSchema.safeParse(entry); + return parsed.success && parsed.data.type === "plan" ? [parsed.data] : []; + }); +} diff --git a/packages/core/src/canvas/runStatus.test.ts b/packages/core/src/canvas/runStatus.test.ts new file mode 100644 index 0000000000..7c19dd9715 --- /dev/null +++ b/packages/core/src/canvas/runStatus.test.ts @@ -0,0 +1,75 @@ +import type { TaskRunStatus } from "@posthog/shared/domain-types"; +import { describe, expect, it } from "vitest"; +import { + RUN_STATUS_FILTER_OPTIONS, + RUN_STATUS_LABELS, + runStatusLabel, + runStatusVariant, +} from "./runStatus"; + +const ALL_STATUSES: TaskRunStatus[] = [ + "not_started", + "queued", + "in_progress", + "completed", + "failed", + "cancelled", +]; + +describe("runStatusLabel", () => { + it.each([ + ["completed", "Ready"], + ["in_progress", "In progress"], + ["not_started", "Not started"], + ["queued", "Queued"], + ["failed", "Failed"], + ["cancelled", "Cancelled"], + ] as const)("labels %s as %s", (status, expected) => { + expect(runStatusLabel(status)).toBe(expected); + }); + + it.each([null, undefined])("returns null for %s", (status) => { + expect(runStatusLabel(status)).toBeNull(); + }); +}); + +describe("runStatusVariant", () => { + it.each([ + ["completed", "success"], + ["failed", "destructive"], + ["in_progress", "info"], + ["queued", "default"], + ["not_started", "default"], + ["cancelled", "default"], + ] as const)("maps %s to %s", (status, expected) => { + expect(runStatusVariant(status)).toBe(expected); + }); + + it("falls back to default when there is no run", () => { + expect(runStatusVariant(null)).toBe("default"); + }); +}); + +describe("RUN_STATUS_FILTER_OPTIONS", () => { + it("leads with the any-status option", () => { + expect(RUN_STATUS_FILTER_OPTIONS[0]).toEqual({ + value: null, + label: "Any status", + }); + }); + + it("offers every run status, so none is silently unfilterable", () => { + const offered = RUN_STATUS_FILTER_OPTIONS.map((o) => o.value).filter( + (v) => v !== null, + ); + expect(new Set(offered)).toEqual(new Set(ALL_STATUSES)); + }); + + it("reuses the shared labels rather than restating them", () => { + for (const option of RUN_STATUS_FILTER_OPTIONS) { + if (option.value) { + expect(option.label).toBe(RUN_STATUS_LABELS[option.value]); + } + } + }); +}); diff --git a/packages/core/src/canvas/runStatus.ts b/packages/core/src/canvas/runStatus.ts new file mode 100644 index 0000000000..f96bb33f98 --- /dev/null +++ b/packages/core/src/canvas/runStatus.ts @@ -0,0 +1,55 @@ +import type { TaskRunStatus } from "@posthog/shared/domain-types"; + +export type RunStatusVariant = + | "default" + | "destructive" + | "info" + | "success" + | "warning"; + +export const RUN_STATUS_LABELS: Record = { + not_started: "Not started", + queued: "Queued", + in_progress: "In progress", + completed: "Ready", + failed: "Failed", + cancelled: "Cancelled", +}; + +const RUN_STATUS_VARIANTS: Record = { + not_started: "default", + queued: "default", + in_progress: "info", + completed: "success", + failed: "destructive", + cancelled: "default", +}; + +export function runStatusLabel( + status: TaskRunStatus | null | undefined, +): string | null { + return status ? RUN_STATUS_LABELS[status] : null; +} + +export function runStatusVariant( + status: TaskRunStatus | null | undefined, +): RunStatusVariant { + return status ? RUN_STATUS_VARIANTS[status] : "default"; +} + +export const RUN_STATUS_FILTER_OPTIONS: readonly { + value: TaskRunStatus | null; + label: string; +}[] = [ + { value: null, label: "Any status" }, + ...( + [ + "not_started", + "queued", + "in_progress", + "completed", + "failed", + "cancelled", + ] as const + ).map((value) => ({ value, label: RUN_STATUS_LABELS[value] })), +]; diff --git a/packages/core/src/command-center/grid.test.ts b/packages/core/src/command-center/grid.test.ts index e1af10334a..947423a6e9 100644 --- a/packages/core/src/command-center/grid.test.ts +++ b/packages/core/src/command-center/grid.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { BRAINROT_CELL, clampZoom, + countActiveTaskCells, getCellCount, getCellSessionId, getGridDimensions, @@ -97,3 +98,34 @@ describe("getCellSessionId", () => { expect(getCellSessionId(2)).toBe("cc-cell-2"); }); }); + +describe("countActiveTaskCells", () => { + const live = new Set(["task-1", "task-2"]); + + it("counts only cells whose task still exists", () => { + expect(countActiveTaskCells(["task-1", "task-2"], live)).toBe(2); + }); + + // Cells are persisted and only pruned on archive, so a deleted task's id + // lingers forever — counting the array's non-empty entries would never drop. + it("ignores a task that has since been deleted", () => { + expect(countActiveTaskCells(["task-1", "deleted-task"], live)).toBe(1); + }); + + it.each([ + { name: "empty cells", cells: [null, null] }, + { name: "the brainrot sentinel", cells: [BRAINROT_CELL] }, + { name: "terminal cells", cells: [makeTerminalCellValue("abc123")] }, + ])("does not count $name", ({ cells }) => { + expect(countActiveTaskCells(cells, live)).toBe(0); + }); + + it("counts a mixed grid correctly", () => { + expect( + countActiveTaskCells( + [null, BRAINROT_CELL, "task-1", "deleted", makeTerminalCellValue("t")], + live, + ), + ).toBe(1); + }); +}); diff --git a/packages/core/src/command-center/grid.ts b/packages/core/src/command-center/grid.ts index 7d2e5f8e16..36eaf5fa75 100644 --- a/packages/core/src/command-center/grid.ts +++ b/packages/core/src/command-center/grid.ts @@ -49,6 +49,21 @@ export function getTerminalCellCwd(value: string | null): string | null { return colon === -1 ? null : decodeURIComponent(rest.slice(colon + 1)); } +/** + * How many cells hold a task that still exists. + * + * Cells are persisted and only pruned when a task is archived — deleting one + * leaves its id behind forever — so a count has to be taken against the live + * task list rather than trusting the array's length. Excludes the brainrot and + * terminal sentinels, which are ambient chrome rather than parked work. + */ +export function countActiveTaskCells( + cells: readonly (string | null)[], + liveTaskIds: ReadonlySet, +): number { + return cells.filter((cell) => cell != null && liveTaskIds.has(cell)).length; +} + 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 c6b6c42644..d7a40fd29e 100644 --- a/packages/shared/src/analytics-events.ts +++ b/packages/shared/src/analytics-events.ts @@ -254,10 +254,19 @@ export type SidebarNavItem = | "more" | "customize_sidebar"; +/** Which sidebar shell the click came from, so the two can be compared. */ +export type SidebarLayout = "code" | "channels"; + export interface SidebarNavItemClickedProperties { item: SidebarNavItem; /** True when the row was clicked inside the expanded More section. */ in_more: boolean; + /** + * Which shell rendered the row. Both shells fire this event with the same + * item names, so without it the layouts are indistinguishable — and comparing + * them is the whole point of running one behind a flag. + */ + layout?: SidebarLayout; } export interface SidebarCustomizedProperties { @@ -872,6 +881,8 @@ export type ChannelsSurface = | "canvas" | "context" | "thread_panel" + // The task's tabbed Activity panel (new channels layout). + | "activity_panel" | "activity"; export type ChannelActionType = @@ -905,7 +916,8 @@ export type ChannelActionType = | "mention_member" | "view_activity" | "open_mention" - | "canvas_mode_toggle"; + | "canvas_mode_toggle" + | "activity_tab_change"; export interface ChannelActionProperties { action_type: ChannelActionType; @@ -924,6 +936,8 @@ export interface ChannelActionProperties { suggestion_label?: string; /** For canvas_mode_toggle: whether canvas mode is being armed. */ armed?: boolean; + /** For activity_tab_change: the tab landed on. */ + tab?: string; /** Whether the underlying mutation resolved successfully. */ success?: boolean; } @@ -991,6 +1005,8 @@ export interface ChannelsSpaceViewedProperties { /** Total channels visible when the space mounts. */ channel_count: number; starred_count: number; + /** Which shell the space was entered through. */ + layout?: SidebarLayout; } // Subscription / billing events diff --git a/packages/shared/src/flags.ts b/packages/shared/src/flags.ts index cab52efaec..81c9442141 100644 --- a/packages/shared/src/flags.ts +++ b/packages/shared/src/flags.ts @@ -14,6 +14,12 @@ export const DISCOVERY_RUN_FLAG = "posthog-code-discovery-run"; // Gates the entire canvas feature: the app rail's Channels space, the /website // routes, channels and dashboards. export const PROJECT_BLUEBIRD_FLAG = "project-bluebird"; +/** + * Gates the new channels layout (channel-scoped sidebar + task Activity panel). + * Off keeps the previous experience and its "Enable channels" toggle. Requires + * project-bluebird. The key predates the rename, matching the live flag. + */ +export const CHANNELS_LAYOUT_FLAG = "code-spaces-layout"; // Gates the Loops feature: the sidebar Loops space and the per-channel Loops tab. export const LOOPS_FLAG = "loops"; export const TASKS_PREWARM_SANDBOX_FLAG = "tasks-prewarm-sandbox"; diff --git a/packages/ui/src/features/browser-tabs/TabShortcutFallback.test.tsx b/packages/ui/src/features/browser-tabs/TabShortcutFallback.test.tsx new file mode 100644 index 0000000000..4151943bb4 --- /dev/null +++ b/packages/ui/src/features/browser-tabs/TabShortcutFallback.test.tsx @@ -0,0 +1,30 @@ +import { render } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { TabShortcutFallback } from "./TabShortcutFallback"; + +function pressCloseTab(): KeyboardEvent { + const event = new KeyboardEvent("keydown", { + key: "w", + code: "KeyW", + metaKey: true, + bubbles: true, + cancelable: true, + }); + document.dispatchEvent(event); + return event; +} + +describe("TabShortcutFallback", () => { + // Without a preventDefault here the key reaches Electron's Window ▸ Close + // role and takes the window — and everything unsaved in it — with it. + it("swallows Cmd+W so the host menu never sees it", () => { + render(); + expect(pressCloseTab().defaultPrevented).toBe(true); + }); + + // Disabled is how the BrowserTabStrip keeps ownership where it is mounted. + it("leaves the key alone when disabled", () => { + render(); + expect(pressCloseTab().defaultPrevented).toBe(false); + }); +}); diff --git a/packages/ui/src/features/browser-tabs/TabShortcutFallback.tsx b/packages/ui/src/features/browser-tabs/TabShortcutFallback.tsx new file mode 100644 index 0000000000..5ed7820766 --- /dev/null +++ b/packages/ui/src/features/browser-tabs/TabShortcutFallback.tsx @@ -0,0 +1,26 @@ +import { SHORTCUTS } from "@posthog/ui/features/command/keyboard-shortcuts"; +import { useHotkeys } from "react-hotkeys-hook"; + +/** + * Renders nothing — claims Cmd/Ctrl+W wherever BrowserTabStrip isn't mounted. + * + * The strip's own CLOSE_TAB handler preventDefaults unconditionally, because + * otherwise the key reaches Electron's Window ▸ Close role (`{ role: + * "windowMenu" }` in the host menu) and closes the window, losing everything in + * it. Any route that renders the app without the strip — the whole channels + * layout, and the settings shell either way — needs someone else to hold the key. + * + * The task view's editor panel keeps closing its own tab from + * usePanelKeyboardShortcuts; that handler runs too, and this one only swallows. + */ +export function TabShortcutFallback({ enabled }: { enabled: boolean }) { + useHotkeys( + SHORTCUTS.CLOSE_TAB, + (event) => { + event.preventDefault(); + }, + { enabled, enableOnFormTags: true, enableOnContentEditable: true }, + ); + + return null; +} diff --git a/packages/ui/src/features/canvas/components/ActivityPanel.tsx b/packages/ui/src/features/canvas/components/ActivityPanel.tsx new file mode 100644 index 0000000000..06d075f578 --- /dev/null +++ b/packages/ui/src/features/canvas/components/ActivityPanel.tsx @@ -0,0 +1,265 @@ +import { CaretRightIcon } from "@phosphor-icons/react"; +import { Button, cn, Tabs, TabsList, TabsTrigger } from "@posthog/quill"; +import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; +import type { Task } from "@posthog/shared/domain-types"; +import { ActivityTimeline } from "@posthog/ui/features/canvas/components/ActivityTimeline"; +import { TaskCard } from "@posthog/ui/features/canvas/components/ChannelFeedView"; +import { TaskArtifactsList } from "@posthog/ui/features/canvas/components/TaskArtifactsList"; +import { + AgentStatusLine, + ThreadLoadingState, + ThreadPanelHeader, + ThreadReplyComposer, + ThreadTimeline, +} from "@posthog/ui/features/canvas/components/ThreadPanel"; +import { useThreadConversation } from "@posthog/ui/features/canvas/hooks/useThreadConversation"; +import { buildConversationItems } from "@posthog/ui/features/sessions/components/buildConversationItems"; +import { taskDetailQuery } from "@posthog/ui/features/tasks/queries"; +import { track } from "@posthog/ui/shell/analytics"; +import { useQuery } from "@tanstack/react-query"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +type ActivityTab = "timeline" | "artifacts" | "comments"; + +const ACTIVITY_TABS: readonly { key: ActivityTab; label: string }[] = [ + { key: "timeline", label: "Timeline" }, + { key: "artifacts", label: "Artifacts" }, + { key: "comments", label: "Comments" }, +] as const; + +const TABS_WITH_COMPOSER: ReadonlySet = new Set([ + "timeline", + "comments", +]); + +const TIMESTAMP_END_CLASS = + "[&_[data-slot=thread-item-timestamp]]:ml-auto [&_[data-slot=thread-item-timestamp]]:shrink-0 [&_[data-slot=thread-item-timestamp]]:pl-2"; + +function ActivityTabsRow({ + tab, + onTabChange, +}: { + tab: ActivityTab; + onTabChange: (tab: ActivityTab) => void; +}) { + return ( +
+ onTabChange(value as ActivityTab)} + > + + {ACTIVITY_TABS.map((t) => ( + + {t.label} + + ))} + + +
+ ); +} + +function ActivityConversation({ + task, + channelId, + onClose, + onToggleCollapsed, + onOpenFull, + showTaskSummary, +}: { + task: Task; + channelId: string; + onClose?: () => void; + onToggleCollapsed?: () => void; + onOpenFull?: () => void; + showTaskSummary: boolean; +}) { + const taskId = task.id; + const { + timeline, + agentStatus, + events, + isPromptPending, + isReady, + members, + currentUser, + isTaskAuthor, + canForward, + draft, + setDraft, + isSubmitDisabled, + submit, + sendMessageToAgent, + deleteMessage, + onMentionInsert, + } = useThreadConversation(task, { surface: "activity_panel" }); + + const [tab, setTab] = useState("timeline"); + const handleTabChange = useCallback( + (next: ActivityTab) => { + setTab(next); + track(ANALYTICS_EVENTS.CHANNEL_ACTION, { + action_type: "activity_tab_change", + surface: "activity_panel", + task_id: taskId, + tab: next, + }); + }, + [taskId], + ); + + const commentRows = useMemo( + () => timeline.filter((row) => row.kind === "human"), + [timeline], + ); + const conversationItems = useMemo( + () => + tab === "timeline" + ? buildConversationItems(events, isPromptPending).items + : [], + [tab, events, isPromptPending], + ); + + const scrollRef = useRef(null); + // biome-ignore lint/correctness/useExhaustiveDependencies: scroll when rendered thread content changes + useEffect(() => { + scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight }); + }, [timeline, events.length, agentStatus?.phase, tab]); + + const showComposer = TABS_WITH_COMPOSER.has(tab); + + const body = () => { + if (tab === "artifacts") { + return ; + } + if (tab === "comments") { + return ( + + ); + } + if (!isReady) return ; + return ( + + ); + }; + + return ( +
+ + + + {showTaskSummary && ( +
+ +
+ )} +
+ {body()} +
+ + {showComposer && agentStatus && } + + {showComposer && ( + + )} +
+ ); +} + +export function ActivityPanel({ + taskId, + channelId, + task: taskProp, + onClose, + collapsed, + onToggleCollapsed, + onOpenFull, + showTaskSummary = true, +}: { + taskId: string; + channelId: string; + task?: Task; + onClose?: () => void; + collapsed?: boolean; + onToggleCollapsed?: () => void; + onOpenFull?: () => void; + showTaskSummary?: boolean; +}) { + const { data: fetchedTask } = useQuery({ + ...taskDetailQuery(taskId), + enabled: !taskProp && !collapsed, + }); + const task = taskProp ?? fetchedTask; + + if (collapsed) { + return ( +
+ +
+ ); + } + + if (!task) { + return ; + } + + return ( + + ); +} diff --git a/packages/ui/src/features/canvas/components/ActivityTimeline.tsx b/packages/ui/src/features/canvas/components/ActivityTimeline.tsx new file mode 100644 index 0000000000..482aa35c50 --- /dev/null +++ b/packages/ui/src/features/canvas/components/ActivityTimeline.tsx @@ -0,0 +1,262 @@ +import { CheckCircleIcon, XCircleIcon } from "@phosphor-icons/react"; +import type { ThreadTimelineRow } from "@posthog/core/canvas/threadTimeline"; +import { + ThreadItem, + ThreadItemAuthor, + ThreadItemBody, + ThreadItemContent, + ThreadItemGroup, + ThreadItemGutter, + ThreadItemHeader, +} from "@posthog/quill"; +import type { + Task, + TaskThreadMessage, + UserBasic, +} from "@posthog/shared/domain-types"; +import { isTerminalStatus } from "@posthog/shared/domain-types"; +import { UserAvatar } from "@posthog/ui/features/auth/UserAvatar"; +import { + ThreadArtifactRow, + ThreadMessageRow, +} from "@posthog/ui/features/canvas/components/ThreadPanel"; +import { ThreadTimestamp } from "@posthog/ui/features/canvas/components/ThreadTimestamp"; +import { userDisplayName } from "@posthog/ui/features/canvas/utils/userDisplay"; +import type { buildConversationItems } from "@posthog/ui/features/sessions/components/buildConversationItems"; +import { Fragment, type ReactNode, useMemo } from "react"; + +type ConversationItem = ReturnType< + typeof buildConversationItems +>["items"][number]; + +function ActivityEventRow({ + node, + title, + action, + timestamp, +}: { + node: ReactNode; + title: string; + action?: string; + timestamp: string; +}) { + return ( +
+
+
{node}
+
+ + {title} + {action && {action}} + + +
+ ); +} + +function EventNode({ icon }: { icon: ReactNode }) { + return ( + + {icon} + + ); +} + +function UserMessageRow({ + author, + content, + timestamp, +}: { + author?: UserBasic | null; + content: string; + timestamp: string; +}) { + return ( + + + + + + + + {author ? userDisplayName(author) : "You"} + + + + + + {content} + + + + + ); +} + +export function ActivityTimeline({ + task, + timeline, + conversationItems, + currentUserUuid, + currentUserEmail, + isTaskAuthor, + canForward, + onSendToAgent, + onDelete, +}: { + task: Task; + timeline: ThreadTimelineRow[]; + conversationItems: ConversationItem[]; + currentUserUuid?: string; + currentUserEmail?: string | null; + isTaskAuthor: boolean; + canForward: boolean; + onSendToAgent: (messageId: string) => void; + onDelete: (messageId: string) => void; +}) { + const nodes = useMemo(() => { + const entries: { key: string; ts: number; node: ReactNode }[] = []; + const createdTs = Date.parse(task.created_at) || 0; + entries.push({ + key: "task-created", + ts: createdTs, + node: ( + + } + title={task.created_by ? userDisplayName(task.created_by) : "Someone"} + action="created this task" + timestamp={task.created_at} + /> + ), + }); + + for (const item of conversationItems) { + if (item.type !== "user_message") continue; + entries.push({ + key: `user-message-${item.id}`, + ts: item.timestamp, + node: ( + + ), + }); + } + + let hasPrArtifact = false; + for (const row of timeline) { + if (row.kind === "artifact" && row.artifact.kind === "pr") { + hasPrArtifact = true; + } + entries.push({ + key: `thread-${row.message.id}`, + ts: row.timestamp, + node: + row.kind === "human" ? ( + onSendToAgent(row.message.id)} + onDelete={() => onDelete(row.message.id)} + /> + ) : ( + + ), + }); + } + + const updatedTs = Date.parse(task.updated_at) || createdTs; + const outputPr = task.latest_run?.output?.pr_url; + if (typeof outputPr === "string" && outputPr && !hasPrArtifact) { + entries.push({ + key: "output-pr", + ts: updatedTs, + node: ( + + ), + }); + } + + const runStatus = task.latest_run?.status; + if (runStatus && isTerminalStatus(runStatus)) { + const succeeded = runStatus === "completed"; + entries.push({ + key: "run-status", + ts: updatedTs + 1, + node: ( + + ) : ( + + ) + } + /> + } + title={`Task ${runStatus.replace(/_/g, " ")}`} + timestamp={task.updated_at} + /> + ), + }); + } + + return entries.sort((a, b) => a.ts - b.ts); + }, [ + conversationItems, + timeline, + task, + isTaskAuthor, + canForward, + currentUserUuid, + currentUserEmail, + onSendToAgent, + onDelete, + ]); + + return ( +
+
+
+ + {nodes.map((entry) => ( + {entry.node} + ))} + +
+
+ ); +} diff --git a/packages/ui/src/features/canvas/components/ChannelBreadcrumb.tsx b/packages/ui/src/features/canvas/components/ChannelBreadcrumb.tsx index 6644ac1ddb..763d156e32 100644 --- a/packages/ui/src/features/canvas/components/ChannelBreadcrumb.tsx +++ b/packages/ui/src/features/canvas/components/ChannelBreadcrumb.tsx @@ -1,10 +1,10 @@ -import { HashIcon } from "@phosphor-icons/react"; import { Button, Tooltip, TooltipContent, TooltipTrigger, } from "@posthog/quill"; +import { channelGlyph } from "@posthog/ui/features/canvas/components/channelGlyph"; import { HeaderTitleEditor } from "@posthog/ui/features/task-detail/HeaderTitleEditor"; import { Flex, Text } from "@radix-ui/themes"; import { useNavigate } from "@tanstack/react-router"; @@ -52,7 +52,10 @@ export function ChannelBreadcrumb({ const channelSegment = ( <> - + {channelGlyph(channelName, { + size: 12, + className: "mt-px shrink-0 text-muted-foreground/80", + })} = { - not_started: "Not started", - queued: "Queued", - in_progress: "In progress", - // "Ready", not "Completed": the agent has finished its work and the task is - // ready to look at, but the change itself isn't necessarily shipped/done. - completed: "Ready", - failed: "Failed", - cancelled: "Cancelled", -}; - // Once a PR exists its GitHub state is the truest top-line status — more // accurate than the run status, which routinely lingers on "in_progress" // (or a stale cloud status) after the agent opens the PR. Mirrors the PR @@ -101,18 +94,10 @@ const PR_STATE_LABELS: Record< }; function statusBadge(status: TaskRunStatus) { - const variant = - status === "completed" - ? "success" - : status === "failed" - ? "destructive" - : status === "in_progress" - ? "info" - : "default"; return ( - + {status === "in_progress" && } - {STATUS_LABELS[status]} + {RUN_STATUS_LABELS[status]} ); } diff --git a/packages/ui/src/features/canvas/components/ChannelHeader.tsx b/packages/ui/src/features/canvas/components/ChannelHeader.tsx index bc08328a46..7b954d61bb 100644 --- a/packages/ui/src/features/canvas/components/ChannelHeader.tsx +++ b/packages/ui/src/features/canvas/components/ChannelHeader.tsx @@ -1,25 +1,57 @@ -import { HashIcon } from "@phosphor-icons/react"; +import { StarIcon } from "@phosphor-icons/react"; import { Button, cn } from "@posthog/quill"; +import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; import { ChannelTabs } from "@posthog/ui/features/canvas/components/ChannelTabs"; -import { useChannels } from "@posthog/ui/features/canvas/hooks/useChannels"; +import { channelGlyph } from "@posthog/ui/features/canvas/components/channelGlyph"; +import { useChannelStarToggle } from "@posthog/ui/features/canvas/hooks/useChannelStars"; +import { + type Channel, + useChannels, +} from "@posthog/ui/features/canvas/hooks/useChannels"; +import { useChannelsLayout } from "@posthog/ui/features/canvas/hooks/useChannelsLayout"; import { useMarkChannelSeen } from "@posthog/ui/features/canvas/hooks/useMarkChannelSeen"; +import { PERSONAL_CHANNEL_NAME } from "@posthog/ui/features/canvas/hooks/useTaskChannels"; +import { track } from "@posthog/ui/shell/analytics"; import { Text } from "@radix-ui/themes"; import { useNavigate, useRouterState } from "@tanstack/react-router"; -// The shared channel header: a clickable "# channel" that doubles as the Home -// item — it routes to the channel home (`/website/$channelId`, like the sidebar -// channel row) and highlights `bg-fill-selected` while you're there, the same -// pathname-driven active state the rest of the channel tab strip uses. Followed -// by that strip (Artifacts / Recents / CONTEXT.md), rendered into the -// header bar by every channel view so the tabs stay in view. +// The feed-side counterpart to the switcher's hover star. +function ChannelStarButton({ channel }: { channel: Channel }) { + const { isStarred, toggleStar } = useChannelStarToggle(channel); + return ( + + ); +} + +// The shared channel header. The new layout drops the section tab strip — the +// channel sidebar carries those entries — while flag off keeps it. export function ChannelHeader({ channelId }: { channelId: string }) { const navigate = useNavigate(); + const channelsLayout = useChannelsLayout(); const { channels } = useChannels(); - const channelName = channels.find((c) => c.id === channelId)?.name; + const channel = channels.find((c) => c.id === channelId); + const channelName = channel?.name; const pathname = useRouterState({ select: (s) => s.location.pathname }); const isHome = pathname === `/website/${channelId}`; - // Every channel surface renders this header, so it is where "the viewer is - // in this channel" is known — and therefore where the channel is marked read. + // Every channel surface renders this header, so mark the channel read here. useMarkChannelSeen(channelName); return ( @@ -33,12 +65,18 @@ export function ChannelHeader({ channelId }: { channelId: string }) { size="sm" className={cn("min-w-0", isHome ? "bg-fill-selected" : "")} > - + {channelGlyph(channelName, { + size: 20, + className: "shrink-0 text-muted-foreground/80", + })} {channelName ?? "Channel"} - + {channelsLayout && channel && channel.name !== PERSONAL_CHANNEL_NAME && ( + + )} + {!channelsLayout && }
); } diff --git a/packages/ui/src/features/canvas/components/ChannelHotkeys.test.tsx b/packages/ui/src/features/canvas/components/ChannelHotkeys.test.tsx new file mode 100644 index 0000000000..e4e7cfe547 --- /dev/null +++ b/packages/ui/src/features/canvas/components/ChannelHotkeys.test.tsx @@ -0,0 +1,96 @@ +import { render } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + channelsLayout: true, + slots: [] as { id: string; name: string; path: string }[], + navigateToChannel: vi.fn(), +})); + +vi.mock("@posthog/ui/features/canvas/hooks/useChannelsLayout", () => ({ + useChannelsLayout: () => mocks.channelsLayout, +})); +vi.mock("@posthog/ui/features/canvas/hooks/useStarredChannelSlots", () => ({ + useStarredChannelSlots: () => ({ + slots: mocks.slots, + rest: [], + slotFor: () => undefined, + }), +})); +vi.mock("@posthog/ui/router/navigationBridge", () => ({ + navigateToChannel: (...args: unknown[]) => mocks.navigateToChannel(...args), +})); +vi.mock("@posthog/ui/shell/analytics", () => ({ track: vi.fn() })); + +import { useCurrentChannelStore } from "@posthog/ui/features/canvas/stores/currentChannelStore"; +import { ChannelHotkeys } from "./ChannelHotkeys"; + +function press(digit: string, modifiers: Partial = {}) { + document.dispatchEvent( + new KeyboardEvent("keydown", { + key: digit, + code: `Digit${digit}`, + bubbles: true, + cancelable: true, + ...modifiers, + }), + ); +} + +describe("ChannelHotkeys", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.channelsLayout = true; + mocks.slots = [ + { id: "me-id", name: "me", path: "/me" }, + { id: "eng-id", name: "eng", path: "/eng" }, + ]; + useCurrentChannelStore.setState({ currentChannelId: null }); + }); + + // The regression: this component is rendered ALONE — no sidebar, no switcher. + // Binding the keys inside the switcher left them unowned exactly when the + // channel list hadn't resolved yet. + it("switches channels without the sidebar being mounted", () => { + render(); + + press("1", { metaKey: true }); + + expect(mocks.navigateToChannel).toHaveBeenCalledWith("me-id"); + expect(useCurrentChannelStore.getState().currentChannelId).toBe("me-id"); + }); + + it("maps slot 2 to the first starred channel", () => { + render(); + press("2", { metaKey: true }); + expect(mocks.navigateToChannel).toHaveBeenCalledWith("eng-id"); + }); + + // mod+0 belongs to the host's "Actual Size" accelerator. + it("ignores mod+0", () => { + render(); + press("0", { metaKey: true }); + expect(mocks.navigateToChannel).not.toHaveBeenCalled(); + }); + + // ctrl+1-9 is the editor-panel tab switcher on every platform. + it("leaves pure ctrl presses to the panel tab switcher", () => { + render(); + press("1", { ctrlKey: true }); + expect(mocks.navigateToChannel).not.toHaveBeenCalled(); + }); + + it("does nothing for a slot with no channel behind it", () => { + mocks.slots = [{ id: "me-id", name: "me", path: "/me" }]; + render(); + press("5", { metaKey: true }); + expect(mocks.navigateToChannel).not.toHaveBeenCalled(); + }); + + it("stays out of the way when the layout is off", () => { + mocks.channelsLayout = false; + render(); + press("1", { metaKey: true }); + expect(mocks.navigateToChannel).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/ui/src/features/canvas/components/ChannelHotkeys.tsx b/packages/ui/src/features/canvas/components/ChannelHotkeys.tsx new file mode 100644 index 0000000000..16bfb4a0fa --- /dev/null +++ b/packages/ui/src/features/canvas/components/ChannelHotkeys.tsx @@ -0,0 +1,53 @@ +import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; +import { useChannelsLayout } from "@posthog/ui/features/canvas/hooks/useChannelsLayout"; +import { useStarredChannelSlots } from "@posthog/ui/features/canvas/hooks/useStarredChannelSlots"; +import { useCurrentChannelStore } from "@posthog/ui/features/canvas/stores/currentChannelStore"; +import { SHORTCUTS } from "@posthog/ui/features/command/keyboard-shortcuts"; +import { navigateToChannel } from "@posthog/ui/router/navigationBridge"; +import { track } from "@posthog/ui/shell/analytics"; +import { useHotkeys } from "react-hotkeys-hook"; + +/** + * Renders nothing — the unconditional owner of ⌘1-9 (switch channel) under the + * channels layout. + * + * Mounted from the root rather than from the switcher: the switcher only renders + * once a channel is already scoped, so binding there left the keys with no owner + * exactly when the user most needs them (channel list still loading, or failed). + * GlobalEventHandlers yields SWITCH_TASK to this whenever the layout is on, so + * there must always be someone listening. + */ +export function ChannelHotkeys() { + const channelsLayout = useChannelsLayout(); + const { slots } = useStarredChannelSlots(); + const setCurrentChannel = useCurrentChannelStore((s) => s.setCurrentChannel); + + useHotkeys( + SHORTCUTS.SWITCH_STARRED_CHANNEL, + (event, handler) => { + // Same ctrl guard as SWITCH_TASK: plain ctrl+N is the editor-panel tab + // switcher (SWITCH_TAB), so leave ctrl-only presses to it. + if (event.ctrlKey && !event.metaKey) return; + const slot = Number.parseInt(handler.keys?.[0] ?? "", 10); + if (Number.isNaN(slot)) return; + const channel = slots[slot - 1]; + if (!channel) return; + setCurrentChannel(channel.id); + navigateToChannel(channel.id); + track(ANALYTICS_EVENTS.CHANNEL_ACTION, { + action_type: "open_channel", + surface: "sidebar", + channel_id: channel.id, + }); + }, + { + enabled: channelsLayout, + enableOnFormTags: true, + enableOnContentEditable: true, + preventDefault: true, + }, + [slots, setCurrentChannel], + ); + + return null; +} diff --git a/packages/ui/src/features/canvas/components/ChannelItemRow.tsx b/packages/ui/src/features/canvas/components/ChannelItemRow.tsx new file mode 100644 index 0000000000..b19beca7da --- /dev/null +++ b/packages/ui/src/features/canvas/components/ChannelItemRow.tsx @@ -0,0 +1,174 @@ +import { PreviewCard } from "@base-ui/react/preview-card"; +import { Archive, FileTextIcon, PushPin } from "@phosphor-icons/react"; +import type { ChannelItemModel } from "@posthog/core/canvas/channelItems"; +import { + runStatusLabel, + runStatusVariant, +} from "@posthog/core/canvas/runStatus"; +import { Avatar, AvatarFallback, Badge } from "@posthog/quill"; +import { formatRelativeTimeShort } from "@posthog/shared"; +import { UserAvatar } from "@posthog/ui/features/auth/UserAvatar"; +import { iconForTemplate } from "@posthog/ui/features/canvas/components/canvasTemplateIcon"; +import { userDisplayName } from "@posthog/ui/features/canvas/utils/userDisplay"; +import { SidebarItem } from "@posthog/ui/features/sidebar/components/SidebarItem"; +import { NestedButton } from "@posthog/ui/primitives/NestedButton"; +import { Tooltip } from "@posthog/ui/primitives/Tooltip"; +import type { ReactNode } from "react"; + +/** + * What a row can do. One object per channel rather than closures per item, so + * the item list stays plain data and doesn't rebuild on every navigation. + */ +export interface ChannelItemActions { + open: (item: ChannelItemModel) => void; + togglePin: (item: ChannelItemModel) => void; + archive: (item: ChannelItemModel) => void; +} + +// The channel sidebar's own chrome. Deliberately not shared with the Code +// sidebar's TaskItem: that one is still on the absolute gray scale, while these +// rows use the theme's fill/foreground tokens. +const HOVER_ACTION_CLASS = + "flex h-5 w-5 cursor-pointer items-center justify-center rounded text-muted-foreground transition-colors hover:bg-fill-hover hover:text-foreground"; +const HOVER_TOOLBAR_CLASS = + "hidden shrink-0 items-center gap-0.5 group-hover:flex"; +const TIMESTAMP_CLASS = + "shrink-0 text-[11px] text-muted-foreground group-hover:hidden"; + +function itemIcon(item: ChannelItemModel): ReactNode { + return item.kind === "canvas" ? ( + // Matches the schema's own default for boards saved before templating. + iconForTemplate(item.templateId ?? "freeform", { + size: 15, + className: "text-violet-9", + }) + ) : ( + + ); +} + +function authorLabel(item: ChannelItemModel): string | null { + if (item.authorUser) return userDisplayName(item.authorUser); + return item.authorName; +} + +export function ChannelItemRow({ + item, + isActive, + actions, +}: { + item: ChannelItemModel; + isActive: boolean; + actions: ChannelItemActions; +}) { + const icon = itemIcon(item); + const statusLabel = runStatusLabel(item.rawStatus); + const author = authorLabel(item); + + return ( + + + {item.title}} + isActive={isActive} + onClick={() => actions.open(item)} + endContent={ + <> + + {formatRelativeTimeShort(item.ts)} + + + + actions.togglePin(item)} + > + + + + {/* Canvases can't be archived. */} + {item.kind === "task" && ( + + actions.archive(item)} + > + + + + )} + + + } + /> + + } + /> + + + +
+ + {icon} + +
+

+ {item.title} +

+

+ {item.kind === "canvas" ? "Canvas" : "Task"} · updated{" "} + {formatRelativeTimeShort(item.ts)} +

+
+
+ {statusLabel && ( +
+ + {statusLabel} + +
+ )} + {author && ( +
+ {item.authorUser ? ( + + ) : ( + + + {author.charAt(0).toUpperCase()} + + + )} +
+

+ {author} +

+

+ Created by +

+
+
+ )} +
+
+
+
+ ); +} diff --git a/packages/ui/src/features/canvas/components/ChannelNav.tsx b/packages/ui/src/features/canvas/components/ChannelNav.tsx new file mode 100644 index 0000000000..c6f7181a72 --- /dev/null +++ b/packages/ui/src/features/canvas/components/ChannelNav.tsx @@ -0,0 +1,131 @@ +import { BellIcon, EnvelopeSimple, Lightning } from "@phosphor-icons/react"; +import { countUnseenActivity } from "@posthog/core/canvas/mentionActivity"; +import { cn } from "@posthog/quill"; +import { + ANALYTICS_EVENTS, + type SidebarNavItem, +} from "@posthog/shared/analytics-events"; +import { useMentionActivity } from "@posthog/ui/features/canvas/hooks/useMentionActivity"; +import { useActivitySeenStore } from "@posthog/ui/features/canvas/stores/activitySeenStore"; +import { + formatHotkey, + SHORTCUTS, +} from "@posthog/ui/features/command/keyboard-shortcuts"; +import { useCommandCenterActiveCount } from "@posthog/ui/features/command-center/useCommandCenterActiveCount"; +import { useInboxAllReports } from "@posthog/ui/features/inbox/hooks/useInboxAllReports"; +import { CountBadge } from "@posthog/ui/primitives/CountBadge"; +import { Tooltip } from "@posthog/ui/primitives/Tooltip"; +import { + navigateToActivity, + navigateToInbox, + navigateToWebsiteCommandCenter, +} from "@posthog/ui/router/navigationBridge"; +import { useAppView } from "@posthog/ui/router/useAppView"; +import { track } from "@posthog/ui/shell/analytics"; +import type { ReactNode } from "react"; +import { useMemo } from "react"; + +const INBOX_REFETCH_INTERVAL_MS = 60_000; + +const ICON_BADGE_CLASS = + "-top-1 -right-1 absolute h-3.5 min-w-3.5 w-auto px-1 font-semibold text-[9px] ring-2 ring-chrome"; + +function NavIcon({ + icon, + label, + shortcut, + isActive, + onClick, + badge, +}: { + icon: ReactNode; + label: string; + shortcut?: string; + isActive: boolean; + onClick: () => void; + badge?: ReactNode; +}) { + return ( + + + + ); +} + +export function ChannelNav() { + const view = useAppView(); + + const { counts } = useInboxAllReports({ + ignoreFilters: true, + refetchIntervalMs: INBOX_REFETCH_INTERVAL_MS, + }); + const { items: activityItems } = useMentionActivity(); + const lastSeenAt = useActivitySeenStore((s) => s.lastSeenAt); + const unseenActivity = useMemo( + () => countUnseenActivity(activityItems, lastSeenAt), + [activityItems, lastSeenAt], + ); + const commandCenterCount = useCommandCenterActiveCount(); + + const withTrack = (item: SidebarNavItem, action: () => void) => () => { + track(ANALYTICS_EVENTS.SIDEBAR_NAV_ITEM_CLICKED, { + item, + in_more: false, + layout: "channels", + }); + action(); + }; + + const isActivity = view.type === "activity"; + const isCommandCenter = view.type === "command-center"; + + return ( +
+ } + label="Inbox" + shortcut={formatHotkey(SHORTCUTS.INBOX)} + isActive={view.type === "inbox"} + onClick={withTrack("inbox", navigateToInbox)} + badge={} + /> + } + label="Activity" + isActive={isActivity} + onClick={withTrack("activity", navigateToActivity)} + badge={ + + } + /> + + } + label="Command Center" + isActive={isCommandCenter} + onClick={withTrack("command_center", navigateToWebsiteCommandCenter)} + badge={ + + } + /> +
+ ); +} diff --git a/packages/ui/src/features/canvas/components/ChannelSidebar.tsx b/packages/ui/src/features/canvas/components/ChannelSidebar.tsx new file mode 100644 index 0000000000..bf8a323c9d --- /dev/null +++ b/packages/ui/src/features/canvas/components/ChannelSidebar.tsx @@ -0,0 +1,382 @@ +import { + BookOpenTextIcon, + ChatsCircleIcon, + FunnelSimple as FunnelSimpleIcon, + MagnifyingGlass, + PackageIcon, + RepeatIcon, +} from "@phosphor-icons/react"; +import type { CreatedByFilter } from "@posthog/core/canvas/channelItems"; +import { filterChannelItems } from "@posthog/core/canvas/channelItems"; +import { RUN_STATUS_FILTER_OPTIONS } from "@posthog/core/canvas/runStatus"; +import { + cn, + DropdownMenu, + DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuTrigger, + Empty, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, + Input, + MenuLabel, + Skeleton, +} from "@posthog/quill"; +import { LOOPS_FLAG } from "@posthog/shared"; +import type { TaskRunStatus } from "@posthog/shared/domain-types"; +import { ChannelItemRow } from "@posthog/ui/features/canvas/components/ChannelItemRow"; +import { ChannelSwitcher } from "@posthog/ui/features/canvas/components/ChannelSwitcher"; +import { NewTaskFab } from "@posthog/ui/features/canvas/components/NewTaskFab"; +import { useChannelItems } from "@posthog/ui/features/canvas/hooks/useChannelItems"; +import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; +import { SidebarItem } from "@posthog/ui/features/sidebar/components/SidebarItem"; +import { useNavigate, useRouterState } from "@tanstack/react-router"; +import { type ReactNode, useMemo, useState } from "react"; + +const CREATED_BY_OPTIONS: readonly { value: CreatedByFilter; label: string }[] = + [ + { value: "anyone", label: "Anyone" }, + { value: "me", label: "Me" }, + { value: "others", label: "Other people" }, + ] as const; + +const HEADER_ICON_BUTTON_CLASS = + "flex size-5 shrink-0 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-fill-hover hover:text-foreground"; + +const cnHeaderButton = (active: boolean) => + cn(HEADER_ICON_BUTTON_CLASS, active && "bg-fill-selected text-foreground"); + +const RECENTS_CAP = 30; + +function RecentSectionHeader({ + searchOpen, + onToggleSearch, + query, + onQueryChange, + createdByFilter, + onCreatedByChange, + statusFilter, + onStatusChange, + filtersActive, +}: { + searchOpen: boolean; + onToggleSearch: () => void; + query: string; + onQueryChange: (value: string) => void; + createdByFilter: CreatedByFilter; + onCreatedByChange: (value: CreatedByFilter) => void; + statusFilter: TaskRunStatus | null; + onStatusChange: (value: TaskRunStatus | null) => void; + filtersActive: boolean; +}) { + return ( + <> +
+
+ Recent +
+ + + + + + } + /> + + Created by + + onCreatedByChange(value as CreatedByFilter) + } + > + {CREATED_BY_OPTIONS.map((option) => ( + + {option.label} + + ))} + + + Status + + onStatusChange( + value === "any" ? null : (value as TaskRunStatus), + ) + } + > + {RUN_STATUS_FILTER_OPTIONS.map((option) => ( + + {option.label} + + ))} + + + +
+ {searchOpen && ( +
+ onQueryChange(event.target.value)} + placeholder="Search…" + aria-label="Search recent items" + className="h-6 text-[12px]" + /> +
+ )} + + ); +} + +// Varied widths so the loading state reads as the list it becomes. +const SKELETON_ROW_WIDTHS = [ + "w-3/5", + "w-4/5", + "w-2/5", + "w-3/4", + "w-1/2", + "w-2/3", +] as const; + +function ChannelItemsSkeleton() { + return ( +
+ + {SKELETON_ROW_WIDTHS.map((width) => ( +
+ + +
+ ))} +
+ ); +} + +/** + * The sidebar body while a channel is active: the switcher, the channel's + * sections, then its pinned and recent tasks & canvases. + */ +export function ChannelSidebar({ channelId }: { channelId: string }) { + const navigate = useNavigate(); + const pathname = useRouterState({ select: (s) => s.location.pathname }); + const loopsEnabled = useFeatureFlag(LOOPS_FLAG, import.meta.env.DEV); + + const { items, actions, me, isLoading, channelMissing } = + useChannelItems(channelId); + + const [searchOpen, setSearchOpen] = useState(false); + const [query, setQuery] = useState(""); + const [createdByFilter, setCreatedByFilter] = + useState("anyone"); + const [statusFilter, setStatusFilter] = useState(null); + const filtersActive = createdByFilter !== "anyone" || statusFilter !== null; + + const base = `/website/${channelId}`; + // Activeness is a key comparison rather than a flag baked into each item, so + // navigating doesn't rebuild the list. + const activeKey = useMemo(() => { + const dashboard = pathname.match(/\/dashboards\/([^/]+)$/); + if (dashboard) return `canvas:${dashboard[1]}`; + const task = pathname.match(/\/tasks\/([^/]+)$/); + return task ? `task:${task[1]}` : null; + }, [pathname]); + + const pinnedItems = useMemo(() => items.filter((i) => i.pinned), [items]); + const recentItems = useMemo( + () => + filterChannelItems( + items.filter((i) => !i.pinned), + { query, createdBy: createdByFilter, status: statusFilter, me }, + ).slice(0, RECENTS_CAP), + [items, query, createdByFilter, statusFilter, me], + ); + + const sectionRow = ( + label: string, + icon: ReactNode, + to: string, + onClick: () => void, + ) => ( + + ); + + return ( +
+ + +
+ {sectionRow( + "Feed", + , + base, + () => + void navigate({ to: "/website/$channelId", params: { channelId } }), + )} + {sectionRow( + "Context", + , + `${base}/context`, + () => + void navigate({ + to: "/website/$channelId/context", + params: { channelId }, + }), + )} + {loopsEnabled && + sectionRow( + "Loops", + , + `${base}/loops`, + () => + void navigate({ + to: "/website/$channelId/loops", + params: { channelId }, + }), + )} + {sectionRow( + "Artifacts", + , + `${base}/artifacts`, + () => + void navigate({ + to: "/website/$channelId/artifacts", + params: { channelId }, + }), + )} +
+ + {/* Relative so the FAB can float over the list. */} +
+
+ {isLoading && items.length === 0 && } + + {channelMissing && ( + + + + + + Channel unavailable + + It may have been deleted, or belong to another project. + + + + )} + + {pinnedItems.length > 0 && ( + <> + Pinned +
+ {pinnedItems.map((item) => ( + + ))} +
+ + )} + + {(items.some((i) => !i.pinned) || filtersActive || searchOpen) && ( + <> + { + if (searchOpen) setQuery(""); + setSearchOpen(!searchOpen); + }} + query={query} + onQueryChange={setQuery} + createdByFilter={createdByFilter} + onCreatedByChange={setCreatedByFilter} + statusFilter={statusFilter} + onStatusChange={setStatusFilter} + filtersActive={filtersActive} + /> + {recentItems.length > 0 ? ( +
+ {recentItems.map((item) => ( + + ))} +
+ ) : ( + + + + + + No matches + + Try a different search or clear the filters. + + + + )} + + )} + + {!isLoading && !channelMissing && items.length === 0 && ( + + + + + + Nothing here yet + + Tasks and canvases you create in this channel show up here. + + + + )} +
+ +
+
+ ); +} diff --git a/packages/ui/src/features/canvas/components/ChannelSwitcher.tsx b/packages/ui/src/features/canvas/components/ChannelSwitcher.tsx new file mode 100644 index 0000000000..8ee851a4be --- /dev/null +++ b/packages/ui/src/features/canvas/components/ChannelSwitcher.tsx @@ -0,0 +1,302 @@ +import { Popover as BasePopover } from "@base-ui/react/popover"; +import { CaretUpDownIcon, PlusIcon, StarIcon } from "@phosphor-icons/react"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuTrigger, + cn, + Input, + Kbd, + Popover, + PopoverContent, + PopoverTrigger, + Separator, + Skeleton, +} from "@posthog/quill"; +import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; +import { CreateChannelModal } from "@posthog/ui/features/canvas/components/CreateChannelModal"; +import { channelGlyph } from "@posthog/ui/features/canvas/components/channelGlyph"; +import { useChannelStarToggle } from "@posthog/ui/features/canvas/hooks/useChannelStars"; +import { + type Channel, + useChannels, +} from "@posthog/ui/features/canvas/hooks/useChannels"; +import { useStarredChannelSlots } from "@posthog/ui/features/canvas/hooks/useStarredChannelSlots"; +import { PERSONAL_CHANNEL_NAME } from "@posthog/ui/features/canvas/hooks/useTaskChannels"; +import { useCurrentChannelStore } from "@posthog/ui/features/canvas/stores/currentChannelStore"; +import { formatHotkey } from "@posthog/ui/features/command/keyboard-shortcuts"; +import { navigateToChannel } from "@posthog/ui/router/navigationBridge"; +import { track } from "@posthog/ui/shell/analytics"; +import { useState } from "react"; + +// Clears the row's 8px `mx-2` inset plus an 8px gap, so the panel lands past +// the sidebar edge rather than over it. +const POPOVER_SIDE_OFFSET = 16; + +function SwitcherRow({ + channel, + active, + onPick, + hotkeySlot, +}: { + channel: Channel; + active: boolean; + onPick: () => void; + hotkeySlot?: number; +}) { + const isMe = channel.name === PERSONAL_CHANNEL_NAME; + const { isStarred, toggleStar } = useChannelStarToggle(channel); + + const trackedToggleStar = () => { + track(ANALYTICS_EVENTS.CHANNEL_ACTION, { + action_type: isStarred ? "unstar" : "star", + surface: "sidebar", + channel_id: channel.id, + }); + toggleStar(); + }; + + const row = ( +
+ + {isMe ? ( + // #me is permanently pinned, so its star is inert — but the slot + // still has to be filled to keep the rows aligned. + + + + ) : ( + + )} +
+ ); + + if (isMe) return row; + return ( + + + + + + {isStarred ? "Unstar channel" : "Star channel"} + + + + ); +} + +// An overlay rather than a sibling: a sibling would shrink the trigger, which +// the popover anchors to (see POPOVER_SIDE_OFFSET). +function TriggerStar({ channel }: { channel: Channel }) { + const { isStarred, toggleStar } = useChannelStarToggle(channel); + return ( + + ); +} + +/** + * The channel header as a switcher: clicking the "# channel" title opens a + * searchable popover — "#me" first, then starred channels, then the rest. + */ +export function ChannelSwitcher({ channelId }: { channelId: string }) { + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(""); + const [createOpen, setCreateOpen] = useState(false); + const { channels, isLoading: isLoadingChannels } = useChannels(); + const setCurrentChannel = useCurrentChannelStore((s) => s.setCurrentChannel); + const current = channels.find((c) => c.id === channelId); + // ChannelHotkeys owns the keys these slots describe; sharing the derivation + // keeps the advertised key and the key that fires in agreement. + const { slots, rest, slotFor } = useStarredChannelSlots(); + + const normalizedQuery = query.trim().toLowerCase(); + const matches = (c: Channel) => + !normalizedQuery || c.name.toLowerCase().includes(normalizedQuery); + const topFiltered = slots.filter(matches); + const restFiltered = rest.filter(matches); + + const pick = (channel: Channel) => { + setOpen(false); + setQuery(""); + setCurrentChannel(channel.id); + navigateToChannel(channel.id); + track(ANALYTICS_EVENTS.CHANNEL_ACTION, { + action_type: "open_channel", + surface: "sidebar", + channel_id: channel.id, + }); + }; + + const showStar = current != null && current.name !== PERSONAL_CHANNEL_NAME; + + return ( +
+ + {/* Portaled because ResizableSidebar's transform makes the sidebar a + containing block, which would clip a fixed overlay; z-40 keeps it + under the popup's z-50. */} + + + + + + {channelGlyph(current?.name, { + size: 14, + className: "text-muted-foreground", + })} + + + {current ? ( + current.name + ) : isLoadingChannels ? ( + // A placeholder word here would read as a real channel named + // "channel"; a skeleton says "still loading" honestly. + + ) : ( + "Unavailable" + )} + + + + + } + /> + +
+ setQuery(event.target.value)} + placeholder="Search channels…" + aria-label="Search channels" + className="h-7 text-[13px]" + /> +
+
+ {topFiltered.map((channel) => ( + pick(channel)} + hotkeySlot={slotFor(channel)} + /> + ))} + {topFiltered.length > 0 && restFiltered.length > 0 && ( + + )} + {restFiltered.map((channel) => ( + pick(channel)} + /> + ))} + {topFiltered.length === 0 && restFiltered.length === 0 && ( +

+ No channels match "{query.trim()}". +

+ )} +
+ + +
+
+ {showStar && current && } + {/* Inert star for #me, so the trigger reads the same on every channel. */} + {current && !showStar && ( + + + + )} + +
+ ); +} diff --git a/packages/ui/src/features/canvas/components/ChannelsList.tsx b/packages/ui/src/features/canvas/components/ChannelsList.tsx index 9279e0e8e2..e3ff859bea 100644 --- a/packages/ui/src/features/canvas/components/ChannelsList.tsx +++ b/packages/ui/src/features/canvas/components/ChannelsList.tsx @@ -7,7 +7,6 @@ import { FileTextIcon, HashIcon, LinkIcon, - LockSimpleIcon, PencilSimpleIcon, PlusIcon, StarIcon, @@ -43,6 +42,7 @@ import { TooltipTrigger, } from "@posthog/quill"; import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; +import { channelGlyph } from "@posthog/ui/features/canvas/components/channelGlyph"; import { RenameChannelModal } from "@posthog/ui/features/canvas/components/RenameChannelModal"; import { trackAndCreateCanvas } from "@posthog/ui/features/canvas/createCanvasAnalytics"; import { ensurePersonalChannel } from "@posthog/ui/features/canvas/ensurePersonalChannel"; @@ -61,13 +61,18 @@ import { useTaskChannels, } from "@posthog/ui/features/canvas/hooks/useTaskChannels"; import { useIsChannelUnread } from "@posthog/ui/features/canvas/hooks/useUnreadChannels"; +import { + resetCurrentChannel, + useCurrentChannelStore, +} from "@posthog/ui/features/canvas/stores/currentChannelStore"; import { copyChannelLink } from "@posthog/ui/features/canvas/utils/copyChannelLink"; import { useSidebarStore } from "@posthog/ui/features/sidebar/sidebarStore"; import { toast } from "@posthog/ui/primitives/toast"; +import { openTaskInput } from "@posthog/ui/router/useOpenTask"; import { track } from "@posthog/ui/shell/analytics"; import { Box, Flex } from "@radix-ui/themes"; import { useNavigate, useRouterState } from "@tanstack/react-router"; -import { Fragment, type ReactNode, useEffect, useRef, useState } from "react"; +import { Fragment, type ReactNode, useState } from "react"; import { hostClient } from "../hostClient"; // One actionable entry in a channel's menu, rendered the same whether it @@ -127,6 +132,12 @@ function useChannelActions(channel: Channel): { await deleteChannel(channel.id); removeStar(); + // Unscope immediately if this was the current channel — otherwise the + // sidebar renders a dead id (and new tasks file against it) until the + // channels list refetches. useCurrentChannel is the backstop. + if (useCurrentChannelStore.getState().currentChannelId === channel.id) { + resetCurrentChannel(); + } track(ANALYTICS_EVENTS.CHANNEL_ACTION, { action_type: "delete", surface: "sidebar", @@ -346,18 +357,18 @@ function ChannelSection({ params: { channelId: channel.id }, }); }} - className="w-full min-w-0 justify-start gap-2 data-selected:bg-fill-selected data-selected:text-gray-12" + className="w-full min-w-0 justify-start gap-2 data-selected:bg-fill-selected data-selected:text-foreground" > - + ), + })} @@ -560,7 +568,7 @@ function PersonalChannelRow() { surface: "sidebar", channel_id: channelId, }); - void navigate({ to: "/website/$channelId/new", params: { channelId } }); + openTaskInput({ channelId }); }; const newCanvas = async () => { @@ -583,18 +591,18 @@ function PersonalChannelRow() { data-selected={isActive || undefined} disabled={isCreating} onClick={() => void open()} - className="w-full min-w-0 justify-start gap-2 data-selected:bg-fill-selected data-selected:text-gray-12" + className="w-full min-w-0 justify-start gap-2 data-selected:bg-fill-selected data-selected:text-foreground" > - + ), + })} {PERSONAL_CHANNEL_NAME} - {/* The lock and the hover "+" share the right edge, so fade the lock - out as the "+" comes in. */} -
@@ -757,19 +754,6 @@ export function ChannelsList() { const starred = channels.filter((c) => starredRefToShortcutId.has(c.path)); const others = channels.filter((c) => !starredRefToShortcutId.has(c.path)); - // Fire CHANNELS_SPACE_VIEWED once per space mount, after channels first load - // (so the counts are accurate). The sidebar stays mounted while navigating - // between channels, so this naturally fires once per entry into the space. - const viewedTrackedRef = useRef(false); - useEffect(() => { - if (isLoading || viewedTrackedRef.current) return; - viewedTrackedRef.current = true; - track(ANALYTICS_EVENTS.CHANNELS_SPACE_VIEWED, { - channel_count: channels.length, - starred_count: starred.length, - }); - }, [isLoading, channels.length, starred.length]); - return ( // One shared provider groups every row tooltip so that once one shows, // moving to the next row reveals its tooltip instantly (no re-delay). diff --git a/packages/ui/src/features/canvas/components/ChannelsSidebar.test.tsx b/packages/ui/src/features/canvas/components/ChannelsSidebar.test.tsx new file mode 100644 index 0000000000..f4f9c10634 --- /dev/null +++ b/packages/ui/src/features/canvas/components/ChannelsSidebar.test.tsx @@ -0,0 +1,261 @@ +import { Theme } from "@radix-ui/themes"; +import { render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + featureFlags: new Map(), + channelsLayout: false, + channelsEnabled: false, + channels: [] as { id: string; name: string; path: string }[], + channelsLoading: false, + archivedTaskIds: new Set(), + navigateToArchived: vi.fn(), + track: vi.fn(), + routeChannelId: undefined as string | undefined, +})); + +vi.mock("@posthog/ui/shell/analytics", () => ({ + track: (...args: unknown[]) => mocks.track(...args), +})); + +vi.mock("@posthog/ui/features/feature-flags/useFeatureFlag", () => ({ + useFeatureFlag: (key: string) => mocks.featureFlags.get(key) ?? true, +})); +vi.mock("@posthog/ui/features/canvas/hooks/useChannelsLayout", () => ({ + useChannelsLayout: () => mocks.channelsLayout, +})); +vi.mock("@posthog/ui/features/canvas/hooks/useChannels", () => ({ + useChannels: () => ({ + channels: mocks.channels, + isLoading: mocks.channelsLoading, + }), +})); +vi.mock("@posthog/ui/features/archive/useArchivedTaskIds", () => ({ + useArchivedTaskIds: () => mocks.archivedTaskIds, +})); +vi.mock("@posthog/ui/features/canvas/hooks/useChannelStars", () => ({ + useChannelStars: () => ({ starredRefToShortcutId: new Map() }), +})); +vi.mock("@posthog/ui/router/navigationBridge", () => ({ + navigateToArchived: (...args: unknown[]) => mocks.navigateToArchived(...args), +})); + +// The sidebar's children each mount their own query stack; this suite is about +// the shell's own decisions, so they're stubbed out. +vi.mock("@posthog/ui/features/canvas/components/ChannelNav", () => ({ + ChannelNav: () => null, +})); +vi.mock("@posthog/ui/features/canvas/components/ChannelSidebar", () => ({ + ChannelSidebar: ({ channelId }: { channelId: string }) => ( +
{channelId}
+ ), +})); +vi.mock("@posthog/ui/features/canvas/components/ChannelsList", () => ({ + ChannelsList: () =>
, +})); +vi.mock("@posthog/ui/features/canvas/components/ChannelsFab", () => ({ + ChannelsFab: () => null, +})); +vi.mock("@posthog/ui/features/sidebar/components/SidebarNavSection", () => ({ + SidebarNavSection: () =>
, +})); +vi.mock("@posthog/ui/features/sidebar/components/SidebarMenu", () => ({ + SidebarMenu: () =>
, +})); +vi.mock("@posthog/ui/features/sidebar/components/ProjectSwitcher", () => ({ + ProjectSwitcher: () => null, +})); +vi.mock("@posthog/ui/features/sidebar/components/UpdateBanner", () => ({ + UpdateBanner: () => null, +})); +vi.mock("@posthog/ui/features/loops/components/LoopsPromoCard", () => ({ + LoopsPromoCard: () => null, +})); +vi.mock("@posthog/ui/features/workspace/useWorkspace", () => ({ + useWorkspaces: () => ({ data: {}, isFetched: true }), +})); +vi.mock("@tanstack/react-router", () => ({ + useParams: () => ({ channelId: mocks.routeChannelId }), +})); + +import { PROJECT_BLUEBIRD_FLAG } from "@posthog/shared"; +import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; +import { useCurrentChannelStore } from "@posthog/ui/features/canvas/stores/currentChannelStore"; +import { useSidebarStore } from "@posthog/ui/features/sidebar/sidebarStore"; +import { ChannelsSidebar } from "./ChannelsSidebar"; + +function renderSidebar() { + return render( + + + , + ); +} + +const ME = { id: "me-id", name: "me", path: "/me" }; + +describe("ChannelsSidebar", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.featureFlags.clear(); + mocks.channelsLayout = false; + mocks.channels = []; + mocks.channelsLoading = false; + mocks.archivedTaskIds = new Set(); + mocks.track.mockClear(); + mocks.routeChannelId = undefined; + useCurrentChannelStore.setState({ currentChannelId: null }); + useSidebarStore.setState({ channelsEnabled: false, open: true }); + }); + + describe("the Archived row", () => { + beforeEach(() => { + mocks.archivedTaskIds = new Set(["archived-1"]); + }); + + // The layout puts an Archive action on every item row, so hiding the + // destination leaves archived tasks with nowhere to be seen — + // navigateToArchived has no other caller in the app. + it("is present under the channels layout", () => { + mocks.channelsLayout = true; + mocks.channels = [ME]; + renderSidebar(); + expect(screen.getByText("Archived")).toBeTruthy(); + }); + + it("is present with neither channels world on", () => { + renderSidebar(); + expect(screen.getByText("Archived")).toBeTruthy(); + }); + + // The alpha replaced the task list with the channel tree, so the row went + // with it — that part is deliberate. + it("is absent in the channels alpha", () => { + useSidebarStore.setState({ channelsEnabled: true }); + mocks.featureFlags.set(PROJECT_BLUEBIRD_FLAG, true); + renderSidebar(); + expect(screen.queryByText("Archived")).toBeNull(); + }); + + it("stays hidden when nothing is archived", () => { + mocks.channelsLayout = true; + mocks.archivedTaskIds = new Set(); + renderSidebar(); + expect(screen.queryByText("Archived")).toBeNull(); + }); + }); + + describe("auto-scoping to #me", () => { + it("keeps a deep-linked channel instead of overwriting it with #me", () => { + mocks.channelsLayout = true; + mocks.channels = [ME, { id: "eng-id", name: "eng", path: "/eng" }]; + mocks.routeChannelId = "eng-id"; + + renderSidebar(); + + expect(useCurrentChannelStore.getState().currentChannelId).toBe("eng-id"); + }); + + it("scopes to the personal channel once the list lands", () => { + mocks.channelsLayout = true; + mocks.channels = [ME]; + renderSidebar(); + expect(useCurrentChannelStore.getState().currentChannelId).toBe("me-id"); + }); + + // Both flags behind the layout re-evaluate on every flags payload, so a + // momentary false must not permanently strand the sidebar unscoped: the + // auto-scope latch has to reset when the layout turns off. + it("re-scopes after the layout flag flickers off and back on", () => { + mocks.channelsLayout = true; + mocks.channels = [ME]; + const { rerender } = renderSidebar(); + expect(useCurrentChannelStore.getState().currentChannelId).toBe("me-id"); + + mocks.channelsLayout = false; + rerender( + + + , + ); + expect(useCurrentChannelStore.getState().currentChannelId).toBeNull(); + + mocks.channelsLayout = true; + rerender( + + + , + ); + expect(useCurrentChannelStore.getState().currentChannelId).toBe("me-id"); + }); + + it("does not scope to a channel the project does not have", () => { + mocks.channelsLayout = true; + mocks.channels = [{ id: "eng", name: "eng", path: "/eng" }]; + renderSidebar(); + expect(useCurrentChannelStore.getState().currentChannelId).toBeNull(); + expect(screen.queryByTestId("channel-sidebar")).toBeNull(); + }); + + // A stale id from a previous project must not be rendered as a channel. + it("clears a scoped channel missing from the loaded list", () => { + mocks.channelsLayout = true; + mocks.channels = [ME]; + useCurrentChannelStore.setState({ currentChannelId: "from-old-project" }); + renderSidebar(); + expect(useCurrentChannelStore.getState().currentChannelId).not.toBe( + "from-old-project", + ); + }); + }); + + it("renders the flag-off sidebar menu untouched", () => { + renderSidebar(); + expect(screen.getByTestId("sidebar-menu")).toBeTruthy(); + expect(screen.getByTestId("sidebar-nav-section")).toBeTruthy(); + }); + + describe("space-viewed tracking", () => { + // The event used to fire from ChannelsList, which the new layout barely + // renders — so space adoption would have read as zero once the flag landed. + it("fires from the shell under the channels layout", () => { + mocks.channelsLayout = true; + mocks.channels = [ME, { id: "eng", name: "eng", path: "/eng" }]; + renderSidebar(); + expect(mocks.track).toHaveBeenCalledWith( + ANALYTICS_EVENTS.CHANNELS_SPACE_VIEWED, + { channel_count: 1, starred_count: 0, layout: "channels" }, + ); + }); + + it("does not fire outside the channels world", () => { + mocks.channels = [ME]; + renderSidebar(); + expect(mocks.track).not.toHaveBeenCalledWith( + ANALYTICS_EVENTS.CHANNELS_SPACE_VIEWED, + expect.anything(), + ); + }); + + it("fires again after leaving and re-entering the channels world", () => { + mocks.channelsLayout = true; + mocks.channels = [ME]; + const { rerender } = renderSidebar(); + + mocks.channelsLayout = false; + rerender( + + + , + ); + mocks.channelsLayout = true; + rerender( + + + , + ); + + expect(mocks.track).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx b/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx index 3afc553050..45d6bc7eec 100644 --- a/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx +++ b/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx @@ -2,9 +2,16 @@ import { ArchiveIcon } from "@phosphor-icons/react"; import { Separator } from "@posthog/quill"; import { PROJECT_BLUEBIRD_FLAG } from "@posthog/shared"; import { useArchivedTaskIds } from "@posthog/ui/features/archive/useArchivedTaskIds"; +import { ChannelNav } from "@posthog/ui/features/canvas/components/ChannelNav"; +import { ChannelSidebar } from "@posthog/ui/features/canvas/components/ChannelSidebar"; import { ChannelsFab } from "@posthog/ui/features/canvas/components/ChannelsFab"; import { ChannelsList } from "@posthog/ui/features/canvas/components/ChannelsList"; import { useChannelsSidebarStore } from "@posthog/ui/features/canvas/components/channelsSidebarStore"; +import { useChannelsLayout } from "@posthog/ui/features/canvas/hooks/useChannelsLayout"; +import { useCurrentChannel } from "@posthog/ui/features/canvas/hooks/useCurrentChannel"; +import { PERSONAL_CHANNEL_NAME } from "@posthog/ui/features/canvas/hooks/useTaskChannels"; +import { useTrackChannelsSpaceViewed } from "@posthog/ui/features/canvas/hooks/useTrackChannelsSpaceViewed"; +import { useCurrentChannelStore } from "@posthog/ui/features/canvas/stores/currentChannelStore"; import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; import { LoopsPromoCard } from "@posthog/ui/features/loops/components/LoopsPromoCard"; import { useOnboardingStore } from "@posthog/ui/features/onboarding/onboardingStore"; @@ -12,6 +19,7 @@ import { ProjectSwitcher } from "@posthog/ui/features/sidebar/components/Project import { SidebarMenu } from "@posthog/ui/features/sidebar/components/SidebarMenu"; import { SidebarNavSection } from "@posthog/ui/features/sidebar/components/SidebarNavSection"; import { UpdateBanner } from "@posthog/ui/features/sidebar/components/UpdateBanner"; +import { CHANNELS_SIDEBAR_MIN_WIDTH } from "@posthog/ui/features/sidebar/constants"; import { beginSidebarPeek, cancelSidebarPeek, @@ -20,16 +28,14 @@ import { } from "@posthog/ui/features/sidebar/sidebarPeekStore"; import { useSidebarStore } from "@posthog/ui/features/sidebar/sidebarStore"; import { useWorkspaces } from "@posthog/ui/features/workspace/useWorkspace"; +import { ErrorBoundary } from "@posthog/ui/primitives/ErrorBoundary"; import { useSidebarEdgeHoverPeek } from "@posthog/ui/primitives/hooks/useSidebarEdgeHoverPeek"; import { ResizableSidebar } from "@posthog/ui/primitives/ResizableSidebar"; import { navigateToArchived } from "@posthog/ui/router/navigationBridge"; import { Box, Flex } from "@radix-ui/themes"; -import { useDeferredValue, useEffect } from "react"; +import { useParams } from "@tanstack/react-router"; +import { useDeferredValue, useEffect, useRef } from "react"; -// The unified app sidebar (Code merged into the Bluebird chrome). Top to -// bottom: workspace switcher, the merged global nav, the "Enable channels" -// opt-in, then the body — the task list by default, swapped for the channel -// tree once channels are enabled — and Settings pinned to the bottom. export function ChannelsSidebar() { const width = useChannelsSidebarStore((state) => state.width); const setWidth = useChannelsSidebarStore((state) => state.setWidth); @@ -78,15 +84,59 @@ export function ChannelsSidebar() { ); const channelsEnabled = useSidebarStore((s) => s.channelsEnabled) && bluebirdEnabled; - // The Switch (in SidebarNavSection) reads the live value and flips instantly. - // Swapping the sidebar body mounts a heavy tree (ChannelsList: the channels - // query + a provider-laden row per channel), so defer that decision: the - // urgent commit keeps the current body and paints the toggle, then the tree - // mounts in a follow-up non-blocking render. - const bodyChannelsEnabled = useDeferredValue(channelsEnabled); + const channelsLayout = useChannelsLayout(); + const channelsWorld = channelsLayout || channelsEnabled; + const bodyChannelsWorld = useDeferredValue(channelsWorld); + const showArchivedRow = channelsLayout || !bodyChannelsWorld; + useTrackChannelsSpaceViewed({ + enabled: channelsWorld, + layout: channelsLayout ? "channels" : "code", + }); + + const minWidth = channelsLayout ? CHANNELS_SIDEBAR_MIN_WIDTH : undefined; + useEffect(() => { + if (channelsLayout && width < CHANNELS_SIDEBAR_MIN_WIDTH) { + setWidth(CHANNELS_SIDEBAR_MIN_WIDTH); + } + }, [channelsLayout, width, setWidth]); const archivedTaskIds = useArchivedTaskIds(); + const params = useParams({ strict: false }); + const routeChannelId = params.channelId; + const setCurrentChannel = useCurrentChannelStore((s) => s.setCurrentChannel); + const { currentChannelId, channels } = useCurrentChannel({ + enabled: channelsLayout, + }); + useEffect(() => { + if (!channelsLayout || !routeChannelId) return; + setCurrentChannel(routeChannelId); + }, [channelsLayout, routeChannelId, setCurrentChannel]); + const inChannel = channelsLayout && currentChannelId != null; + + const autoScopedRef = useRef(false); + useEffect(() => { + if (!channelsLayout) { + autoScopedRef.current = false; + return; + } + // A route-scoped channel wins over the default. Both effects run from the + // same render on a cold deep link, so without this guard the route effect + // writes its channel and this later effect immediately overwrites it with + // #me using the stale `currentChannelId` captured by that render. + if (routeChannelId || autoScopedRef.current || currentChannelId) return; + const me = channels.find((c) => c.name === PERSONAL_CHANNEL_NAME); + if (!me) return; + autoScopedRef.current = true; + setCurrentChannel(me.id); + }, [ + channelsLayout, + channels, + currentChannelId, + routeChannelId, + setCurrentChannel, + ]); + return ( - {/* The nav owns the "Enable channels" toggle + Canvas rows (gated by - the same flag), so this section carries the whole merged nav. */} - + {!inChannel && } - {/* Body: the channel tree when channels are on, otherwise the task - list. Each owns its own scroll region. Gated on the deferred value so - the toggle paints before this heavy swap. */} - {bodyChannelsEnabled ? ( + {inChannel && currentChannelId ? ( + <> + + + } + resetKey={currentChannelId} + > + + + + + ) : bodyChannelsWorld ? ( <> - {/* The fab is a sibling of the scroll region, not a child, so it - stays pinned to the bottom-right instead of scrolling away. */} @@ -129,9 +186,7 @@ export function ChannelsSidebar() { - {/* Archived is a task-list affordance — hidden while channels are on, - since the body then shows the channel tree, not tasks. */} - {!channelsEnabled && archivedTaskIds.size > 0 && ( + {showArchivedRow && archivedTaskIds.size > 0 && ( + } + /> + + + { + track(ANALYTICS_EVENTS.SIDEBAR_NAV_ITEM_CLICKED, { + item: "new_task", + in_more: false, + }); + openTaskInput({ channelId }); + }} + > + + New task + + { + trackAndCreateCanvas( + channelId, + undefined, + "sidebar", + () => void createAndOpenCanvas(), + ); + }} + > + + New canvas + + + + ); +} diff --git a/packages/ui/src/features/canvas/components/TaskArtifactsList.test.tsx b/packages/ui/src/features/canvas/components/TaskArtifactsList.test.tsx new file mode 100644 index 0000000000..42782087fc --- /dev/null +++ b/packages/ui/src/features/canvas/components/TaskArtifactsList.test.tsx @@ -0,0 +1,63 @@ +import type { Task, TaskRun } from "@posthog/shared/domain-types"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + runs: [] as TaskRun[], +})); + +vi.mock("@posthog/ui/features/canvas/hooks/useTaskRuns", () => ({ + useTaskRuns: () => ({ runs: mocks.runs, isLoading: false }), +})); +vi.mock("@posthog/ui/features/git-interaction/usePrArtifact", () => ({ + usePrArtifact: (url: string) => ({ + safeUrl: url, + title: `Pull request #${url.split("/").at(-1)}`, + stateLabel: "Open", + Icon: () => , + iconColor: "currentColor", + }), +})); +vi.mock("@posthog/ui/features/pr-review/usePrComments", () => ({ + usePrComments: () => ({ data: undefined }), +})); +vi.mock("@posthog/ui/features/pr-review/usePrReviewThreads", () => ({ + usePrReviewThreads: () => ({ data: undefined }), +})); + +import { useReviewNavigationStore } from "@posthog/ui/features/code-review/reviewNavigationStore"; +import { TaskArtifactsList } from "./TaskArtifactsList"; + +const task = { + id: "task-1", + latest_run: null, +} as unknown as Task; + +function run(id: string, prNumber: number): TaskRun { + return { + id, + output: { pr_url: `https://github.com/acme/repo/pull/${prNumber}` }, + } as unknown as TaskRun; +} + +describe("TaskArtifactsList", () => { + beforeEach(() => { + mocks.runs = [run("run-1", 1), run("run-2", 2)]; + useReviewNavigationStore.setState({ + reviewModes: {}, + selectedPrUrls: {}, + }); + }); + + it("opens the PR represented by the selected historical row", () => { + render(); + + fireEvent.click(screen.getByText("Pull request #2")); + + const state = useReviewNavigationStore.getState(); + expect(state.selectedPrUrls[task.id]).toBe( + "https://github.com/acme/repo/pull/2", + ); + expect(state.reviewModes[task.id]).toBe("split"); + }); +}); diff --git a/packages/ui/src/features/canvas/components/TaskArtifactsList.tsx b/packages/ui/src/features/canvas/components/TaskArtifactsList.tsx new file mode 100644 index 0000000000..0f50a76fbe --- /dev/null +++ b/packages/ui/src/features/canvas/components/TaskArtifactsList.tsx @@ -0,0 +1,328 @@ +import { + ArrowSquareOutIcon, + ClipboardTextIcon, + PackageIcon, + SlackLogoIcon, +} from "@phosphor-icons/react"; +import { + parseRunPlans, + type RunArtifact, +} from "@posthog/core/canvas/runArtifactSchemas"; +import type { ThreadTimelineRow } from "@posthog/core/canvas/threadTimeline"; +import { + Empty, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from "@posthog/quill"; +import type { + Task, + TaskRun, + TaskThreadMessage, +} from "@posthog/shared/domain-types"; +import { useOptionalAuthenticatedClient } from "@posthog/ui/features/auth/authClient"; +import { iconForTemplate } from "@posthog/ui/features/canvas/components/canvasTemplateIcon"; +import { useTaskRuns } from "@posthog/ui/features/canvas/hooks/useTaskRuns"; +import { useReviewNavigationStore } from "@posthog/ui/features/code-review/reviewNavigationStore"; +import { usePrArtifact } from "@posthog/ui/features/git-interaction/usePrArtifact"; +import { usePrComments } from "@posthog/ui/features/pr-review/usePrComments"; +import { usePrReviewThreads } from "@posthog/ui/features/pr-review/usePrReviewThreads"; +import { toast } from "@posthog/ui/primitives/toast"; +import { openExternalUrl } from "@posthog/ui/shell/openExternal"; +import { parseHttpsUrl, parseShareLink } from "@posthog/ui/utils/posthogLinks"; +import { navigateToShareTarget } from "@posthog/ui/utils/shareLinks"; +import { getPostHogUrl } from "@posthog/ui/utils/urls"; +import { type ReactNode, useMemo, useState } from "react"; + +type ArtifactRow = + | { kind: "pr"; key: string; url: string } + | { kind: "canvas"; key: string; name: string; url: string | null } + | { + kind: "plan"; + key: string; + name: string; + storagePath: string | null; + runId: string | null; + } + | { kind: "slack"; key: string; url: string }; + +function readRunPlans(run: TaskRun): RunArtifact[] { + return parseRunPlans((run as { artifacts?: unknown }).artifacts); +} + +const UUID_RE = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i; +function planTitle(name: string | undefined): string { + if (!name || UUID_RE.test(name)) return "Plan"; + return name; +} + +function buildRows( + task: Task, + timeline: ThreadTimelineRow[], + runs: TaskRun[], +): ArtifactRow[] { + const rows: ArtifactRow[] = []; + const seenPrUrls = new Set(); + const seenPlanKeys = new Set(); + + const addPr = (url: string, key: string) => { + if (seenPrUrls.has(url)) return; + seenPrUrls.add(url); + rows.push({ kind: "pr", key, url }); + }; + + for (const row of timeline) { + if (row.kind !== "artifact") continue; + if (row.artifact.kind === "pr") { + addPr(row.artifact.url, row.message.id); + } else { + rows.push({ + kind: "canvas", + key: row.message.id, + name: row.artifact.name, + url: row.artifact.url, + }); + } + } + + const allRuns = + runs.length > 0 ? runs : task.latest_run ? [task.latest_run] : []; + for (const run of allRuns) { + const outputPr = run.output?.pr_url; + if (typeof outputPr === "string" && outputPr) { + addPr(outputPr, `output-pr:${outputPr}`); + } + for (const plan of readRunPlans(run)) { + const key = `plan:${plan.id ?? plan.storage_path ?? plan.name}`; + if (seenPlanKeys.has(key)) continue; + seenPlanKeys.add(key); + rows.push({ + kind: "plan", + key, + name: planTitle(plan.name), + storagePath: plan.storage_path ?? null, + runId: run.id, + }); + } + } + + const slackUrl = task.latest_run?.state?.slack_thread_url; + if (typeof slackUrl === "string" && slackUrl) { + rows.push({ kind: "slack", key: "slack-thread", url: slackUrl }); + } + + return rows; +} + +function ArtifactListRow({ + icon, + title, + detail, + external, + onOpen, + onHoverStart, +}: { + icon: ReactNode; + title: string; + detail?: string | null; + external?: boolean; + onOpen?: () => void; + onHoverStart?: () => void; +}) { + return ( + + ); +} + +function PrRow({ url, taskId }: { url: string; taskId: string }) { + const { safeUrl, title, stateLabel, Icon, iconColor } = usePrArtifact(url); + const setReviewMode = useReviewNavigationStore((s) => s.setReviewMode); + const setSelectedPrUrl = useReviewNavigationStore((s) => s.setSelectedPrUrl); + + const [countsWanted, setCountsWanted] = useState(false); + const comments = usePrComments(countsWanted ? safeUrl : null); + const threads = usePrReviewThreads(countsWanted ? safeUrl : null); + + const commentCount = + (comments.data?.length ?? 0) + + (threads.data ?? []).reduce( + (sum, thread) => sum + thread.comments.length, + 0, + ); + const detailParts = [ + stateLabel, + comments.data || threads.data + ? `${commentCount} ${commentCount === 1 ? "comment" : "comments"}` + : null, + ].filter(Boolean); + + return ( + + } + title={title} + detail={detailParts.join(" · ") || null} + onHoverStart={() => setCountsWanted(true)} + onOpen={ + safeUrl + ? () => { + setSelectedPrUrl(taskId, safeUrl); + setReviewMode(taskId, "split"); + } + : undefined + } + /> + ); +} + +function CanvasRow({ name, url }: { name: string; url: string | null }) { + const parsed = url ? parseHttpsUrl(url) : null; + const target = parsed ? parseShareLink(parsed.href) : null; + const open = + parsed && target + ? () => { + const currentPostHogUrl = getPostHogUrl("/"); + const currentPostHogOrigin = currentPostHogUrl + ? parseHttpsUrl(currentPostHogUrl)?.origin + : null; + if (parsed.origin === currentPostHogOrigin) { + navigateToShareTarget(target); + } else { + openExternalUrl(parsed.href); + } + } + : undefined; + return ( + + ); +} + +function PlanRow({ + taskId, + runId, + name, + storagePath, +}: { + taskId: string; + runId: string | null; + name: string; + storagePath: string | null; +}) { + const client = useOptionalAuthenticatedClient(); + const canOpen = !!client && !!runId && !!storagePath; + const onOpen = canOpen + ? () => { + client + .presignTaskRunArtifact( + taskId, + runId as string, + storagePath as string, + ) + .then((url) => openExternalUrl(url)) + .catch((error: unknown) => { + toast.error("Couldn't open plan", { + description: + error instanceof Error ? error.message : String(error), + }); + }); + } + : undefined; + return ( + } + title={name} + detail="Plan" + external={canOpen} + onOpen={onOpen} + /> + ); +} + +export function TaskArtifactsList({ + task, + timeline, +}: { + task: Task; + timeline: ThreadTimelineRow[]; +}) { + const { runs } = useTaskRuns(task.id); + const rows = useMemo( + () => buildRows(task, timeline, runs), + [task, timeline, runs], + ); + + if (rows.length === 0) { + return ( + + + + + + No artifacts yet + + Pull requests and canvases produced while working on this task show + up here. + + + + ); + } + + return ( +
+ {rows.map((row) => + row.kind === "pr" ? ( + + ) : row.kind === "canvas" ? ( + + ) : row.kind === "plan" ? ( + + ) : ( + } + title="Slack thread" + detail="External" + external + onOpen={() => openExternalUrl(row.url)} + /> + ), + )} +
+ ); +} diff --git a/packages/ui/src/features/canvas/components/ThreadPanel.tsx b/packages/ui/src/features/canvas/components/ThreadPanel.tsx index 8b242509ee..6e54d9e7f8 100644 --- a/packages/ui/src/features/canvas/components/ThreadPanel.tsx +++ b/packages/ui/src/features/canvas/components/ThreadPanel.tsx @@ -7,19 +7,11 @@ import { TrashIcon, XIcon, } from "@phosphor-icons/react"; -import { - buildThreadTimeline, - deriveThreadAgentStatus, - hasAgentMention, - shouldSuspendThreadSession, - type ThreadAgentStatus, - type ThreadArtifact, - type ThreadTimelineRow, +import type { + ThreadAgentStatus, + ThreadArtifact, + ThreadTimelineRow, } from "@posthog/core/canvas/threadTimeline"; -import { - getPrVisualConfig, - parsePrNumber, -} from "@posthog/core/git-interaction/prStatus"; import { Avatar, AvatarFallback, @@ -47,44 +39,27 @@ import { ThreadItemGutter, ThreadItemHeader, } from "@posthog/quill"; -import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; import type { Task, TaskThreadMessage, UserBasic, } from "@posthog/shared/domain-types"; -import { isTerminalStatus } from "@posthog/shared/domain-types"; -import { useOptionalAuthenticatedClient } from "@posthog/ui/features/auth/authClient"; import { UserAvatar } from "@posthog/ui/features/auth/UserAvatar"; -import { useCurrentUser } from "@posthog/ui/features/auth/useCurrentUser"; import { TaskCard } from "@posthog/ui/features/canvas/components/ChannelFeedView"; import { iconForTemplate } from "@posthog/ui/features/canvas/components/canvasTemplateIcon"; import { MentionComposer } from "@posthog/ui/features/canvas/components/MentionComposer"; import { MentionText } from "@posthog/ui/features/canvas/components/MentionText"; import { ThreadTimestamp } from "@posthog/ui/features/canvas/components/ThreadTimestamp"; -import { useOrgMembers } from "@posthog/ui/features/canvas/hooks/useOrgMembers"; -import { - useDeleteTaskThreadMessage, - usePostTaskThreadMessage, - usePostTaskThreadMessageToAgent, - useSendTaskThreadMessageToAgent, - useTaskThread, -} from "@posthog/ui/features/canvas/hooks/useTaskThread"; +import { useThreadConversation } from "@posthog/ui/features/canvas/hooks/useThreadConversation"; import { userDisplayName } from "@posthog/ui/features/canvas/utils/userDisplay"; -import { getPrVisualIcon } from "@posthog/ui/features/git-interaction/prIcon"; -import { usePrDetails } from "@posthog/ui/features/git-interaction/usePrDetails"; -import { useSessionConnection } from "@posthog/ui/features/sessions/hooks/useSessionConnection"; -import { useSessionViewState } from "@posthog/ui/features/sessions/hooks/useSessionViewState"; -import { usePendingPermissionsForTask } from "@posthog/ui/features/sessions/sessionStore"; +import { usePrArtifact } from "@posthog/ui/features/git-interaction/usePrArtifact"; import { taskDetailQuery } from "@posthog/ui/features/tasks/queries"; -import { toast } from "@posthog/ui/primitives/toast"; -import { track } from "@posthog/ui/shell/analytics"; import { openExternalUrl } from "@posthog/ui/shell/openExternal"; -import { parseShareLink } from "@posthog/ui/utils/posthogLinks"; +import { parseHttpsUrl, parseShareLink } from "@posthog/ui/utils/posthogLinks"; import { navigateToShareTarget } from "@posthog/ui/utils/shareLinks"; import { getPostHogUrl } from "@posthog/ui/utils/urls"; import { useQuery } from "@tanstack/react-query"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useRef } from "react"; export function ThreadMessageRow({ message, @@ -215,15 +190,6 @@ function ArtifactCardButton({ ); } -function parseHttpsUrl(url: string): URL | null { - try { - const parsedUrl = new URL(url); - return parsedUrl.protocol === "https:" ? parsedUrl : null; - } catch { - return null; - } -} - function CanvasArtifactCard({ name, url, @@ -257,28 +223,19 @@ function CanvasArtifactCard({ } function PrArtifactCard({ url }: { url: string }) { - const parsedUrl = parseHttpsUrl(url); - const safeUrl = - parsedUrl?.origin === "https://github.com" ? parsedUrl.href : null; - const { - meta: { state, merged, draft }, - } = usePrDetails(safeUrl); - const config = getPrVisualConfig(state ?? "open", merged, draft); - const PrIcon = getPrVisualIcon(config.icon); - const prNumber = safeUrl ? parsePrNumber(safeUrl) : null; + const { safeUrl, title, stateLabel, Icon, iconColor } = usePrArtifact(url); return ( } - title={prNumber ? `Pull request #${prNumber}` : "Pull request"} - // Only show the resolved state once we have it, to avoid a flash of "Open". - detail={state ? config.label : null} + title={title} + detail={stateLabel} onOpen={safeUrl ? () => openExternalUrl(safeUrl) : undefined} /> ); @@ -319,7 +276,7 @@ export function ThreadArtifactRow({ ); } -function ThreadLoadingState() { +export function ThreadLoadingState() { return ( @@ -332,11 +289,15 @@ function ThreadLoadingState() { ); } -function ThreadHeader({ +/** The panel's title row and window controls. Shared with ActivityPanel, which + * is the same chrome under a different title. */ +export function ThreadPanelHeader({ + title, onClose, onToggleCollapsed, onOpenFull, }: { + title: string; onClose?: () => void; onToggleCollapsed?: () => void; onOpenFull?: () => void; @@ -344,7 +305,7 @@ function ThreadHeader({ return (
- Thread + {title}
{onOpenFull && (
); diff --git a/packages/ui/src/features/canvas/components/ThreadSidebar.tsx b/packages/ui/src/features/canvas/components/ThreadSidebar.tsx index 4a81cf8700..1dfa13cb29 100644 --- a/packages/ui/src/features/canvas/components/ThreadSidebar.tsx +++ b/packages/ui/src/features/canvas/components/ThreadSidebar.tsx @@ -1,15 +1,15 @@ import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; import type { Task } from "@posthog/shared/domain-types"; +import { ActivityPanel } from "@posthog/ui/features/canvas/components/ActivityPanel"; import { ThreadPanel } from "@posthog/ui/features/canvas/components/ThreadPanel"; +import { useChannelsLayout } from "@posthog/ui/features/canvas/hooks/useChannelsLayout"; import { useThreadPanelStore } from "@posthog/ui/features/canvas/stores/threadPanelStore"; import { ResizableSidebar } from "@posthog/ui/primitives/ResizableSidebar"; import { track } from "@posthog/ui/shell/analytics"; import { useState } from "react"; -// The right-hand dock hosting a task's ThreadPanel: a thin rail when -// collapsed, a resizable sidebar otherwise. Shared by the channel feed and the -// task detail route; owns the panel-store size/collapse reads so parents don't -// re-render on every resize tick. +// The right-hand dock for a task's thread (collapsible, resizable). Flag on +// swaps the legacy ThreadPanel for the tabbed ActivityPanel. export function ThreadSidebar({ taskId, channelId, @@ -31,19 +31,21 @@ export function ThreadSidebar({ const setWidth = useThreadPanelStore((s) => s.setWidth); const setCollapsed = useThreadPanelStore((s) => s.setCollapsed); const [isResizing, setIsResizing] = useState(false); + const channelsLayout = useChannelsLayout(); + const Panel = channelsLayout ? ActivityPanel : ThreadPanel; const toggleCollapsed = (next: boolean) => { setCollapsed(next); track(ANALYTICS_EVENTS.CHANNEL_ACTION, { action_type: next ? "collapse_thread" : "expand_thread", - surface: "thread_panel", + surface: channelsLayout ? "activity_panel" : "thread_panel", task_id: taskId, }); }; if (collapsed) { return ( - - openPr(item.prUrl)} + onClick={openPr} /> ), )} @@ -171,8 +166,8 @@ export function WebsiteChannelArtifacts({ channelId }: { channelId: string }) { } // A PR artifact row. The PR's lifecycle state (open / draft / merged / closed) -// is fetched per-URL (deduped + cached by usePrDetails) so the icon and label -// reflect the live state. +// 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. function PrArtifactRow({ title, prUrl, @@ -182,37 +177,28 @@ function PrArtifactRow({ title: string; prUrl: string; ts: number; - onClick: () => void; + onClick: (safeUrl: string) => void; }) { const { - meta: { state, merged, draft }, - } = usePrDetails(prUrl); - const config = getPrVisualConfig(state ?? "open", merged, draft); - const PrIcon = getPrVisualIcon(config.icon); - const prNumber = parsePrNumber(prUrl); + safeUrl, + title: prTitle, + stateLabel, + Icon, + iconColor, + accentColor, + } = usePrArtifact(prUrl); - const subtitle = [ - prNumber ? `Pull request #${prNumber}` : "Pull request", - // Only show the resolved state once we have it, to avoid a flash of "Open". - state ? config.label : null, - formatRelativeTimeShort(ts), - ] + const subtitle = [prTitle, stateLabel, formatRelativeTimeShort(ts)] .filter(Boolean) .join(" · "); return ( - } + accent={accentColor} + icon={} title={title} subtitle={subtitle} - onClick={onClick} + onClick={safeUrl ? () => onClick(safeUrl) : undefined} /> ); } @@ -228,13 +214,15 @@ function ArtifactRow({ accent: string; title: string; subtitle: string; - onClick: () => void; + /** Absent for a row with nowhere safe to go — a non-github PR link. */ + onClick?: () => void; }) { return (
- {localWorkspaces && ( + {(localWorkspaces || channelsLayout) && ( - - - - + + + )} + {localWorkspaces && ( + + + + + )} )} - {/* Tabs work in both spaces: channel tabs under /website and plain - task tabs in the Code experience. The strip's route→tab effect - noops on param-less routes (inbox, agents, new-task), so it's safe - to mount everywhere. */} - + {/* The new layout has no global tab strip (tabs live inside the + task view); search/inbox/activity live in the sidebar nav. The + strip is also the only global owner of Cmd+W, so something has to + keep holding that key when it isn't mounted. */} + {channelsLayout ? ( + + ) : ( + + )} {/* Gated so an empty right-side group can't claim a no-drag rect in the title bar for nothing — every pixel without controls should drag the window. */} - {(billingEnabled || channelsEnabled) && ( + {(billingEnabled || channelsEnabled || channelsLayout) && ( - {channelsEnabled && ( + {(channelsEnabled || channelsLayout) && (