From 154fa3a3f6e760357c79ee4b196cf31ff79f318a Mon Sep 17 00:00:00 2001 From: Raquel Smith Date: Sat, 27 Jun 2026 15:12:07 -0700 Subject: [PATCH 1/3] feat(channels): replace Home canvas with a static channel homepage Channels no longer auto-create a "Home" canvas. Clicking a channel name in the sidebar now expands the channel in the tree and opens a new static homepage at the channel index (/website/$channelId). The homepage: - "What can I do for you today?" heading that fades away as the recent list scrolls up, Slack-style. - A chat-like stack of the channel's recent tasks + canvases (most recent at the bottom, against the composer), pinned to the bottom on load. - A prompt box that files a new task into the channel (reuses the task-creation pipeline, repo-less), with a "See suggestions" toggle that reveals the same starter prompts as the new-task screen. - A CONTEXT.md button pinned to the far right of the top nav bar. The canvases grid moved to its own /canvases sub-route (the channel index used to be the grid). The home-canvas service/hooks are left intact for back-compat. Drive-by: fix a build-breaking `sonner` import in FolderPicker (the package was removed); point it at the repo toast primitive so the app builds. Generated-By: PostHog Code Task-Id: 41e5619b-61ee-4428-a8e0-355ffcb746a7 --- packages/shared/src/analytics-events.ts | 1 + .../canvas/components/ChannelHomeComposer.tsx | 236 +++++++++++++++ .../canvas/components/ChannelsList.tsx | 10 +- .../canvas/components/CreateChannelModal.tsx | 12 +- .../canvas/components/WebsiteChannelHome.tsx | 284 ++++++++++++++++++ .../canvas/components/WebsiteLayout.tsx | 6 +- .../features/folder-picker/FolderPicker.tsx | 2 +- packages/ui/src/router/routeTree.gen.ts | 22 ++ .../routes/website/$channelId/canvases.tsx | 11 + .../routes/website/$channelId/index.tsx | 8 +- 10 files changed, 577 insertions(+), 15 deletions(-) create mode 100644 packages/ui/src/features/canvas/components/ChannelHomeComposer.tsx create mode 100644 packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx create mode 100644 packages/ui/src/router/routes/website/$channelId/canvases.tsx diff --git a/packages/shared/src/analytics-events.ts b/packages/shared/src/analytics-events.ts index 1eaf429c35..fdacda2a17 100644 --- a/packages/shared/src/analytics-events.ts +++ b/packages/shared/src/analytics-events.ts @@ -776,6 +776,7 @@ export type ChannelsSurface = | "sidebar" | "command_menu" | "new_task" + | "channel_home" | "dashboards_grid" | "canvas" | "context"; diff --git a/packages/ui/src/features/canvas/components/ChannelHomeComposer.tsx b/packages/ui/src/features/canvas/components/ChannelHomeComposer.tsx new file mode 100644 index 0000000000..3284419dcf --- /dev/null +++ b/packages/ui/src/features/canvas/components/ChannelHomeComposer.tsx @@ -0,0 +1,236 @@ +import { CaretDownIcon } from "@phosphor-icons/react"; +import { isValidConfigValue } from "@posthog/core/task-detail/configOptions"; +import { cn } from "@posthog/quill"; +import type { Task } from "@posthog/shared/domain-types"; +import { CHANNEL_TASK_SUGGESTIONS } from "@posthog/ui/features/canvas/channelTaskSuggestions"; +import { SuggestedPromptCard } from "@posthog/ui/features/task-detail/components/SuggestedPromptCard"; +import { Text } from "@radix-ui/themes"; +import { AnimatePresence, motion } from "framer-motion"; +import { useCallback, useRef, useState } from "react"; +import { useConnectivity } from "../../../hooks/useConnectivity"; +import { PromptInput } from "../../message-editor/components/PromptInput"; +import { useDraftStore } from "../../message-editor/draftStore"; +import type { EditorHandle } from "../../message-editor/types"; +import { ReasoningLevelSelector } from "../../sessions/components/ReasoningLevelSelector"; +import { UnifiedModelSelector } from "../../sessions/components/UnifiedModelSelector"; +import { getCurrentModeFromConfigOptions } from "../../sessions/sessionStore"; +import { + type AgentAdapter, + useSettingsStore, +} from "../../settings/settingsStore"; +import { usePreviewConfig } from "../../task-detail/hooks/usePreviewConfig"; +import { useTaskCreation } from "../../task-detail/hooks/useTaskCreation"; + +interface ChannelHomeComposerProps { + channelId: string; + channelName?: string; + /** Channel CONTEXT.md, attached to the created task as background. */ + channelContext?: string; + onTaskCreated: (task: Task) => void; +} + +// The prompt box at the bottom of a channel's homepage. A trimmed-down sibling +// of TaskInput: it reuses the same task-creation pipeline (model/mode/reasoning +// preview config + useTaskCreation) but drops the repo/branch pickers โ€” channel +// tasks run repo-less and the agent attaches a repo lazily if it needs one. The +// starter-prompt suggestions live here too, hidden behind a "See suggestions" +// toggle below the box. +export function ChannelHomeComposer({ + channelId, + channelName, + channelContext, + onTaskCreated, +}: ChannelHomeComposerProps) { + const sessionId = `channel-home:${channelId}`; + const editorRef = useRef(null); + const [editorIsEmpty, setEditorIsEmpty] = useState(true); + const [suggestionsOpen, setSuggestionsOpen] = useState(false); + const { isOnline } = useConnectivity(); + + const { + lastUsedAdapter, + setLastUsedAdapter, + lastUsedWorkspaceMode, + allowBypassPermissions, + defaultInitialTaskMode, + lastUsedInitialTaskMode, + setLastUsedReasoningEffort, + setLastUsedModel, + } = useSettingsStore(); + + const adapter = lastUsedAdapter; + const setAdapter = (next: AgentAdapter) => setLastUsedAdapter(next); + + const { modeOption, modelOption, thoughtOption, isLoading, setConfigOption } = + usePreviewConfig(adapter); + + const currentModel = + modelOption?.type === "select" ? modelOption.currentValue : undefined; + const adapterDefault = adapter === "codex" ? "auto" : "plan"; + const modeFallback = + defaultInitialTaskMode === "last_used" && + lastUsedInitialTaskMode && + isValidConfigValue(modeOption, lastUsedInitialTaskMode) + ? lastUsedInitialTaskMode + : adapterDefault; + const currentExecutionMode = + getCurrentModeFromConfigOptions(modeOption ? [modeOption] : undefined) ?? + modeFallback; + const currentReasoningLevel = + thoughtOption?.type === "select" ? thoughtOption.currentValue : undefined; + + // Channels are a repo-less chat box: keep the user's last-used workspace mode + // but never require a repo (allowNoRepo), matching the new-task screen. + const workspaceMode = lastUsedWorkspaceMode || "local"; + + const { isCreatingTask, canSubmit, handleSubmit } = useTaskCreation({ + editorRef, + sessionId, + selectedDirectory: "", + workspaceMode, + editorIsEmpty, + adapter, + executionMode: currentExecutionMode, + model: currentModel, + reasoningLevel: currentReasoningLevel, + allowNoRepo: true, + channelContext, + channelName, + onTaskCreated, + }); + + const handleModeChange = useCallback( + (value: string) => { + if (modeOption) setConfigOption(modeOption.id, value); + }, + [modeOption, setConfigOption], + ); + const handleModelChange = useCallback( + (value: string) => { + if (modelOption) { + setConfigOption(modelOption.id, value); + setLastUsedModel(value); + } + }, + [modelOption, setConfigOption, setLastUsedModel], + ); + const handleThoughtChange = useCallback( + (value: string) => { + if (thoughtOption) { + setConfigOption(thoughtOption.id, value); + setLastUsedReasoningEffort(value); + } + }, + [thoughtOption, setConfigOption, setLastUsedReasoningEffort], + ); + + const handleSuggestionSelect = useCallback( + (prompt: string, mode?: string) => { + // Pending content (not setContent) preserves the multi-line template's + // line breaks and focuses at the end; mirrors the new-task screen. + useDraftStore.getState().actions.setPendingContent(sessionId, { + segments: [{ type: "text", text: prompt }], + }); + if (mode && isValidConfigValue(modeOption, mode)) { + setConfigOption(modeOption.id, mode); + } + setSuggestionsOpen(false); + }, + [sessionId, modeOption, setConfigOption], + ); + + const hints = ["@ to add files", "/ for skills"].join(", "); + + return ( +
+ + {suggestionsOpen && ( + +
+ + Suggestions + +
+ {CHANNEL_TASK_SUGGESTIONS.map((suggestion) => ( + + handleSuggestionSelect(suggestion.prompt, suggestion.mode) + } + /> + ))} +
+
+
+ )} +
+ + + } + reasoningSelector={ + !isLoading && ( + + ) + } + onEmptyChange={setEditorIsEmpty} + onSubmitClick={handleSubmit} + onSubmit={() => { + if (canSubmit) handleSubmit(); + }} + /> + + +
+ ); +} diff --git a/packages/ui/src/features/canvas/components/ChannelsList.tsx b/packages/ui/src/features/canvas/components/ChannelsList.tsx index 625a45f763..52d2d274ff 100644 --- a/packages/ui/src/features/canvas/components/ChannelsList.tsx +++ b/packages/ui/src/features/canvas/components/ChannelsList.tsx @@ -79,7 +79,6 @@ import { useCreateAndOpenDashboard, useDashboardMutations, useDashboards, - useOpenHomeCanvas, usePrefetchDashboards, } from "@posthog/ui/features/canvas/hooks/useDashboards"; import { useNestedGenerationTaskIds } from "@posthog/ui/features/canvas/hooks/useNestedGenerationTaskIds"; @@ -957,7 +956,6 @@ function ChannelSection({ channels: Channel[]; }) { const navigate = useNavigate(); - const openHomeCanvas = useOpenHomeCanvas(); const pathname = useRouterState({ select: (s) => s.location.pathname }); const { data: tasks } = useTasks(); const archivedTaskIds = useArchivedTaskIds(); @@ -1102,7 +1100,13 @@ function ChannelSection({ surface: "sidebar", channel_id: channel.id, }); - void openHomeCanvas(channel); + // Clicking the channel name expands the channel in the tree + // and opens its static homepage. + setOpen(true); + void navigate({ + to: "/website/$channelId", + params: { channelId: channel.id }, + }); }} className="w-full min-w-0 justify-start ps-8 data-selected:bg-fill-selected data-selected:text-gray-12" > diff --git a/packages/ui/src/features/canvas/components/CreateChannelModal.tsx b/packages/ui/src/features/canvas/components/CreateChannelModal.tsx index b76001ad70..2622386528 100644 --- a/packages/ui/src/features/canvas/components/CreateChannelModal.tsx +++ b/packages/ui/src/features/canvas/components/CreateChannelModal.tsx @@ -3,10 +3,10 @@ import { validateChannelName } from "@posthog/core/canvas/channelName"; import { Button } from "@posthog/quill"; import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; import { useChannelMutations } from "@posthog/ui/features/canvas/hooks/useChannels"; -import { useOpenHomeCanvas } from "@posthog/ui/features/canvas/hooks/useDashboards"; import { toast } from "@posthog/ui/primitives/toast"; import { track } from "@posthog/ui/shell/analytics"; import { Dialog, Flex, IconButton, Text, TextField } from "@radix-ui/themes"; +import { useNavigate } from "@tanstack/react-router"; import { useState } from "react"; // Matches Slack's "Create a channel" naming constraint. @@ -22,7 +22,7 @@ export function CreateChannelModal({ onOpenChange, }: CreateChannelModalProps) { const { createChannel, isCreating } = useChannelMutations(); - const openHomeCanvas = useOpenHomeCanvas(); + const navigate = useNavigate(); const [name, setName] = useState(""); // Reset the field each time the modal opens so a previous draft never lingers. @@ -61,9 +61,11 @@ export function CreateChannelModal({ return; } onOpenChange(false); - // Create + seed the channel's home canvas and open it in the main pane. A - // freshly created channel has no homeCanvasId yet, so this creates one. - await openHomeCanvas(channel); + // Open the new channel's static homepage. + void navigate({ + to: "/website/$channelId", + params: { channelId: channel.id }, + }); }; return ( diff --git a/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx b/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx new file mode 100644 index 0000000000..e42f40590a --- /dev/null +++ b/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx @@ -0,0 +1,284 @@ +import { CaretRightIcon, FileTextIcon, HashIcon } from "@phosphor-icons/react"; +import type { ChannelTaskRecord } from "@posthog/core/canvas/channelTaskSchemas"; +import type { DashboardSummary } from "@posthog/core/canvas/dashboardSchemas"; +import { Button } from "@posthog/quill"; +import { formatRelativeTimeShort } from "@posthog/shared"; +import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; +import type { Task } from "@posthog/shared/domain-types"; +import { useArchivedTaskIds } from "@posthog/ui/features/archive/useArchivedTaskIds"; +import { ChannelHomeComposer } from "@posthog/ui/features/canvas/components/ChannelHomeComposer"; +import { iconForTemplate } from "@posthog/ui/features/canvas/components/canvasTemplateIcon"; +import { useChannels } from "@posthog/ui/features/canvas/hooks/useChannels"; +import { + useChannelTaskMutations, + useChannelTasks, +} from "@posthog/ui/features/canvas/hooks/useChannelTasks"; +import { useDashboards } from "@posthog/ui/features/canvas/hooks/useDashboards"; +import { useFolderInstructions } from "@posthog/ui/features/canvas/hooks/useFolderInstructions"; +import { taskDetailQuery } from "@posthog/ui/features/tasks/queries"; +import { useTasks } from "@posthog/ui/features/tasks/useTasks"; +import { useSetHeaderContent } from "@posthog/ui/hooks/useSetHeaderContent"; +import { toast } from "@posthog/ui/primitives/toast"; +import { track } from "@posthog/ui/shell/analytics"; +import { Text } from "@radix-ui/themes"; +import { useQueryClient } from "@tanstack/react-query"; +import { useNavigate } from "@tanstack/react-router"; +import { + type ReactNode, + useCallback, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react"; + +// Distance (px) over which the heading fades out as the history scrolls up, +// so it reads like a channel intro that scrolls away behind the messages. +const HEADING_FADE_DISTANCE = 140; + +type RecentItem = { + key: string; + kind: "task" | "canvas"; + title: string; + ts: number; + icon: ReactNode; + accent: string; + onClick: () => void; +}; + +// A channel's static homepage. Replaces the old auto-created "Home" canvas: a +// heading, a chat-like stack of the channel's recent tasks + canvases (most +// recent at the bottom, against the prompt box), and a composer that files new +// tasks into the channel. +export function WebsiteChannelHome({ channelId }: { channelId: string }) { + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const { channels } = useChannels(); + const channelName = channels.find((c) => c.id === channelId)?.name; + const { fileTask } = useChannelTaskMutations(); + + const { data: instructions } = useFolderInstructions(channelId); + const channelContext = instructions?.content; + + const openContext = useCallback(() => { + track(ANALYTICS_EVENTS.CHANNEL_ACTION, { + action_type: "edit_context_open", + surface: "channel_home", + channel_id: channelId, + }); + void navigate({ + to: "/website/$channelId/context", + params: { channelId }, + }); + }, [channelId, navigate]); + + // "# channel" on the left, an open-CONTEXT.md button pinned to the far right. + useSetHeaderContent( + useMemo( + () => ( +
+
+ + + {channelName ?? "Channel"} + +
+ +
+ ), + [channelName, openContext], + ), + ); + + const { dashboards } = useDashboards(channelId); + const { tasks: filedTasks } = useChannelTasks(channelId); + const { data: tasks } = useTasks(); + const archivedTaskIds = useArchivedTaskIds(); + + const items = useMemo(() => { + const canvasItems: RecentItem[] = dashboards.map((d: DashboardSummary) => ({ + key: `canvas:${d.id}`, + kind: "canvas", + title: d.name, + ts: d.updatedAt, + icon: iconForTemplate(d.templateId, { + size: 15, + className: "text-violet-9", + }), + accent: "violet", + onClick: () => + navigate({ + to: "/website/$channelId/dashboards/$dashboardId", + params: { channelId, dashboardId: d.id }, + }), + })); + + const taskById = new Map(tasks?.map((t) => [t.id, t]) ?? []); + const taskItems: RecentItem[] = filedTasks + .filter( + (f: ChannelTaskRecord) => + !archivedTaskIds.has(f.taskId) && taskById.has(f.taskId), + ) + .map((f: ChannelTaskRecord) => { + const task = taskById.get(f.taskId) as Task; + return { + key: `task:${f.id}`, + kind: "task" as const, + title: task.title || "Untitled task", + ts: Date.parse(task.updated_at) || 0, + icon: , + accent: "blue", + onClick: () => + navigate({ + to: "/website/$channelId/tasks/$taskId", + params: { channelId, taskId: f.taskId }, + }), + }; + }); + + // Oldest first so the most recent settles at the bottom, against the box. + return [...canvasItems, ...taskItems].sort((a, b) => a.ts - b.ts); + }, [dashboards, filedTasks, tasks, archivedTaskIds, channelId, navigate]); + + const scrollRef = useRef(null); + const [headingOpacity, setHeadingOpacity] = useState(1); + + const handleScroll = useCallback(() => { + const el = scrollRef.current; + if (!el) return; + setHeadingOpacity(Math.max(0, 1 - el.scrollTop / HEADING_FADE_DISTANCE)); + }, []); + + // Pin to the bottom (newest) on load and whenever the item count changes (the + // recent lists fetch async after mount), the way a chat view opens on its + // latest message. + // biome-ignore lint/correctness/useExhaustiveDependencies: items.length is the intended re-pin trigger, even though the body reads it via the ref + useLayoutEffect(() => { + const el = scrollRef.current; + if (!el) return; + el.scrollTop = el.scrollHeight; + handleScroll(); + }, [handleScroll, items.length]); + + const onTaskCreated = useCallback( + (task: Task) => { + queryClient.setQueryData(taskDetailQuery(task.id).queryKey, task); + void fileTask(channelId, task.id, task.title) + .then(() => + track(ANALYTICS_EVENTS.CHANNEL_ACTION, { + action_type: "file_task", + surface: "channel_home", + channel_id: channelId, + task_id: task.id, + success: true, + }), + ) + .catch((error: unknown) => { + track(ANALYTICS_EVENTS.CHANNEL_ACTION, { + action_type: "file_task", + surface: "channel_home", + channel_id: channelId, + task_id: task.id, + success: false, + }); + toast.error("Couldn't file task to channel", { + description: error instanceof Error ? error.message : String(error), + }); + }); + void navigate({ + to: "/website/$channelId/tasks/$taskId", + params: { channelId, taskId: task.id }, + }); + }, + [channelId, fileTask, navigate, queryClient], + ); + + return ( +
+
+
+
+

+ What can I do for you today? +

+ + Ask anything, kick off a task, or pick up where you left off. + +
+ +
+ + {items.length > 0 && ( +
+ {items.map((item) => ( + + ))} +
+ )} +
+
+ +
+ +
+
+ ); +} + +function RecentItemRow({ item }: { item: RecentItem }) { + return ( + + ); +} + +// A small task glyph for the recent list, tinted to match the row's accent. +function TaskGlyph() { + return ( +