diff --git a/packages/shared/src/analytics-events.ts b/packages/shared/src/analytics-events.ts index 1eaf429c3..fdacda2a1 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 000000000..aa5b3300f --- /dev/null +++ b/packages/ui/src/features/canvas/components/ChannelHomeComposer.tsx @@ -0,0 +1,262 @@ +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 { + forwardRef, + useCallback, + useImperativeHandle, + 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 { + type WorkspaceMode, + WorkspaceModeSelect, +} from "../../task-detail/components/WorkspaceModeSelect"; +import { usePreviewConfig } from "../../task-detail/hooks/usePreviewConfig"; +import { useTaskCreation } from "../../task-detail/hooks/useTaskCreation"; + +export interface ChannelHomeComposerHandle { + /** Drop a starter prompt into the editor and apply its mode, if any. */ + applySuggestion: (prompt: string, mode?: string) => void; +} + +interface ChannelHomeComposerProps { + channelId: string; + channelName?: string; + /** Channel CONTEXT.md, attached to the created task as background. */ + channelContext?: string; + onTaskCreated: (task: Task) => void; + /** Whether the suggestion list (rendered by the parent) is open. */ + suggestionsOpen: boolean; + onToggleSuggestions: () => 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 render in the parent's task list; this owns the +// local/cloud selector and the "See suggestions" toggle above the box. +export const ChannelHomeComposer = forwardRef< + ChannelHomeComposerHandle, + ChannelHomeComposerProps +>(function ChannelHomeComposer( + { + channelId, + channelName, + channelContext, + onTaskCreated, + suggestionsOpen, + onToggleSuggestions, + }, + ref, +) { + const sessionId = `channel-home:${channelId}`; + const editorRef = useRef(null); + const [editorIsEmpty, setEditorIsEmpty] = useState(true); + const { isOnline } = useConnectivity(); + + const { + lastUsedAdapter, + setLastUsedAdapter, + lastUsedWorkspaceMode, + setLastUsedWorkspaceMode, + setLastUsedLocalWorkspaceMode, + allowBypassPermissions, + defaultInitialTaskMode, + lastUsedInitialTaskMode, + setLastUsedReasoningEffort, + setLastUsedModel, + } = useSettingsStore(); + + const adapter = lastUsedAdapter; + const setAdapter = useCallback( + (next: AgentAdapter) => setLastUsedAdapter(next), + [setLastUsedAdapter], + ); + + // Repo-less channel tasks only run local or cloud (worktree needs a repo), so + // collapse any lingering worktree preference down to local for the initial pick. + const [workspaceMode, setWorkspaceModeState] = useState( + lastUsedWorkspaceMode === "cloud" ? "cloud" : "local", + ); + const [selectedCloudEnvId, setSelectedCloudEnvId] = useState( + null, + ); + const setWorkspaceMode = useCallback( + (mode: WorkspaceMode) => { + setWorkspaceModeState(mode); + setLastUsedWorkspaceMode(mode); + if (mode !== "cloud") setLastUsedLocalWorkspaceMode(mode); + }, + [setLastUsedWorkspaceMode, setLastUsedLocalWorkspaceMode], + ); + + 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; + + const { isCreatingTask, canSubmit, handleSubmit } = useTaskCreation({ + editorRef, + sessionId, + selectedDirectory: "", + workspaceMode, + sandboxEnvironmentId: + workspaceMode === "cloud" && selectedCloudEnvId + ? selectedCloudEnvId + : undefined, + 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], + ); + + useImperativeHandle( + ref, + () => ({ + applySuggestion: (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); + } + }, + }), + [sessionId, modeOption, setConfigOption], + ); + + const hints = ["@ to add files", "/ for skills"].join(", "); + + return ( +
+
+ + +
+ + + } + 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 625a45f76..52d2d274f 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 b76001ad7..262238652 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 000000000..9143cad48 --- /dev/null +++ b/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx @@ -0,0 +1,336 @@ +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 { CHANNEL_TASK_SUGGESTIONS } from "@posthog/ui/features/canvas/channelTaskSuggestions"; +import { + ChannelHomeComposer, + type ChannelHomeComposerHandle, +} 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 { SuggestedPromptCard } from "@posthog/ui/features/task-detail/components/SuggestedPromptCard"; +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) the list can scroll away from the bottom before the header has +// fully faded. The header sits over the top of the list and is fully shown at +// rest (pinned to the newest item); scrolling up to read history fades it out. +const HEADER_FADE_DISTANCE = 160; + +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 composerRef = useRef(null); + const [headerOpacity, setHeaderOpacity] = useState(1); + const [suggestionsOpen, setSuggestionsOpen] = useState(false); + + const handleScroll = useCallback(() => { + const el = scrollRef.current; + if (!el) return; + // Full opacity when pinned to the bottom; fade out as the list scrolls up. + const distanceFromBottom = el.scrollHeight - el.clientHeight - el.scrollTop; + setHeaderOpacity( + Math.max(0, 1 - distanceFromBottom / HEADER_FADE_DISTANCE), + ); + }, []); + + // Pin to the bottom (newest) on load, when the item count changes (the recent + // lists fetch async after mount), and when the suggestions open below the + // list — the way a chat view opens on its latest message. + // biome-ignore lint/correctness/useExhaustiveDependencies: items.length and suggestionsOpen are the intended re-pin triggers, even though the body reads layout via the ref + useLayoutEffect(() => { + const el = scrollRef.current; + if (!el) return; + el.scrollTop = el.scrollHeight; + handleScroll(); + }, [handleScroll, items.length, suggestionsOpen]); + + const handleSuggestionSelect = useCallback( + (prompt: string, mode?: string) => { + composerRef.current?.applySuggestion(prompt, mode); + setSuggestionsOpen(false); + }, + [], + ); + + 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 ( +
+
+
+
+
+ {items.map((item) => ( + + ))} + + {suggestionsOpen && ( +
+ + Suggestions + +
+ {CHANNEL_TASK_SUGGESTIONS.map((suggestion) => ( + + handleSuggestionSelect( + suggestion.prompt, + suggestion.mode, + ) + } + /> + ))} +
+
+ )} +
+
+
+ + {/* Header sits over the top of the list with a gradient that fades the + list out beneath it. Fully shown at rest; fades as the list scrolls + up so every task can be read. */} +
+
+

+ What can I do for you today? +

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