From 66321bf9a3e9eb2fdef1f984533ad00815d15be6 Mon Sep 17 00:00:00 2001 From: Raquel Smith Date: Tue, 14 Jul 2026 10:36:17 -0700 Subject: [PATCH] Instant-clear channel composer with optimistic kickoffs + self-post scroll (rebased on main) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-resolved version of PR #3350 on top of current main. Reconciles the optimistic-clear / pending-kickoff / self-post-scroll feature with main's newly added Slack-style channel intro + system feed messages: the feed now interleaves tasks and system rows by timestamp (main) while still appending optimistic "Starting…" pending rows and following the user's own posts to the bottom (this PR). WebsiteChannelHome keeps both the pending state and main's context.md onboarding dialog. Single squashed commit so it can be force-applied onto the PR branch. Generated-By: PostHog Code Task-Id: 20f9fe72-9b7b-44bb-9fde-75f94be4b6ee --- .../canvas/components/ChannelFeedView.tsx | 102 +++++++++++++++++- .../canvas/components/ChannelHomeComposer.tsx | 66 +++++++++++- .../canvas/components/WebsiteChannelHome.tsx | 40 ++++++- 3 files changed, 200 insertions(+), 8 deletions(-) diff --git a/packages/ui/src/features/canvas/components/ChannelFeedView.tsx b/packages/ui/src/features/canvas/components/ChannelFeedView.tsx index 3ca78d0b50..90fff8e7df 100644 --- a/packages/ui/src/features/canvas/components/ChannelFeedView.tsx +++ b/packages/ui/src/features/canvas/components/ChannelFeedView.tsx @@ -33,6 +33,7 @@ import { ThreadItemRepliesLabel, ThreadItemRepliesMeta, ThreadItemTimestamp, + useChatMessageScroller, } from "@posthog/quill"; import { formatRelativeTimeShort, getLocalDayDiff } from "@posthog/shared"; import type { Task, TaskRunStatus } from "@posthog/shared/domain-types"; @@ -50,7 +51,14 @@ import { } from "@posthog/ui/features/sidebar/useTaskPrStatus"; import { useInView } from "@posthog/ui/primitives/hooks/useInView"; import { Text } from "@radix-ui/themes"; -import { Fragment, memo, type ReactNode, useMemo } from "react"; +import { + Fragment, + memo, + type ReactNode, + useEffect, + useMemo, + useRef, +} from "react"; // Feed rows poll their reply counts slower than the open thread panel — the // shared query key means an open panel naturally speeds the row up too. @@ -236,6 +244,18 @@ function TaskStatusBadge({ display }: { display: TaskStatusDisplay }) { ); } +// A kickoff a user just submitted, before its task exists on the backend. The +// feed shows it optimistically so a submit reacts instantly instead of waiting +// on the create round trip; it's swapped for the real card once created. +export interface PendingKickoff { + id: string; + prompt: string; +} + +// A stable empty default so the `pending` prop doesn't hand memoized children a +// fresh array every render. +const NO_PENDING: PendingKickoff[] = []; + // The task the message kicked off, as a card everyone in the channel sees: // bold title + status up top, then run metadata. function TaskCard({ task, onOpen }: { task: Task; onOpen: () => void }) { @@ -471,6 +491,55 @@ function FeedRow({ ); } +// The optimistic kickoff row: the user's message plus a "Starting…" card, +// shown the moment they submit. Deliberately dumb — no per-task data hooks or +// polls (there's no task id to query yet); it's replaced by a real FeedRow as +// soon as the task is created. +function PendingFeedRow({ + pending, + createdAt, +}: { + pending: PendingKickoff; + createdAt: string; +}) { + return ( + + + + + + + + + + + + You + now + + + {pending.prompt} + + + + + + Starting… + + + + + + + ); +} + // A card-less feed row for a synthetic announcement. Rows with an `author` // render as that user (initials avatar + name — e.g. "Adam L · joined mobile"); // the rest render as "PostHog / Agent" (context lifecycle updates). Same chrome @@ -509,6 +578,22 @@ function SystemFeedRow({ message }: { message: ChannelFeedSystemMessage }) { ); } +// Follow the feed to the bottom when *this* user posts, but not when a +// teammate's card arrives via polling — a new `pending` kickoff is only ever +// added by the local composer, so it's the right signal. Must live inside the +// scroller provider to reach `scrollToEnd`. Renders nothing. +function FollowOwnPost({ latestPendingId }: { latestPendingId?: string }) { + const { scrollToEnd } = useChatMessageScroller(); + const prevRef = useRef(latestPendingId); + useEffect(() => { + if (latestPendingId && latestPendingId !== prevRef.current) { + scrollToEnd(); + } + prevRef.current = latestPendingId; + }, [latestPendingId, scrollToEnd]); + return null; +} + // A single feed entry, either a real task card or a synthetic system row, tagged // with the timestamp used to interleave the two. type FeedEntry = @@ -526,6 +611,7 @@ type FeedEntry = // "PostHog agent" system rows (context lifecycle) are interleaved by timestamp. export function ChannelFeedView({ tasks, + pending = NO_PENDING, systemMessages, isLoading, emptyState, @@ -534,6 +620,7 @@ export function ChannelFeedView({ onOpenThread, }: { tasks: Task[]; + pending?: PendingKickoff[]; systemMessages?: ChannelFeedSystemMessage[]; isLoading: boolean; emptyState?: React.ReactNode; @@ -572,7 +659,7 @@ export function ChannelFeedView({ return merged; }, [tasks, systemMessages]); - if (isLoading && entries.length === 0) { + if (isLoading && entries.length === 0 && pending.length === 0) { return (
@@ -580,14 +667,16 @@ export function ChannelFeedView({ ); } - if (entries.length === 0 && !intro) { + if (entries.length === 0 && pending.length === 0 && !intro) { return
{emptyState}
; } const now = new Date(); + const latestPendingId = pending[pending.length - 1]?.id; return ( + {/* Horizontal padding is load-bearing: ThreadItem's actions float at @@ -621,6 +710,13 @@ export function ChannelFeedView({ ); })} + {pending.map((p) => ( + + ))} diff --git a/packages/ui/src/features/canvas/components/ChannelHomeComposer.tsx b/packages/ui/src/features/canvas/components/ChannelHomeComposer.tsx index e732ef2b83..1233aff0db 100644 --- a/packages/ui/src/features/canvas/components/ChannelHomeComposer.tsx +++ b/packages/ui/src/features/canvas/components/ChannelHomeComposer.tsx @@ -15,6 +15,7 @@ import { track } from "../../../shell/analytics"; import { useOptionalAuthenticatedClient } from "../../auth/authClient"; import { useUserRepositoryIntegration } from "../../integrations/useIntegrations"; import { PromptInput } from "../../message-editor/components/PromptInput"; +import { contentToPlainText } from "../../message-editor/content"; import { useDraftStore } from "../../message-editor/draftStore"; import type { EditorHandle } from "../../message-editor/types"; import { toastError } from "../../notifications/errorDetails"; @@ -44,6 +45,7 @@ import { normalizeChannelName, PERSONAL_CHANNEL_NAME, } from "../hooks/useTaskChannels"; +import type { PendingKickoff } from "./ChannelFeedView"; export interface ChannelHomeComposerHandle { /** Drop a starter prompt into the editor and apply its mode, if any. */ @@ -58,6 +60,10 @@ interface ChannelHomeComposerProps { /** Backend channel UUID that will own the created task (its feed home). */ backendChannelId?: string; onTaskCreated: (task: Task) => void; + /** Post an optimistic kickoff to the feed the instant a submit is accepted. */ + onPendingStart: (kickoff: PendingKickoff) => void; + /** Drop that optimistic kickoff once the task is created (or creation fails). */ + onPendingEnd: (id: string) => void; } // The prompt box at the bottom of a channel's homepage. A trimmed-down sibling @@ -70,7 +76,15 @@ export const ChannelHomeComposer = forwardRef< ChannelHomeComposerHandle, ChannelHomeComposerProps >(function ChannelHomeComposer( - { channelId, channelName, channelContext, backendChannelId, onTaskCreated }, + { + channelId, + channelName, + channelContext, + backendChannelId, + onTaskCreated, + onPendingStart, + onPendingEnd, + }, ref, ) { const sessionId = `channel-home:${channelId}`; @@ -242,6 +256,23 @@ export const ChannelHomeComposer = forwardRef< queryClient, ]); + // In-flight optimistic kickoff ids, oldest first. Submits are serialized + // (the composer is disabled while creating), so retiring the oldest on each + // task-ready callback matches create order and keeps adds/removes balanced — + // no row is ever orphaned, even if two creates briefly overlap. + const pendingIdsRef = useRef([]); + + const handleTaskCreated = useCallback( + (task: Task) => { + // onTaskCreated swaps the real card in; drop the matching "Starting…" + // row in the same tick so the two never show at once. + onTaskCreated(task); + const id = pendingIdsRef.current.shift(); + if (id) onPendingEnd(id); + }, + [onTaskCreated, onPendingEnd], + ); + const { isCreatingTask, canSubmit, handleSubmit } = useTaskCreation({ editorRef, sessionId, @@ -260,9 +291,38 @@ export const ChannelHomeComposer = forwardRef< channelContext, channelName, channelId: backendChannelId, - onTaskCreated, + onTaskCreated: handleTaskCreated, }); + // Own the submit so the composer clears the instant a keystroke is accepted + // (not after the create round trip), which is what stops the "looks like it + // didn't take" double-submit. We snapshot the content and hand it to + // handleSubmit as an override so clearing early can't race the read. + const submit = useCallback(async () => { + const editor = editorRef.current; + if (!editor || !canSubmit) return; + const content = editor.getContent(); + const prompt = contentToPlainText(content).trim(); + if (!prompt) return; + + editor.clear(); + const id = + globalThis.crypto?.randomUUID?.() ?? + `pending-${prompt.length}-${Date.now()}`; + pendingIdsRef.current.push(id); + onPendingStart({ id, prompt }); + + const created = await handleSubmit(content); + if (!created) { + // Creation failed — onTaskCreated never fired, so this id is still + // queued. Pull its row and give the full structured prompt (chips and + // attachments, not just flattened text) back so the user can retry. + pendingIdsRef.current = pendingIdsRef.current.filter((p) => p !== id); + onPendingEnd(id); + editor.insertEditorContent(content); + } + }, [canSubmit, handleSubmit, onPendingStart, onPendingEnd]); + const handleModeChange = useCallback( (value: string) => { if (modeOption) setConfigOption(modeOption.id, value); @@ -307,7 +367,7 @@ export const ChannelHomeComposer = forwardRef< const hints = ["@ to add files", "/ for skills"].join(", "); const isBusy = isCreatingTask || isStartingCanvas; - const submitComposer = canvasArmed ? handleCanvasSubmit : handleSubmit; + const submitComposer = canvasArmed ? handleCanvasSubmit : submit; return (
diff --git a/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx b/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx index 1ab2f674da..bc471627d7 100644 --- a/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx +++ b/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx @@ -1,8 +1,12 @@ +import { insertTaskDedup } from "@posthog/core/tasks/taskDelete"; import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; import type { Task } from "@posthog/shared/domain-types"; import { isTerminalStatus } from "@posthog/shared/domain-types"; import { CHANNEL_TASK_SUGGESTIONS } from "@posthog/ui/features/canvas/channelTaskSuggestions"; -import { ChannelFeedView } from "@posthog/ui/features/canvas/components/ChannelFeedView"; +import { + ChannelFeedView, + type PendingKickoff, +} from "@posthog/ui/features/canvas/components/ChannelFeedView"; import { ChannelHeader } from "@posthog/ui/features/canvas/components/ChannelHeader"; import { ChannelHomeComposer, @@ -78,6 +82,27 @@ export function WebsiteChannelHome({ channelId }: { channelId: string }) { const composerRef = useRef(null); + // Optimistic kickoffs: the message a user just submitted, shown in the feed + // with a "Starting…" card while its task is created in the background. Each + // is tagged with the channel it was fired in and filtered to the current one, + // so a still-in-flight kickoff never bleeds into another channel's feed. + const [pending, setPending] = useState< + (PendingKickoff & { channelId: string })[] + >([]); + const addPending = useCallback( + (kickoff: PendingKickoff) => { + setPending((prev) => [...prev, { ...kickoff, channelId }]); + }, + [channelId], + ); + const removePending = useCallback((id: string) => { + setPending((prev) => prev.filter((p) => p.id !== id)); + }, []); + const visiblePending = useMemo( + () => pending.filter((p) => p.channelId === channelId), + [pending, channelId], + ); + // The "Create your context.md" dialog, opened from the welcome message's // onboarding checklist. Describe-mode: seeds a plan session for this context. const [contextMdDialogOpen, setContextMdDialogOpen] = useState(false); @@ -111,6 +136,14 @@ export function WebsiteChannelHome({ channelId }: { channelId: string }) { const onTaskCreated = useCallback( (task: Task) => { queryClient.setQueryData(taskDetailQuery(task.id).queryKey, task); + // Splice the real card straight into the feed so it appears now rather + // than after the invalidate refetch (or the next 5s poll) lands. Seed a + // fresh list when the feed cache hasn't populated yet — insertTaskDedup + // no-ops on an undefined cache, which would otherwise drop the card. + queryClient.setQueryData( + channelFeedQueryKey(backendChannel?.id), + (old) => (old ? insertTaskDedup(old, task) : [task]), + ); invalidateFeed(); void fileTask(channelId, task.id, task.title) .then(() => @@ -135,7 +168,7 @@ export function WebsiteChannelHome({ channelId }: { channelId: string }) { }); }); }, - [channelId, fileTask, invalidateFeed, queryClient], + [backendChannel?.id, channelId, fileTask, invalidateFeed, queryClient], ); // The task route's mount effect points the panel at the task, so navigating @@ -230,6 +263,7 @@ export function WebsiteChannelHome({ channelId }: { channelId: string }) {