diff --git a/packages/ui/src/features/canvas/components/ChannelPicker.test.tsx b/packages/ui/src/features/canvas/components/ChannelPicker.test.tsx new file mode 100644 index 0000000000..891f289bb7 --- /dev/null +++ b/packages/ui/src/features/canvas/components/ChannelPicker.test.tsx @@ -0,0 +1,84 @@ +import { Theme } from "@radix-ui/themes"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { ChannelPicker } from "./ChannelPicker"; + +// Parent-ordered: me first, then the rest. +const CHANNEL_NAMES = ["me", "marketing", "support"]; + +function renderPicker(props?: Partial[0]>): { + onChange: ReturnType; +} { + const onChange = vi.fn(); + render( + + + , + ); + return { onChange }; +} + +describe("ChannelPicker", () => { + it("defaults to 'No channel'", () => { + renderPicker(); + expect(screen.getByRole("combobox", { name: "Channel" })).toHaveTextContent( + "No channel", + ); + }); + + it("shows the selected channel's name (incl. the personal 'me')", () => { + renderPicker({ value: "me" }); + expect(screen.getByRole("combobox", { name: "Channel" })).toHaveTextContent( + "me", + ); + }); + + it("lists 'No channel' first, then the channels in the given order", async () => { + const user = userEvent.setup(); + // Deliberately not alphabetical: the parent orders the list and the picker + // must preserve that order (No channel → me → starred → rest). + renderPicker({ channelNames: ["me", "zulu", "alpha"] }); + + await user.click(screen.getByRole("combobox", { name: "Channel" })); + await screen.findByRole("option", { name: "No channel" }); + + expect(screen.getAllByRole("option").map((el) => el.textContent)).toEqual([ + "No channel", + "me", + "zulu", + "alpha", + ]); + }); + + it("emits the channel name when a channel is picked", async () => { + const user = userEvent.setup(); + const { onChange } = renderPicker(); + + await user.click(screen.getByRole("combobox", { name: "Channel" })); + await user.click(await screen.findByRole("option", { name: "support" })); + + await waitFor(() => expect(onChange).toHaveBeenCalledWith("support")); + }); + + it("emits null when 'No channel' is picked", async () => { + const user = userEvent.setup(); + const { onChange } = renderPicker({ value: "me" }); + + await user.click(screen.getByRole("combobox", { name: "Channel" })); + await user.click(await screen.findByRole("option", { name: "No channel" })); + + await waitFor(() => expect(onChange).toHaveBeenCalledWith(null)); + }); + + // Type-to-filter is the shared Combobox's own behavior (same as the repo + // picker) and its popup search input doesn't mount as a queryable field in + // jsdom, so it isn't asserted here — see BranchSelector.test.tsx, which drives + // that combobox via props for the same reason. +}); diff --git a/packages/ui/src/features/canvas/components/ChannelPicker.tsx b/packages/ui/src/features/canvas/components/ChannelPicker.tsx new file mode 100644 index 0000000000..35472aa43f --- /dev/null +++ b/packages/ui/src/features/canvas/components/ChannelPicker.tsx @@ -0,0 +1,135 @@ +import { CaretDown, HashIcon, Prohibit } from "@phosphor-icons/react"; +import { + Button, + Combobox, + ComboboxContent, + ComboboxEmpty, + ComboboxInput, + ComboboxItem, + ComboboxList, + ComboboxTrigger, +} from "@posthog/quill"; +import { useRef, useState } from "react"; + +// The opt-out option: run the task in the normal repo flow, not bound to a +// channel. Maps to a null value. The label can't collide with a channel name — +// those are lowercase/hyphen with no spaces. +const NO_CHANNEL_LABEL = "No channel"; + +interface ChannelPickerProps { + /** Selected channel name, or `null` for "No channel" (normal repo flow). */ + value: string | null; + onChange: (channelName: string | null) => void; + /** + * Channel names to list, already ordered by the parent (me → starred → rest). + * The "No channel" option is prepended here. + */ + channelNames: string[]; + isLoading: boolean; + disabled?: boolean; + // Accepted for API parity with the sibling pills; the button renders size="sm". + size?: "1" | "2"; +} + +// A searchable pill for choosing which channel a new task runs in, sitting +// alongside the workspace-mode and repo pills on the new-task composer. Dumb + +// presentational: the parent owns the channels query, ordering, and selection. +// "No channel" keeps the normal repo flow; picking any channel (including the +// personal "me") makes the task run repo-less. Built on the same Combobox as the +// repo picker so you can type to filter a long list. +export function ChannelPicker({ + value, + onChange, + channelNames, + isLoading, + disabled, +}: ChannelPickerProps) { + const triggerRef = useRef(null); + const [open, setOpen] = useState(false); + const [search, setSearch] = useState(""); + + const items = [NO_CHANNEL_LABEL, ...channelNames]; + const selectedLabel = value ?? NO_CHANNEL_LABEL; + + return ( + + onChange(!name || name === NO_CHANNEL_LABEL ? null : name) + } + open={open} + onOpenChange={(next) => { + setOpen(next); + if (!next) setSearch(""); + }} + inputValue={search} + onInputValueChange={setSearch} + disabled={disabled} + > + + {value === null ? ( + + ) : ( + + )} + {selectedLabel} + + + } + /> + + + + {isLoading ? "Loading channels…" : "No channels found."} + + + {(name: string) => ( + + {name === NO_CHANNEL_LABEL ? ( + + ) : ( + + )} + {name} + + )} + + + + ); +} diff --git a/packages/ui/src/features/task-detail/components/TaskInput.tsx b/packages/ui/src/features/task-detail/components/TaskInput.tsx index 0c0d51fca6..4f20ae69b2 100644 --- a/packages/ui/src/features/task-detail/components/TaskInput.tsx +++ b/packages/ui/src/features/task-detail/components/TaskInput.tsx @@ -9,7 +9,11 @@ import { isValidConfigValue } from "@posthog/core/task-detail/configOptions"; import { useServiceOptional } from "@posthog/di/react"; import { useHostTRPC, useHostTRPCClient } from "@posthog/host-router/react"; import { ButtonGroup } from "@posthog/quill"; -import { type AgentRuntime, ANALYTICS_EVENTS } from "@posthog/shared"; +import { + type AgentRuntime, + ANALYTICS_EVENTS, + PROJECT_BLUEBIRD_FLAG, +} from "@posthog/shared"; import type { Task } from "@posthog/shared/domain-types"; import { openSettings } from "@posthog/ui/features/settings/hooks/useOpenSettings"; import type { TaskInputReportAssociation } from "@posthog/ui/features/task-detail/stores/taskInputPrefillStore"; @@ -35,6 +39,14 @@ import { } from "../../autoresearch/autoresearchDraftStore"; import { toStageSelectOptions } from "../../autoresearch/stageModels"; import { useAutoresearchEnabled } from "../../autoresearch/useAutoresearchEnabled"; +import { ChannelPicker } from "../../canvas/components/ChannelPicker"; +import { useChannelStars } from "../../canvas/hooks/useChannelStars"; +import { useChannels } from "../../canvas/hooks/useChannels"; +import { useFolderInstructions } from "../../canvas/hooks/useFolderInstructions"; +import { + PERSONAL_CHANNEL_NAME, + useBackendChannel, +} from "../../canvas/hooks/useTaskChannels"; import { useFileSearchStore } from "../../command/fileSearchStore"; import { NewTaskFilePreview } from "../../command/NewTaskFilePreview"; import { EnvironmentSelector } from "../../environments/EnvironmentSelector"; @@ -253,18 +265,115 @@ export function TaskInput({ reportAssociation ?? null, ); + // --- Channel selection (Home-space channels; project-bluebird only) --- + // The generic composer lets the user target a channel instead of a repo. When + // the composer is already bound to a fixed channel via props (the per-channel + // screen) or forced repo-less by the caller, defer entirely to those props and + // don't offer the picker. + const bluebirdEnabled = useFeatureFlag( + PROJECT_BLUEBIRD_FLAG, + import.meta.env.DEV, + ); + const channelSelectorEnabled = + bluebirdEnabled && !channelContextId && !allowNoRepo; + const { channels: allChannels, isLoading: channelsLoading } = useChannels({ + enabled: channelSelectorEnabled, + }); + const { starredRefToShortcutId } = useChannelStars({ + enabled: channelSelectorEnabled, + }); + // The map is rebuilt every render, so key the ordering memo on a signature of + // its contents instead — it only re-sorts when the starred set changes. + const starredSignature = Array.from(starredRefToShortcutId.keys()) + .sort() + .join("\n"); + // The picker's channel list, ordered me → starred → the rest. The picker + // prepends a "No channel" option, giving No channel → me → starred → rest as + // one flat list. "me" (the personal channel) is listed first and always — + // even before its folder is provisioned — so it's reliably pickable. + // Alphabetical order within each group comes from useChannels. + // biome-ignore lint/correctness/useExhaustiveDependencies: starredRefToShortcutId's identity changes every render; starredSignature captures its contents + const orderedChannelNames = useMemo(() => { + const others = allChannels.filter((c) => c.name !== PERSONAL_CHANNEL_NAME); + const starred = others + .filter((c) => starredRefToShortcutId.has(c.path)) + .map((c) => c.name); + const rest = others + .filter((c) => !starredRefToShortcutId.has(c.path)) + .map((c) => c.name); + return [PERSONAL_CHANNEL_NAME, ...starred, ...rest]; + }, [allChannels, starredSignature]); + + // null = "No channel" (normal repo flow); any other value is a channel name + // (repo-less), including the personal "me". + const [selectedChannelName, setSelectedChannelName] = useState( + null, + ); + // Drop a stale selection if a (non-personal) channel disappears once the list + // has loaded — "me" always stays valid. Inline during render so the stale + // name never reaches a commit; self-terminating. + if ( + selectedChannelName && + selectedChannelName !== PERSONAL_CHANNEL_NAME && + !channelsLoading && + !allChannels.some((c) => c.name === selectedChannelName) + ) { + setSelectedChannelName(null); + } + + const channelChosen = channelSelectorEnabled && selectedChannelName !== null; + // The picked channel's folder (for its CONTEXT.md + folder id), if one exists. + // "me" can be picked before its folder is provisioned, in which case there's + // simply no CONTEXT.md to attach — the task still runs in the #me feed. + const selectedFolder = useMemo( + () => + selectedChannelName + ? (allChannels.find((c) => c.name === selectedChannelName) ?? null) + : null, + [selectedChannelName, allChannels], + ); + // Resolve the picked channel's backend feed id (by name) + CONTEXT.md. Both + // hooks no-op until a channel is chosen. + const { channel: selectedBackendChannel } = useBackendChannel( + channelChosen ? (selectedChannelName ?? undefined) : undefined, + ); + const { data: selectedChannelInstructions } = useFolderInstructions( + selectedFolder?.id ?? null, + { enabled: !!selectedFolder }, + ); + + // Props (per-channel screen) win over an in-composer pick, so those flows stay + // unaffected and channel context is never injected twice. + const effectiveAllowNoRepo = allowNoRepo || channelChosen; + const effectiveChannelContext = channelChosen + ? selectedChannelInstructions?.content + : channelContext; + const effectiveChannelName = channelChosen + ? (selectedChannelName ?? undefined) + : channelName; + const effectiveChannelContextId = channelChosen + ? selectedFolder?.id + : channelContextId; + const effectiveChannelId = channelChosen + ? selectedBackendChannel?.id + : undefined; + // Channel CONTEXT.md is included by default; the chip lets the user drop it // from this task's prompt. Re-include whenever the source context changes // (e.g. switching channels) so a dismissal doesn't stick across channels. + // Adjusted inline during render via a prev-value comparison (matching + // prevEffectiveRepoPath below) rather than an effect, so the chip never shows + // a stale dismissal for a commit. const [channelContextDismissed, setChannelContextDismissed] = useState(false); - const lastChannelContextRef = useRef(channelContext); - useEffect(() => { - if (lastChannelContextRef.current !== channelContext) { - lastChannelContextRef.current = channelContext; - setChannelContextDismissed(false); - } - }, [channelContext]); - const includeChannelContext = !!channelContext && !channelContextDismissed; + const [prevChannelContext, setPrevChannelContext] = useState( + effectiveChannelContext, + ); + if (effectiveChannelContext !== prevChannelContext) { + setPrevChannelContext(effectiveChannelContext); + setChannelContextDismissed(false); + } + const includeChannelContext = + !!effectiveChannelContext && !channelContextDismissed; const adapter = lastUsedAdapter; const prefillRequestKey = initialPromptKey ?? initialPrompt; @@ -696,7 +805,10 @@ export function TaskInput({ ); } - const effectiveWorkspaceMode = workspaceMode; + // A channel task runs repo-less, so worktree (which needs a repo) is invalid; + // collapse it to local, mirroring ChannelHomeComposer. + const effectiveWorkspaceMode = + channelChosen && workspaceMode === "worktree" ? "local" : workspaceMode; // Get current values from preview config options for task creation. // Defaults ensure values are always passed even before the preview config loads. @@ -876,10 +988,11 @@ export function TaskInput({ ? selectedCustomImageId : undefined, signalReportId: activeReportAssociation?.reportId, - channelContext: includeChannelContext ? channelContext : undefined, - channelName, - channelContextId, - allowNoRepo, + channelContext: includeChannelContext ? effectiveChannelContext : undefined, + channelName: effectiveChannelName, + channelId: effectiveChannelId, + channelContextId: effectiveChannelContextId, + allowNoRepo: effectiveAllowNoRepo, }); // Wraps the prompt in the autoresearch kickoff: protocol preamble first, @@ -1114,15 +1227,28 @@ export function TaskInput({ /> )} - {!allowNoRepo && workspaceMode === "worktree" && ( + {channelSelectorEnabled && ( + + )} + {!allowNoRepo && effectiveWorkspaceMode === "worktree" && ( ) : ( )} - {!allowNoRepo && workspaceMode !== "cloud" && ( + {!allowNoRepo && effectiveWorkspaceMode !== "cloud" && ( )} {cloudRegion === "dev" && ( @@ -1376,7 +1512,10 @@ export function TaskInput({ > - {channelName ? `#${channelName} ` : ""}CONTEXT.md + {effectiveChannelName + ? `#${effectiveChannelName} ` + : ""} + CONTEXT.md @@ -1384,7 +1523,10 @@ export function TaskInput({ <> - {channelName ? `#${channelName} ` : ""}CONTEXT.md + {effectiveChannelName + ? `#${effectiveChannelName} ` + : ""} + CONTEXT.md )} @@ -1402,6 +1544,7 @@ export function TaskInput({ )} {effectiveWorkspaceMode === "cloud" && + !channelChosen && !isLoadingRepos && !hasGithubIntegration && (