From 553f63c96b27c40eab362d1bf5bfd032d923ee1e Mon Sep 17 00:00:00 2001 From: Shy Alter Date: Wed, 22 Jul 2026 12:46:11 +0200 Subject: [PATCH 1/5] feat(tasks): add channel selector to new-task composer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a channel-picker pill next to the repo picker on the new-task composer. Selecting a channel makes the task run repo-less (owned by the channel's backend feed, with its CONTEXT.md attached) and greys out the repo/branch pickers; "No channel" restores the normal repo flow. Gated by the project-bluebird flag and hidden on screens already bound to a fixed channel. - New ChannelPicker presentational pill (mirrors WorkspaceModeSelect). - TaskInput derives effective channel context/name/id + backend feed id from the picked channel, collapses worktree→local (repo-less), and disables the repo cluster. Generated-By: PostHog Code Task-Id: 33a1c8b2-6345-4dac-b733-b9e22c53e3d7 --- .../canvas/components/ChannelPicker.test.tsx | 85 ++++++++++ .../canvas/components/ChannelPicker.tsx | 105 ++++++++++++ .../task-detail/components/TaskInput.tsx | 152 +++++++++++++++--- 3 files changed, 324 insertions(+), 18 deletions(-) create mode 100644 packages/ui/src/features/canvas/components/ChannelPicker.test.tsx create mode 100644 packages/ui/src/features/canvas/components/ChannelPicker.tsx 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..6ff263dfad --- /dev/null +++ b/packages/ui/src/features/canvas/components/ChannelPicker.test.tsx @@ -0,0 +1,85 @@ +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 type { Channel } from "../hooks/useChannels"; +import { ChannelPicker } from "./ChannelPicker"; + +const CHANNELS: Channel[] = [ + { id: "chan-1", name: "marketing", path: "/marketing" }, + { id: "chan-2", name: "support", path: "/support" }, +]; + +function renderPicker(props?: Partial[0]>): { + onChange: ReturnType; +} { + const onChange = vi.fn(); + render( + + + , + ); + return { onChange }; +} + +describe("ChannelPicker", () => { + it("shows 'No channel' when nothing is selected", () => { + renderPicker(); + expect(screen.getByText("No channel")).toBeInTheDocument(); + }); + + it("shows the '#name' of the selected channel", () => { + renderPicker({ value: "chan-1" }); + expect(screen.getByText("#marketing")).toBeInTheDocument(); + }); + + it("emits the channel id when a channel is picked", async () => { + const user = userEvent.setup(); + const { onChange } = renderPicker(); + + await user.click(screen.getByRole("button", { name: "Channel" })); + await user.click( + await screen.findByRole("menuitemradio", { name: "support" }), + ); + + await waitFor(() => expect(onChange).toHaveBeenCalledWith("chan-2")); + }); + + it("maps the 'No channel' sentinel back to null", async () => { + const user = userEvent.setup(); + const { onChange } = renderPicker({ value: "chan-1" }); + + await user.click(screen.getByRole("button", { name: "Channel" })); + await user.click( + await screen.findByRole("menuitemradio", { + name: "No channel · work in a repo", + }), + ); + + await waitFor(() => expect(onChange).toHaveBeenCalledWith(null)); + }); + + it("lists exactly the channels it is given (filtering is the parent's job)", async () => { + const user = userEvent.setup(); + // A "me" channel passed through renders like any other — the component does + // no #me filtering itself; TaskInput removes it upstream. + renderPicker({ + channels: [...CHANNELS, { id: "me-id", name: "me", path: "/me" }], + }); + + await user.click(screen.getByRole("button", { name: "Channel" })); + + expect( + await screen.findByRole("menuitemradio", { name: "me" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("menuitemradio", { name: "marketing" }), + ).toBeInTheDocument(); + }); +}); 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..e2196fbfea --- /dev/null +++ b/packages/ui/src/features/canvas/components/ChannelPicker.tsx @@ -0,0 +1,105 @@ +import { CaretDown, HashIcon } from "@phosphor-icons/react"; +import { + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuTrigger, + MenuLabel, +} from "@posthog/quill"; +import type { Channel } from "../hooks/useChannels"; + +// Radio values must be non-empty strings, and `null` can't key a radio item, so +// "No channel" gets a sentinel value that maps back to `null` on change. +const NO_CHANNEL_VALUE = "__none__"; + +interface ChannelPickerProps { + /** Selected channel folder id, or `null` for "No channel" (work in a repo). */ + value: string | null; + onChange: (channelId: string | null) => void; + /** Channels to list, already filtered by the parent (e.g. #me removed). */ + channels: Channel[]; + isLoading: boolean; + disabled?: boolean; + // Accepted for API parity with the sibling pills; the button renders size="sm". + size?: "1" | "2"; +} + +// A 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 and the selected id. Picking a channel makes +// the task run repo-less (the parent greys out the repo/branch pickers); "No +// channel" restores the normal repo flow. +export function ChannelPicker({ + value, + onChange, + channels, + isLoading, + disabled, +}: ChannelPickerProps) { + const selected = value ? channels.find((c) => c.id === value) : undefined; + const triggerLabel = selected ? `#${selected.name}` : "No channel"; + + return ( + + + + + + {triggerLabel} + + + } + /> + + Channel + + onChange(next === NO_CHANNEL_VALUE ? null : next) + } + > + + + + + + No channel · work in a repo + + + {channels.map((channel) => ( + + + + + {channel.name} + + ))} + + {isLoading && channels.length === 0 && ( +
+ Loading channels… +
+ )} +
+
+ ); +} diff --git a/packages/ui/src/features/task-detail/components/TaskInput.tsx b/packages/ui/src/features/task-detail/components/TaskInput.tsx index 0c0d51fca6..48ef1f6d49 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 { useChannels } from "../../canvas/hooks/useChannels"; +import { useFolderInstructions } from "../../canvas/hooks/useFolderInstructions"; +import { + normalizeChannelName, + 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,88 @@ 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, + }); + // Omit the personal #me channel: "No channel" already routes tasks to it via + // useTaskCreation's default, so listing it would be a duplicate way to say the + // same thing. + const selectableChannels = useMemo( + () => + allChannels.filter( + (c) => normalizeChannelName(c.name) !== PERSONAL_CHANNEL_NAME, + ), + [allChannels], + ); + const [selectedChannelId, setSelectedChannelId] = useState( + null, + ); + const selectedChannel = useMemo( + () => + selectedChannelId + ? (selectableChannels.find((c) => c.id === selectedChannelId) ?? null) + : null, + [selectedChannelId, selectableChannels], + ); + // Drop the selection if the channel disappears (deleted/renamed elsewhere) + // once the list has loaded, so the composer never sticks in repo-less mode + // pointing at a channel that no longer exists. + useEffect(() => { + if (selectedChannelId && !channelsLoading && !selectedChannel) { + setSelectedChannelId(null); + } + }, [selectedChannelId, channelsLoading, selectedChannel]); + // Resolve the picked channel's backend feed id + CONTEXT.md. Both hooks no-op + // (return undefined/null) until a channel is selected. + const { channel: selectedBackendChannel } = useBackendChannel( + selectedChannel?.name, + ); + const { data: selectedChannelInstructions } = useFolderInstructions( + selectedChannel?.id ?? null, + { enabled: !!selectedChannel }, + ); + + const channelChosen = channelSelectorEnabled && !!selectedChannel; + // 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 + ? selectedChannel?.name + : channelName; + const effectiveChannelContextId = channelChosen + ? selectedChannel?.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. const [channelContextDismissed, setChannelContextDismissed] = useState(false); - const lastChannelContextRef = useRef(channelContext); + const lastChannelContextRef = useRef(effectiveChannelContext); useEffect(() => { - if (lastChannelContextRef.current !== channelContext) { - lastChannelContextRef.current = channelContext; + if (lastChannelContextRef.current !== effectiveChannelContext) { + lastChannelContextRef.current = effectiveChannelContext; setChannelContextDismissed(false); } - }, [channelContext]); - const includeChannelContext = !!channelContext && !channelContextDismissed; + }, [effectiveChannelContext]); + const includeChannelContext = + !!effectiveChannelContext && !channelContextDismissed; const adapter = lastUsedAdapter; const prefillRequestKey = initialPromptKey ?? initialPrompt; @@ -696,7 +778,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 +961,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 +1200,28 @@ export function TaskInput({ /> )} - {!allowNoRepo && workspaceMode === "worktree" && ( + {channelSelectorEnabled && ( + + )} + {!allowNoRepo && effectiveWorkspaceMode === "worktree" && ( ) : ( )} - {!allowNoRepo && workspaceMode !== "cloud" && ( + {!allowNoRepo && effectiveWorkspaceMode !== "cloud" && ( )} {cloudRegion === "dev" && ( @@ -1376,7 +1485,10 @@ export function TaskInput({ > - {channelName ? `#${channelName} ` : ""}CONTEXT.md + {effectiveChannelName + ? `#${effectiveChannelName} ` + : ""} + CONTEXT.md @@ -1384,7 +1496,10 @@ export function TaskInput({ <> - {channelName ? `#${channelName} ` : ""}CONTEXT.md + {effectiveChannelName + ? `#${effectiveChannelName} ` + : ""} + CONTEXT.md )} @@ -1402,6 +1517,7 @@ export function TaskInput({ )} {effectiveWorkspaceMode === "cloud" && + !channelChosen && !isLoadingRepos && !hasGithubIntegration && (
From cb9cf601aa4d88e4b12316a5f90d07e65dda6b68 Mon Sep 17 00:00:00 2001 From: Shy Alter Date: Thu, 23 Jul 2026 18:38:21 +0200 Subject: [PATCH 2/5] fix(tasks): adjust channel state during render, not in effects react-doctor flagged the channel-selection effects (no-adjust-state-on-prop-change). Replace both with the render-time pattern the file already uses for prevEffectiveRepoPath: - Clear a selected channel that no longer exists with a self-terminating inline guard instead of a useEffect. - Reset the CONTEXT.md dismissal via a prev-value comparison during render instead of a useRef + useEffect. No behavior change; removes the extra stale-UI commit. Generated-By: PostHog Code Task-Id: 33a1c8b2-6345-4dac-b733-b9e22c53e3d7 --- .../task-detail/components/TaskInput.tsx | 29 ++++++++++--------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/packages/ui/src/features/task-detail/components/TaskInput.tsx b/packages/ui/src/features/task-detail/components/TaskInput.tsx index 48ef1f6d49..88ba4d42ef 100644 --- a/packages/ui/src/features/task-detail/components/TaskInput.tsx +++ b/packages/ui/src/features/task-detail/components/TaskInput.tsx @@ -301,12 +301,12 @@ export function TaskInput({ ); // Drop the selection if the channel disappears (deleted/renamed elsewhere) // once the list has loaded, so the composer never sticks in repo-less mode - // pointing at a channel that no longer exists. - useEffect(() => { - if (selectedChannelId && !channelsLoading && !selectedChannel) { - setSelectedChannelId(null); - } - }, [selectedChannelId, channelsLoading, selectedChannel]); + // pointing at a channel that no longer exists. Adjusted inline during render + // (not via an effect) so the stale id never reaches a commit; the guard is + // self-terminating because clearing the id makes selectedChannel null too. + if (selectedChannelId && !channelsLoading && !selectedChannel) { + setSelectedChannelId(null); + } // Resolve the picked channel's backend feed id + CONTEXT.md. Both hooks no-op // (return undefined/null) until a channel is selected. const { channel: selectedBackendChannel } = useBackendChannel( @@ -337,14 +337,17 @@ export function TaskInput({ // 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(effectiveChannelContext); - useEffect(() => { - if (lastChannelContextRef.current !== effectiveChannelContext) { - lastChannelContextRef.current = effectiveChannelContext; - setChannelContextDismissed(false); - } - }, [effectiveChannelContext]); + const [prevChannelContext, setPrevChannelContext] = useState( + effectiveChannelContext, + ); + if (effectiveChannelContext !== prevChannelContext) { + setPrevChannelContext(effectiveChannelContext); + setChannelContextDismissed(false); + } const includeChannelContext = !!effectiveChannelContext && !channelContextDismissed; From dad353c69c7d86dadaee4867c25c39d1f5c96da1 Mon Sep 17 00:00:00 2001 From: Shy Alter Date: Thu, 23 Jul 2026 19:02:28 +0200 Subject: [PATCH 3/5] feat(tasks): make the channel picker searchable and default to #me - Rebuild ChannelPicker on the shared Combobox (same primitive as the repo picker) so opening it reveals a search field you can type into to filter a long channel list. - Replace the "No channel" default with the personal "me" channel: the pill now reads "me" by default and selecting it maps to the repo flow (task owned by the #me feed), matching what happened under the hood anyway. Test updated for the combobox interaction (me default, list contents, id/null mapping). Generated-By: PostHog Code Task-Id: 33a1c8b2-6345-4dac-b733-b9e22c53e3d7 --- .../canvas/components/ChannelPicker.test.tsx | 60 ++++---- .../canvas/components/ChannelPicker.tsx | 137 ++++++++++-------- 2 files changed, 108 insertions(+), 89 deletions(-) diff --git a/packages/ui/src/features/canvas/components/ChannelPicker.test.tsx b/packages/ui/src/features/canvas/components/ChannelPicker.test.tsx index 6ff263dfad..7bbb4f9a05 100644 --- a/packages/ui/src/features/canvas/components/ChannelPicker.test.tsx +++ b/packages/ui/src/features/canvas/components/ChannelPicker.test.tsx @@ -29,57 +29,53 @@ function renderPicker(props?: Partial[0]>): { } describe("ChannelPicker", () => { - it("shows 'No channel' when nothing is selected", () => { + it("defaults to the personal 'me' channel", () => { renderPicker(); - expect(screen.getByText("No channel")).toBeInTheDocument(); + expect(screen.getByRole("combobox", { name: "Channel" })).toHaveTextContent( + "me", + ); }); - it("shows the '#name' of the selected channel", () => { + it("shows the selected channel's name", () => { renderPicker({ value: "chan-1" }); - expect(screen.getByText("#marketing")).toBeInTheDocument(); + expect(screen.getByRole("combobox", { name: "Channel" })).toHaveTextContent( + "marketing", + ); + }); + + it("lists 'me' plus the channels when opened", async () => { + const user = userEvent.setup(); + renderPicker(); + + await user.click(screen.getByRole("combobox", { name: "Channel" })); + + expect(await screen.findByRole("option", { name: "me" })).toBeVisible(); + expect(screen.getByRole("option", { name: "marketing" })).toBeVisible(); + expect(screen.getByRole("option", { name: "support" })).toBeVisible(); }); it("emits the channel id when a channel is picked", async () => { const user = userEvent.setup(); const { onChange } = renderPicker(); - await user.click(screen.getByRole("button", { name: "Channel" })); - await user.click( - await screen.findByRole("menuitemradio", { name: "support" }), - ); + await user.click(screen.getByRole("combobox", { name: "Channel" })); + await user.click(await screen.findByRole("option", { name: "support" })); await waitFor(() => expect(onChange).toHaveBeenCalledWith("chan-2")); }); - it("maps the 'No channel' sentinel back to null", async () => { + it("emits null when the personal 'me' channel is picked", async () => { const user = userEvent.setup(); const { onChange } = renderPicker({ value: "chan-1" }); - await user.click(screen.getByRole("button", { name: "Channel" })); - await user.click( - await screen.findByRole("menuitemradio", { - name: "No channel · work in a repo", - }), - ); + await user.click(screen.getByRole("combobox", { name: "Channel" })); + await user.click(await screen.findByRole("option", { name: "me" })); await waitFor(() => expect(onChange).toHaveBeenCalledWith(null)); }); - it("lists exactly the channels it is given (filtering is the parent's job)", async () => { - const user = userEvent.setup(); - // A "me" channel passed through renders like any other — the component does - // no #me filtering itself; TaskInput removes it upstream. - renderPicker({ - channels: [...CHANNELS, { id: "me-id", name: "me", path: "/me" }], - }); - - await user.click(screen.getByRole("button", { name: "Channel" })); - - expect( - await screen.findByRole("menuitemradio", { name: "me" }), - ).toBeInTheDocument(); - expect( - screen.getByRole("menuitemradio", { name: "marketing" }), - ).toBeInTheDocument(); - }); + // 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 index e2196fbfea..96c70c4f05 100644 --- a/packages/ui/src/features/canvas/components/ChannelPicker.tsx +++ b/packages/ui/src/features/canvas/components/ChannelPicker.tsx @@ -1,21 +1,24 @@ import { CaretDown, HashIcon } from "@phosphor-icons/react"; import { Button, - DropdownMenu, - DropdownMenuContent, - DropdownMenuRadioGroup, - DropdownMenuRadioItem, - DropdownMenuTrigger, - MenuLabel, + Combobox, + ComboboxContent, + ComboboxEmpty, + ComboboxInput, + ComboboxItem, + ComboboxList, + ComboboxTrigger, } from "@posthog/quill"; +import { useMemo, useRef, useState } from "react"; import type { Channel } from "../hooks/useChannels"; -// Radio values must be non-empty strings, and `null` can't key a radio item, so -// "No channel" gets a sentinel value that maps back to `null` on change. -const NO_CHANNEL_VALUE = "__none__"; +// The default option: the user's personal channel. Selecting it maps to a null +// id, which the composer treats as "no explicit channel" — the task still runs +// in the normal repo flow and useTaskCreation routes it to the #me feed. +const PERSONAL_CHANNEL_LABEL = "me"; interface ChannelPickerProps { - /** Selected channel folder id, or `null` for "No channel" (work in a repo). */ + /** Selected channel folder id, or `null` for the personal "me" channel. */ value: string | null; onChange: (channelId: string | null) => void; /** Channels to list, already filtered by the parent (e.g. #me removed). */ @@ -26,11 +29,12 @@ interface ChannelPickerProps { size?: "1" | "2"; } -// A 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 and the selected id. Picking a channel makes -// the task run repo-less (the parent greys out the repo/branch pickers); "No -// channel" restores the normal repo flow. +// 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 and the selected id. +// Picking a named channel makes the task run repo-less (the parent greys out the +// repo/branch pickers); the default "me" restores the normal repo flow. Built on +// the same Combobox as the repo picker so you can type to filter a long list. export function ChannelPicker({ value, onChange, @@ -38,24 +42,56 @@ export function ChannelPicker({ isLoading, disabled, }: ChannelPickerProps) { - const selected = value ? channels.find((c) => c.id === value) : undefined; - const triggerLabel = selected ? `#${selected.name}` : "No channel"; + const triggerRef = useRef(null); + const [open, setOpen] = useState(false); + const [search, setSearch] = useState(""); + + // "me" leads, then the real channels. The combobox filters on these names, so + // its value is a channel name (or "me") that we map back to an id on change. + const optionNames = useMemo( + () => [PERSONAL_CHANNEL_LABEL, ...channels.map((c) => c.name)], + [channels], + ); + const selectedName = value + ? (channels.find((c) => c.id === value)?.name ?? PERSONAL_CHANNEL_LABEL) + : PERSONAL_CHANNEL_LABEL; return ( - - { + if (!name || name === PERSONAL_CHANNEL_LABEL) { + onChange(null); + return; + } + onChange(channels.find((c) => c.name === name)?.id ?? null); + }} + open={open} + onOpenChange={(next) => { + setOpen(next); + if (!next) setSearch(""); + }} + inputValue={search} + onInputValueChange={setSearch} + disabled={disabled} + > + - - - - {triggerLabel} + + {selectedName} } /> - - Channel - - onChange(next === NO_CHANNEL_VALUE ? null : next) - } - > - - - - - - No channel · work in a repo - - - {channels.map((channel) => ( - - - - - {channel.name} - - ))} - - {isLoading && channels.length === 0 && ( -
- Loading channels… -
- )} -
-
+ + + {isLoading ? "Loading channels…" : "No channels found."} + + + {(name: string) => ( + + + {name} + + )} + + + ); } From a2bd8d74e18c213f3d4933c61f0aa19e56b0f405 Mon Sep 17 00:00:00 2001 From: Shy Alter Date: Thu, 23 Jul 2026 19:13:17 +0200 Subject: [PATCH 4/5] =?UTF-8?q?feat(tasks):=20order=20the=20channel=20pick?= =?UTF-8?q?er=20me=20=E2=86=92=20starred=20=E2=86=92=20the=20rest?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Float starred channels to the top of the picker (right after the default "me"), then the remaining channels — mirroring the sidebar's me → starred → channels order, but as one flat list with no section headings. Ordering is computed in the composer from the shared channel-stars query; the picker preserves whatever order it's given. Generated-By: PostHog Code Task-Id: 33a1c8b2-6345-4dac-b733-b9e22c53e3d7 --- .../canvas/components/ChannelPicker.test.tsx | 28 ++++++++++++++++++ .../task-detail/components/TaskInput.tsx | 29 ++++++++++++++++--- 2 files changed, 53 insertions(+), 4 deletions(-) diff --git a/packages/ui/src/features/canvas/components/ChannelPicker.test.tsx b/packages/ui/src/features/canvas/components/ChannelPicker.test.tsx index 7bbb4f9a05..499c455471 100644 --- a/packages/ui/src/features/canvas/components/ChannelPicker.test.tsx +++ b/packages/ui/src/features/canvas/components/ChannelPicker.test.tsx @@ -74,6 +74,34 @@ describe("ChannelPicker", () => { await waitFor(() => expect(onChange).toHaveBeenCalledWith(null)); }); + it("renders 'me' first, then the channels in the given order", async () => { + const user = userEvent.setup(); + // Deliberately not alphabetical: the parent orders the list (me → starred → + // rest) and the picker must preserve that order rather than re-sorting. + render( + + + , + ); + + await user.click(screen.getByRole("combobox", { name: "Channel" })); + await screen.findByRole("option", { name: "me" }); + + expect(screen.getAllByRole("option").map((el) => el.textContent)).toEqual([ + "me", + "zulu", + "alpha", + ]); + }); + // 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 diff --git a/packages/ui/src/features/task-detail/components/TaskInput.tsx b/packages/ui/src/features/task-detail/components/TaskInput.tsx index 88ba4d42ef..2560ca8705 100644 --- a/packages/ui/src/features/task-detail/components/TaskInput.tsx +++ b/packages/ui/src/features/task-detail/components/TaskInput.tsx @@ -40,6 +40,7 @@ import { 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 { @@ -279,9 +280,8 @@ export function TaskInput({ const { channels: allChannels, isLoading: channelsLoading } = useChannels({ enabled: channelSelectorEnabled, }); - // Omit the personal #me channel: "No channel" already routes tasks to it via - // useTaskCreation's default, so listing it would be a duplicate way to say the - // same thing. + // Omit the personal #me channel: the picker always shows it as the default + // "me" option, so listing it again would just duplicate it. const selectableChannels = useMemo( () => allChannels.filter( @@ -289,6 +289,27 @@ export function TaskInput({ ), [allChannels], ); + // Order the list me → starred → the rest, mirroring the sidebar but as one + // flat list (the "me" default is prepended by the picker; here we just float + // the starred channels to the top, keeping each group's alphabetical order). + 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"); + // biome-ignore lint/correctness/useExhaustiveDependencies: starredRefToShortcutId's identity changes every render; starredSignature captures its contents + const orderedChannels = useMemo(() => { + const starred = selectableChannels.filter((c) => + starredRefToShortcutId.has(c.path), + ); + const rest = selectableChannels.filter( + (c) => !starredRefToShortcutId.has(c.path), + ); + return [...starred, ...rest]; + }, [selectableChannels, starredSignature]); const [selectedChannelId, setSelectedChannelId] = useState( null, ); @@ -1218,7 +1239,7 @@ export function TaskInput({ Date: Thu, 23 Jul 2026 19:23:30 +0200 Subject: [PATCH 5/5] feat(tasks): add a No channel option; make me a real channel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Selecting "me" now targets the personal channel as a real repo-less channel task (like any other channel), instead of being the repo-flow default. That default is now a distinct "No channel" option (null → normal repo flow with the repo/branch pickers active). Order is No channel → me → starred → the rest, one flat list. The picker is name-based so "me" is always pickable even before its folder is provisioned; the parent resolves the folder (for CONTEXT.md) and backend feed by name. Generated-By: PostHog Code Task-Id: 33a1c8b2-6345-4dac-b733-b9e22c53e3d7 --- .../canvas/components/ChannelPicker.test.tsx | 73 +++++--------- .../canvas/components/ChannelPicker.tsx | 97 ++++++++++--------- .../task-detail/components/TaskInput.tsx | 95 +++++++++--------- 3 files changed, 125 insertions(+), 140 deletions(-) diff --git a/packages/ui/src/features/canvas/components/ChannelPicker.test.tsx b/packages/ui/src/features/canvas/components/ChannelPicker.test.tsx index 499c455471..891f289bb7 100644 --- a/packages/ui/src/features/canvas/components/ChannelPicker.test.tsx +++ b/packages/ui/src/features/canvas/components/ChannelPicker.test.tsx @@ -2,13 +2,10 @@ 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 type { Channel } from "../hooks/useChannels"; import { ChannelPicker } from "./ChannelPicker"; -const CHANNELS: Channel[] = [ - { id: "chan-1", name: "marketing", path: "/marketing" }, - { id: "chan-2", name: "support", path: "/support" }, -]; +// Parent-ordered: me first, then the rest. +const CHANNEL_NAMES = ["me", "marketing", "support"]; function renderPicker(props?: Partial[0]>): { onChange: ReturnType; @@ -19,7 +16,7 @@ function renderPicker(props?: Partial[0]>): { @@ -29,79 +26,57 @@ function renderPicker(props?: Partial[0]>): { } describe("ChannelPicker", () => { - it("defaults to the personal 'me' channel", () => { + it("defaults to 'No channel'", () => { renderPicker(); expect(screen.getByRole("combobox", { name: "Channel" })).toHaveTextContent( - "me", + "No channel", ); }); - it("shows the selected channel's name", () => { - renderPicker({ value: "chan-1" }); + it("shows the selected channel's name (incl. the personal 'me')", () => { + renderPicker({ value: "me" }); expect(screen.getByRole("combobox", { name: "Channel" })).toHaveTextContent( - "marketing", + "me", ); }); - it("lists 'me' plus the channels when opened", async () => { + it("lists 'No channel' first, then the channels in the given order", async () => { const user = userEvent.setup(); - renderPicker(); + // 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(await screen.findByRole("option", { name: "me" })).toBeVisible(); - expect(screen.getByRole("option", { name: "marketing" })).toBeVisible(); - expect(screen.getByRole("option", { name: "support" })).toBeVisible(); + expect(screen.getAllByRole("option").map((el) => el.textContent)).toEqual([ + "No channel", + "me", + "zulu", + "alpha", + ]); }); - it("emits the channel id when a channel is picked", async () => { + 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("chan-2")); + await waitFor(() => expect(onChange).toHaveBeenCalledWith("support")); }); - it("emits null when the personal 'me' channel is picked", async () => { + it("emits null when 'No channel' is picked", async () => { const user = userEvent.setup(); - const { onChange } = renderPicker({ value: "chan-1" }); + const { onChange } = renderPicker({ value: "me" }); await user.click(screen.getByRole("combobox", { name: "Channel" })); - await user.click(await screen.findByRole("option", { name: "me" })); + await user.click(await screen.findByRole("option", { name: "No channel" })); await waitFor(() => expect(onChange).toHaveBeenCalledWith(null)); }); - it("renders 'me' first, then the channels in the given order", async () => { - const user = userEvent.setup(); - // Deliberately not alphabetical: the parent orders the list (me → starred → - // rest) and the picker must preserve that order rather than re-sorting. - render( - - - , - ); - - await user.click(screen.getByRole("combobox", { name: "Channel" })); - await screen.findByRole("option", { name: "me" }); - - expect(screen.getAllByRole("option").map((el) => el.textContent)).toEqual([ - "me", - "zulu", - "alpha", - ]); - }); - // 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 diff --git a/packages/ui/src/features/canvas/components/ChannelPicker.tsx b/packages/ui/src/features/canvas/components/ChannelPicker.tsx index 96c70c4f05..35472aa43f 100644 --- a/packages/ui/src/features/canvas/components/ChannelPicker.tsx +++ b/packages/ui/src/features/canvas/components/ChannelPicker.tsx @@ -1,4 +1,4 @@ -import { CaretDown, HashIcon } from "@phosphor-icons/react"; +import { CaretDown, HashIcon, Prohibit } from "@phosphor-icons/react"; import { Button, Combobox, @@ -9,20 +9,22 @@ import { ComboboxList, ComboboxTrigger, } from "@posthog/quill"; -import { useMemo, useRef, useState } from "react"; -import type { Channel } from "../hooks/useChannels"; +import { useRef, useState } from "react"; -// The default option: the user's personal channel. Selecting it maps to a null -// id, which the composer treats as "no explicit channel" — the task still runs -// in the normal repo flow and useTaskCreation routes it to the #me feed. -const PERSONAL_CHANNEL_LABEL = "me"; +// 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 folder id, or `null` for the personal "me" channel. */ + /** Selected channel name, or `null` for "No channel" (normal repo flow). */ value: string | null; - onChange: (channelId: string | null) => void; - /** Channels to list, already filtered by the parent (e.g. #me removed). */ - channels: Channel[]; + 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". @@ -31,14 +33,14 @@ interface ChannelPickerProps { // 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 and the selected id. -// Picking a named channel makes the task run repo-less (the parent greys out the -// repo/branch pickers); the default "me" restores the normal repo flow. Built on -// the same Combobox as the repo picker so you can type to filter a long list. +// 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, - channels, + channelNames, isLoading, disabled, }: ChannelPickerProps) { @@ -46,27 +48,16 @@ export function ChannelPicker({ const [open, setOpen] = useState(false); const [search, setSearch] = useState(""); - // "me" leads, then the real channels. The combobox filters on these names, so - // its value is a channel name (or "me") that we map back to an id on change. - const optionNames = useMemo( - () => [PERSONAL_CHANNEL_LABEL, ...channels.map((c) => c.name)], - [channels], - ); - const selectedName = value - ? (channels.find((c) => c.id === value)?.name ?? PERSONAL_CHANNEL_LABEL) - : PERSONAL_CHANNEL_LABEL; + const items = [NO_CHANNEL_LABEL, ...channelNames]; + const selectedLabel = value ?? NO_CHANNEL_LABEL; return ( { - if (!name || name === PERSONAL_CHANNEL_LABEL) { - onChange(null); - return; - } - onChange(channels.find((c) => c.name === name)?.id ?? null); - }} + items={items} + value={selectedLabel} + onValueChange={(name) => + onChange(!name || name === NO_CHANNEL_LABEL ? null : name) + } open={open} onOpenChange={(next) => { setOpen(next); @@ -86,12 +77,20 @@ export function ChannelPicker({ disabled={disabled} aria-label="Channel" > - - {selectedName} + {value === null ? ( + + ) : ( + + )} + {selectedLabel} {(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 2560ca8705..4f20ae69b2 100644 --- a/packages/ui/src/features/task-detail/components/TaskInput.tsx +++ b/packages/ui/src/features/task-detail/components/TaskInput.tsx @@ -44,7 +44,6 @@ import { useChannelStars } from "../../canvas/hooks/useChannelStars"; import { useChannels } from "../../canvas/hooks/useChannels"; import { useFolderInstructions } from "../../canvas/hooks/useFolderInstructions"; import { - normalizeChannelName, PERSONAL_CHANNEL_NAME, useBackendChannel, } from "../../canvas/hooks/useTaskChannels"; @@ -280,18 +279,6 @@ export function TaskInput({ const { channels: allChannels, isLoading: channelsLoading } = useChannels({ enabled: channelSelectorEnabled, }); - // Omit the personal #me channel: the picker always shows it as the default - // "me" option, so listing it again would just duplicate it. - const selectableChannels = useMemo( - () => - allChannels.filter( - (c) => normalizeChannelName(c.name) !== PERSONAL_CHANNEL_NAME, - ), - [allChannels], - ); - // Order the list me → starred → the rest, mirroring the sidebar but as one - // flat list (the "me" default is prepended by the picker; here we just float - // the starred channels to the top, keeping each group's alphabetical order). const { starredRefToShortcutId } = useChannelStars({ enabled: channelSelectorEnabled, }); @@ -300,45 +287,61 @@ export function TaskInput({ 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 orderedChannels = useMemo(() => { - const starred = selectableChannels.filter((c) => - starredRefToShortcutId.has(c.path), - ); - const rest = selectableChannels.filter( - (c) => !starredRefToShortcutId.has(c.path), - ); - return [...starred, ...rest]; - }, [selectableChannels, starredSignature]); - const [selectedChannelId, setSelectedChannelId] = useState( + 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, ); - const selectedChannel = useMemo( + // 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( () => - selectedChannelId - ? (selectableChannels.find((c) => c.id === selectedChannelId) ?? null) + selectedChannelName + ? (allChannels.find((c) => c.name === selectedChannelName) ?? null) : null, - [selectedChannelId, selectableChannels], + [selectedChannelName, allChannels], ); - // Drop the selection if the channel disappears (deleted/renamed elsewhere) - // once the list has loaded, so the composer never sticks in repo-less mode - // pointing at a channel that no longer exists. Adjusted inline during render - // (not via an effect) so the stale id never reaches a commit; the guard is - // self-terminating because clearing the id makes selectedChannel null too. - if (selectedChannelId && !channelsLoading && !selectedChannel) { - setSelectedChannelId(null); - } - // Resolve the picked channel's backend feed id + CONTEXT.md. Both hooks no-op - // (return undefined/null) until a channel is selected. + // 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( - selectedChannel?.name, + channelChosen ? (selectedChannelName ?? undefined) : undefined, ); const { data: selectedChannelInstructions } = useFolderInstructions( - selectedChannel?.id ?? null, - { enabled: !!selectedChannel }, + selectedFolder?.id ?? null, + { enabled: !!selectedFolder }, ); - const channelChosen = channelSelectorEnabled && !!selectedChannel; // 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; @@ -346,10 +349,10 @@ export function TaskInput({ ? selectedChannelInstructions?.content : channelContext; const effectiveChannelName = channelChosen - ? selectedChannel?.name + ? (selectedChannelName ?? undefined) : channelName; const effectiveChannelContextId = channelChosen - ? selectedChannel?.id + ? selectedFolder?.id : channelContextId; const effectiveChannelId = channelChosen ? selectedBackendChannel?.id @@ -1237,9 +1240,9 @@ export function TaskInput({ /> {channelSelectorEnabled && (