diff --git a/packages/ui/src/features/canvas/components/ChannelFeedView.tsx b/packages/ui/src/features/canvas/components/ChannelFeedView.tsx index fd5a38f871..7325f130f7 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 } from "@posthog/shared"; import type { Task, TaskRunStatus } from "@posthog/shared/domain-types"; @@ -49,7 +50,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. @@ -237,6 +245,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 }) { @@ -472,23 +492,90 @@ 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… + + + + + + + ); +} + +// 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; +} + // The Slack-style channel feed: every task kicked off in the channel, oldest // first, rendered as a kickoff message + task card. Multiplayer — the list is // team-visible and polls for teammates' cards and status flips. export function ChannelFeedView({ tasks, + pending = NO_PENDING, isLoading, emptyState, onOpenTask, onOpenThread, }: { tasks: Task[]; + pending?: PendingKickoff[]; isLoading: boolean; emptyState?: React.ReactNode; onOpenTask: (task: Task) => void; onOpenThread: (task: Task) => void; }) { - if (isLoading && tasks.length === 0) { + if (isLoading && tasks.length === 0 && pending.length === 0) { return (
@@ -496,14 +583,16 @@ export function ChannelFeedView({ ); } - if (tasks.length === 0) { + if (tasks.length === 0 && pending.length === 0) { return
{emptyState}
; } const now = new Date(); + const latestPendingId = pending[pending.length - 1]?.id; return ( + {/* Horizontal padding is load-bearing: ThreadItem's actions float at @@ -532,6 +621,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 c35b0a7f50..905ad23b51 100644 --- a/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx +++ b/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx @@ -1,7 +1,11 @@ +import { insertTaskDedup } from "@posthog/core/tasks/taskDelete"; import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; import type { Task } 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, @@ -28,7 +32,7 @@ import { track } from "@posthog/ui/shell/analytics"; import { Heading, Text } from "@radix-ui/themes"; import { useQueryClient } from "@tanstack/react-query"; import { useNavigate } from "@tanstack/react-router"; -import { useCallback, useEffect, useMemo, useRef } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; // A channel: a Slack-style multiplayer feed. Each member message kicks off a // task rendered as a card everyone in the channel sees; the composer stays @@ -55,6 +59,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], + ); + const threadTaskId = useThreadPanelStore((s) => s.taskId); const openThread = useThreadPanelStore((s) => s.openThread); const closeThread = useThreadPanelStore((s) => s.closeThread); @@ -84,6 +109,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(() => @@ -108,7 +141,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 @@ -171,6 +204,7 @@ export function WebsiteChannelHome({ channelId }: { channelId: string }) {