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/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/shared/src/analytics-events.ts b/packages/shared/src/analytics-events.ts index 99ddc02757..f5285b079b 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 { @@ -906,7 +915,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; @@ -925,6 +935,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; } @@ -992,6 +1004,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/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/WebsiteNewTask.test.tsx b/packages/ui/src/features/canvas/components/WebsiteNewTask.test.tsx index 0ee1e45e2e..ad0160f9d5 100644 --- a/packages/ui/src/features/canvas/components/WebsiteNewTask.test.tsx +++ b/packages/ui/src/features/canvas/components/WebsiteNewTask.test.tsx @@ -12,23 +12,27 @@ if (typeof globalThis.ResizeObserver === "undefined") { } as unknown as typeof ResizeObserver; } -const { track, useFolderInstructions } = vi.hoisted(() => ({ +const { track, useFolderInstructions, taskInputProps } = vi.hoisted(() => ({ track: vi.fn(), useFolderInstructions: vi.fn(), + taskInputProps: vi.fn(), })); // TaskInput is a huge hook-heavy component; stub it down to just the surface // this test cares about — a button that fires onContextChipClick when wired. vi.mock("@posthog/ui/features/task-detail/components/TaskInput", () => ({ - TaskInput: ({ onContextChipClick }: { onContextChipClick?: () => void }) => ( - - ), + TaskInput: (props: { onContextChipClick?: () => void }) => { + taskInputProps(props); + return ( + + ); + }, })); vi.mock("@posthog/ui/features/canvas/hooks/useChannels", () => ({ @@ -39,6 +43,11 @@ vi.mock("@posthog/ui/features/canvas/hooks/useChannels", () => ({ vi.mock("@posthog/ui/features/canvas/hooks/useChannelTasks", () => ({ useChannelTaskMutations: () => ({ fileTask: vi.fn() }), })); +vi.mock("@posthog/ui/features/canvas/hooks/useTaskChannels", () => ({ + useBackendChannel: () => ({ + channel: { id: "backend-channel-1", name: "project-bluebird" }, + }), +})); vi.mock("@posthog/ui/features/canvas/hooks/useFolderInstructions", () => ({ useFolderInstructions, })); @@ -46,7 +55,22 @@ vi.mock("@posthog/ui/shell/analytics", () => ({ track })); vi.mock("@tanstack/react-query", () => ({ useQueryClient: () => ({ setQueryData: vi.fn() }), })); -vi.mock("@tanstack/react-router", () => ({ useNavigate: () => vi.fn() })); +vi.mock("@tanstack/react-router", () => ({ + useNavigate: () => vi.fn(), + // The view reads the matched route so it can pass new-task prefill through. + useRouterState: ({ + select, + }: { + select: (s: { + matches: { routeId: string; params: Record }[]; + }) => unknown; + }) => + select({ + matches: [ + { routeId: "/website/$channelId/new", params: { channelId: "chan-1" } }, + ], + }), +})); import { WebsiteNewTask } from "./WebsiteNewTask"; @@ -62,6 +86,19 @@ describe("WebsiteNewTask context panel", () => { beforeEach(() => { track.mockReset(); useFolderInstructions.mockReset(); + taskInputProps.mockReset(); + }); + + it("creates the task in the channel's backend feed", () => { + useFolderInstructions.mockReturnValue({ data: undefined }); + renderNewTask(); + + expect(taskInputProps).toHaveBeenLastCalledWith( + expect.objectContaining({ + channelId: "backend-channel-1", + channelContextId: "chan-1", + }), + ); }); it("opens the context panel and tracks view_context when the chip is clicked", async () => { diff --git a/packages/ui/src/features/canvas/components/WebsiteNewTask.tsx b/packages/ui/src/features/canvas/components/WebsiteNewTask.tsx index 37863bbae8..8e17239f1f 100644 --- a/packages/ui/src/features/canvas/components/WebsiteNewTask.tsx +++ b/packages/ui/src/features/canvas/components/WebsiteNewTask.tsx @@ -6,11 +6,13 @@ import { ChannelContextPanel } from "@posthog/ui/features/canvas/components/Chan import { useChannels } from "@posthog/ui/features/canvas/hooks/useChannels"; import { useChannelTaskMutations } from "@posthog/ui/features/canvas/hooks/useChannelTasks"; import { useFolderInstructions } from "@posthog/ui/features/canvas/hooks/useFolderInstructions"; +import { useBackendChannel } from "@posthog/ui/features/canvas/hooks/useTaskChannels"; import { TaskInput } from "@posthog/ui/features/task-detail/components/TaskInput"; import { taskDetailQuery } from "@posthog/ui/features/tasks/queries"; import { useSetHeaderContent } from "@posthog/ui/hooks/useSetHeaderContent"; import { ResizableSidebar } from "@posthog/ui/primitives/ResizableSidebar"; import { toast } from "@posthog/ui/primitives/toast"; +import { useAppView } from "@posthog/ui/router/useAppView"; import { track } from "@posthog/ui/shell/analytics"; import { Flex } from "@radix-ui/themes"; import { useQueryClient } from "@tanstack/react-query"; @@ -23,10 +25,12 @@ import { useCallback, useMemo, useState } from "react"; // channel folder on the project's desktop_file_system surface. export function WebsiteNewTask({ channelId }: { channelId: string }) { const navigate = useNavigate(); + const view = useAppView(); const queryClient = useQueryClient(); const { fileTask } = useChannelTaskMutations(); const { channels } = useChannels(); const channelName = channels.find((c) => c.id === channelId)?.name; + const { channel: backendChannel } = useBackendChannel(channelName); // Surface the channel breadcrumb in the shared header, same as the other // channel scenes ("# channel / New task"). @@ -110,8 +114,16 @@ export function WebsiteNewTask({ channelId }: { channelId: string }) { onTaskCreated={onTaskCreated} channelContext={channelContext} channelName={channelName} + channelId={backendChannel?.id} channelContextId={channelId} allowNoRepo + // So a prompt handed to openTaskInput survives routing into a channel. + initialPrompt={view.initialPrompt} + initialPromptKey={view.taskInputRequestId} + initialCloudRepository={view.initialCloudRepository} + initialModel={view.initialModel} + initialMode={view.initialMode} + reportAssociation={view.reportAssociation} suggestions={CHANNEL_TASK_SUGGESTIONS} onSuggestionSelect={(label) => track(ANALYTICS_EVENTS.CHANNEL_ACTION, { diff --git a/packages/ui/src/features/canvas/hooks/useChannelItems.test.tsx b/packages/ui/src/features/canvas/hooks/useChannelItems.test.tsx new file mode 100644 index 0000000000..b75bdaa76e --- /dev/null +++ b/packages/ui/src/features/canvas/hooks/useChannelItems.test.tsx @@ -0,0 +1,173 @@ +import { renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + channels: { + channels: [] as { id: string; name: string; path: string }[], + isLoading: true, + }, + dashboards: { dashboards: [] as unknown[], isLoading: false }, + feed: { tasks: [] as unknown[], isLoading: false }, + currentUser: undefined as { uuid: string; first_name?: string } | undefined, + currentUserLoading: false, + useBackendChannel: vi.fn(), + // Stable identities, mirroring the real hooks — a fresh function per render + // would hide the very memoization this file asserts. + setPinned: vi.fn(), + togglePin: vi.fn(), + archiveTask: vi.fn(), + navigate: vi.fn(), +})); + +vi.mock("@posthog/ui/features/canvas/hooks/useChannels", () => ({ + useChannels: () => mocks.channels, +})); +vi.mock("@posthog/ui/features/canvas/hooks/useDashboards", () => ({ + useDashboards: () => mocks.dashboards, + useDashboardMutations: () => ({ setPinned: mocks.setPinned }), +})); +vi.mock("@posthog/ui/features/canvas/hooks/useChannelFeed", () => ({ + useChannelFeed: () => mocks.feed, +})); +vi.mock("@posthog/ui/features/canvas/hooks/useTaskChannels", () => ({ + PERSONAL_CHANNEL_NAME: "me", + useBackendChannel: (name: string | undefined) => { + mocks.useBackendChannel(name); + return { channel: undefined, isLoading: false }; + }, +})); +vi.mock("@posthog/ui/features/archive/useArchivedTaskIds", () => ({ + useArchivedTaskIds: () => new Set(), +})); +vi.mock("@posthog/ui/features/archive/useArchiveTask", () => ({ + useArchiveTask: () => ({ archiveTask: mocks.archiveTask }), +})); +vi.mock("@posthog/ui/features/sidebar/usePinnedTasks", () => ({ + usePinnedTasks: () => ({ + pinnedTaskIds: new Set(), + togglePin: mocks.togglePin, + }), +})); +vi.mock("@posthog/ui/features/auth/authClient", () => ({ + useOptionalAuthenticatedClient: () => undefined, +})); +vi.mock("@posthog/ui/features/auth/useCurrentUser", () => ({ + useCurrentUser: () => ({ + data: mocks.currentUser, + isLoading: mocks.currentUserLoading, + }), +})); +vi.mock("@tanstack/react-router", () => ({ + useNavigate: () => mocks.navigate, +})); + +import { useChannelItems } from "./useChannelItems"; + +const ME = { uuid: "me-uuid", first_name: "Ada", last_name: "Lovelace" }; + +function canvas(id: string, createdBy?: string) { + return { + id, + channelId: "c1", + name: id, + templateId: "freeform", + createdBy, + createdAt: 0, + updatedAt: 1_000, + }; +} + +describe("useChannelItems", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.channels = { channels: [], isLoading: true }; + mocks.dashboards = { dashboards: [], isLoading: false }; + mocks.feed = { tasks: [], isLoading: false }; + mocks.currentUser = undefined; + mocks.currentUserLoading = false; + }); + + // The bug this pins: a placeholder channel name reaches useBackendChannel, + // whose resolve-or-create effect provisions a real backend channel named + // after the placeholder on every cold load. + it("never hands a channel name to the resolver while the list is pending", () => { + renderHook(() => useChannelItems("c1")); + expect(mocks.useBackendChannel).toHaveBeenCalledWith(undefined); + expect(mocks.useBackendChannel).not.toHaveBeenCalledWith("channel"); + }); + + it("reports loading and no items until the channel's identity is known", () => { + // Dashboards are keyed on the route param so they can resolve first — + // which is exactly how foreign items used to flash into #me. + mocks.dashboards = { + dashboards: [canvas("d1", "Grace Hopper")], + isLoading: false, + }; + + const { result } = renderHook(() => useChannelItems("c1")); + + expect(result.current.items).toEqual([]); + expect(result.current.isLoading).toBe(true); + }); + + it("passes the real name through once the list lands", () => { + mocks.channels = { + channels: [{ id: "c1", name: "eng", path: "/eng" }], + isLoading: false, + }; + renderHook(() => useChannelItems("c1")); + expect(mocks.useBackendChannel).toHaveBeenCalledWith("eng"); + }); + + it("filters the personal channel to the viewer once identity resolves", () => { + mocks.channels = { + channels: [{ id: "c1", name: "me", path: "/me" }], + isLoading: false, + }; + mocks.dashboards = { + dashboards: [ + canvas("mine", "Ada Lovelace"), + canvas("theirs", "Grace Hopper"), + ], + isLoading: false, + }; + mocks.currentUser = ME; + + const { result } = renderHook(() => useChannelItems("c1")); + + expect(result.current.items.map((i) => i.id)).toEqual(["mine"]); + }); + + it("keeps #me private while the viewer is loading", () => { + mocks.channels = { + channels: [{ id: "c1", name: "me", path: "/me" }], + isLoading: false, + }; + mocks.dashboards = { + dashboards: [ + canvas("mine", "Ada Lovelace"), + canvas("theirs", "Grace Hopper"), + ], + isLoading: false, + }; + mocks.currentUser = undefined; + mocks.currentUserLoading = true; + + const { result } = renderHook(() => useChannelItems("c1")); + + expect(result.current.isLoading).toBe(true); + expect(result.current.items).toEqual([]); + }); + + it("reports a channel that is not in the project rather than spinning", () => { + mocks.channels = { + channels: [{ id: "other", name: "eng", path: "/eng" }], + isLoading: false, + }; + + const { result } = renderHook(() => useChannelItems("deleted")); + + expect(result.current.channelMissing).toBe(true); + expect(result.current.isLoading).toBe(false); + }); +}); diff --git a/packages/ui/src/features/canvas/hooks/useChannelItems.tsx b/packages/ui/src/features/canvas/hooks/useChannelItems.tsx new file mode 100644 index 0000000000..14014f14f6 --- /dev/null +++ b/packages/ui/src/features/canvas/hooks/useChannelItems.tsx @@ -0,0 +1,153 @@ +import { + buildChannelItems, + type ChannelItemModel, + type ChannelItemOwner, +} from "@posthog/core/canvas/channelItems"; +import { useArchivedTaskIds } from "@posthog/ui/features/archive/useArchivedTaskIds"; +import { useArchiveTask } from "@posthog/ui/features/archive/useArchiveTask"; +import { useOptionalAuthenticatedClient } from "@posthog/ui/features/auth/authClient"; +import { useCurrentUser } from "@posthog/ui/features/auth/useCurrentUser"; +import type { ChannelItemActions } from "@posthog/ui/features/canvas/components/ChannelItemRow"; +import { useChannelFeed } from "@posthog/ui/features/canvas/hooks/useChannelFeed"; +import { useChannels } from "@posthog/ui/features/canvas/hooks/useChannels"; +import { + useDashboardMutations, + useDashboards, +} from "@posthog/ui/features/canvas/hooks/useDashboards"; +import { + PERSONAL_CHANNEL_NAME, + useBackendChannel, +} from "@posthog/ui/features/canvas/hooks/useTaskChannels"; +import { userDisplayName } from "@posthog/ui/features/canvas/utils/userDisplay"; +import { usePinnedTasks } from "@posthog/ui/features/sidebar/usePinnedTasks"; +import { toast } from "@posthog/ui/primitives/toast"; +import { useNavigate } from "@tanstack/react-router"; +import { useMemo } from "react"; + +/** + * A channel's canvases + task feed as merged, newest-first items, plus the row + * actions and the viewer's identity for the recent-list filters. + * + * The channel's *name* is resolved here rather than accepted as an argument: it + * feeds `useBackendChannel`, whose resolve-or-create effect provisions a backend + * channel for any name it's handed. A caller with a half-loaded channel list has + * nothing truthful to pass, and a placeholder would create a real channel named + * after the placeholder. While the name is unknown the hook reports loading and + * yields nothing — which also keeps the personal-channel ownership filter from + * running against an identity we haven't established yet. + */ +export function useChannelItems(channelId: string): { + items: ChannelItemModel[]; + actions: ChannelItemActions; + /** Who the viewer is, for the created-by filter. */ + me: ChannelItemOwner; + isLoading: boolean; + /** The channel id resolves to no channel in this project. */ + channelMissing: boolean; +} { + const navigate = useNavigate(); + + const { channels, isLoading: channelsLoading } = useChannels(); + const channel = channels.find((c) => c.id === channelId); + const channelName = channel?.name; + const identityKnown = channelName !== undefined; + const isPersonal = channelName === PERSONAL_CHANNEL_NAME; + + const { dashboards, isLoading: dashboardsLoading } = useDashboards(channelId); + const { channel: backendChannel, isLoading: channelLoading } = + useBackendChannel(channelName); + const { tasks: feedTasks, isLoading: feedLoading } = useChannelFeed( + backendChannel?.id, + ); + const archivedTaskIds = useArchivedTaskIds(); + const { pinnedTaskIds, togglePin } = usePinnedTasks(); + const { archiveTask } = useArchiveTask({ navigateSpace: "website" }); + const { setPinned: setCanvasPinned } = useDashboardMutations(); + const client = useOptionalAuthenticatedClient(); + const { data: currentUser, isLoading: viewerLoading } = useCurrentUser({ + client, + }); + + const meUuid = currentUser?.uuid ?? null; + const meName = currentUser ? userDisplayName(currentUser) : null; + const me = useMemo( + () => ({ uuid: meUuid, name: meName }), + [meUuid, meName], + ); + const viewerKnown = meUuid != null || meName != null; + + const items = useMemo( + () => + identityKnown && (!isPersonal || viewerKnown) + ? buildChannelItems({ + dashboards, + feedTasks, + archivedTaskIds, + pinnedTaskIds, + // The personal channel is yours — but don't filter until we know + // who you are, or #me flashes everyone's items on a cold load. + ownedBy: isPersonal && viewerKnown ? me : null, + }) + : [], + [ + identityKnown, + dashboards, + feedTasks, + archivedTaskIds, + pinnedTaskIds, + isPersonal, + viewerKnown, + me, + ], + ); + + const actions = useMemo( + () => ({ + open: (item) => { + if (item.kind === "canvas") { + void navigate({ + to: "/website/$channelId/dashboards/$dashboardId", + params: { channelId, dashboardId: item.id }, + }); + } else { + void navigate({ + to: "/website/$channelId/tasks/$taskId", + params: { channelId, taskId: item.id }, + }); + } + }, + togglePin: (item) => { + const pin = + item.kind === "canvas" + ? setCanvasPinned(item.id, !item.pinned) + : togglePin(item.id); + pin.catch(() => { + toast.error("Couldn't update pin"); + }); + }, + archive: (item) => { + void archiveTask({ taskId: item.id }); + }, + }), + [channelId, navigate, setCanvasPinned, togglePin, archiveTask], + ); + + // A channel that isn't in the list will never resolve, so stop reporting + // loading and let the caller say so instead of spinning forever. + const channelMissing = !channelsLoading && !channel; + + return { + items, + actions, + me, + isLoading: + !channelMissing && + (channelsLoading || + !identityKnown || + dashboardsLoading || + channelLoading || + feedLoading || + (isPersonal && viewerLoading)), + channelMissing, + }; +} diff --git a/packages/ui/src/features/canvas/hooks/useChannelsLayout.ts b/packages/ui/src/features/canvas/hooks/useChannelsLayout.ts new file mode 100644 index 0000000000..2d891f42be --- /dev/null +++ b/packages/ui/src/features/canvas/hooks/useChannelsLayout.ts @@ -0,0 +1,15 @@ +import { CHANNELS_LAYOUT_FLAG, PROJECT_BLUEBIRD_FLAG } from "@posthog/shared"; +import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; + +/** + * The single gate for the new channels layout — read this, not the raw flag. + * No dev default, so dev matches prod; bluebird keeps its own backend guard. + */ +export function useChannelsLayout(): boolean { + const bluebirdEnabled = useFeatureFlag( + PROJECT_BLUEBIRD_FLAG, + import.meta.env.DEV, + ); + const layoutEnabled = useFeatureFlag(CHANNELS_LAYOUT_FLAG, false); + return layoutEnabled && bluebirdEnabled; +} diff --git a/packages/ui/src/features/canvas/hooks/useCurrentChannel.test.tsx b/packages/ui/src/features/canvas/hooks/useCurrentChannel.test.tsx new file mode 100644 index 0000000000..d023026f98 --- /dev/null +++ b/packages/ui/src/features/canvas/hooks/useCurrentChannel.test.tsx @@ -0,0 +1,80 @@ +import { renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const channelsResult = vi.hoisted(() => ({ + current: { + channels: [] as { id: string; name: string; path: string }[], + isLoading: true, + }, +})); +vi.mock("@posthog/ui/features/canvas/hooks/useChannels", () => ({ + useChannels: () => channelsResult.current, +})); + +import { useCurrentChannelStore } from "@posthog/ui/features/canvas/stores/currentChannelStore"; +import { useCurrentChannel } from "./useCurrentChannel"; + +function channel(id: string, name: string) { + return { id, name, path: `/${name}` }; +} + +describe("useCurrentChannel", () => { + beforeEach(() => { + useCurrentChannelStore.setState({ currentChannelId: null }); + channelsResult.current = { channels: [], isLoading: true }; + }); + + it("holds a scoped id while the channel list is still loading", () => { + useCurrentChannelStore.setState({ currentChannelId: "c1" }); + channelsResult.current = { channels: [], isLoading: true }; + + const { result } = renderHook(() => useCurrentChannel({ enabled: true })); + + // A pending list is not evidence of absence — clearing here would unscope + // the sidebar on every cold load. + expect(useCurrentChannelStore.getState().currentChannelId).toBe("c1"); + expect(result.current.currentChannelId).toBeNull(); + }); + + it("resolves the channel once the list lands", () => { + useCurrentChannelStore.setState({ currentChannelId: "c1" }); + channelsResult.current = { + channels: [channel("c1", "eng")], + isLoading: false, + }; + + const { result } = renderHook(() => useCurrentChannel({ enabled: true })); + + expect(result.current.currentChannelId).toBe("c1"); + expect(result.current.channels.map((c) => c.name)).toEqual(["eng"]); + // Resolving must not clear a channel that does exist. + expect(useCurrentChannelStore.getState().currentChannelId).toBe("c1"); + }); + + it("clears a channel the loaded list does not contain", () => { + // The shape of a project switch: the store still names the old project's + // channel, and the refetched list has never heard of it. + useCurrentChannelStore.setState({ currentChannelId: "from-old-project" }); + channelsResult.current = { + channels: [channel("c9", "new")], + isLoading: false, + }; + + const { result } = renderHook(() => useCurrentChannel({ enabled: true })); + + expect(useCurrentChannelStore.getState().currentChannelId).toBeNull(); + expect(result.current.currentChannelId).toBeNull(); + }); + + it("unscopes entirely when the layout is disabled", () => { + useCurrentChannelStore.setState({ currentChannelId: "c1" }); + channelsResult.current = { + channels: [channel("c1", "eng")], + isLoading: false, + }; + + renderHook(() => useCurrentChannel({ enabled: false })); + + expect(useCurrentChannelStore.getState().currentChannelId).toBeNull(); + }); +}); diff --git a/packages/ui/src/features/canvas/hooks/useCurrentChannel.ts b/packages/ui/src/features/canvas/hooks/useCurrentChannel.ts new file mode 100644 index 0000000000..09ef81bdcf --- /dev/null +++ b/packages/ui/src/features/canvas/hooks/useCurrentChannel.ts @@ -0,0 +1,48 @@ +import { + type Channel, + useChannels, +} from "@posthog/ui/features/canvas/hooks/useChannels"; +import { + resetCurrentChannel, + useCurrentChannelStore, +} from "@posthog/ui/features/canvas/stores/currentChannelStore"; +import { useEffect } from "react"; + +/** + * The scoped channel, resolved against the project's live channel list. + * + * Sole owner of the invariant "the current channel is a channel that exists in + * the current project". The store is a bare id with no project affinity, so a + * project switch, a channel deletion, or a route pointing at something stale + * would otherwise leave it naming a channel nobody can load — and + * `openTaskInput` files new tasks against whatever it holds. + * + * Self-heals rather than validating at each read site: `openTaskInput` runs + * outside React and can't see the channel list, and by the time the auth side + * effects call it the query cache has already been cleared. + */ +export function useCurrentChannel({ enabled }: { enabled: boolean }): { + currentChannelId: string | null; + channels: Channel[]; +} { + const storedChannelId = useCurrentChannelStore((s) => s.currentChannelId); + const { channels, isLoading } = useChannels({ enabled }); + + const currentChannel = storedChannelId + ? channels.find((c) => c.id === storedChannelId) + : undefined; + // A pending list is not evidence of absence — only clear once we've actually + // seen the project's channels and this one wasn't among them. + const isStale = storedChannelId != null && !isLoading && !currentChannel; + + useEffect(() => { + if (!enabled || isStale) resetCurrentChannel(); + }, [enabled, isStale]); + + return { + // Never hand back an id we couldn't resolve, so callers can't navigate to + // or file against a dead channel in the window before the effect runs. + currentChannelId: currentChannel?.id ?? null, + channels, + }; +} diff --git a/packages/ui/src/features/canvas/stores/currentChannelStore.ts b/packages/ui/src/features/canvas/stores/currentChannelStore.ts new file mode 100644 index 0000000000..4c40d6c109 --- /dev/null +++ b/packages/ui/src/features/canvas/stores/currentChannelStore.ts @@ -0,0 +1,15 @@ +import { create } from "zustand"; + +interface CurrentChannelState { + currentChannelId: string | null; + setCurrentChannel: (channelId: string | null) => void; +} + +export const useCurrentChannelStore = create()((set) => ({ + currentChannelId: null, + setCurrentChannel: (currentChannelId) => set({ currentChannelId }), +})); + +export function resetCurrentChannel(): void { + useCurrentChannelStore.setState({ currentChannelId: null }); +} diff --git a/packages/ui/src/features/task-detail/components/TaskInput.tsx b/packages/ui/src/features/task-detail/components/TaskInput.tsx index 0c0d51fca6..14fc8a1377 100644 --- a/packages/ui/src/features/task-detail/components/TaskInput.tsx +++ b/packages/ui/src/features/task-detail/components/TaskInput.tsx @@ -110,6 +110,8 @@ interface TaskInputProps { channelContext?: string; /** Display name of the channel the CONTEXT.md came from (for the chip). */ channelName?: string; + /** Backend channel UUID that owns the created task and feed entry. */ + channelId?: string; /** * Desktop file-system folder id that owns the channel's CONTEXT.md. When set, * the injected context lets the agent publish upkeep corrections addressed to @@ -154,6 +156,7 @@ export function TaskInput({ reportAssociation, channelContext, channelName, + channelId, channelContextId, allowNoRepo, suggestions, @@ -269,12 +272,21 @@ export function TaskInput({ const adapter = lastUsedAdapter; const prefillRequestKey = initialPromptKey ?? initialPrompt; + // Applying a prefilled prompt replaces whatever the composer had, so it must + // happen exactly once per request — not again on every remount, which would + // clobber a draft the user typed in between. + const lastAppliedPromptKeyRef = useRef(null); useEffect(() => { if (!initialPrompt || !prefillRequestKey) return; + if (lastAppliedPromptKeyRef.current === prefillRequestKey) return; + lastAppliedPromptKeyRef.current = prefillRequestKey; useDraftStore.getState().actions.setPendingContent(sessionId, { segments: [{ type: "text", text: initialPrompt }], }); - }, [initialPrompt, prefillRequestKey, sessionId]); + if (initialPromptKey) { + useTaskInputPrefillStore.getState().consumePrompt(initialPromptKey); + } + }, [initialPrompt, initialPromptKey, prefillRequestKey, sessionId]); useEffect(() => { reportInputHadContentRef.current = false; @@ -878,6 +890,7 @@ export function TaskInput({ signalReportId: activeReportAssociation?.reportId, channelContext: includeChannelContext ? channelContext : undefined, channelName, + channelId, channelContextId, allowNoRepo, }); diff --git a/packages/ui/src/features/task-detail/hooks/useRefreshedTask.ts b/packages/ui/src/features/task-detail/hooks/useRefreshedTask.ts index e2f6be477d..df35a8f382 100644 --- a/packages/ui/src/features/task-detail/hooks/useRefreshedTask.ts +++ b/packages/ui/src/features/task-detail/hooks/useRefreshedTask.ts @@ -9,5 +9,9 @@ export function useRefreshedTask(taskId: string, initialTask: Task): Task { refetchOnMount: "always", }); - return data; + // The refetch can resolve without data (e.g. a stale / cross-project id + // where getTask returns nothing), leaving `data` undefined despite the + // Task return type. Fall back to the last-known task so consumers + // (useTaskData → getTaskRepository) never read off undefined and crash. + return data ?? initialTask; } diff --git a/packages/ui/src/features/task-detail/stores/taskInputPrefillStore.ts b/packages/ui/src/features/task-detail/stores/taskInputPrefillStore.ts index d7ba861f4b..1b91d76385 100644 --- a/packages/ui/src/features/task-detail/stores/taskInputPrefillStore.ts +++ b/packages/ui/src/features/task-detail/stores/taskInputPrefillStore.ts @@ -20,7 +20,13 @@ interface PrefillStoreState { prefill: TaskInputPrefill; setPrefill: (prefill: TaskInputPrefill) => void; clearReportAssociation: () => void; - clear: () => void; + /** + * Retire a prompt once the composer has applied it. Without this the prompt + * outlives its navigation and is re-applied — over the user's own draft — the + * next time a new-task screen mounts. Scoped by requestId so a newer prefill + * that landed in between is left alone. + */ + consumePrompt: (requestId: string) => void; } // Holds transient state used to prefill the TaskInput screen when navigation @@ -38,5 +44,16 @@ export const useTaskInputPrefillStore = create((set) => ({ initialCloudRepository: undefined, }, })), - clear: () => set({ prefill: {} }), + consumePrompt: (requestId) => + set((s) => + s.prefill.requestId === requestId + ? { + prefill: { + ...s.prefill, + initialPrompt: undefined, + requestId: undefined, + }, + } + : s, + ), })); diff --git a/packages/ui/src/router/useOpenTask.test.ts b/packages/ui/src/router/useOpenTask.test.ts new file mode 100644 index 0000000000..941bc6745d --- /dev/null +++ b/packages/ui/src/router/useOpenTask.test.ts @@ -0,0 +1,135 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const navigateToChannelNewTask = vi.fn(); +const navigateToWebsiteNew = vi.fn(); +const navigateToCode = vi.fn(); + +vi.mock("./navigationBridge", () => ({ + navigateToChannelNewTask: (...args: unknown[]) => + navigateToChannelNewTask(...args), + navigateToWebsiteNew: () => navigateToWebsiteNew(), + navigateToCode: () => navigateToCode(), + navigateToChannelTask: vi.fn(), + navigateToTaskDetail: vi.fn(), + navigateToFolderSettings: vi.fn(), +})); +vi.mock("@posthog/di/container", () => ({ + resolveService: vi.fn(), + resolveServiceOptional: vi.fn(), +})); +vi.mock("@posthog/ui/shell/analytics", () => ({ + track: vi.fn(), + setActiveTaskContext: vi.fn(), +})); + +import { + resetCurrentChannel, + useCurrentChannelStore, +} from "@posthog/ui/features/canvas/stores/currentChannelStore"; +import { useTaskInputPrefillStore } from "@posthog/ui/features/task-detail/stores/taskInputPrefillStore"; +import { openTaskInput } from "./useOpenTask"; + +describe("openTaskInput channel scoping", () => { + beforeEach(() => { + vi.clearAllMocks(); + useCurrentChannelStore.setState({ currentChannelId: null }); + useTaskInputPrefillStore.setState({ prefill: {} }); + }); + + // Without the channels layout nothing sets a current channel, so creates must + // land where they always did rather than being pulled into a channel. + it("routes to Code when no channel is current", () => { + openTaskInput(); + expect(navigateToCode).toHaveBeenCalledTimes(1); + expect(navigateToChannelNewTask).not.toHaveBeenCalled(); + }); + + it("still honours an explicit website space when no channel is current", () => { + openTaskInput({ space: "website" }); + expect(navigateToWebsiteNew).toHaveBeenCalledTimes(1); + expect(navigateToChannelNewTask).not.toHaveBeenCalled(); + }); + + it("routes into the current channel once one is scoped", () => { + useCurrentChannelStore.setState({ currentChannelId: "chan-1" }); + openTaskInput(); + expect(navigateToChannelNewTask).toHaveBeenCalledWith("chan-1"); + expect(navigateToCode).not.toHaveBeenCalled(); + }); + + it("carries prefill into the channel route", () => { + useCurrentChannelStore.setState({ currentChannelId: "chan-1" }); + openTaskInput({ initialPrompt: "ship it" }); + expect(navigateToChannelNewTask).toHaveBeenCalledWith("chan-1"); + }); + + // A caller that names its channel must not be overridden by whatever the + // sidebar happens to be scoped to. + it("prefers an explicit channel over the scoped one", () => { + useCurrentChannelStore.setState({ currentChannelId: "chan-1" }); + openTaskInput({ channelId: "chan-9" }); + expect(navigateToChannelNewTask).toHaveBeenCalledWith("chan-9"); + }); + + // useArchiveTask and friends pass space: "code" deliberately; a scoped + // channel silently hijacking that is how a create lands in the wrong place. + it("honours an explicit Code space even while a channel is scoped", () => { + useCurrentChannelStore.setState({ currentChannelId: "chan-1" }); + openTaskInput({ space: "code" }); + expect(navigateToCode).toHaveBeenCalledTimes(1); + expect(navigateToChannelNewTask).not.toHaveBeenCalled(); + }); + + // The auth side effects call resetCurrentChannel() before openTaskInput() so + // a project switch can't file the next task into the old project's channel. + it("routes to Code again once the channel is reset", () => { + useCurrentChannelStore.setState({ currentChannelId: "chan-1" }); + resetCurrentChannel(); + openTaskInput(); + expect(navigateToCode).toHaveBeenCalledTimes(1); + expect(navigateToChannelNewTask).not.toHaveBeenCalled(); + }); + + it("replaces a stale prompt rather than leaving it to be re-applied", () => { + openTaskInput({ initialPrompt: "old prompt" }); + const stale = useTaskInputPrefillStore.getState().prefill.requestId; + + openTaskInput({ channelId: "chan-1" }); + + const { prefill } = useTaskInputPrefillStore.getState(); + expect(prefill.initialPrompt).toBeUndefined(); + expect(prefill.requestId).not.toBe(stale); + }); +}); + +describe("taskInputPrefillStore.consumePrompt", () => { + beforeEach(() => { + useTaskInputPrefillStore.setState({ prefill: {} }); + }); + + it("retires the prompt it was given", () => { + useTaskInputPrefillStore.setState({ + prefill: { requestId: "r1", initialPrompt: "hello", folderId: "f1" }, + }); + + useTaskInputPrefillStore.getState().consumePrompt("r1"); + + const { prefill } = useTaskInputPrefillStore.getState(); + expect(prefill.initialPrompt).toBeUndefined(); + expect(prefill.requestId).toBeUndefined(); + // Folder scoping is not a one-shot prompt; it must survive. + expect(prefill.folderId).toBe("f1"); + }); + + it("leaves a newer prefill alone", () => { + useTaskInputPrefillStore.setState({ + prefill: { requestId: "r2", initialPrompt: "newer" }, + }); + + useTaskInputPrefillStore.getState().consumePrompt("r1"); + + expect(useTaskInputPrefillStore.getState().prefill.initialPrompt).toBe( + "newer", + ); + }); +}); diff --git a/packages/ui/src/router/useOpenTask.ts b/packages/ui/src/router/useOpenTask.ts index f5f01fb607..a42d37dc8c 100644 --- a/packages/ui/src/router/useOpenTask.ts +++ b/packages/ui/src/router/useOpenTask.ts @@ -1,6 +1,7 @@ import { resolveService, resolveServiceOptional } from "@posthog/di/container"; import { ANALYTICS_EVENTS } from "@posthog/shared"; import type { Task } from "@posthog/shared/domain-types"; +import { useCurrentChannelStore } from "@posthog/ui/features/canvas/stores/currentChannelStore"; import { NAVIGATION_TASK_BINDER, type NavigationTaskBinder, @@ -76,6 +77,12 @@ export interface TaskInputNavigationOptions { // Which space's new-task screen to open. Both render the same TaskInput; the // channels variant keeps the channels chrome instead of switching to Code. space?: "code" | "website"; + /** + * Create inside this channel. Callers that already know the channel should + * say so rather than relying on the sidebar's scope agreeing with them — and + * routing through here is what clears any stale prefill. + */ + channelId?: string; } /** @@ -115,7 +122,19 @@ export function openTaskInput( : undefined, }, }); - if (options.space === "website") { + // In the channels layout every entry point (⌘N, the command menu, the "+") + // creates inside the channel you're in. A current channel only exists while + // that layout is on (ChannelsSidebar), so this needs no flag of its own. + // Precedence: an explicit channel wins; asking for Code explicitly opts out + // of channel scoping; otherwise the scoped channel decides. + const channelId = + options.channelId ?? + (options.space === "code" + ? null + : useCurrentChannelStore.getState().currentChannelId); + if (channelId) { + nav.navigateToChannelNewTask(channelId); + } else if (options.space === "website") { nav.navigateToWebsiteNew(); } else { nav.navigateToCode();