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 }) {