From b35e91800cf178d434332a979ddd371d365d10b6 Mon Sep 17 00:00:00 2001 From: Peter Kirkham Date: Wed, 15 Jul 2026 16:28:23 +0100 Subject: [PATCH 01/13] feat(channels): ship production thread and feed UX Generated-By: PostHog Code Task-Id: 87a79e23-20c4-440b-818b-28154924ffbc --- .../core/src/canvas/threadTimeline.test.ts | 98 +++ packages/core/src/canvas/threadTimeline.ts | 116 ++++ .../canvas/components/ChannelFeedView.tsx | 137 ++-- .../canvas/components/ThreadPanel.tsx | 586 ++++++++++++++---- .../canvas/components/ThreadSidebar.tsx | 14 +- .../canvas/components/ThreadTimestamp.tsx | 45 ++ .../canvas/components/WebsiteChannelHome.tsx | 33 +- .../hooks/useChannelFeedMessages.test.ts | 23 + .../canvas/hooks/useChannelFeedMessages.ts | 8 +- .../canvas/stores/threadPanelStore.test.ts | 37 ++ .../canvas/stores/threadPanelStore.ts | 30 +- .../canvas/taskCardNavigation.test.ts | 30 + .../src/features/canvas/taskCardNavigation.ts | 21 + .../website/$channelId/tasks/$taskId.tsx | 15 +- 14 files changed, 968 insertions(+), 225 deletions(-) create mode 100644 packages/core/src/canvas/threadTimeline.test.ts create mode 100644 packages/core/src/canvas/threadTimeline.ts create mode 100644 packages/ui/src/features/canvas/components/ThreadTimestamp.tsx create mode 100644 packages/ui/src/features/canvas/hooks/useChannelFeedMessages.test.ts create mode 100644 packages/ui/src/features/canvas/stores/threadPanelStore.test.ts create mode 100644 packages/ui/src/features/canvas/taskCardNavigation.test.ts create mode 100644 packages/ui/src/features/canvas/taskCardNavigation.ts diff --git a/packages/core/src/canvas/threadTimeline.test.ts b/packages/core/src/canvas/threadTimeline.test.ts new file mode 100644 index 0000000000..145386ebe3 --- /dev/null +++ b/packages/core/src/canvas/threadTimeline.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from "vitest"; +import { + buildThreadTimeline, + deriveThreadAgentStatus, + shouldSuspendThreadSession, +} from "./threadTimeline"; + +describe("buildThreadTimeline", () => { + it("interleaves prompts, human replies, and agent turns chronologically", () => { + const timeline = buildThreadTimeline({ + prompts: [{ id: "prompt", text: "Start", timestamp: 100 }], + humanMessages: [ + { + id: "human", + content: "Reply", + createdAt: "1970-01-01T00:00:00.150Z", + }, + ], + agentMessages: [{ id: "agent", text: "Done", timestamp: 200 }], + }); + + expect(timeline.map((row) => row.kind)).toEqual([ + "prompt", + "human", + "agent", + ]); + }); + + it("keeps malformed timestamps at the end", () => { + const timeline = buildThreadTimeline({ + prompts: [{ id: "prompt", text: "Start", timestamp: 100 }], + humanMessages: [{ id: "human", content: "Reply", createdAt: "invalid" }], + agentMessages: [{ id: "agent", text: "Done", timestamp: 200 }], + }); + + expect(timeline.map((row) => row.kind)).toEqual([ + "prompt", + "agent", + "human", + ]); + }); +}); + +describe("deriveThreadAgentStatus", () => { + it.each([ + { + name: "returns no status before activity", + input: {}, + expected: null, + }, + { + name: "prioritizes failures", + input: { hasActivity: true, hasError: true, errorTitle: "Run failed" }, + expected: { phase: "error", label: "Run failed" }, + }, + { + name: "prioritizes pending permissions over active work", + input: { + hasActivity: true, + pendingPermissionCount: 1, + isPromptPending: true, + }, + expected: { phase: "needs_input", label: "Needs input" }, + }, + { + name: "reports active work", + input: { hasActivity: true, isPromptPending: true }, + expected: { phase: "active", label: "Working…" }, + }, + { + name: "reports shipped work", + input: { hasActivity: true, hasPullRequest: true }, + expected: { phase: "complete", label: "Shipped" }, + }, + ])("$name", ({ input, expected }) => { + expect(deriveThreadAgentStatus(input)).toEqual(expected); + }); +}); + +describe("shouldSuspendThreadSession", () => { + it("suspends a local runless task so reading cannot start work", () => { + expect( + shouldSuspendThreadSession({ + isCloud: false, + hasRun: false, + hasSession: false, + }), + ).toBe(true); + }); + + it.each([ + { isCloud: true, hasRun: false, hasSession: false }, + { isCloud: false, hasRun: true, hasSession: false }, + { isCloud: false, hasRun: false, hasSession: true }, + ])("keeps an existing or cloud session attached", (input) => { + expect(shouldSuspendThreadSession(input)).toBe(false); + }); +}); diff --git a/packages/core/src/canvas/threadTimeline.ts b/packages/core/src/canvas/threadTimeline.ts new file mode 100644 index 0000000000..aa28dc49b4 --- /dev/null +++ b/packages/core/src/canvas/threadTimeline.ts @@ -0,0 +1,116 @@ +export interface ThreadAgentMessage { + id: string; + text: string; + timestamp?: number; +} + +export interface ThreadHumanMessage { + id: string; + content: string; + createdAt: string; + value?: T; +} + +export type ThreadTimelineRow = + | { kind: "prompt"; timestamp: number; message: ThreadAgentMessage } + | { kind: "agent"; timestamp: number; message: ThreadAgentMessage } + | { kind: "human"; timestamp: number; message: ThreadHumanMessage }; + +function validTimestamp(timestamp: number | undefined): number { + return timestamp !== undefined && Number.isFinite(timestamp) + ? timestamp + : Number.MAX_SAFE_INTEGER; +} + +function parsedTimestamp(timestamp: string): number { + const parsed = Date.parse(timestamp); + return Number.isFinite(parsed) ? parsed : Number.MAX_SAFE_INTEGER; +} + +export function buildThreadTimeline({ + prompts, + agentMessages, + humanMessages, +}: { + prompts: ThreadAgentMessage[]; + agentMessages: ThreadAgentMessage[]; + humanMessages: ThreadHumanMessage[]; +}): ThreadTimelineRow[] { + return [ + ...prompts.map( + (message): ThreadTimelineRow => ({ + kind: "prompt", + timestamp: validTimestamp(message.timestamp), + message, + }), + ), + ...humanMessages.map( + (message): ThreadTimelineRow => ({ + kind: "human", + timestamp: parsedTimestamp(message.createdAt), + message, + }), + ), + ...agentMessages.map( + (message): ThreadTimelineRow => ({ + kind: "agent", + timestamp: validTimestamp(message.timestamp), + message, + }), + ), + ].sort((left, right) => left.timestamp - right.timestamp); +} + +export type ThreadAgentPhase = "active" | "needs_input" | "complete" | "error"; + +export interface ThreadAgentStatus { + phase: ThreadAgentPhase; + label: string; +} + +export function deriveThreadAgentStatus({ + hasActivity = false, + hasError = false, + cloudStatus, + errorTitle, + pendingPermissionCount = 0, + isPromptPending = false, + isInitializing = false, + hasPullRequest = false, +}: { + hasActivity?: boolean; + hasError?: boolean; + cloudStatus?: string | null; + errorTitle?: string | null; + pendingPermissionCount?: number; + isPromptPending?: boolean; + isInitializing?: boolean; + hasPullRequest?: boolean; +}): ThreadAgentStatus | null { + if (!hasActivity) return null; + if (hasError || cloudStatus === "failed") { + return { phase: "error", label: errorTitle ?? "Failed" }; + } + if (pendingPermissionCount > 0) { + return { phase: "needs_input", label: "Needs input" }; + } + if (isPromptPending || isInitializing) { + return { phase: "active", label: "Working…" }; + } + return { + phase: "complete", + label: hasPullRequest ? "Shipped" : "Ready to ship", + }; +} + +export function shouldSuspendThreadSession({ + isCloud, + hasRun, + hasSession, +}: { + isCloud: boolean; + hasRun: boolean; + hasSession: boolean; +}): boolean { + return !isCloud && !hasRun && !hasSession; +} diff --git a/packages/ui/src/features/canvas/components/ChannelFeedView.tsx b/packages/ui/src/features/canvas/components/ChannelFeedView.tsx index 90fff8e7df..782581ecc0 100644 --- a/packages/ui/src/features/canvas/components/ChannelFeedView.tsx +++ b/packages/ui/src/features/canvas/components/ChannelFeedView.tsx @@ -44,15 +44,19 @@ import { mentionChipClass } from "@posthog/ui/features/canvas/components/Mention import type { ChannelFeedSystemMessage } from "@posthog/ui/features/canvas/hooks/useChannelFeedMessages"; import { useChannelTaskData } from "@posthog/ui/features/canvas/hooks/useChannelTaskData"; import { useTaskThread } from "@posthog/ui/features/canvas/hooks/useTaskThread"; +import { shouldOpenTaskCardInline } from "@posthog/ui/features/canvas/taskCardNavigation"; import { userDisplayName } from "@posthog/ui/features/canvas/utils/userDisplay"; +import { useSessionSelector } from "@posthog/ui/features/sessions/useSession"; import { type SidebarPrState, useTaskPrStatus, } from "@posthog/ui/features/sidebar/useTaskPrStatus"; import { useInView } from "@posthog/ui/primitives/hooks/useInView"; import { Text } from "@radix-ui/themes"; +import { Link } from "@tanstack/react-router"; import { Fragment, + type MouseEvent, memo, type ReactNode, useEffect, @@ -159,6 +163,14 @@ interface TaskStatusDisplay { // deliberate end state we should not soften with a PR. function useTaskStatusDisplay(task: Task): TaskStatusDisplay { const data = useChannelTaskData(task); + const agentSettledAfterRun = useSessionSelector( + task.id, + (session) => + !!session && + (session.events?.length ?? 0) > 0 && + !session.isPromptPending && + (session.pendingPermissions?.size ?? 0) === 0, + ); const { prState } = useTaskPrStatus({ id: task.id, cloudPrUrl: data?.cloudPrUrl ?? null, @@ -198,7 +210,9 @@ function useTaskStatusDisplay(task: Task): TaskStatusDisplay { } else if (!status) { base = Draft; } else if (environment === "cloud" || isTerminalStatus(status)) { - base = statusBadge(status); + const effectiveStatus = + agentSettledAfterRun && !isTerminalStatus(status) ? "completed" : status; + base = statusBadge(effectiveStatus); } else { // Local, non-terminal: the run status is unreliable (the backend row stays // "queued" while the agent runs on the creator's machine), so we render no @@ -258,61 +272,84 @@ 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 }) { +export function TaskCard({ + task, + channelId, + onOpen, + inThread = false, +}: { + task: Task; + channelId: string; + onOpen?: () => void; + inThread?: boolean; +}) { const statusDisplay = useTaskStatusDisplay(task); const prUrl = typeof task.latest_run?.output?.pr_url === "string" ? task.latest_run.output.pr_url : undefined; const stage = task.latest_run?.stage; + const handleClick = (event: MouseEvent) => { + if (!onOpen || !shouldOpenTaskCardInline(event)) return; + event.preventDefault(); + onOpen(); + }; return ( - - -
-
- {/* Same live status icon as the code side nav, so the card and the - nav never disagree (generating spinner, needs-permission, cloud - status colors, PR state). */} - - - {task.title || "Untitled task"} - -
- -
- {(stage || task.repository || prUrl) && ( -
- {task.repository && ( - - - {task.repository} - - )} - {stage && ( - - {stage} - - )} - {prUrl && ( - - - PR + + +
+
+ + + {task.title || "Untitled task"} - )} +
+
- )} -
-
+ {(stage || task.repository || prUrl) && ( +
+ {task.repository && ( + + + {task.repository} + + )} + {stage && ( + + {stage} + + )} + {prUrl && ( + + + PR + + )} +
+ )} + + + ); } @@ -388,11 +425,13 @@ function ReplyFooter({ const FeedItem = memo(function FeedItem({ task, + channelId, inView, onOpenTask, onOpenThread, }: { task: Task; + channelId: string; inView: boolean; onOpenTask: (task: Task) => void; onOpenThread: (task: Task) => void; @@ -435,7 +474,11 @@ const FeedItem = memo(function FeedItem({ )} - onOpenTask(task)} /> + onOpenThread(task)} + /> void; onOpenThread: (task: Task) => void; }) { @@ -483,6 +528,7 @@ function FeedRow({ > diff --git a/packages/ui/src/features/canvas/components/ThreadPanel.tsx b/packages/ui/src/features/canvas/components/ThreadPanel.tsx index 47e5ef82ea..6826c6577e 100644 --- a/packages/ui/src/features/canvas/components/ThreadPanel.tsx +++ b/packages/ui/src/features/canvas/components/ThreadPanel.tsx @@ -1,4 +1,5 @@ import { + ArrowSquareOutIcon, CaretRightIcon, DotsThreeIcon, PaperPlaneRightIcon, @@ -6,6 +7,13 @@ import { TrashIcon, XIcon, } from "@phosphor-icons/react"; +import { + buildThreadTimeline, + deriveThreadAgentStatus, + shouldSuspendThreadSession, + type ThreadAgentMessage, + type ThreadAgentStatus, +} from "@posthog/core/canvas/threadTimeline"; import { Avatar, AvatarFallback, @@ -17,9 +25,19 @@ import { DropdownMenuTrigger, InputGroupAddon, InputGroupButton, + Skeleton, + SkeletonText, Spinner, + ThreadItem, + ThreadItemAction, + ThreadItemActions, + ThreadItemAuthor, + ThreadItemBody, + ThreadItemContent, + ThreadItemGroup, + ThreadItemGutter, + ThreadItemHeader, } from "@posthog/quill"; -import { formatRelativeTimeShort } from "@posthog/shared"; import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; import type { Task, @@ -30,20 +48,31 @@ import { isTerminalStatus } from "@posthog/shared/domain-types"; import { useOptionalAuthenticatedClient } from "@posthog/ui/features/auth/authClient"; import { useCurrentUser } from "@posthog/ui/features/auth/useCurrentUser"; import { getUserInitials } from "@posthog/ui/features/auth/userInitials"; +import { TaskCard } from "@posthog/ui/features/canvas/components/ChannelFeedView"; import { MentionComposer } from "@posthog/ui/features/canvas/components/MentionComposer"; import { MentionText } from "@posthog/ui/features/canvas/components/MentionText"; +import { ThreadTimestamp } from "@posthog/ui/features/canvas/components/ThreadTimestamp"; import { useOrgMembers } from "@posthog/ui/features/canvas/hooks/useOrgMembers"; import { useTaskThread, useTaskThreadMutations, } from "@posthog/ui/features/canvas/hooks/useTaskThread"; import { userDisplayName } from "@posthog/ui/features/canvas/utils/userDisplay"; +import type { ConversationItem } from "@posthog/ui/features/sessions/components/buildConversationItems"; +import { + ChatMarkdown, + ChatStreamingMarkdown, +} from "@posthog/ui/features/sessions/components/chat-thread/ChatMarkdown"; +import { extractChannelContext } from "@posthog/ui/features/sessions/components/session-update/channelContext"; +import { useConversationItems } from "@posthog/ui/features/sessions/hooks/useConversationItems"; +import { useSessionConnection } from "@posthog/ui/features/sessions/hooks/useSessionConnection"; +import { useSessionViewState } from "@posthog/ui/features/sessions/hooks/useSessionViewState"; +import { usePendingPermissionsForTask } from "@posthog/ui/features/sessions/sessionStore"; import { taskDetailQuery } from "@posthog/ui/features/tasks/queries"; import { toast } from "@posthog/ui/primitives/toast"; import { track } from "@posthog/ui/shell/analytics"; -import { Text } from "@radix-ui/themes"; import { useQuery } from "@tanstack/react-query"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; function ThreadMessageRow({ message, @@ -55,7 +84,6 @@ function ThreadMessageRow({ onDelete, }: { message: TaskThreadMessage; - /** Whether the current user authored the task (may forward to the agent). */ isTaskAuthor: boolean; isOwnMessage: boolean; currentUserEmail?: string | null; @@ -67,97 +95,237 @@ function ThreadMessageRow({ const showMenu = (isTaskAuthor && !forwarded) || isOwnMessage; return ( -
- - {getUserInitials(message.author)} - -
-
- - {userDisplayName(message.author)} - - - {formatRelativeTimeShort(message.created_at)} - -
- + + + + {getUserInitials(message.author)} + + + + + {userDisplayName(message.author)} + + + + + {forwarded && ( - + Sent to agent )} -
+ {showMenu && ( - - - - - } - /> - - {isTaskAuthor && !forwarded && ( - - - Send to agent - - )} - {isOwnMessage && ( - - - Delete message - - )} - - + + + + + + } + /> + + {isTaskAuthor && !forwarded && ( + + + Send to agent + + )} + {isOwnMessage && ( + + + Delete message + + )} + + + )} -
+ ); } -// The right-hand thread dock: the human-only conversation around a task. -// Nothing here reaches the agent unless the task author explicitly forwards a -// message ("Send to agent" in the row's hover menu). -export function ThreadPanel({ - taskId, - task: taskProp, +function agentTurns(items: ConversationItem[]): ThreadAgentMessage[] { + const turns: ThreadAgentMessage[] = []; + let current: ThreadAgentMessage | null = null; + for (const item of items) { + if (item.type === "user_message") { + if (current) turns.push(current); + current = null; + continue; + } + if ( + item.type === "session_update" && + item.update.sessionUpdate === "agent_message_chunk" && + "content" in item.update && + item.update.content.type === "text" && + item.update.content.text.trim() + ) { + current = { + id: item.id, + text: item.update.content.text, + timestamp: item.timestamp, + }; + } + } + if (current) turns.push(current); + return turns; +} + +function agentPrompts(items: ConversationItem[]): ThreadAgentMessage[] { + const prompts: ThreadAgentMessage[] = []; + for (const item of items) { + if (item.type !== "user_message") continue; + const text = ( + extractChannelContext(item.content)?.stripped ?? item.content + ).trim(); + if (!text) continue; + prompts.push({ id: item.id, text, timestamp: item.timestamp }); + } + return prompts; +} + +function AgentStatusChip({ status }: { status: ThreadAgentStatus }) { + switch (status.phase) { + case "active": + return ( + + + {status.label} + + ); + case "needs_input": + return {status.label}; + case "error": + return {status.label}; + default: + return {status.label}; + } +} + +function AgentTurnRow({ + message, + status, + streaming, +}: { + message?: ThreadAgentMessage; + status?: ThreadAgentStatus; + streaming: boolean; +}) { + return ( + + + + + + + + + + + Agent + {status && } + {message?.timestamp !== undefined && ( + + )} + + {message?.text && ( + +
+ {streaming ? ( + + ) : ( + + )} +
+
+ )} +
+
+ ); +} + +function UserPromptRow({ + message, + author, +}: { + message: ThreadAgentMessage; + author: TaskThreadMessage["author"]; +}) { + return ( + + + + {getUserInitials(author)} + + + + + {userDisplayName(author)} + {message.timestamp !== undefined && ( + + )} + + + {message.text} + + + + ); +} + +function ThreadTimelineSkeleton() { + return ( + + {[0, 1, 2].map((i) => ( + + + + + + + + + + + + ))} + + ); +} + +function ThreadConversation({ + task, + channelId, onClose, - collapsed, onToggleCollapsed, - showTaskTitle = true, + onOpenFull, + showTaskSummary, }: { - taskId: string; - /** The thread's task when the caller already has it; fetched otherwise. */ - task?: Task; + task: Task; + channelId: string; onClose?: () => void; - collapsed?: boolean; onToggleCollapsed?: () => void; - /** - * Show the task title under the "Thread" heading. Hidden in the task detail - * view, where the TaskDetail header already names the task. - */ - showTaskTitle?: boolean; + onOpenFull?: () => void; + showTaskSummary: boolean; }) { + const taskId = task.id; const client = useOptionalAuthenticatedClient(); const { data: currentUser } = useCurrentUser({ client }); - const { data: fetchedTask } = useQuery({ - ...taskDetailQuery(taskId), - enabled: !taskProp, - }); - const task = taskProp ?? fetchedTask; - const { messages, isLoading } = useTaskThread(collapsed ? undefined : taskId); + const { messages, isLoading } = useTaskThread(taskId); const { postMessage, deleteMessage, @@ -165,7 +333,82 @@ export function ThreadPanel({ isPosting, isSendingToAgent, } = useTaskThreadMutations(taskId); - const { members } = useOrgMembers({ enabled: !collapsed }); + const { members } = useOrgMembers(); + + const { + session, + repoPath, + isCloud, + events, + cloudStatus, + isPromptPending, + isInitializing, + hasError, + errorTitle, + } = useSessionViewState(taskId, task); + useSessionConnection({ + taskId, + task, + session, + repoPath, + isCloud, + isSuspended: shouldSuspendThreadSession({ + isCloud, + hasRun: Boolean(task.latest_run?.id), + hasSession: Boolean(session), + }), + }); + const { items } = useConversationItems(events, isPromptPending); + const pendingPermissions = usePendingPermissionsForTask(taskId); + const prUrl = + typeof task.latest_run?.output?.pr_url === "string" + ? task.latest_run.output.pr_url + : undefined; + + const agentMsgs = useMemo(() => agentTurns(items), [items]); + const promptMsgs = useMemo(() => agentPrompts(items), [items]); + + const agentStatus = useMemo( + () => + deriveThreadAgentStatus({ + hasActivity: events.length > 0 || !!task.latest_run, + hasError, + cloudStatus, + errorTitle, + pendingPermissionCount: pendingPermissions.size, + isPromptPending, + isInitializing, + hasPullRequest: !!prUrl, + }), + [ + events.length, + task.latest_run, + hasError, + cloudStatus, + errorTitle, + pendingPermissions.size, + isPromptPending, + isInitializing, + prUrl, + ], + ); + + const timeline = useMemo( + () => + buildThreadTimeline({ + prompts: promptMsgs, + agentMessages: agentMsgs, + humanMessages: messages.map((message) => ({ + id: message.id, + content: message.content, + createdAt: message.created_at, + value: message, + })), + }), + [promptMsgs, messages, agentMsgs], + ); + + const lastAgentId = agentMsgs[agentMsgs.length - 1]?.id; const [draft, setDraft] = useState(""); const scrollRef = useRef(null); @@ -182,17 +425,21 @@ export function ThreadPanel({ [taskId], ); - // Keep the newest message in view, Slack-style. - // biome-ignore lint/correctness/useExhaustiveDependencies: scroll on new messages + // biome-ignore lint/correctness/useExhaustiveDependencies: scroll on new content useEffect(() => { scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight }); - }, [messages.length]); + }, [ + messages.length, + promptMsgs.length, + agentMsgs.length, + agentMsgs[agentMsgs.length - 1]?.text, + agentStatus?.phase, + ]); const isTaskAuthor = - !!currentUser?.uuid && currentUser.uuid === task?.created_by?.uuid; - // Forwarding needs a run the workflow can still signal, one send at a time. + !!currentUser?.uuid && currentUser.uuid === task.created_by?.uuid; const canForward = - !!task?.latest_run && + !!task.latest_run && !isTerminalStatus(task.latest_run.status) && !isSendingToAgent; @@ -224,34 +471,25 @@ export function ThreadPanel({ }); }; - if (collapsed) { - return ( -
- -
- ); - } + const isEmpty = timeline.length === 0 && !agentStatus; + const isReady = !isInitializing && !isLoading; return (
- - Thread - - {showTaskTitle && task && ( - - {task.title || "Untitled task"} - - )} + Thread
+ {onOpenFull && ( + + )} {onToggleCollapsed && ( +
+ ); + } + + if (!task) { + return ( +
+ +
+ ); + } + + return ( + + ); +} diff --git a/packages/ui/src/features/canvas/components/ThreadSidebar.tsx b/packages/ui/src/features/canvas/components/ThreadSidebar.tsx index fb4d7703dd..4a81cf8700 100644 --- a/packages/ui/src/features/canvas/components/ThreadSidebar.tsx +++ b/packages/ui/src/features/canvas/components/ThreadSidebar.tsx @@ -12,16 +12,19 @@ import { useState } from "react"; // re-render on every resize tick. export function ThreadSidebar({ taskId, + channelId, task, onClose, - showTaskTitle, + onOpenFull, + showTaskSummary, }: { taskId: string; + channelId: string; /** The thread's task when the caller already has it; fetched otherwise. */ task?: Task; onClose?: () => void; - /** Forwarded to ThreadPanel; hidden in the task detail view. */ - showTaskTitle?: boolean; + onOpenFull?: () => void; + showTaskSummary?: boolean; }) { const collapsed = useThreadPanelStore((s) => s.collapsed); const width = useThreadPanelStore((s) => s.width); @@ -42,6 +45,7 @@ export function ThreadSidebar({ return ( toggleCollapsed(false)} @@ -60,10 +64,12 @@ export function ThreadSidebar({ > toggleCollapsed(true)} - showTaskTitle={showTaskTitle} + onOpenFull={onOpenFull} + showTaskSummary={showTaskSummary} /> ); diff --git a/packages/ui/src/features/canvas/components/ThreadTimestamp.tsx b/packages/ui/src/features/canvas/components/ThreadTimestamp.tsx new file mode 100644 index 0000000000..ef754f19e8 --- /dev/null +++ b/packages/ui/src/features/canvas/components/ThreadTimestamp.tsx @@ -0,0 +1,45 @@ +import { + ThreadItemTimestamp, + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@posthog/quill"; + +function ordinal(value: number): string { + const remainder = value % 100; + const suffixes = ["th", "st", "nd", "rd"]; + return `${value}${suffixes[(remainder - 20) % 10] ?? suffixes[remainder] ?? suffixes[0]}`; +} + +function formatClock(date: Date): string { + const minutes = String(date.getMinutes()).padStart(2, "0"); + const meridiem = date.getHours() >= 12 ? "pm" : "am"; + const hour = date.getHours() % 12 || 12; + return `${hour}:${minutes}${meridiem}`; +} + +function formatTooltip(date: Date): string { + const month = date.toLocaleString("en-US", { month: "long" }); + return `${month} ${ordinal(date.getDate())} at ${formatClock(date)}`; +} + +export function ThreadTimestamp({ dateTime }: { dateTime: string }) { + const date = new Date(dateTime); + if (Number.isNaN(date.getTime())) return null; + + return ( + + + + {formatClock(date)} + + } + /> + {formatTooltip(date)} + + + ); +} diff --git a/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx b/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx index bc471627d7..005afd42cd 100644 --- a/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx +++ b/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx @@ -43,7 +43,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, useState } from "react"; +import { useCallback, 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 @@ -107,16 +107,12 @@ export function WebsiteChannelHome({ channelId }: { channelId: string }) { // onboarding checklist. Describe-mode: seeds a plan session for this context. const [contextMdDialogOpen, setContextMdDialogOpen] = useState(false); - const threadTaskId = useThreadPanelStore((s) => s.taskId); + const threadTaskId = useThreadPanelStore( + (s) => s.openByChannel[channelId] ?? null, + ); const openThread = useThreadPanelStore((s) => s.openThread); const closeThread = useThreadPanelStore((s) => s.closeThread); - // A thread from another channel shouldn't linger when switching feeds. - // biome-ignore lint/correctness/useExhaustiveDependencies: re-close per channel - useEffect(() => { - closeThread(); - }, [closeThread, channelId]); - const handleSuggestionSelect = useCallback( (prompt: string, mode?: string) => { composerRef.current?.applySuggestion(prompt, mode); @@ -171,21 +167,23 @@ export function WebsiteChannelHome({ channelId }: { channelId: string }) { [backendChannel?.id, channelId, fileTask, invalidateFeed, queryClient], ); - // The task route's mount effect points the panel at the task, so navigating - // is enough here. - const handleOpenTask = useCallback( - (task: Task) => { + const handleOpenFull = useCallback( + (taskId: string) => { void navigate({ to: "/website/$channelId/tasks/$taskId", - params: { channelId, taskId: task.id }, + params: { channelId, taskId }, }); }, [channelId, navigate], ); + const handleOpenTask = useCallback( + (task: Task) => handleOpenFull(task.id), + [handleOpenFull], + ); const handleOpenThread = useCallback( - (task: Task) => openThread(task.id), - [openThread], + (task: Task) => openThread(channelId, task.id), + [channelId, openThread], ); const threadTask = threadTaskId @@ -262,6 +260,7 @@ export function WebsiteChannelHome({ channelId }: { channelId: string }) {
closeThread(channelId)} + onOpenFull={() => handleOpenFull(threadTaskId)} /> )} diff --git a/packages/ui/src/features/canvas/hooks/useChannelFeedMessages.test.ts b/packages/ui/src/features/canvas/hooks/useChannelFeedMessages.test.ts new file mode 100644 index 0000000000..b222a17140 --- /dev/null +++ b/packages/ui/src/features/canvas/hooks/useChannelFeedMessages.test.ts @@ -0,0 +1,23 @@ +import { useAuthenticatedQuery } from "@posthog/ui/hooks/useAuthenticatedQuery"; +import { renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { useChannelFeedMessages } from "./useChannelFeedMessages"; + +vi.mock("@posthog/ui/hooks/useAuthenticatedQuery", () => ({ + useAuthenticatedQuery: vi.fn(() => ({ data: [], isLoading: false })), +})); + +describe("useChannelFeedMessages", () => { + beforeEach(() => { + vi.mocked(useAuthenticatedQuery).mockClear(); + }); + + it("keeps polling after a transient query error", () => { + renderHook(() => useChannelFeedMessages("channel-id")); + + expect(vi.mocked(useAuthenticatedQuery).mock.calls[0]?.[2]).toMatchObject({ + retry: false, + refetchInterval: 5_000, + }); + }); +}); diff --git a/packages/ui/src/features/canvas/hooks/useChannelFeedMessages.ts b/packages/ui/src/features/canvas/hooks/useChannelFeedMessages.ts index 4ffb818045..a7442a620e 100644 --- a/packages/ui/src/features/canvas/hooks/useChannelFeedMessages.ts +++ b/packages/ui/src/features/canvas/hooks/useChannelFeedMessages.ts @@ -80,14 +80,8 @@ export function useChannelFeedMessages(channelId: string | undefined): { (client) => client.getChannelFeed(channelId as string), { enabled: !!channelId, - // The endpoint may not be deployed yet (posthog#70320): don't retry, and - // stop polling once the query errors — otherwise every open channel view - // streams 404s (poll tick × default retries) forever. retry: false, - refetchInterval: (query) => - query.state.status === "error" - ? false - : CHANNEL_FEED_MESSAGES_POLL_INTERVAL_MS, + refetchInterval: CHANNEL_FEED_MESSAGES_POLL_INTERVAL_MS, }, ); const messages = useMemo( diff --git a/packages/ui/src/features/canvas/stores/threadPanelStore.test.ts b/packages/ui/src/features/canvas/stores/threadPanelStore.test.ts new file mode 100644 index 0000000000..722bd29263 --- /dev/null +++ b/packages/ui/src/features/canvas/stores/threadPanelStore.test.ts @@ -0,0 +1,37 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { useThreadPanelStore } from "./threadPanelStore"; + +describe("threadPanelStore", () => { + beforeEach(() => { + useThreadPanelStore.setState({ + openByChannel: {}, + collapsed: false, + width: 360, + }); + }); + + it("keeps each channel tab's open thread independent", () => { + const { openThread } = useThreadPanelStore.getState(); + + openThread("channel-a", "task-a"); + openThread("channel-b", "task-b"); + + expect(useThreadPanelStore.getState().openByChannel).toEqual({ + "channel-a": "task-a", + "channel-b": "task-b", + }); + }); + + it("closes only the active channel's thread", () => { + useThreadPanelStore.setState({ + openByChannel: { "channel-a": "task-a", "channel-b": "task-b" }, + }); + + useThreadPanelStore.getState().closeThread("channel-a"); + + expect(useThreadPanelStore.getState().openByChannel).toEqual({ + "channel-a": null, + "channel-b": "task-b", + }); + }); +}); diff --git a/packages/ui/src/features/canvas/stores/threadPanelStore.ts b/packages/ui/src/features/canvas/stores/threadPanelStore.ts index e003ad89cb..ee2c66427e 100644 --- a/packages/ui/src/features/canvas/stores/threadPanelStore.ts +++ b/packages/ui/src/features/canvas/stores/threadPanelStore.ts @@ -2,20 +2,18 @@ import { electronStorage } from "@posthog/ui/shell/rendererStorage"; import { create } from "zustand"; import { persist } from "zustand/middleware"; -// View state for the thread side panel — the right-hand human conversation -// dock next to a channel feed or task detail. Which thread is open is -// per-navigation state; collapse and width are persisted user preferences so -// the panel keeps its shape across tasks and channels. const DEFAULT_PANEL_WIDTH = 360; interface ThreadPanelState { - /** Task whose thread is open, or null when the panel is closed. */ - taskId: string | null; + openByChannel: Record; collapsed: boolean; width: number; - /** Points the panel at a task; expands it unless `expand: false`. */ - openThread: (taskId: string, opts?: { expand?: boolean }) => void; - closeThread: () => void; + openThread: ( + channelId: string, + taskId: string, + opts?: { expand?: boolean }, + ) => void; + closeThread: (channelId: string) => void; setCollapsed: (collapsed: boolean) => void; setWidth: (width: number) => void; } @@ -23,12 +21,18 @@ interface ThreadPanelState { export const useThreadPanelStore = create()( persist( (set) => ({ - taskId: null, + openByChannel: {}, collapsed: false, width: DEFAULT_PANEL_WIDTH, - openThread: (taskId, opts) => - set(opts?.expand === false ? { taskId } : { taskId, collapsed: false }), - closeThread: () => set({ taskId: null }), + openThread: (channelId, taskId, opts) => + set((state) => ({ + openByChannel: { ...state.openByChannel, [channelId]: taskId }, + ...(opts?.expand === false ? {} : { collapsed: false }), + })), + closeThread: (channelId) => + set((state) => ({ + openByChannel: { ...state.openByChannel, [channelId]: null }, + })), setCollapsed: (collapsed) => set({ collapsed }), setWidth: (width) => set({ width }), }), diff --git a/packages/ui/src/features/canvas/taskCardNavigation.test.ts b/packages/ui/src/features/canvas/taskCardNavigation.test.ts new file mode 100644 index 0000000000..667ff66870 --- /dev/null +++ b/packages/ui/src/features/canvas/taskCardNavigation.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { shouldOpenTaskCardInline } from "./taskCardNavigation"; + +const PRIMARY_CLICK = { + defaultPrevented: false, + button: 0, + metaKey: false, + ctrlKey: false, + shiftKey: false, + altKey: false, +}; + +describe("shouldOpenTaskCardInline", () => { + it("opens an unmodified primary click in the thread dock", () => { + expect(shouldOpenTaskCardInline(PRIMARY_CLICK)).toBe(true); + }); + + it.each([ + { defaultPrevented: true }, + { button: 1 }, + { metaKey: true }, + { ctrlKey: true }, + { shiftKey: true }, + { altKey: true }, + ])("leaves browser navigation intact for %o", (override) => { + expect(shouldOpenTaskCardInline({ ...PRIMARY_CLICK, ...override })).toBe( + false, + ); + }); +}); diff --git a/packages/ui/src/features/canvas/taskCardNavigation.ts b/packages/ui/src/features/canvas/taskCardNavigation.ts new file mode 100644 index 0000000000..27ad8f0abb --- /dev/null +++ b/packages/ui/src/features/canvas/taskCardNavigation.ts @@ -0,0 +1,21 @@ +export interface TaskCardPointerIntent { + defaultPrevented: boolean; + button: number; + metaKey: boolean; + ctrlKey: boolean; + shiftKey: boolean; + altKey: boolean; +} + +export function shouldOpenTaskCardInline( + event: TaskCardPointerIntent, +): boolean { + return ( + !event.defaultPrevented && + event.button === 0 && + !event.metaKey && + !event.ctrlKey && + !event.shiftKey && + !event.altKey + ); +} diff --git a/packages/ui/src/router/routes/website/$channelId/tasks/$taskId.tsx b/packages/ui/src/router/routes/website/$channelId/tasks/$taskId.tsx index 85fbec93f8..b50fe78fe3 100644 --- a/packages/ui/src/router/routes/website/$channelId/tasks/$taskId.tsx +++ b/packages/ui/src/router/routes/website/$channelId/tasks/$taskId.tsx @@ -1,7 +1,6 @@ import type { Task } from "@posthog/shared/domain-types"; import { ThreadSidebar } from "@posthog/ui/features/canvas/components/ThreadSidebar"; import { useChannels } from "@posthog/ui/features/canvas/hooks/useChannels"; -import { useThreadPanelStore } from "@posthog/ui/features/canvas/stores/threadPanelStore"; import { useTaskViewed } from "@posthog/ui/features/sidebar/useTaskViewed"; import { TaskDetail } from "@posthog/ui/features/task-detail/components/TaskDetail"; import { @@ -49,13 +48,6 @@ function ChannelTaskDetailRoute() { markAsViewed(taskId); }, [taskId, markAsViewed]); - // Opening a task shows its thread docked on the right, keeping the user's - // collapse preference. The panel follows the task being viewed. - const openThread = useThreadPanelStore((s) => s.openThread); - useEffect(() => { - openThread(taskId, { expand: false }); - }, [openThread, taskId]); - const { data: fetched } = useQuery({ ...taskDetailQuery(taskId), enabled: !fromList && !loaderTask, @@ -77,7 +69,12 @@ function ChannelTaskDetailRoute() { channelId={channelId} />
- +
); } From c9cb075e7e0945d62537ee8f10a071dcdd8d3b1b Mon Sep 17 00:00:00 2001 From: Peter Kirkham Date: Wed, 15 Jul 2026 16:41:28 +0100 Subject: [PATCH 02/13] fix(channels): retain mention styling and Quill guidance Restore the production-safe mention presentation and the local Quill development skill from the source work. Context generation remains provided by the implementation already merged on main. Generated-By: PostHog Code Task-Id: 87a79e23-20c4-440b-818b-28154924ffbc --- .claude/skills/quill-code/SKILL.md | 93 +++++++++++++++++++ .../canvas/components/MentionComposer.tsx | 3 +- .../canvas/components/MentionText.test.tsx | 24 +++++ .../canvas/components/MentionText.tsx | 6 +- .../canvas/components/mention-chip.css | 12 +++ 5 files changed, 134 insertions(+), 4 deletions(-) create mode 100644 .claude/skills/quill-code/SKILL.md create mode 100644 packages/ui/src/features/canvas/components/MentionText.test.tsx create mode 100644 packages/ui/src/features/canvas/components/mention-chip.css diff --git a/.claude/skills/quill-code/SKILL.md b/.claude/skills/quill-code/SKILL.md new file mode 100644 index 0000000000..a61c9429b1 --- /dev/null +++ b/.claude/skills/quill-code/SKILL.md @@ -0,0 +1,93 @@ +--- +name: quill-code +description: Edit the @posthog/quill design system locally and consume the change in this repo (posthog-code) before it is published to npm. Use when changing quill components/primitives/tokens, when a quill change must be tested inside the Code app, or when the user mentions quill, the design system, the .local-quill tarball, or the @posthog/quill pnpm override. +--- + +# quill-code + +`@posthog/quill` is **not** in this repo. It is a published catalog dependency whose +source lives in the main PostHog monorepo at `../posthog/packages/quill`. To test an +unpublished quill change inside this repo (posthog-code), you build quill, pack it to a +tarball, and point a pnpm `overrides` entry at that tarball. This is a **temporary +local-dev** state — revert before merging (see below). + +## Quill layout (where to edit) + +`../posthog/packages/quill` is the **workspace** (`@posthog/quill-workspace`). It +contains sub-packages, each a layer of the design system: + +- `packages/primitives/src` — base components (Card, Badge, Button, Progress, …) +- `packages/components/src` — composed components (DataTable, DateTimePicker, Metric) +- `packages/blocks/src` — **product-level blocks** (e.g. `ExperimentCard`). Add the + file here and export it from `packages/blocks/src/index.ts`. +- `packages/quill/src` — the **aggregate** that re-exports all layers as `@posthog/quill`. + +A new export in any sub-package flows to `@posthog/quill` automatically on build. + +## The loop (every quill change) + +1. Edit/add the component in the right sub-package (above) and export it from that + package's `src/index.ts`. +2. Re-sync into this repo manually — build → pack → point the override → reinstall: + + ```bash + QUILL_DIR="${QUILL_DIR:-../posthog/packages/quill/packages/quill}" + + # a. Build the WHOLE quill workspace (two levels up from the aggregate), so the + # sub-packages rebuild BEFORE the aggregate bundles them. + ( cd "$QUILL_DIR/../.." && pnpm build ) + + # b. Pack into .local-quill/ under a UNIQUE filename. pnpm pins a tarball by + # integrity, so a stable name caches stale across re-syncs — drop old local + # tarballs first, then rename the packed file to a unique local name. + rm -f .local-quill/posthog-quill-local-*.tgz + ( cd "$QUILL_DIR" && npm pack --pack-destination "$(git rev-parse --show-toplevel)/.local-quill" ) + mv .local-quill/posthog-quill-[0-9]*.tgz ".local-quill/posthog-quill-local-$(git rev-parse --short HEAD)-$$.tgz" + + # c. Point the override at the new tarball, then reinstall. + # Edit pnpm-workspace.yaml so overrides['@posthog/quill'] = file:./.local-quill/ + pnpm install + ``` + + > Building only the aggregate (`packages/quill/packages/quill`) re-bundles the + > sub-packages' **stale** `dist/`, so edits to primitives/components/blocks are + > silently dropped. Always build at the workspace root. +3. Verify in the Code app (`pnpm dev`, or the `test-electron-app` skill). Repeat from 1. + +After every quill edit you **must** re-run the sync — the app consumes the tarball, not +the quill source, so unsynced edits are invisible here. + +If quill lives elsewhere, set `QUILL_DIR=/abs/path/to/posthog/packages/quill/packages/quill`. + +## Why a tarball, not `link:` + +`link:` symlinks into the mono's `node_modules` and drags in its **React 18** types, +colliding with this repo's **React 19** (dual-React → broken typecheck + +invalid-hook-call at runtime). The tarball is copied into this repo's store and deduped +against React 19. The filename is **content-hashed** because pnpm pins a tarball by +integrity, so a stable filename gets cached stale across re-syncs. + +## The override (what the script rewrites) + +In `pnpm-workspace.yaml`, under `overrides:`: + +```yaml +'@posthog/quill': file:./.local-quill/posthog-quill-local-.tgz +``` + +There is also a permanent pin you should leave alone: + +```yaml +'@posthog/quill>@base-ui/react': ^1.3.0 # quill ships a broken catalog: dep; do not remove +``` + +## Reverting (before merge) + +The override is local-dev only. Once the quill change is published to npm: + +1. Bump the catalog version in `pnpm-workspace.yaml` (`'@posthog/quill': 0.3.0-beta.x`) + to the published version. +2. Restore the override line to point back at the catalog, or remove the `file:` override. +3. `pnpm install`. + +Do not commit a `file:./.local-quill/...` override or the `.local-quill/` tarballs. diff --git a/packages/ui/src/features/canvas/components/MentionComposer.tsx b/packages/ui/src/features/canvas/components/MentionComposer.tsx index aaee6d89aa..e2c07e5c15 100644 --- a/packages/ui/src/features/canvas/components/MentionComposer.tsx +++ b/packages/ui/src/features/canvas/components/MentionComposer.tsx @@ -13,6 +13,7 @@ import Placeholder from "@tiptap/extension-placeholder"; import { EditorContent, useEditor } from "@tiptap/react"; import StarterKit from "@tiptap/starter-kit"; import { type ReactNode, useEffect, useRef, useState } from "react"; +import "./mention-chip.css"; import "./mention-composer.css"; interface MentionComposerProps { @@ -31,7 +32,7 @@ interface MentionComposerProps { } /** Styled like the chips MentionText renders on sent messages. */ -const MENTION_CHIP_CLASS = "rounded px-0.5 font-medium text-[var(--accent-11)]"; +const MENTION_CHIP_CLASS = "mention-chip"; // Padding lives on the editable element (not the input-group control) so a // click anywhere in the box focuses the editor. diff --git a/packages/ui/src/features/canvas/components/MentionText.test.tsx b/packages/ui/src/features/canvas/components/MentionText.test.tsx new file mode 100644 index 0000000000..75bfe97a37 --- /dev/null +++ b/packages/ui/src/features/canvas/components/MentionText.test.tsx @@ -0,0 +1,24 @@ +import { Theme } from "@radix-ui/themes"; +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { MentionText } from "./MentionText"; + +describe("MentionText", () => { + it("uses the shared mention styles and emphasizes the current user", () => { + render( + + + , + ); + + expect(screen.getByText("@Alice")).toHaveClass("mention-chip"); + expect(screen.getByText("@Alice")).not.toHaveClass("mention-chip--self"); + expect(screen.getByText("@Bob")).toHaveClass( + "mention-chip", + "mention-chip--self", + ); + }); +}); diff --git a/packages/ui/src/features/canvas/components/MentionText.tsx b/packages/ui/src/features/canvas/components/MentionText.tsx index ce6c4bd26a..5f640b440f 100644 --- a/packages/ui/src/features/canvas/components/MentionText.tsx +++ b/packages/ui/src/features/canvas/components/MentionText.tsx @@ -2,6 +2,7 @@ import { splitMentionSegments } from "@posthog/shared"; import { splitLinkSegments } from "@posthog/ui/features/canvas/utils/linkify"; import { Text } from "@radix-ui/themes"; import { Fragment, useMemo } from "react"; +import "./mention-chip.css"; type RenderSegment = | { type: "text"; text: string } @@ -11,8 +12,7 @@ type RenderSegment = // The plain (not-the-viewer) mention chip look, also used by surfaces that // render a mention-styled name without real mention semantics (e.g. the // channel feed's "started a new task" row). -export const mentionChipClass = - "rounded px-0.5 font-medium text-[var(--accent-11)]"; +export const mentionChipClass = "mention-chip"; /** * Thread message content with inline mention tokens rendered as highlighted @@ -60,7 +60,7 @@ export function MentionText({ key={key} className={ selfEmail && segment.email.toLowerCase() === selfEmail - ? "rounded bg-[var(--accent-a4)] px-0.5 font-medium text-[var(--accent-12)]" + ? `${mentionChipClass} mention-chip--self` : mentionChipClass } title={segment.email} diff --git a/packages/ui/src/features/canvas/components/mention-chip.css b/packages/ui/src/features/canvas/components/mention-chip.css new file mode 100644 index 0000000000..55a96cab4b --- /dev/null +++ b/packages/ui/src/features/canvas/components/mention-chip.css @@ -0,0 +1,12 @@ +.mention-chip { + border-radius: var(--radius-1); + background: color-mix(in oklab, var(--primary) 10%, transparent); + color: color-mix(in oklab, var(--primary) 80%, transparent); + font-weight: 500; + padding-inline: 0.125rem; +} + +.mention-chip--self { + background: color-mix(in oklab, var(--primary) 50%, transparent); + color: var(--primary); +} From f723ed9d1c69dda85ff8076afcaac165d24706c2 Mon Sep 17 00:00:00 2001 From: Peter Kirkham Date: Wed, 15 Jul 2026 17:13:34 +0100 Subject: [PATCH 03/13] fix(channels): satisfy thread scroll dependencies Drive the thread auto-scroll effect from the memoized timeline and agent phase so React Doctor sees complete dependencies without maintaining partial message fields. Generated-By: PostHog Code Task-Id: 87a79e23-20c4-440b-818b-28154924ffbc --- .../ui/src/features/canvas/components/ThreadPanel.tsx | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/packages/ui/src/features/canvas/components/ThreadPanel.tsx b/packages/ui/src/features/canvas/components/ThreadPanel.tsx index 6826c6577e..e872045efc 100644 --- a/packages/ui/src/features/canvas/components/ThreadPanel.tsx +++ b/packages/ui/src/features/canvas/components/ThreadPanel.tsx @@ -425,16 +425,10 @@ function ThreadConversation({ [taskId], ); - // biome-ignore lint/correctness/useExhaustiveDependencies: scroll on new content + // biome-ignore lint/correctness/useExhaustiveDependencies: scroll when rendered thread content changes useEffect(() => { scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight }); - }, [ - messages.length, - promptMsgs.length, - agentMsgs.length, - agentMsgs[agentMsgs.length - 1]?.text, - agentStatus?.phase, - ]); + }, [timeline, agentStatus?.phase]); const isTaskAuthor = !!currentUser?.uuid && currentUser.uuid === task.created_by?.uuid; From 7ffce8a6feb3673c7dff6ccd3d641a2cdb3b0431 Mon Sep 17 00:00:00 2001 From: Peter Kirkham Date: Thu, 16 Jul 2026 11:50:35 +0100 Subject: [PATCH 04/13] fix(canvas): render agent status as thread content Generated-By: PostHog Code Task-Id: 87a79e23-20c4-440b-818b-28154924ffbc --- .../core/src/canvas/threadTimeline.test.ts | 5 +++++ packages/core/src/canvas/threadTimeline.ts | 2 +- .../canvas/components/ThreadPanel.test.tsx | 19 +++++++++++++++++++ .../canvas/components/ThreadPanel.tsx | 19 ++++++++++++------- 4 files changed, 37 insertions(+), 8 deletions(-) create mode 100644 packages/ui/src/features/canvas/components/ThreadPanel.test.tsx diff --git a/packages/core/src/canvas/threadTimeline.test.ts b/packages/core/src/canvas/threadTimeline.test.ts index 145386ebe3..47aff1a08f 100644 --- a/packages/core/src/canvas/threadTimeline.test.ts +++ b/packages/core/src/canvas/threadTimeline.test.ts @@ -72,6 +72,11 @@ describe("deriveThreadAgentStatus", () => { input: { hasActivity: true, hasPullRequest: true }, expected: { phase: "complete", label: "Shipped" }, }, + { + name: "reports settled work without a pull request as done", + input: { hasActivity: true }, + expected: { phase: "complete", label: "Done" }, + }, ])("$name", ({ input, expected }) => { expect(deriveThreadAgentStatus(input)).toEqual(expected); }); diff --git a/packages/core/src/canvas/threadTimeline.ts b/packages/core/src/canvas/threadTimeline.ts index aa28dc49b4..f2b9b2875f 100644 --- a/packages/core/src/canvas/threadTimeline.ts +++ b/packages/core/src/canvas/threadTimeline.ts @@ -99,7 +99,7 @@ export function deriveThreadAgentStatus({ } return { phase: "complete", - label: hasPullRequest ? "Shipped" : "Ready to ship", + label: hasPullRequest ? "Shipped" : "Done", }; } diff --git a/packages/ui/src/features/canvas/components/ThreadPanel.test.tsx b/packages/ui/src/features/canvas/components/ThreadPanel.test.tsx new file mode 100644 index 0000000000..b72c326ca3 --- /dev/null +++ b/packages/ui/src/features/canvas/components/ThreadPanel.test.tsx @@ -0,0 +1,19 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { AgentTurnRow } from "./ThreadPanel"; + +describe("AgentTurnRow", () => { + it.each([ + { phase: "active" as const, label: "Working…" }, + { phase: "complete" as const, label: "Done" }, + ])("renders $label in the message area", (statusValue) => { + render(); + + const author = screen.getByText("Agent"); + const status = screen.getByText(statusValue.label); + + expect(author.parentElement).not.toContainElement(status); + expect(author.closest("article")).toContainElement(status); + expect(status.closest('[data-slot="thread-item-body"]')).not.toBeNull(); + }); +}); diff --git a/packages/ui/src/features/canvas/components/ThreadPanel.tsx b/packages/ui/src/features/canvas/components/ThreadPanel.tsx index e872045efc..da2b23cd48 100644 --- a/packages/ui/src/features/canvas/components/ThreadPanel.tsx +++ b/packages/ui/src/features/canvas/components/ThreadPanel.tsx @@ -211,7 +211,7 @@ function AgentStatusChip({ status }: { status: ThreadAgentStatus }) { } } -function AgentTurnRow({ +export function AgentTurnRow({ message, status, streaming, @@ -232,20 +232,25 @@ function AgentTurnRow({ Agent - {status && } {message?.timestamp !== undefined && ( )} - {message?.text && ( + {(message?.text || status) && (
- {streaming ? ( - - ) : ( - + {message?.text && + (streaming ? ( + + ) : ( + + ))} + {status && ( +
+ +
)}
From 8d4d42e2bd8e07e7e48ce92d05e72fed4fc50cfa Mon Sep 17 00:00:00 2001 From: Peter Kirkham Date: Thu, 16 Jul 2026 12:06:20 +0100 Subject: [PATCH 05/13] feat(canvas): distinguish agent-directed thread messages Generated-By: PostHog Code Task-Id: 87a79e23-20c4-440b-818b-28154924ffbc --- .../api-client/src/posthog-client.test.ts | 64 ++++++++++++++ packages/api-client/src/posthog-client.ts | 15 ++++ .../core/src/canvas/threadTimeline.test.ts | 13 +++ packages/core/src/canvas/threadTimeline.ts | 6 ++ .../canvas/components/MentionComposer.tsx | 72 +++++++++++----- .../canvas/components/ThreadPanel.test.tsx | 16 +++- .../canvas/components/ThreadPanel.tsx | 46 ++++++++-- .../canvas/hooks/useTaskThread.test.tsx | 86 +++++++++++++++++++ .../features/canvas/hooks/useTaskThread.ts | 13 ++- .../canvas/utils/mentionComposer.test.ts | 17 ++++ .../features/canvas/utils/mentionComposer.ts | 22 +++++ 11 files changed, 337 insertions(+), 33 deletions(-) create mode 100644 packages/ui/src/features/canvas/hooks/useTaskThread.test.tsx diff --git a/packages/api-client/src/posthog-client.test.ts b/packages/api-client/src/posthog-client.test.ts index 8c29ded301..ee0331af94 100644 --- a/packages/api-client/src/posthog-client.test.ts +++ b/packages/api-client/src/posthog-client.test.ts @@ -1,3 +1,4 @@ +import type { TaskThreadMessage } from "@posthog/shared/domain-types"; import { describe, expect, it, vi } from "vitest"; import { PostHogAPIClient } from "./posthog-client"; @@ -1696,3 +1697,66 @@ describe("PostHogAPIClient", () => { }); }); }); + +describe("task thread messages", () => { + function message( + overrides: Partial = {}, + ): TaskThreadMessage { + return { + id: "message-id", + task: "task-id", + content: "@agent investigate this", + created_at: "2026-07-16T00:00:00Z", + ...overrides, + }; + } + + it("creates a message before forwarding it to the agent", async () => { + const client = new PostHogAPIClient( + "http://localhost:8000", + async () => "token", + async () => "token", + 123, + ); + const create = vi + .spyOn(client, "createTaskThreadMessage") + .mockResolvedValue(message()); + const send = vi + .spyOn(client, "sendTaskThreadMessageToAgent") + .mockResolvedValue( + message({ forwarded_to_agent_at: "2026-07-16T00:00:01Z" }), + ); + + await client.createTaskThreadMessageForAgent( + "task-id", + "@agent investigate this", + ); + + expect(create).toHaveBeenCalledWith("task-id", "@agent investigate this"); + expect(send).toHaveBeenCalledWith("task-id", "message-id"); + expect(create.mock.invocationCallOrder[0]).toBeLessThan( + send.mock.invocationCallOrder[0], + ); + }); + + it("returns the created message when forwarding fails", async () => { + const client = new PostHogAPIClient( + "http://localhost:8000", + async () => "token", + async () => "token", + 123, + ); + const sendError = new Error("No active run"); + vi.spyOn(client, "createTaskThreadMessage").mockResolvedValue(message()); + vi.spyOn(client, "sendTaskThreadMessageToAgent").mockRejectedValue( + sendError, + ); + + await expect( + client.createTaskThreadMessageForAgent( + "task-id", + "@agent investigate this", + ), + ).resolves.toEqual({ message: message(), sendError }); + }); +}); diff --git a/packages/api-client/src/posthog-client.ts b/packages/api-client/src/posthog-client.ts index 88bacc8312..3c6e0351c6 100644 --- a/packages/api-client/src/posthog-client.ts +++ b/packages/api-client/src/posthog-client.ts @@ -2630,6 +2630,21 @@ export class PostHogAPIClient { return (await response.json()) as TaskThreadMessage; } + async createTaskThreadMessageForAgent( + taskId: string, + content: string, + ): Promise<{ message: TaskThreadMessage; sendError: unknown | null }> { + const message = await this.createTaskThreadMessage(taskId, content); + try { + return { + message: await this.sendTaskThreadMessageToAgent(taskId, message.id), + sendError: null, + }; + } catch (sendError) { + return { message, sendError }; + } + } + async deleteTaskThreadMessage( taskId: string, messageId: string, diff --git a/packages/core/src/canvas/threadTimeline.test.ts b/packages/core/src/canvas/threadTimeline.test.ts index 47aff1a08f..11b3e3e771 100644 --- a/packages/core/src/canvas/threadTimeline.test.ts +++ b/packages/core/src/canvas/threadTimeline.test.ts @@ -2,9 +2,22 @@ import { describe, expect, it } from "vitest"; import { buildThreadTimeline, deriveThreadAgentStatus, + hasAgentMention, shouldSuspendThreadSession, } from "./threadTimeline"; +describe("hasAgentMention", () => { + it.each([ + ["at the start", "@agent investigate this", true], + ["after other text", "Could you @Agent check this?", true], + ["inside an email-like token", "person@agent.com", false], + ["as part of a longer handle", "@agents", false], + ["without a mention", "human-only note", false], + ])("detects an agent mention %s", (_name, content, expected) => { + expect(hasAgentMention(content)).toBe(expected); + }); +}); + describe("buildThreadTimeline", () => { it("interleaves prompts, human replies, and agent turns chronologically", () => { const timeline = buildThreadTimeline({ diff --git a/packages/core/src/canvas/threadTimeline.ts b/packages/core/src/canvas/threadTimeline.ts index f2b9b2875f..755f8e0cf3 100644 --- a/packages/core/src/canvas/threadTimeline.ts +++ b/packages/core/src/canvas/threadTimeline.ts @@ -68,6 +68,12 @@ export interface ThreadAgentStatus { label: string; } +const AGENT_MENTION_PATTERN = /(^|\s)@agent\b/i; + +export function hasAgentMention(content: string): boolean { + return AGENT_MENTION_PATTERN.test(content); +} + export function deriveThreadAgentStatus({ hasActivity = false, hasError = false, diff --git a/packages/ui/src/features/canvas/components/MentionComposer.tsx b/packages/ui/src/features/canvas/components/MentionComposer.tsx index e2c07e5c15..fa74ff676e 100644 --- a/packages/ui/src/features/canvas/components/MentionComposer.tsx +++ b/packages/ui/src/features/canvas/components/MentionComposer.tsx @@ -1,10 +1,12 @@ +import { RobotIcon } from "@phosphor-icons/react"; import { Avatar, AvatarFallback, InputGroup } from "@posthog/quill"; import type { UserBasic } from "@posthog/shared/domain-types"; import { getUserInitials } from "@posthog/ui/features/auth/userInitials"; import { + type ComposerMentionCandidate, contentToDoc, docToContent, - filterMentionCandidates, + filterComposerMentionCandidates, } from "@posthog/ui/features/canvas/utils/mentionComposer"; import { userDisplayName } from "@posthog/ui/features/canvas/utils/userDisplay"; import { Text } from "@radix-ui/themes"; @@ -23,6 +25,7 @@ interface MentionComposerProps { onSubmit: () => void; /** The taggable pool; typically the org's members. */ members: UserBasic[]; + allowAgentMention?: boolean; onMentionInsert?: (member: UserBasic) => void; placeholder?: string; rows?: number; @@ -40,8 +43,8 @@ const EDITOR_CLASS = "w-full px-2.5 py-2 outline-none break-words [overflow-wrap:break-word] [white-space:pre-wrap] [word-break:break-word]"; interface SuggestionSession { - items: UserBasic[]; - command: (member: UserBasic) => void; + items: ComposerMentionCandidate[]; + command: (candidate: ComposerMentionCandidate) => void; } /** @@ -55,6 +58,7 @@ export function MentionComposer({ onValueChange, onSubmit, members, + allowAgentMention = false, onMentionInsert, placeholder, rows, @@ -78,6 +82,8 @@ export function MentionComposer({ // latest props and popup state. const membersRef = useRef(members); membersRef.current = members; + const allowAgentMentionRef = useRef(allowAgentMention); + allowAgentMentionRef.current = allowAgentMention; const onValueChangeRef = useRef(onValueChange); onValueChangeRef.current = onValueChange; const onSubmitRef = useRef(onSubmit); @@ -121,11 +127,21 @@ export function MentionComposer({ char: "@", allowSpaces: true, items: ({ query }) => - filterMentionCandidates(membersRef.current, query), - // The suggestion pipeline carries whole members, not node attrs, - // so member data survives into `command`. + filterComposerMentionCandidates( + membersRef.current, + query, + allowAgentMentionRef.current, + ), command: ({ editor: e, range, props }) => { - const member = props as unknown as UserBasic; + const candidate = props as unknown as ComposerMentionCandidate; + if (candidate.kind === "agent") { + e.chain() + .focus() + .insertContentAt(range, { type: "text", text: "@agent " }) + .run(); + return; + } + const { member } = candidate; e.chain() .focus() .insertContentAt(range, [ @@ -143,17 +159,17 @@ export function MentionComposer({ setDismissed(false); setSelectedIndex(0); setSession({ - items: props.items as unknown as UserBasic[], - command: (member) => - props.command(member as unknown as MentionNodeAttrs), + items: props.items as unknown as ComposerMentionCandidate[], + command: (candidate) => + props.command(candidate as unknown as MentionNodeAttrs), }); }, onUpdate: (props) => { setSelectedIndex(0); setSession({ - items: props.items as unknown as UserBasic[], - command: (member) => - props.command(member as unknown as MentionNodeAttrs), + items: props.items as unknown as ComposerMentionCandidate[], + command: (candidate) => + props.command(candidate as unknown as MentionNodeAttrs), }); }, onKeyDown: ({ event }) => { @@ -172,8 +188,8 @@ export function MentionComposer({ return true; } if (event.key === "Enter" || event.key === "Tab") { - const member = items[highlightedRef.current]; - if (member) sessionRef.current?.command(member); + const candidate = items[highlightedRef.current]; + if (candidate) sessionRef.current?.command(candidate); return true; } return false; @@ -227,37 +243,49 @@ export function MentionComposer({
- {session.items.map((member, index) => ( + {session.items.map((candidate, index) => ( ))} diff --git a/packages/ui/src/features/canvas/components/ThreadPanel.test.tsx b/packages/ui/src/features/canvas/components/ThreadPanel.test.tsx index b72c326ca3..5341a14dab 100644 --- a/packages/ui/src/features/canvas/components/ThreadPanel.test.tsx +++ b/packages/ui/src/features/canvas/components/ThreadPanel.test.tsx @@ -1,6 +1,6 @@ import { render, screen } from "@testing-library/react"; import { describe, expect, it } from "vitest"; -import { AgentTurnRow } from "./ThreadPanel"; +import { AgentTurnRow, UserPromptRow } from "./ThreadPanel"; describe("AgentTurnRow", () => { it.each([ @@ -17,3 +17,17 @@ describe("AgentTurnRow", () => { expect(status.closest('[data-slot="thread-item-body"]')).not.toBeNull(); }); }); + +describe("UserPromptRow", () => { + it("prefixes direct task prompts with @agent", () => { + render( + , + ); + + expect(screen.getByText("@agent")).toBeInTheDocument(); + expect(screen.getByText("Investigate this")).toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/features/canvas/components/ThreadPanel.tsx b/packages/ui/src/features/canvas/components/ThreadPanel.tsx index da2b23cd48..10497b36f6 100644 --- a/packages/ui/src/features/canvas/components/ThreadPanel.tsx +++ b/packages/ui/src/features/canvas/components/ThreadPanel.tsx @@ -10,6 +10,7 @@ import { import { buildThreadTimeline, deriveThreadAgentStatus, + hasAgentMention, shouldSuspendThreadSession, type ThreadAgentMessage, type ThreadAgentStatus, @@ -50,7 +51,10 @@ import { useCurrentUser } from "@posthog/ui/features/auth/useCurrentUser"; import { getUserInitials } from "@posthog/ui/features/auth/userInitials"; import { TaskCard } from "@posthog/ui/features/canvas/components/ChannelFeedView"; import { MentionComposer } from "@posthog/ui/features/canvas/components/MentionComposer"; -import { MentionText } from "@posthog/ui/features/canvas/components/MentionText"; +import { + MentionText, + mentionChipClass, +} from "@posthog/ui/features/canvas/components/MentionText"; import { ThreadTimestamp } from "@posthog/ui/features/canvas/components/ThreadTimestamp"; import { useOrgMembers } from "@posthog/ui/features/canvas/hooks/useOrgMembers"; import { @@ -260,7 +264,7 @@ export function AgentTurnRow({ ); } -function UserPromptRow({ +export function UserPromptRow({ message, author, }: { @@ -284,7 +288,7 @@ function UserPromptRow({ )} - {message.text} + @agent {message.text} @@ -333,6 +337,7 @@ function ThreadConversation({ const { messages, isLoading } = useTaskThread(taskId); const { postMessage, + postMessageToAgent, deleteMessage, sendToAgent, isPosting, @@ -442,16 +447,38 @@ function ThreadConversation({ !isTerminalStatus(task.latest_run.status) && !isSendingToAgent; - const submit = () => { + const submit = async () => { const content = draft.trim(); - if (!content || isPosting) return; + if (!content || isPosting || isSendingToAgent) return; + const sendToAgentRequested = hasAgentMention(content); + if (sendToAgentRequested && (!isTaskAuthor || !canForward)) { + toast.error("Couldn't send to agent", { + description: + "Only the task author can @agent while the task has an active run.", + }); + return; + } setDraft(""); - postMessage(content).catch((error: unknown) => { + try { + if (sendToAgentRequested) { + const { sendError } = await postMessageToAgent(content); + if (sendError) { + toast.error("Message posted, but couldn't send it to the agent", { + description: + sendError instanceof Error + ? sendError.message + : String(sendError), + }); + } + } else { + await postMessage(content); + } + } catch (error) { setDraft(content); toast.error("Couldn't post message", { description: error instanceof Error ? error.message : String(error), }); - }); + } }; const handleSendToAgent = (messageId: string) => { @@ -574,8 +601,9 @@ function ThreadConversation({ onValueChange={setDraft} onSubmit={submit} members={members} + allowAgentMention={isTaskAuthor && canForward} onMentionInsert={handleMentionInsert} - placeholder="Reply in thread… @ to tag a teammate" + placeholder="Reply in thread… @agent sends to the agent" rows={2} inputClassName="max-h-40 text-[13px]" > @@ -585,7 +613,7 @@ function ThreadConversation({ variant="primary" size="icon-sm" aria-label="Send" - disabled={!draft.trim() || isPosting} + disabled={!draft.trim() || isPosting || isSendingToAgent} onClick={submit} > diff --git a/packages/ui/src/features/canvas/hooks/useTaskThread.test.tsx b/packages/ui/src/features/canvas/hooks/useTaskThread.test.tsx new file mode 100644 index 0000000000..d2ed4c98b3 --- /dev/null +++ b/packages/ui/src/features/canvas/hooks/useTaskThread.test.tsx @@ -0,0 +1,86 @@ +import type { TaskThreadMessage } from "@posthog/shared/domain-types"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act, renderHook } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockClient = vi.hoisted(() => ({ + createTaskThreadMessage: vi.fn(), + createTaskThreadMessageForAgent: vi.fn(), + sendTaskThreadMessageToAgent: vi.fn(), +})); + +vi.mock("@posthog/ui/features/auth/authClient", () => ({ + useOptionalAuthenticatedClient: () => mockClient, +})); + +import { useTaskThreadMutations } from "./useTaskThread"; + +let queryClient: QueryClient; + +function wrapper({ children }: { children: ReactNode }) { + return ( + {children} + ); +} + +function message(overrides?: Partial): TaskThreadMessage { + return { + id: "message-id", + task: "task-id", + content: "@agent investigate this", + created_at: "2026-07-16T00:00:00Z", + ...overrides, + }; +} + +describe("useTaskThreadMutations", () => { + beforeEach(() => { + vi.clearAllMocks(); + queryClient = new QueryClient({ + defaultOptions: { mutations: { retry: false } }, + }); + }); + + it("posts an @agent message through the combined client operation", async () => { + mockClient.createTaskThreadMessageForAgent.mockResolvedValue({ + message: message({ forwarded_to_agent_at: "2026-07-16T00:00:01Z" }), + sendError: null, + }); + const { result } = renderHook(() => useTaskThreadMutations("task-id"), { + wrapper, + }); + + await act(async () => { + await result.current.postMessageToAgent("@agent investigate this"); + }); + + expect(mockClient.createTaskThreadMessageForAgent).toHaveBeenCalledWith( + "task-id", + "@agent investigate this", + ); + }); + + it("returns a forwarding error after the message has been posted", async () => { + const sendError = new Error("No active run"); + mockClient.createTaskThreadMessageForAgent.mockResolvedValue({ + message: message(), + sendError, + }); + const { result } = renderHook(() => useTaskThreadMutations("task-id"), { + wrapper, + }); + + let outcome: Awaited< + ReturnType + > | null = null; + await act(async () => { + outcome = await result.current.postMessageToAgent( + "@agent investigate this", + ); + }); + + expect(outcome).toEqual({ message: message(), sendError }); + expect(mockClient.createTaskThreadMessageForAgent).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/ui/src/features/canvas/hooks/useTaskThread.ts b/packages/ui/src/features/canvas/hooks/useTaskThread.ts index 7f47f7e889..19c7cf79fd 100644 --- a/packages/ui/src/features/canvas/hooks/useTaskThread.ts +++ b/packages/ui/src/features/canvas/hooks/useTaskThread.ts @@ -60,6 +60,14 @@ export function useTaskThreadMutations(taskId: string | undefined) { onSuccess: invalidate, }); + const postToAgentMutation = useMutation({ + mutationFn: async (content: string) => { + if (!client || !taskId) throw new Error("Not authenticated"); + return client.createTaskThreadMessageForAgent(taskId, content); + }, + onSuccess: invalidate, + }); + const deleteMutation = useMutation({ mutationFn: async (messageId: string) => { if (!client || !taskId) throw new Error("Not authenticated"); @@ -78,10 +86,13 @@ export function useTaskThreadMutations(taskId: string | undefined) { return { postMessage: (content: string) => postMutation.mutateAsync(content), + postMessageToAgent: (content: string) => + postToAgentMutation.mutateAsync(content), deleteMessage: (messageId: string) => deleteMutation.mutateAsync(messageId), sendToAgent: (messageId: string) => sendToAgentMutation.mutateAsync(messageId), isPosting: postMutation.isPending, - isSendingToAgent: sendToAgentMutation.isPending, + isSendingToAgent: + postToAgentMutation.isPending || sendToAgentMutation.isPending, }; } diff --git a/packages/ui/src/features/canvas/utils/mentionComposer.test.ts b/packages/ui/src/features/canvas/utils/mentionComposer.test.ts index e6a4f52658..640ee506a8 100644 --- a/packages/ui/src/features/canvas/utils/mentionComposer.test.ts +++ b/packages/ui/src/features/canvas/utils/mentionComposer.test.ts @@ -7,6 +7,7 @@ import { describe, expect, it } from "vitest"; import { contentToDoc, docToContent, + filterComposerMentionCandidates, filterMentionCandidates, } from "./mentionComposer"; @@ -68,6 +69,22 @@ describe("filterMentionCandidates", () => { }); }); +describe("filterComposerMentionCandidates", () => { + it("offers the agent before matching teammates when enabled", () => { + expect(filterComposerMentionCandidates(members, "a", true)).toEqual([ + { kind: "agent" }, + { kind: "member", member: ann }, + { kind: "member", member: raquel }, + ]); + }); + + it("does not offer the agent when forwarding is unavailable", () => { + expect(filterComposerMentionCandidates(members, "agent", false)).toEqual( + [], + ); + }); +}); + describe("contentToDoc / docToContent", () => { const schema = getSchema([StarterKit, Mention]); diff --git a/packages/ui/src/features/canvas/utils/mentionComposer.ts b/packages/ui/src/features/canvas/utils/mentionComposer.ts index 4a4d923fde..6f63a51924 100644 --- a/packages/ui/src/features/canvas/utils/mentionComposer.ts +++ b/packages/ui/src/features/canvas/utils/mentionComposer.ts @@ -4,6 +4,10 @@ import { userDisplayName } from "@posthog/ui/features/canvas/utils/userDisplay"; import type { Node as PmNode } from "@tiptap/pm/model"; import type { JSONContent } from "@tiptap/react"; +export type ComposerMentionCandidate = + | { kind: "agent" } + | { kind: "member"; member: UserBasic }; + /** Members matching the query, best-first: name prefix, word prefix, email, substring. */ export function filterMentionCandidates( members: UserBasic[], @@ -32,6 +36,24 @@ export function filterMentionCandidates( .map((entry) => entry.member); } +export function filterComposerMentionCandidates( + members: UserBasic[], + query: string, + includeAgent: boolean, + limit = 8, +): ComposerMentionCandidate[] { + const normalizedQuery = query.trim().toLowerCase(); + const includeAgentCandidate = + includeAgent && (!normalizedQuery || "agent".startsWith(normalizedQuery)); + const memberLimit = Math.max(0, limit - (includeAgentCandidate ? 1 : 0)); + return [ + ...(includeAgentCandidate ? [{ kind: "agent" as const }] : []), + ...filterMentionCandidates(members, query, memberLimit).map( + (member): ComposerMentionCandidate => ({ kind: "member", member }), + ), + ]; +} + /** Serialize the composer's editor doc back to content with inline mention tokens. */ export function docToContent(doc: PmNode): string { const lines: string[] = []; From 83fc1f94715e9a61e13048605a7922e41235359c Mon Sep 17 00:00:00 2001 From: Peter Kirkham Date: Thu, 16 Jul 2026 13:35:05 +0100 Subject: [PATCH 06/13] fix(canvas): normalize forwarded agent prompts Generated-By: PostHog Code Task-Id: 87a79e23-20c4-440b-818b-28154924ffbc --- .../core/src/canvas/threadTimeline.test.ts | 19 +++++++++++++++++++ packages/core/src/canvas/threadTimeline.ts | 11 +++++++++++ .../canvas/components/ThreadPanel.test.tsx | 17 +++++++++++++++++ .../canvas/components/ThreadPanel.tsx | 5 ++++- 4 files changed, 51 insertions(+), 1 deletion(-) diff --git a/packages/core/src/canvas/threadTimeline.test.ts b/packages/core/src/canvas/threadTimeline.test.ts index 11b3e3e771..56b4484276 100644 --- a/packages/core/src/canvas/threadTimeline.test.ts +++ b/packages/core/src/canvas/threadTimeline.test.ts @@ -3,6 +3,7 @@ import { buildThreadTimeline, deriveThreadAgentStatus, hasAgentMention, + normalizeAgentPromptText, shouldSuspendThreadSession, } from "./threadTimeline"; @@ -18,6 +19,24 @@ describe("hasAgentMention", () => { }); }); +describe("normalizeAgentPromptText", () => { + it.each([ + [ + "forwarded thread comment", + "[Thread comment from Peter Kirkham] @agent which model are you?", + "which model are you?", + ], + ["direct prompt", "which model are you?", "which model are you?"], + [ + "direct prompt with mention", + "@agent which model are you?", + "which model are you?", + ], + ])("normalizes a %s", (_name, content, expected) => { + expect(normalizeAgentPromptText(content)).toBe(expected); + }); +}); + describe("buildThreadTimeline", () => { it("interleaves prompts, human replies, and agent turns chronologically", () => { const timeline = buildThreadTimeline({ diff --git a/packages/core/src/canvas/threadTimeline.ts b/packages/core/src/canvas/threadTimeline.ts index 755f8e0cf3..b99d6b5dba 100644 --- a/packages/core/src/canvas/threadTimeline.ts +++ b/packages/core/src/canvas/threadTimeline.ts @@ -69,11 +69,22 @@ export interface ThreadAgentStatus { } const AGENT_MENTION_PATTERN = /(^|\s)@agent\b/i; +const THREAD_COMMENT_ATTRIBUTION_PATTERN = + /^\[Thread comment from [^\]\r\n]+\]\s*/i; +const LEADING_AGENT_MENTION_PATTERN = /^@agent\b[\s:]*/i; export function hasAgentMention(content: string): boolean { return AGENT_MENTION_PATTERN.test(content); } +export function normalizeAgentPromptText(content: string): string { + return content + .trim() + .replace(THREAD_COMMENT_ATTRIBUTION_PATTERN, "") + .replace(LEADING_AGENT_MENTION_PATTERN, "") + .trim(); +} + export function deriveThreadAgentStatus({ hasActivity = false, hasError = false, diff --git a/packages/ui/src/features/canvas/components/ThreadPanel.test.tsx b/packages/ui/src/features/canvas/components/ThreadPanel.test.tsx index 5341a14dab..2d3d9eda19 100644 --- a/packages/ui/src/features/canvas/components/ThreadPanel.test.tsx +++ b/packages/ui/src/features/canvas/components/ThreadPanel.test.tsx @@ -30,4 +30,21 @@ describe("UserPromptRow", () => { expect(screen.getByText("@agent")).toBeInTheDocument(); expect(screen.getByText("Investigate this")).toBeInTheDocument(); }); + + it("hides forwarded thread attribution and duplicate agent mentions", () => { + render( + , + ); + + expect(screen.getAllByText("@agent")).toHaveLength(1); + expect(screen.getByText("which model are you?")).toBeInTheDocument(); + expect(screen.queryByText(/Thread comment from/)).not.toBeInTheDocument(); + }); }); diff --git a/packages/ui/src/features/canvas/components/ThreadPanel.tsx b/packages/ui/src/features/canvas/components/ThreadPanel.tsx index 10497b36f6..b713fd35ce 100644 --- a/packages/ui/src/features/canvas/components/ThreadPanel.tsx +++ b/packages/ui/src/features/canvas/components/ThreadPanel.tsx @@ -11,6 +11,7 @@ import { buildThreadTimeline, deriveThreadAgentStatus, hasAgentMention, + normalizeAgentPromptText, shouldSuspendThreadSession, type ThreadAgentMessage, type ThreadAgentStatus, @@ -271,6 +272,8 @@ export function UserPromptRow({ message: ThreadAgentMessage; author: TaskThreadMessage["author"]; }) { + const promptText = normalizeAgentPromptText(message.text); + return ( @@ -288,7 +291,7 @@ export function UserPromptRow({ )} - @agent {message.text} + @agent {promptText} From 021a7014223d62e9ac7bc4de34299c691964ba0d Mon Sep 17 00:00:00 2001 From: Peter Kirkham Date: Thu, 16 Jul 2026 13:59:00 +0100 Subject: [PATCH 07/13] fix(canvas): deduplicate forwarded thread prompts Generated-By: PostHog Code Task-Id: 87a79e23-20c4-440b-818b-28154924ffbc --- .../core/src/canvas/threadTimeline.test.ts | 39 +++++++++++++++++++ packages/core/src/canvas/threadTimeline.ts | 18 ++++++++- .../canvas/components/ThreadPanel.tsx | 1 + 3 files changed, 57 insertions(+), 1 deletion(-) diff --git a/packages/core/src/canvas/threadTimeline.test.ts b/packages/core/src/canvas/threadTimeline.test.ts index 56b4484276..600e227397 100644 --- a/packages/core/src/canvas/threadTimeline.test.ts +++ b/packages/core/src/canvas/threadTimeline.test.ts @@ -38,6 +38,45 @@ describe("normalizeAgentPromptText", () => { }); describe("buildThreadTimeline", () => { + it("omits the session echo of a forwarded thread message", () => { + const timeline = buildThreadTimeline({ + prompts: [ + { + id: "prompt", + text: "[Thread comment from Peter Kirkham] @agent which model are you?", + timestamp: 200, + }, + ], + humanMessages: [ + { + id: "human", + content: "@agent which model are you?", + createdAt: "1970-01-01T00:00:00.100Z", + forwardedToAgent: true, + }, + ], + agentMessages: [], + }); + + expect(timeline.map((row) => row.kind)).toEqual(["human"]); + }); + + it("keeps a thread-comment prompt without a matching forwarded message", () => { + const timeline = buildThreadTimeline({ + prompts: [ + { + id: "prompt", + text: "[Thread comment from Peter Kirkham] @agent which model are you?", + timestamp: 200, + }, + ], + humanMessages: [], + agentMessages: [], + }); + + expect(timeline.map((row) => row.kind)).toEqual(["prompt"]); + }); + it("interleaves prompts, human replies, and agent turns chronologically", () => { const timeline = buildThreadTimeline({ prompts: [{ id: "prompt", text: "Start", timestamp: 100 }], diff --git a/packages/core/src/canvas/threadTimeline.ts b/packages/core/src/canvas/threadTimeline.ts index b99d6b5dba..936e5e47dd 100644 --- a/packages/core/src/canvas/threadTimeline.ts +++ b/packages/core/src/canvas/threadTimeline.ts @@ -8,6 +8,7 @@ export interface ThreadHumanMessage { id: string; content: string; createdAt: string; + forwardedToAgent?: boolean; value?: T; } @@ -36,8 +37,19 @@ export function buildThreadTimeline({ agentMessages: ThreadAgentMessage[]; humanMessages: ThreadHumanMessage[]; }): ThreadTimelineRow[] { + const forwardedHumanContent = new Set( + humanMessages + .filter((message) => message.forwardedToAgent) + .map((message) => normalizeAgentPromptText(message.content)), + ); + const visiblePrompts = prompts.filter( + (message) => + !isThreadCommentPrompt(message.text) || + !forwardedHumanContent.has(normalizeAgentPromptText(message.text)), + ); + return [ - ...prompts.map( + ...visiblePrompts.map( (message): ThreadTimelineRow => ({ kind: "prompt", timestamp: validTimestamp(message.timestamp), @@ -85,6 +97,10 @@ export function normalizeAgentPromptText(content: string): string { .trim(); } +function isThreadCommentPrompt(content: string): boolean { + return THREAD_COMMENT_ATTRIBUTION_PATTERN.test(content.trim()); +} + export function deriveThreadAgentStatus({ hasActivity = false, hasError = false, diff --git a/packages/ui/src/features/canvas/components/ThreadPanel.tsx b/packages/ui/src/features/canvas/components/ThreadPanel.tsx index b713fd35ce..2cfbb98f69 100644 --- a/packages/ui/src/features/canvas/components/ThreadPanel.tsx +++ b/packages/ui/src/features/canvas/components/ThreadPanel.tsx @@ -415,6 +415,7 @@ function ThreadConversation({ id: message.id, content: message.content, createdAt: message.created_at, + forwardedToAgent: !!message.forwarded_to_agent_at, value: message, })), }), From 2f661a10baafa3a3fa5a9b301f6cea4e6fcb35c1 Mon Sep 17 00:00:00 2001 From: Peter Kirkham Date: Thu, 16 Jul 2026 14:53:18 +0100 Subject: [PATCH 08/13] refactor(canvas): align thread UX with architecture Generated-By: PostHog Code Task-Id: 87a79e23-20c4-440b-818b-28154924ffbc --- .../src/renderer/desktop-contributions.ts | 2 + apps/web/src/web-container.ts | 3 + .../api-client/src/posthog-client.test.ts | 64 -------------- packages/api-client/src/posthog-client.ts | 15 ---- packages/core/src/canvas/taskThread.module.ts | 7 ++ .../core/src/canvas/taskThreadService.test.ts | 66 ++++++++++++++ packages/core/src/canvas/taskThreadService.ts | 31 +++++++ .../canvas/components/ChannelFeedView.tsx | 4 +- .../canvas/components/MentionComposer.tsx | 12 +-- .../canvas/components/MentionText.test.tsx | 11 +-- .../canvas/components/MentionText.tsx | 5 +- .../canvas/components/ThreadPanel.tsx | 85 +++++++++---------- .../canvas/hooks/useTaskThread.test.tsx | 34 +++++--- .../features/canvas/hooks/useTaskThread.ts | 82 +++++++++--------- 14 files changed, 223 insertions(+), 198 deletions(-) create mode 100644 packages/core/src/canvas/taskThread.module.ts create mode 100644 packages/core/src/canvas/taskThreadService.test.ts create mode 100644 packages/core/src/canvas/taskThreadService.ts diff --git a/apps/code/src/renderer/desktop-contributions.ts b/apps/code/src/renderer/desktop-contributions.ts index a8aa79788b..359ca591ef 100644 --- a/apps/code/src/renderer/desktop-contributions.ts +++ b/apps/code/src/renderer/desktop-contributions.ts @@ -1,6 +1,7 @@ import { agentChatCoreModule } from "@posthog/core/agent-chat/agentChat.module"; import { autoresearchCoreModule } from "@posthog/core/autoresearch/autoresearch.module"; import { billingCoreModule } from "@posthog/core/billing/billing.module"; +import { taskThreadCoreModule } from "@posthog/core/canvas/taskThread.module"; import { inboxCoreModule } from "@posthog/core/inbox/inbox.module"; import { githubConnectModule } from "@posthog/core/integrations/githubConnect.module"; import { onboardingModule } from "@posthog/core/onboarding/onboarding.module"; @@ -36,6 +37,7 @@ export function registerDesktopContributions(): void { autoresearchCoreModule, billingUiModule, billingCoreModule, + taskThreadCoreModule, browserTabsUiModule, cloneUiModule, connectivityUiModule, diff --git a/apps/web/src/web-container.ts b/apps/web/src/web-container.ts index d871b48534..6d15ba6884 100644 --- a/apps/web/src/web-container.ts +++ b/apps/web/src/web-container.ts @@ -1,5 +1,6 @@ import "reflect-metadata"; import { TypedContainer } from "@inversifyjs/strongly-typed"; +import { taskThreadCoreModule } from "@posthog/core/canvas/taskThread.module"; import { setRootContainer } from "@posthog/di/container"; import { ROOT_LOGGER, type RootLogger } from "@posthog/di/logger"; import { @@ -96,4 +97,6 @@ container.bind(MCP_SANDBOX_PROXY_URL).toConstantValue(() => { return sandboxProxyUrl; }); +container.load(taskThreadCoreModule); + setRootContainer(container); diff --git a/packages/api-client/src/posthog-client.test.ts b/packages/api-client/src/posthog-client.test.ts index ee0331af94..8c29ded301 100644 --- a/packages/api-client/src/posthog-client.test.ts +++ b/packages/api-client/src/posthog-client.test.ts @@ -1,4 +1,3 @@ -import type { TaskThreadMessage } from "@posthog/shared/domain-types"; import { describe, expect, it, vi } from "vitest"; import { PostHogAPIClient } from "./posthog-client"; @@ -1697,66 +1696,3 @@ describe("PostHogAPIClient", () => { }); }); }); - -describe("task thread messages", () => { - function message( - overrides: Partial = {}, - ): TaskThreadMessage { - return { - id: "message-id", - task: "task-id", - content: "@agent investigate this", - created_at: "2026-07-16T00:00:00Z", - ...overrides, - }; - } - - it("creates a message before forwarding it to the agent", async () => { - const client = new PostHogAPIClient( - "http://localhost:8000", - async () => "token", - async () => "token", - 123, - ); - const create = vi - .spyOn(client, "createTaskThreadMessage") - .mockResolvedValue(message()); - const send = vi - .spyOn(client, "sendTaskThreadMessageToAgent") - .mockResolvedValue( - message({ forwarded_to_agent_at: "2026-07-16T00:00:01Z" }), - ); - - await client.createTaskThreadMessageForAgent( - "task-id", - "@agent investigate this", - ); - - expect(create).toHaveBeenCalledWith("task-id", "@agent investigate this"); - expect(send).toHaveBeenCalledWith("task-id", "message-id"); - expect(create.mock.invocationCallOrder[0]).toBeLessThan( - send.mock.invocationCallOrder[0], - ); - }); - - it("returns the created message when forwarding fails", async () => { - const client = new PostHogAPIClient( - "http://localhost:8000", - async () => "token", - async () => "token", - 123, - ); - const sendError = new Error("No active run"); - vi.spyOn(client, "createTaskThreadMessage").mockResolvedValue(message()); - vi.spyOn(client, "sendTaskThreadMessageToAgent").mockRejectedValue( - sendError, - ); - - await expect( - client.createTaskThreadMessageForAgent( - "task-id", - "@agent investigate this", - ), - ).resolves.toEqual({ message: message(), sendError }); - }); -}); diff --git a/packages/api-client/src/posthog-client.ts b/packages/api-client/src/posthog-client.ts index 3c6e0351c6..88bacc8312 100644 --- a/packages/api-client/src/posthog-client.ts +++ b/packages/api-client/src/posthog-client.ts @@ -2630,21 +2630,6 @@ export class PostHogAPIClient { return (await response.json()) as TaskThreadMessage; } - async createTaskThreadMessageForAgent( - taskId: string, - content: string, - ): Promise<{ message: TaskThreadMessage; sendError: unknown | null }> { - const message = await this.createTaskThreadMessage(taskId, content); - try { - return { - message: await this.sendTaskThreadMessageToAgent(taskId, message.id), - sendError: null, - }; - } catch (sendError) { - return { message, sendError }; - } - } - async deleteTaskThreadMessage( taskId: string, messageId: string, diff --git a/packages/core/src/canvas/taskThread.module.ts b/packages/core/src/canvas/taskThread.module.ts new file mode 100644 index 0000000000..852ac4a0b7 --- /dev/null +++ b/packages/core/src/canvas/taskThread.module.ts @@ -0,0 +1,7 @@ +import { ContainerModule } from "inversify"; +import { TASK_THREAD_SERVICE, TaskThreadService } from "./taskThreadService"; + +export const taskThreadCoreModule = new ContainerModule(({ bind }) => { + bind(TaskThreadService).toSelf().inSingletonScope(); + bind(TASK_THREAD_SERVICE).toService(TaskThreadService); +}); diff --git a/packages/core/src/canvas/taskThreadService.test.ts b/packages/core/src/canvas/taskThreadService.test.ts new file mode 100644 index 0000000000..e8f4bd61a7 --- /dev/null +++ b/packages/core/src/canvas/taskThreadService.test.ts @@ -0,0 +1,66 @@ +import type { PostHogAPIClient } from "@posthog/api-client/posthog-client"; +import type { TaskThreadMessage } from "@posthog/shared/domain-types"; +import { describe, expect, it, vi } from "vitest"; +import { TaskThreadService } from "./taskThreadService"; + +function message( + overrides: Partial = {}, +): TaskThreadMessage { + return { + id: "message-id", + task: "task-id", + content: "@agent investigate this", + created_at: "2026-07-16T00:00:00Z", + ...overrides, + }; +} + +function client(overrides: Partial = {}): PostHogAPIClient { + return overrides as PostHogAPIClient; +} + +describe("TaskThreadService", () => { + it("creates a message before forwarding it to the agent", async () => { + const createTaskThreadMessage = vi.fn().mockResolvedValue(message()); + const sendTaskThreadMessageToAgent = vi + .fn() + .mockResolvedValue( + message({ forwarded_to_agent_at: "2026-07-16T00:00:01Z" }), + ); + const service = new TaskThreadService(); + + await service.postMessageToAgent( + client({ createTaskThreadMessage, sendTaskThreadMessageToAgent }), + "task-id", + "@agent investigate this", + ); + + expect(createTaskThreadMessage).toHaveBeenCalledWith( + "task-id", + "@agent investigate this", + ); + expect(sendTaskThreadMessageToAgent).toHaveBeenCalledWith( + "task-id", + "message-id", + ); + expect(createTaskThreadMessage.mock.invocationCallOrder[0]).toBeLessThan( + sendTaskThreadMessageToAgent.mock.invocationCallOrder[0], + ); + }); + + it("returns the created message when forwarding fails", async () => { + const sendError = new Error("No active run"); + const service = new TaskThreadService(); + + await expect( + service.postMessageToAgent( + client({ + createTaskThreadMessage: vi.fn().mockResolvedValue(message()), + sendTaskThreadMessageToAgent: vi.fn().mockRejectedValue(sendError), + }), + "task-id", + "@agent investigate this", + ), + ).resolves.toEqual({ message: message(), sendError }); + }); +}); diff --git a/packages/core/src/canvas/taskThreadService.ts b/packages/core/src/canvas/taskThreadService.ts new file mode 100644 index 0000000000..ca65a5d770 --- /dev/null +++ b/packages/core/src/canvas/taskThreadService.ts @@ -0,0 +1,31 @@ +import type { PostHogAPIClient } from "@posthog/api-client/posthog-client"; +import type { TaskThreadMessage } from "@posthog/shared/domain-types"; +import { injectable } from "inversify"; + +export const TASK_THREAD_SERVICE = Symbol.for( + "posthog.core.canvas.taskThreadService", +); + +export interface PostMessageToAgentResult { + message: TaskThreadMessage; + sendError: unknown | null; +} + +@injectable() +export class TaskThreadService { + async postMessageToAgent( + client: PostHogAPIClient, + taskId: string, + content: string, + ): Promise { + const message = await client.createTaskThreadMessage(taskId, content); + try { + return { + message: await client.sendTaskThreadMessageToAgent(taskId, message.id), + sendError: null, + }; + } catch (sendError) { + return { message, sendError }; + } + } +} diff --git a/packages/ui/src/features/canvas/components/ChannelFeedView.tsx b/packages/ui/src/features/canvas/components/ChannelFeedView.tsx index 782581ecc0..5a76aba567 100644 --- a/packages/ui/src/features/canvas/components/ChannelFeedView.tsx +++ b/packages/ui/src/features/canvas/components/ChannelFeedView.tsx @@ -396,9 +396,7 @@ function ReplyFooter({ - - Reply - + Reply ); } diff --git a/packages/ui/src/features/canvas/components/MentionComposer.tsx b/packages/ui/src/features/canvas/components/MentionComposer.tsx index fa74ff676e..df0826b994 100644 --- a/packages/ui/src/features/canvas/components/MentionComposer.tsx +++ b/packages/ui/src/features/canvas/components/MentionComposer.tsx @@ -9,7 +9,6 @@ import { filterComposerMentionCandidates, } from "@posthog/ui/features/canvas/utils/mentionComposer"; import { userDisplayName } from "@posthog/ui/features/canvas/utils/userDisplay"; -import { Text } from "@radix-ui/themes"; import Mention, { type MentionNodeAttrs } from "@tiptap/extension-mention"; import Placeholder from "@tiptap/extension-placeholder"; import { EditorContent, useEditor } from "@tiptap/react"; @@ -274,19 +273,16 @@ export function MentionComposer({ )} - + {candidate.kind === "agent" ? "Agent" : userDisplayName(candidate.member)} - - + + {candidate.kind === "agent" ? "Send to agent" : candidate.member.email} - + ))}
diff --git a/packages/ui/src/features/canvas/components/MentionText.test.tsx b/packages/ui/src/features/canvas/components/MentionText.test.tsx index 75bfe97a37..788ddf6363 100644 --- a/packages/ui/src/features/canvas/components/MentionText.test.tsx +++ b/packages/ui/src/features/canvas/components/MentionText.test.tsx @@ -1,4 +1,3 @@ -import { Theme } from "@radix-ui/themes"; import { render, screen } from "@testing-library/react"; import { describe, expect, it } from "vitest"; import { MentionText } from "./MentionText"; @@ -6,12 +5,10 @@ import { MentionText } from "./MentionText"; describe("MentionText", () => { it("uses the shared mention styles and emphasizes the current user", () => { render( - - - , + , ); expect(screen.getByText("@Alice")).toHaveClass("mention-chip"); diff --git a/packages/ui/src/features/canvas/components/MentionText.tsx b/packages/ui/src/features/canvas/components/MentionText.tsx index 5f640b440f..6e1be4cf77 100644 --- a/packages/ui/src/features/canvas/components/MentionText.tsx +++ b/packages/ui/src/features/canvas/components/MentionText.tsx @@ -1,6 +1,5 @@ import { splitMentionSegments } from "@posthog/shared"; import { splitLinkSegments } from "@posthog/ui/features/canvas/utils/linkify"; -import { Text } from "@radix-ui/themes"; import { Fragment, useMemo } from "react"; import "./mention-chip.css"; @@ -52,7 +51,7 @@ export function MentionText({ }, [content]); const selfEmail = currentUserEmail?.toLowerCase(); return ( - + {segments.map(({ segment, key }) => { if (segment.type === "mention") { return ( @@ -84,6 +83,6 @@ export function MentionText({ } return {segment.text}; })} - + ); } diff --git a/packages/ui/src/features/canvas/components/ThreadPanel.tsx b/packages/ui/src/features/canvas/components/ThreadPanel.tsx index 2cfbb98f69..57738b3685 100644 --- a/packages/ui/src/features/canvas/components/ThreadPanel.tsx +++ b/packages/ui/src/features/canvas/components/ThreadPanel.tsx @@ -25,10 +25,13 @@ import { DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, + Empty, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, InputGroupAddon, InputGroupButton, - Skeleton, - SkeletonText, Spinner, ThreadItem, ThreadItemAction, @@ -59,8 +62,11 @@ import { import { ThreadTimestamp } from "@posthog/ui/features/canvas/components/ThreadTimestamp"; import { useOrgMembers } from "@posthog/ui/features/canvas/hooks/useOrgMembers"; import { + useDeleteTaskThreadMessage, + usePostTaskThreadMessage, + usePostTaskThreadMessageToAgent, + useSendTaskThreadMessageToAgent, useTaskThread, - useTaskThreadMutations, } from "@posthog/ui/features/canvas/hooks/useTaskThread"; import { userDisplayName } from "@posthog/ui/features/canvas/utils/userDisplay"; import type { ConversationItem } from "@posthog/ui/features/sessions/components/buildConversationItems"; @@ -202,10 +208,10 @@ function AgentStatusChip({ status }: { status: ThreadAgentStatus }) { switch (status.phase) { case "active": return ( - + - {status.label} - + {status.label} + ); case "needs_input": return {status.label}; @@ -298,23 +304,16 @@ export function UserPromptRow({ ); } -function ThreadTimelineSkeleton() { +function ThreadLoadingState() { return ( - - {[0, 1, 2].map((i) => ( - - - - - - - - - - - - ))} - + + + + + + Loading thread + + ); } @@ -338,14 +337,12 @@ function ThreadConversation({ const { data: currentUser } = useCurrentUser({ client }); const { messages, isLoading } = useTaskThread(taskId); - const { - postMessage, - postMessageToAgent, - deleteMessage, - sendToAgent, - isPosting, - isSendingToAgent, - } = useTaskThreadMutations(taskId); + const { postMessage, isPosting } = usePostTaskThreadMessage(taskId); + const { postMessageToAgent, isPostingToAgent } = + usePostTaskThreadMessageToAgent(taskId); + const { deleteMessage } = useDeleteTaskThreadMessage(taskId); + const { sendToAgent, isSending } = useSendTaskThreadMessageToAgent(taskId); + const isSendingToAgent = isPostingToAgent || isSending; const { members } = useOrgMembers(); const { @@ -549,15 +546,21 @@ function ThreadConversation({ )}
{!isReady ? ( - + ) : isEmpty ? ( -
-

- Discuss this task with your team. The agent's status shows up here - too; messages stay between humans unless the task author sends one - to the agent. -

-
+ + + + + + No messages yet + + Discuss this task with your team. The agent's status shows up + here too; messages stay between humans unless the task author + sends one to the agent. + + + ) : ( {timeline.map((row) => @@ -671,11 +674,7 @@ export function ThreadPanel({ } if (!task) { - return ( -
- -
- ); + return ; } return ( diff --git a/packages/ui/src/features/canvas/hooks/useTaskThread.test.tsx b/packages/ui/src/features/canvas/hooks/useTaskThread.test.tsx index d2ed4c98b3..7f19e98753 100644 --- a/packages/ui/src/features/canvas/hooks/useTaskThread.test.tsx +++ b/packages/ui/src/features/canvas/hooks/useTaskThread.test.tsx @@ -6,15 +6,20 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const mockClient = vi.hoisted(() => ({ createTaskThreadMessage: vi.fn(), - createTaskThreadMessageForAgent: vi.fn(), sendTaskThreadMessageToAgent: vi.fn(), })); +const mockTaskThreadService = vi.hoisted(() => ({ + postMessageToAgent: vi.fn(), +})); vi.mock("@posthog/ui/features/auth/authClient", () => ({ useOptionalAuthenticatedClient: () => mockClient, })); +vi.mock("@posthog/di/react", () => ({ + useService: () => mockTaskThreadService, +})); -import { useTaskThreadMutations } from "./useTaskThread"; +import { usePostTaskThreadMessageToAgent } from "./useTaskThread"; let queryClient: QueryClient; @@ -34,7 +39,7 @@ function message(overrides?: Partial): TaskThreadMessage { }; } -describe("useTaskThreadMutations", () => { +describe("usePostTaskThreadMessageToAgent", () => { beforeEach(() => { vi.clearAllMocks(); queryClient = new QueryClient({ @@ -43,19 +48,21 @@ describe("useTaskThreadMutations", () => { }); it("posts an @agent message through the combined client operation", async () => { - mockClient.createTaskThreadMessageForAgent.mockResolvedValue({ + mockTaskThreadService.postMessageToAgent.mockResolvedValue({ message: message({ forwarded_to_agent_at: "2026-07-16T00:00:01Z" }), sendError: null, }); - const { result } = renderHook(() => useTaskThreadMutations("task-id"), { - wrapper, - }); + const { result } = renderHook( + () => usePostTaskThreadMessageToAgent("task-id"), + { wrapper }, + ); await act(async () => { await result.current.postMessageToAgent("@agent investigate this"); }); - expect(mockClient.createTaskThreadMessageForAgent).toHaveBeenCalledWith( + expect(mockTaskThreadService.postMessageToAgent).toHaveBeenCalledWith( + mockClient, "task-id", "@agent investigate this", ); @@ -63,13 +70,14 @@ describe("useTaskThreadMutations", () => { it("returns a forwarding error after the message has been posted", async () => { const sendError = new Error("No active run"); - mockClient.createTaskThreadMessageForAgent.mockResolvedValue({ + mockTaskThreadService.postMessageToAgent.mockResolvedValue({ message: message(), sendError, }); - const { result } = renderHook(() => useTaskThreadMutations("task-id"), { - wrapper, - }); + const { result } = renderHook( + () => usePostTaskThreadMessageToAgent("task-id"), + { wrapper }, + ); let outcome: Awaited< ReturnType @@ -81,6 +89,6 @@ describe("useTaskThreadMutations", () => { }); expect(outcome).toEqual({ message: message(), sendError }); - expect(mockClient.createTaskThreadMessageForAgent).toHaveBeenCalledTimes(1); + expect(mockTaskThreadService.postMessageToAgent).toHaveBeenCalledTimes(1); }); }); diff --git a/packages/ui/src/features/canvas/hooks/useTaskThread.ts b/packages/ui/src/features/canvas/hooks/useTaskThread.ts index 19c7cf79fd..a5d09da53e 100644 --- a/packages/ui/src/features/canvas/hooks/useTaskThread.ts +++ b/packages/ui/src/features/canvas/hooks/useTaskThread.ts @@ -1,8 +1,12 @@ +import { + TASK_THREAD_SERVICE, + type TaskThreadService, +} from "@posthog/core/canvas/taskThreadService"; +import { useService } from "@posthog/di/react"; import type { TaskThreadMessage } from "@posthog/shared/domain-types"; import { useOptionalAuthenticatedClient } from "@posthog/ui/features/auth/authClient"; import { useAuthenticatedQuery } from "@posthog/ui/hooks/useAuthenticatedQuery"; import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { useCallback } from "react"; const THREAD_POLL_INTERVAL_MS = 5_000; @@ -10,18 +14,9 @@ export function taskThreadQueryKey(taskId: string | undefined) { return ["task-thread", taskId ?? "none"] as const; } -/** A task's thread — the human side conversation — in chronological order. */ export function useTaskThread( taskId: string | undefined, - options?: { - /** Poll cadence override; feed rows poll slower than the open panel. */ - pollIntervalMs?: number; - /** - * Gate the fetch/poll. Feed rows pass `inView` so only near-viewport rows - * hit the network; off-screen rows keep any cached messages but idle. - */ - enabled?: boolean; - }, + options?: { pollIntervalMs?: number; enabled?: boolean }, ): { messages: TaskThreadMessage[]; isLoading: boolean; @@ -34,65 +29,68 @@ export function useTaskThread( { enabled: !!taskId && enabled, refetchInterval: pollIntervalMs, - // Fresh-within-the-poll-window so focus/remount doesn't refire every - // feed row's thread query on top of the interval. staleTime: pollIntervalMs, }, ); return { messages: query.data ?? [], isLoading: query.isLoading }; } -export function useTaskThreadMutations(taskId: string | undefined) { +export function usePostTaskThreadMessage(taskId: string | undefined) { const client = useOptionalAuthenticatedClient(); const queryClient = useQueryClient(); - - const invalidate = useCallback(() => { - void queryClient.invalidateQueries({ - queryKey: taskThreadQueryKey(taskId), - }); - }, [queryClient, taskId]); - - const postMutation = useMutation({ + const mutation = useMutation({ mutationFn: async (content: string) => { if (!client || !taskId) throw new Error("Not authenticated"); return client.createTaskThreadMessage(taskId, content); }, - onSuccess: invalidate, + onSuccess: () => + queryClient.invalidateQueries({ queryKey: taskThreadQueryKey(taskId) }), }); + return { postMessage: mutation.mutateAsync, isPosting: mutation.isPending }; +} - const postToAgentMutation = useMutation({ +export function usePostTaskThreadMessageToAgent(taskId: string | undefined) { + const client = useOptionalAuthenticatedClient(); + const queryClient = useQueryClient(); + const service = useService(TASK_THREAD_SERVICE); + const mutation = useMutation({ mutationFn: async (content: string) => { if (!client || !taskId) throw new Error("Not authenticated"); - return client.createTaskThreadMessageForAgent(taskId, content); + return service.postMessageToAgent(client, taskId, content); }, - onSuccess: invalidate, + onSuccess: () => + queryClient.invalidateQueries({ queryKey: taskThreadQueryKey(taskId) }), }); + return { + postMessageToAgent: mutation.mutateAsync, + isPostingToAgent: mutation.isPending, + }; +} - const deleteMutation = useMutation({ +export function useDeleteTaskThreadMessage(taskId: string | undefined) { + const client = useOptionalAuthenticatedClient(); + const queryClient = useQueryClient(); + const mutation = useMutation({ mutationFn: async (messageId: string) => { if (!client || !taskId) throw new Error("Not authenticated"); return client.deleteTaskThreadMessage(taskId, messageId); }, - onSuccess: invalidate, + onSuccess: () => + queryClient.invalidateQueries({ queryKey: taskThreadQueryKey(taskId) }), }); + return { deleteMessage: mutation.mutateAsync }; +} - const sendToAgentMutation = useMutation({ +export function useSendTaskThreadMessageToAgent(taskId: string | undefined) { + const client = useOptionalAuthenticatedClient(); + const queryClient = useQueryClient(); + const mutation = useMutation({ mutationFn: async (messageId: string) => { if (!client || !taskId) throw new Error("Not authenticated"); return client.sendTaskThreadMessageToAgent(taskId, messageId); }, - onSuccess: invalidate, + onSuccess: () => + queryClient.invalidateQueries({ queryKey: taskThreadQueryKey(taskId) }), }); - - return { - postMessage: (content: string) => postMutation.mutateAsync(content), - postMessageToAgent: (content: string) => - postToAgentMutation.mutateAsync(content), - deleteMessage: (messageId: string) => deleteMutation.mutateAsync(messageId), - sendToAgent: (messageId: string) => - sendToAgentMutation.mutateAsync(messageId), - isPosting: postMutation.isPending, - isSendingToAgent: - postToAgentMutation.isPending || sendToAgentMutation.isPending, - }; + return { sendToAgent: mutation.mutateAsync, isSending: mutation.isPending }; } From 3c223af1bbe045d6b8b47704200405bf7543ca53 Mon Sep 17 00:00:00 2001 From: Alessandro Pogliaghi Date: Thu, 16 Jul 2026 14:57:32 +0100 Subject: [PATCH 09/13] fix(cloud): honor selected sandbox image Skip prewarmed sandbox reuse when a cloud environment or custom image is selected, so the cold run provisions with the requested configuration. Generated-By: PostHog Code Task-Id: 6b752fb1-4fa1-45e4-920a-38306e4b7cee --- .../src/task-detail/taskCreationSaga.test.ts | 42 +++++++++++++++++++ .../core/src/task-detail/taskCreationSaga.ts | 4 ++ 2 files changed, 46 insertions(+) diff --git a/packages/core/src/task-detail/taskCreationSaga.test.ts b/packages/core/src/task-detail/taskCreationSaga.test.ts index f3d626053a..19b921d915 100644 --- a/packages/core/src/task-detail/taskCreationSaga.test.ts +++ b/packages/core/src/task-detail/taskCreationSaga.test.ts @@ -687,6 +687,48 @@ describe("TaskCreationSaga", () => { }); }); + it.each([ + { + selection: "sandbox environment", + input: { sandboxEnvironmentId: "environment-123" }, + expectedRunOptions: { sandboxEnvironmentId: "environment-123" }, + }, + { + selection: "custom image", + input: { customImageId: "image-123" }, + expectedRunOptions: { customImageId: "image-123" }, + }, + ])( + "starts a cold run for a selected $selection", + async ({ input, expectedRunOptions }) => { + const createdTask = createTask(); + const startedTask = createTask({ latest_run: createRun() }); + const createTaskMock = vi.fn().mockResolvedValue(createdTask); + const createTaskRunMock = vi.fn().mockResolvedValue(createRun()); + const startTaskRunMock = vi.fn().mockResolvedValue(startedTask); + const saga = makeSaga({ + createTask: createTaskMock, + createTaskRun: createTaskRunMock, + startTaskRun: startTaskRunMock, + }); + + const result = await saga.run({ + content: "Ship the fix", + repository: "posthog/posthog", + workspaceMode: "cloud", + branch: "main", + ...input, + }); + + expect(result.success).toBe(true); + expect(createTaskMock.mock.calls[0][0].branch).toBeUndefined(); + expect(createTaskRunMock).toHaveBeenCalledWith( + "task-123", + expect.objectContaining(expectedRunOptions), + ); + }, + ); + it("uses the selected user GitHub integration for cloud task creation", async () => { const createdTask = createTask({ github_user_integration: "user-integration-123", diff --git a/packages/core/src/task-detail/taskCreationSaga.ts b/packages/core/src/task-detail/taskCreationSaga.ts index b3e0c5218a..6c8be61d85 100644 --- a/packages/core/src/task-detail/taskCreationSaga.ts +++ b/packages/core/src/task-detail/taskCreationSaga.ts @@ -685,6 +685,10 @@ export class TaskCreationSaga extends Saga< augmented, }; + if (input.sandboxEnvironmentId || input.customImageId) { + return { ...base, suppressWarmReuse: true }; + } + const lease = input.repository ? this.deps.host.takeWarmTaskLease({ repository: input.repository, From eb76666d94c5b9d2c70eb0a080d4da429b635c08 Mon Sep 17 00:00:00 2001 From: Peter Kirkham Date: Thu, 16 Jul 2026 15:02:21 +0100 Subject: [PATCH 10/13] fix(canvas): keep agent status out of thread history Generated-By: PostHog Code Task-Id: 87a79e23-20c4-440b-818b-28154924ffbc --- .../core/src/canvas/threadTimeline.test.ts | 9 +-- packages/core/src/canvas/threadTimeline.ts | 9 +-- .../canvas/components/ThreadPanel.test.tsx | 20 +++--- .../canvas/components/ThreadPanel.tsx | 66 +++++++------------ 4 files changed, 36 insertions(+), 68 deletions(-) diff --git a/packages/core/src/canvas/threadTimeline.test.ts b/packages/core/src/canvas/threadTimeline.test.ts index 600e227397..a3b3b355f3 100644 --- a/packages/core/src/canvas/threadTimeline.test.ts +++ b/packages/core/src/canvas/threadTimeline.test.ts @@ -139,14 +139,9 @@ describe("deriveThreadAgentStatus", () => { expected: { phase: "active", label: "Working…" }, }, { - name: "reports shipped work", - input: { hasActivity: true, hasPullRequest: true }, - expected: { phase: "complete", label: "Shipped" }, - }, - { - name: "reports settled work without a pull request as done", + name: "returns no status after work settles", input: { hasActivity: true }, - expected: { phase: "complete", label: "Done" }, + expected: null, }, ])("$name", ({ input, expected }) => { expect(deriveThreadAgentStatus(input)).toEqual(expected); diff --git a/packages/core/src/canvas/threadTimeline.ts b/packages/core/src/canvas/threadTimeline.ts index 936e5e47dd..a440d4e08d 100644 --- a/packages/core/src/canvas/threadTimeline.ts +++ b/packages/core/src/canvas/threadTimeline.ts @@ -73,7 +73,7 @@ export function buildThreadTimeline({ ].sort((left, right) => left.timestamp - right.timestamp); } -export type ThreadAgentPhase = "active" | "needs_input" | "complete" | "error"; +export type ThreadAgentPhase = "active" | "needs_input" | "error"; export interface ThreadAgentStatus { phase: ThreadAgentPhase; @@ -109,7 +109,6 @@ export function deriveThreadAgentStatus({ pendingPermissionCount = 0, isPromptPending = false, isInitializing = false, - hasPullRequest = false, }: { hasActivity?: boolean; hasError?: boolean; @@ -118,7 +117,6 @@ export function deriveThreadAgentStatus({ pendingPermissionCount?: number; isPromptPending?: boolean; isInitializing?: boolean; - hasPullRequest?: boolean; }): ThreadAgentStatus | null { if (!hasActivity) return null; if (hasError || cloudStatus === "failed") { @@ -130,10 +128,7 @@ export function deriveThreadAgentStatus({ if (isPromptPending || isInitializing) { return { phase: "active", label: "Working…" }; } - return { - phase: "complete", - label: hasPullRequest ? "Shipped" : "Done", - }; + return null; } export function shouldSuspendThreadSession({ diff --git a/packages/ui/src/features/canvas/components/ThreadPanel.test.tsx b/packages/ui/src/features/canvas/components/ThreadPanel.test.tsx index 2d3d9eda19..08f0544b63 100644 --- a/packages/ui/src/features/canvas/components/ThreadPanel.test.tsx +++ b/packages/ui/src/features/canvas/components/ThreadPanel.test.tsx @@ -1,20 +1,16 @@ import { render, screen } from "@testing-library/react"; import { describe, expect, it } from "vitest"; -import { AgentTurnRow, UserPromptRow } from "./ThreadPanel"; +import { AgentStatusLine, UserPromptRow } from "./ThreadPanel"; -describe("AgentTurnRow", () => { - it.each([ - { phase: "active" as const, label: "Working…" }, - { phase: "complete" as const, label: "Done" }, - ])("renders $label in the message area", (statusValue) => { - render(); +describe("AgentStatusLine", () => { + it("renders working status outside the conversation timeline", () => { + render(); - const author = screen.getByText("Agent"); - const status = screen.getByText(statusValue.label); + const status = screen.getByText("Working…"); - expect(author.parentElement).not.toContainElement(status); - expect(author.closest("article")).toContainElement(status); - expect(status.closest('[data-slot="thread-item-body"]')).not.toBeNull(); + expect(status.closest("article")).toBeNull(); + expect(status.closest('[data-slot="thread-item-body"]')).toBeNull(); + expect(status.closest("output")).not.toBeNull(); }); }); diff --git a/packages/ui/src/features/canvas/components/ThreadPanel.tsx b/packages/ui/src/features/canvas/components/ThreadPanel.tsx index 57738b3685..9e24c6a376 100644 --- a/packages/ui/src/features/canvas/components/ThreadPanel.tsx +++ b/packages/ui/src/features/canvas/components/ThreadPanel.tsx @@ -204,31 +204,27 @@ function agentPrompts(items: ConversationItem[]): ThreadAgentMessage[] { return prompts; } -function AgentStatusChip({ status }: { status: ThreadAgentStatus }) { - switch (status.phase) { - case "active": - return ( - - - {status.label} - - ); - case "needs_input": - return {status.label}; - case "error": - return {status.label}; - default: - return {status.label}; - } +export function AgentStatusLine({ status }: { status: ThreadAgentStatus }) { + return ( + + {status.phase === "active" ? ( + + ) : ( + + )} + {status.label} + + ); } export function AgentTurnRow({ message, - status, streaming, }: { - message?: ThreadAgentMessage; - status?: ThreadAgentStatus; + message: ThreadAgentMessage; streaming: boolean; }) { return ( @@ -243,25 +239,19 @@ export function AgentTurnRow({ Agent - {message?.timestamp !== undefined && ( + {message.timestamp !== undefined && ( )} - {(message?.text || status) && ( + {message.text && (
- {message?.text && - (streaming ? ( - - ) : ( - - ))} - {status && ( -
- -
+ {streaming ? ( + + ) : ( + )}
@@ -370,11 +360,6 @@ function ThreadConversation({ }); const { items } = useConversationItems(events, isPromptPending); const pendingPermissions = usePendingPermissionsForTask(taskId); - const prUrl = - typeof task.latest_run?.output?.pr_url === "string" - ? task.latest_run.output.pr_url - : undefined; - const agentMsgs = useMemo(() => agentTurns(items), [items]); const promptMsgs = useMemo(() => agentPrompts(items), [items]); @@ -388,7 +373,6 @@ function ThreadConversation({ pendingPermissionCount: pendingPermissions.size, isPromptPending, isInitializing, - hasPullRequest: !!prUrl, }), [ events.length, @@ -399,7 +383,6 @@ function ThreadConversation({ pendingPermissions.size, isPromptPending, isInitializing, - prUrl, ], ); @@ -498,7 +481,7 @@ function ThreadConversation({ }); }; - const isEmpty = timeline.length === 0 && !agentStatus; + const isEmpty = timeline.length === 0; const isReady = !isInitializing && !isLoading; return ( @@ -595,13 +578,12 @@ function ThreadConversation({ /> ), )} - {agentStatus && !(agentStatus.phase === "complete" && !!prUrl) && ( - - )}
)}
+ {agentStatus && } +
Date: Thu, 16 Jul 2026 15:12:26 +0100 Subject: [PATCH 11/13] fix(canvas): align thread mention rendering Generated-By: PostHog Code Task-Id: 87a79e23-20c4-440b-818b-28154924ffbc --- .../canvas/components/ActivityView.tsx | 2 +- .../canvas/components/MentionText.test.tsx | 18 +++++++++++ .../canvas/components/MentionText.tsx | 30 ++++++++++++++++--- 3 files changed, 45 insertions(+), 5 deletions(-) diff --git a/packages/ui/src/features/canvas/components/ActivityView.tsx b/packages/ui/src/features/canvas/components/ActivityView.tsx index e3323ae71c..4ee90d610f 100644 --- a/packages/ui/src/features/canvas/components/ActivityView.tsx +++ b/packages/ui/src/features/canvas/components/ActivityView.tsx @@ -104,7 +104,7 @@ function ActivityRow({ diff --git a/packages/ui/src/features/canvas/components/MentionText.test.tsx b/packages/ui/src/features/canvas/components/MentionText.test.tsx index 788ddf6363..f9d8a7d396 100644 --- a/packages/ui/src/features/canvas/components/MentionText.test.tsx +++ b/packages/ui/src/features/canvas/components/MentionText.test.tsx @@ -18,4 +18,22 @@ describe("MentionText", () => { "mention-chip--self", ); }); + + it("highlights a literal agent mention", () => { + render(); + + expect(screen.getByText("@agent")).toHaveClass("mention-chip"); + }); + + it("does not highlight agent text inside another token", () => { + render(); + + expect(screen.queryByText("@agent")).not.toBeInTheDocument(); + }); + + it("inherits the surrounding message text size", () => { + render(); + + expect(screen.getByText("A thread reply")).not.toHaveClass("text-xs"); + }); }); diff --git a/packages/ui/src/features/canvas/components/MentionText.tsx b/packages/ui/src/features/canvas/components/MentionText.tsx index 6e1be4cf77..39285e57d5 100644 --- a/packages/ui/src/features/canvas/components/MentionText.tsx +++ b/packages/ui/src/features/canvas/components/MentionText.tsx @@ -6,6 +6,7 @@ import "./mention-chip.css"; type RenderSegment = | { type: "text"; text: string } | { type: "link"; text: string; href: string } + | { type: "agent"; text: string } | { type: "mention"; name: string; email: string }; // The plain (not-the-viewer) mention chip look, also used by surfaces that @@ -35,6 +36,22 @@ export function MentionText({ entries.push({ segment, key: `${offset}` }); offset += length; }; + const pushText = (text: string) => { + let cursor = 0; + for (const match of text.matchAll(/(^|\s)(@agent)\b/gi)) { + const mentionStart = (match.index ?? 0) + match[1].length; + for (const part of splitLinkSegments( + text.slice(cursor, mentionStart), + )) { + push(part, part.text.length); + } + push({ type: "agent", text: match[2] }, match[2].length); + cursor = mentionStart + match[2].length; + } + for (const part of splitLinkSegments(text.slice(cursor))) { + push(part, part.text.length); + } + }; for (const segment of splitMentionSegments(content)) { if (segment.type === "mention") { push( @@ -42,17 +59,22 @@ export function MentionText({ segment.text.length, ); } else { - for (const part of splitLinkSegments(segment.text)) { - push(part, part.text.length); - } + pushText(segment.text); } } return entries; }, [content]); const selfEmail = currentUserEmail?.toLowerCase(); return ( - + {segments.map(({ segment, key }) => { + if (segment.type === "agent") { + return ( + + {segment.text} + + ); + } if (segment.type === "mention") { return ( Date: Thu, 16 Jul 2026 15:34:03 +0100 Subject: [PATCH 12/13] fix(canvas): address thread feed review findings Generated-By: PostHog Code Task-Id: 87a79e23-20c4-440b-818b-28154924ffbc --- packages/core/src/canvas/channelFeed.test.ts | 28 ++ packages/core/src/canvas/channelFeed.ts | 23 ++ .../canvas/components/ChannelFeedView.tsx | 18 +- .../canvas/components/ThreadPanel.test.tsx | 31 ++ .../canvas/components/ThreadPanel.tsx | 346 +++++++++++------- .../canvas/components/threadAgentTurns.ts | 33 ++ .../hooks/useChannelFeedMessages.test.ts | 39 +- .../canvas/hooks/useChannelFeedMessages.ts | 6 +- 8 files changed, 363 insertions(+), 161 deletions(-) create mode 100644 packages/core/src/canvas/channelFeed.test.ts create mode 100644 packages/core/src/canvas/channelFeed.ts create mode 100644 packages/ui/src/features/canvas/components/threadAgentTurns.ts diff --git a/packages/core/src/canvas/channelFeed.test.ts b/packages/core/src/canvas/channelFeed.test.ts new file mode 100644 index 0000000000..ebe14b6f4c --- /dev/null +++ b/packages/core/src/canvas/channelFeed.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { taskFeedRunStatus } from "./channelFeed"; + +describe("taskFeedRunStatus", () => { + it.each(["queued", "in_progress"] as const)( + "preserves a cloud %s status even when local session activity has settled", + (status) => { + expect( + taskFeedRunStatus({ + status, + environment: "cloud", + }), + ).toBe(status); + }, + ); + + it("hides an unreliable non-terminal local status", () => { + expect( + taskFeedRunStatus({ status: "queued", environment: "local" }), + ).toBeNull(); + }); + + it("keeps a terminal local status", () => { + expect( + taskFeedRunStatus({ status: "completed", environment: "local" }), + ).toBe("completed"); + }); +}); diff --git a/packages/core/src/canvas/channelFeed.ts b/packages/core/src/canvas/channelFeed.ts new file mode 100644 index 0000000000..684253d656 --- /dev/null +++ b/packages/core/src/canvas/channelFeed.ts @@ -0,0 +1,23 @@ +import type { TaskRunStatus } from "@posthog/shared/domain-types"; +import { isTerminalStatus } from "@posthog/shared/domain-types"; + +const PERMANENT_CHANNEL_FEED_FAILURES = new Set([401, 403, 404]); + +export function shouldPollChannelFeed(error: unknown): boolean { + if (!error || typeof error !== "object" || !("status" in error)) return true; + const status = (error as { status?: unknown }).status; + return ( + typeof status !== "number" || !PERMANENT_CHANNEL_FEED_FAILURES.has(status) + ); +} + +export function taskFeedRunStatus({ + status, + environment, +}: { + status: TaskRunStatus | null | undefined; + environment: string | null | undefined; +}): TaskRunStatus | null { + if (!status) return null; + return environment === "cloud" || isTerminalStatus(status) ? status : null; +} diff --git a/packages/ui/src/features/canvas/components/ChannelFeedView.tsx b/packages/ui/src/features/canvas/components/ChannelFeedView.tsx index 5a76aba567..af127b60ab 100644 --- a/packages/ui/src/features/canvas/components/ChannelFeedView.tsx +++ b/packages/ui/src/features/canvas/components/ChannelFeedView.tsx @@ -4,6 +4,7 @@ import { GitBranchIcon, RobotIcon, } from "@phosphor-icons/react"; +import { taskFeedRunStatus } from "@posthog/core/canvas/channelFeed"; import { Avatar, AvatarFallback, @@ -37,7 +38,6 @@ import { } from "@posthog/quill"; import { formatRelativeTimeShort, getLocalDayDiff } from "@posthog/shared"; import type { Task, TaskRunStatus } from "@posthog/shared/domain-types"; -import { isTerminalStatus } from "@posthog/shared/domain-types"; import { getUserInitials } from "@posthog/ui/features/auth/userInitials"; import { TaskTabIcon } from "@posthog/ui/features/browser-tabs/TaskTabIcon"; import { mentionChipClass } from "@posthog/ui/features/canvas/components/MentionText"; @@ -46,7 +46,6 @@ import { useChannelTaskData } from "@posthog/ui/features/canvas/hooks/useChannel import { useTaskThread } from "@posthog/ui/features/canvas/hooks/useTaskThread"; import { shouldOpenTaskCardInline } from "@posthog/ui/features/canvas/taskCardNavigation"; import { userDisplayName } from "@posthog/ui/features/canvas/utils/userDisplay"; -import { useSessionSelector } from "@posthog/ui/features/sessions/useSession"; import { type SidebarPrState, useTaskPrStatus, @@ -163,14 +162,6 @@ interface TaskStatusDisplay { // deliberate end state we should not soften with a PR. function useTaskStatusDisplay(task: Task): TaskStatusDisplay { const data = useChannelTaskData(task); - const agentSettledAfterRun = useSessionSelector( - task.id, - (session) => - !!session && - (session.events?.length ?? 0) > 0 && - !session.isPromptPending && - (session.pendingPermissions?.size ?? 0) === 0, - ); const { prState } = useTaskPrStatus({ id: task.id, cloudPrUrl: data?.cloudPrUrl ?? null, @@ -178,6 +169,7 @@ function useTaskStatusDisplay(task: Task): TaskStatusDisplay { }); const status = data?.taskRunStatus ?? task.latest_run?.status; const environment = data?.taskRunEnvironment ?? task.latest_run?.environment; + const displayStatus = taskFeedRunStatus({ status, environment }); // `prState` is resolved async from git/`gh` and is routinely null for cloud // tasks (the details fetch hasn't landed, or there's no cached row). But the // PR URL itself is a hard signal a PR exists — the card's "PR" link keys off @@ -209,10 +201,8 @@ function useTaskStatusDisplay(task: Task): TaskStatusDisplay { base = null; } else if (!status) { base = Draft; - } else if (environment === "cloud" || isTerminalStatus(status)) { - const effectiveStatus = - agentSettledAfterRun && !isTerminalStatus(status) ? "completed" : status; - base = statusBadge(effectiveStatus); + } else if (displayStatus) { + base = statusBadge(displayStatus); } else { // Local, non-terminal: the run status is unreliable (the backend row stays // "queued" while the agent runs on the creator's machine), so we render no diff --git a/packages/ui/src/features/canvas/components/ThreadPanel.test.tsx b/packages/ui/src/features/canvas/components/ThreadPanel.test.tsx index 08f0544b63..18bb9017db 100644 --- a/packages/ui/src/features/canvas/components/ThreadPanel.test.tsx +++ b/packages/ui/src/features/canvas/components/ThreadPanel.test.tsx @@ -1,6 +1,37 @@ +import type { ConversationItem } from "@posthog/ui/features/sessions/components/buildConversationItems"; import { render, screen } from "@testing-library/react"; import { describe, expect, it } from "vitest"; import { AgentStatusLine, UserPromptRow } from "./ThreadPanel"; +import { agentTurns } from "./threadAgentTurns"; + +describe("agentTurns", () => { + it("accumulates every text chunk in one agent turn", () => { + const items = [ + { + type: "session_update", + id: "first", + timestamp: 10, + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "Hello" }, + }, + }, + { + type: "session_update", + id: "second", + timestamp: 20, + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: " there" }, + }, + }, + ] as ConversationItem[]; + + expect(agentTurns(items)).toEqual([ + { id: "first", text: "Hello there", timestamp: 10 }, + ]); + }); +}); describe("AgentStatusLine", () => { it("renders working status outside the conversation timeline", () => { diff --git a/packages/ui/src/features/canvas/components/ThreadPanel.tsx b/packages/ui/src/features/canvas/components/ThreadPanel.tsx index 9e24c6a376..2fd35d705e 100644 --- a/packages/ui/src/features/canvas/components/ThreadPanel.tsx +++ b/packages/ui/src/features/canvas/components/ThreadPanel.tsx @@ -15,6 +15,7 @@ import { shouldSuspendThreadSession, type ThreadAgentMessage, type ThreadAgentStatus, + type ThreadTimelineRow, } from "@posthog/core/canvas/threadTimeline"; import { Avatar, @@ -60,6 +61,7 @@ import { mentionChipClass, } from "@posthog/ui/features/canvas/components/MentionText"; import { ThreadTimestamp } from "@posthog/ui/features/canvas/components/ThreadTimestamp"; +import { agentTurns } from "@posthog/ui/features/canvas/components/threadAgentTurns"; import { useOrgMembers } from "@posthog/ui/features/canvas/hooks/useOrgMembers"; import { useDeleteTaskThreadMessage, @@ -164,33 +166,6 @@ function ThreadMessageRow({ ); } -function agentTurns(items: ConversationItem[]): ThreadAgentMessage[] { - const turns: ThreadAgentMessage[] = []; - let current: ThreadAgentMessage | null = null; - for (const item of items) { - if (item.type === "user_message") { - if (current) turns.push(current); - current = null; - continue; - } - if ( - item.type === "session_update" && - item.update.sessionUpdate === "agent_message_chunk" && - "content" in item.update && - item.update.content.type === "text" && - item.update.content.text.trim() - ) { - current = { - id: item.id, - text: item.update.content.text, - timestamp: item.timestamp, - }; - } - } - if (current) turns.push(current); - return turns; -} - function agentPrompts(items: ConversationItem[]): ThreadAgentMessage[] { const prompts: ThreadAgentMessage[] = []; for (const item of items) { @@ -307,6 +282,181 @@ function ThreadLoadingState() { ); } +function ThreadHeader({ + onClose, + onToggleCollapsed, + onOpenFull, +}: { + onClose?: () => void; + onToggleCollapsed?: () => void; + onOpenFull?: () => void; +}) { + return ( +
+
+ Thread +
+ {onOpenFull && ( + + )} + {onToggleCollapsed && ( + + )} + {onClose && ( + + )} +
+ ); +} + +function ThreadTimeline({ + timeline, + isReady, + taskAuthor, + currentUserUuid, + currentUserEmail, + isTaskAuthor, + canForward, + lastAgentId, + agentActive, + onSendToAgent, + onDelete, +}: { + timeline: ThreadTimelineRow[]; + isReady: boolean; + taskAuthor: UserBasic | null | undefined; + currentUserUuid?: string; + currentUserEmail?: string; + isTaskAuthor: boolean; + canForward: boolean; + lastAgentId?: string; + agentActive: boolean; + onSendToAgent: (messageId: string) => void; + onDelete: (messageId: string) => void; +}) { + if (!isReady) return ; + if (timeline.length === 0) { + return ( + + + + + + No messages yet + + Discuss this task with your team. The agent's status shows up here + too; messages stay between humans unless the task author sends one + to the agent. + + + + ); + } + + return ( + + {timeline.map((row) => + row.kind === "prompt" ? ( + + ) : row.kind === "human" ? ( + onSendToAgent(row.message.id)} + onDelete={() => onDelete(row.message.id)} + /> + ) : ( + + ), + )} + + ); +} + +function ThreadReplyComposer({ + draft, + onDraftChange, + onSubmit, + members, + allowAgentMention, + onMentionInsert, + disabled, +}: { + draft: string; + onDraftChange: (value: string) => void; + onSubmit: () => void; + members: UserBasic[]; + allowAgentMention: boolean; + onMentionInsert: (member: UserBasic) => void; + disabled: boolean; +}) { + return ( +
+ + + + + + + + + +
+ ); +} + function ThreadConversation({ task, channelId, @@ -481,46 +631,15 @@ function ThreadConversation({ }); }; - const isEmpty = timeline.length === 0; const isReady = !isInitializing && !isLoading; return (
-
-
- Thread -
- {onOpenFull && ( - - )} - {onToggleCollapsed && ( - - )} - {onClose && ( - - )} -
+ {showTaskSummary && (
@@ -528,89 +647,32 @@ function ThreadConversation({
)}
- {!isReady ? ( - - ) : isEmpty ? ( - - - - - - No messages yet - - Discuss this task with your team. The agent's status shows up - here too; messages stay between humans unless the task author - sends one to the agent. - - - - ) : ( - - {timeline.map((row) => - row.kind === "prompt" ? ( - - ) : row.kind === "human" ? ( - handleSendToAgent(row.message.id)} - onDelete={() => handleDelete(row.message.id)} - /> - ) : ( - - ), - )} - - )} +
{agentStatus && } -
- - - - - - - - - -
+
); } diff --git a/packages/ui/src/features/canvas/components/threadAgentTurns.ts b/packages/ui/src/features/canvas/components/threadAgentTurns.ts new file mode 100644 index 0000000000..ddcc58ec80 --- /dev/null +++ b/packages/ui/src/features/canvas/components/threadAgentTurns.ts @@ -0,0 +1,33 @@ +import type { ThreadAgentMessage } from "@posthog/core/canvas/threadTimeline"; +import type { ConversationItem } from "@posthog/ui/features/sessions/components/buildConversationItems"; + +export function agentTurns(items: ConversationItem[]): ThreadAgentMessage[] { + const turns: ThreadAgentMessage[] = []; + let current: ThreadAgentMessage | null = null; + for (const item of items) { + if (item.type === "user_message") { + if (current) turns.push(current); + current = null; + continue; + } + if ( + item.type === "session_update" && + item.update.sessionUpdate === "agent_message_chunk" && + "content" in item.update && + item.update.content.type === "text" && + item.update.content.text.trim() + ) { + if (current) { + current.text += item.update.content.text; + } else { + current = { + id: item.id, + text: item.update.content.text, + timestamp: item.timestamp, + }; + } + } + } + if (current) turns.push(current); + return turns; +} diff --git a/packages/ui/src/features/canvas/hooks/useChannelFeedMessages.test.ts b/packages/ui/src/features/canvas/hooks/useChannelFeedMessages.test.ts index b222a17140..e3cad7c73b 100644 --- a/packages/ui/src/features/canvas/hooks/useChannelFeedMessages.test.ts +++ b/packages/ui/src/features/canvas/hooks/useChannelFeedMessages.test.ts @@ -12,12 +12,43 @@ describe("useChannelFeedMessages", () => { vi.mocked(useAuthenticatedQuery).mockClear(); }); + function refetchInterval() { + renderHook(() => useChannelFeedMessages("channel-id")); + + return vi.mocked(useAuthenticatedQuery).mock.calls[0]?.[2]?.refetchInterval; + } + it("keeps polling after a transient query error", () => { + const interval = refetchInterval(); + + expect(interval).toBeTypeOf("function"); + expect( + typeof interval === "function" + ? interval({ state: { error: new Error("Network failure") } } as never) + : interval, + ).toBe(5_000); + }); + + it.each([401, 403, 404])( + "stops polling after a permanent %s response", + (status) => { + const interval = refetchInterval(); + const error = Object.assign(new Error("Request failed"), { status }); + + expect(interval).toBeTypeOf("function"); + expect( + typeof interval === "function" + ? interval({ state: { error } } as never) + : interval, + ).toBe(false); + }, + ); + + it("disables query retries", () => { renderHook(() => useChannelFeedMessages("channel-id")); - expect(vi.mocked(useAuthenticatedQuery).mock.calls[0]?.[2]).toMatchObject({ - retry: false, - refetchInterval: 5_000, - }); + expect(vi.mocked(useAuthenticatedQuery).mock.calls[0]?.[2]?.retry).toBe( + false, + ); }); }); diff --git a/packages/ui/src/features/canvas/hooks/useChannelFeedMessages.ts b/packages/ui/src/features/canvas/hooks/useChannelFeedMessages.ts index a7442a620e..0a3f2d0e0f 100644 --- a/packages/ui/src/features/canvas/hooks/useChannelFeedMessages.ts +++ b/packages/ui/src/features/canvas/hooks/useChannelFeedMessages.ts @@ -1,3 +1,4 @@ +import { shouldPollChannelFeed } from "@posthog/core/canvas/channelFeed"; import type { ChannelFeedMessage, TaskChannel, @@ -81,7 +82,10 @@ export function useChannelFeedMessages(channelId: string | undefined): { { enabled: !!channelId, retry: false, - refetchInterval: CHANNEL_FEED_MESSAGES_POLL_INTERVAL_MS, + refetchInterval: (query) => + shouldPollChannelFeed(query.state.error) + ? CHANNEL_FEED_MESSAGES_POLL_INTERVAL_MS + : false, }, ); const messages = useMemo( From 496e683d7e6c3f619f57bfeb4c07c61b4c5556c0 Mon Sep 17 00:00:00 2001 From: Alessandro Pogliaghi Date: Thu, 16 Jul 2026 15:54:04 +0100 Subject: [PATCH 13/13] fix(cloud): prewarm selected sandbox image Include cloud environment and custom image selections in warm requests and lease matching. Reuse matching configured warm runs while preserving cold fallback when no configured lease is available. Generated-By: PostHog Code Task-Id: 6b752fb1-4fa1-45e4-920a-38306e4b7cee --- .../api-client/src/posthog-client.test.ts | 33 ++++++++++++++++ packages/api-client/src/posthog-client.ts | 8 ++++ .../core/src/task-detail/taskCreationHost.ts | 2 + .../src/task-detail/taskCreationSaga.test.ts | 39 ++++++++++++++++++- .../core/src/task-detail/taskCreationSaga.ts | 23 ++++++++--- .../task-detail/components/TaskInput.tsx | 2 + .../task-detail/hooks/useWarmTask.test.tsx | 37 ++++++++++++++++++ .../features/task-detail/hooks/useWarmTask.ts | 18 +++++++++ .../task-detail/hooks/warmTaskLease.ts | 4 ++ .../task-detail/taskCreationHostImpl.ts | 2 + 10 files changed, 162 insertions(+), 6 deletions(-) diff --git a/packages/api-client/src/posthog-client.test.ts b/packages/api-client/src/posthog-client.test.ts index 8c29ded301..3e6509ac08 100644 --- a/packages/api-client/src/posthog-client.test.ts +++ b/packages/api-client/src/posthog-client.test.ts @@ -544,6 +544,39 @@ describe("PostHogAPIClient", () => { ); }); + it("forwards the selected sandbox environment and custom image", async () => { + const fetch = vi.fn().mockResolvedValue({ + ok: true, + text: async () => + JSON.stringify({ task_id: "task-1", run_id: "run-1" }), + }); + const client = makeClient(fetch); + + await client.warmTask({ + repository: "PostHog/posthog", + github_integration: 42, + sandbox_environment_id: "environment-123", + custom_image_id: "image-123", + }); + + expect(fetch).toHaveBeenCalledWith( + expect.objectContaining({ + overrides: { + body: JSON.stringify({ + repository: "PostHog/posthog", + github_integration: 42, + branch: null, + runtime_adapter: null, + model: null, + reasoning_effort: null, + sandbox_environment_id: "environment-123", + custom_image_id: "image-123", + }), + }, + }), + ); + }); + it("sends a null branch when none is provided", async () => { const fetch = vi.fn().mockResolvedValue({ ok: true, diff --git a/packages/api-client/src/posthog-client.ts b/packages/api-client/src/posthog-client.ts index 7543fb83d4..f2967294ee 100644 --- a/packages/api-client/src/posthog-client.ts +++ b/packages/api-client/src/posthog-client.ts @@ -2817,6 +2817,8 @@ export class PostHogAPIClient { runtime_adapter?: string | null; model?: string | null; reasoning_effort?: string | null; + sandbox_environment_id?: string | null; + custom_image_id?: string | null; }): Promise<{ task_id: string; run_id: string } | null> { const teamId = await this.getTeamId(); const urlPath = `/api/projects/${teamId}/tasks/warm/`; @@ -2833,6 +2835,12 @@ export class PostHogAPIClient { runtime_adapter: options.runtime_adapter ?? null, model: options.model ?? null, reasoning_effort: options.reasoning_effort ?? null, + ...(options.sandbox_environment_id + ? { sandbox_environment_id: options.sandbox_environment_id } + : {}), + ...(options.custom_image_id + ? { custom_image_id: options.custom_image_id } + : {}), }), }, }); diff --git a/packages/core/src/task-detail/taskCreationHost.ts b/packages/core/src/task-detail/taskCreationHost.ts index cbf55daa39..a419ccf60a 100644 --- a/packages/core/src/task-detail/taskCreationHost.ts +++ b/packages/core/src/task-detail/taskCreationHost.ts @@ -125,6 +125,8 @@ export interface ITaskCreationHost { runtimeAdapter?: string | null; model?: string | null; reasoningEffort?: string | null; + sandboxEnvironmentId?: string | null; + customImageId?: string | null; }): { taskId: string; runId: string } | null; uploadRunAttachments( client: TaskCreationApiClient, diff --git a/packages/core/src/task-detail/taskCreationSaga.test.ts b/packages/core/src/task-detail/taskCreationSaga.test.ts index 19b921d915..b53f5f990f 100644 --- a/packages/core/src/task-detail/taskCreationSaga.test.ts +++ b/packages/core/src/task-detail/taskCreationSaga.test.ts @@ -569,6 +569,8 @@ describe("TaskCreationSaga", () => { runtimeAdapter: null, model: null, reasoningEffort: null, + sandboxEnvironmentId: null, + customImageId: null, }); // The bundle must land on the warm run before createTask triggers activation. expect(mockHost.uploadRunAttachments).toHaveBeenCalledWith( @@ -699,8 +701,9 @@ describe("TaskCreationSaga", () => { expectedRunOptions: { customImageId: "image-123" }, }, ])( - "starts a cold run for a selected $selection", + "falls back to a cold run without a matching warm $selection lease", async ({ input, expectedRunOptions }) => { + mockHost.takeWarmTaskLease.mockReturnValue(null); const createdTask = createTask(); const startedTask = createTask({ latest_run: createRun() }); const createTaskMock = vi.fn().mockResolvedValue(createdTask); @@ -729,6 +732,40 @@ describe("TaskCreationSaga", () => { }, ); + it("reuses a warm run built from the selected custom image", async () => { + mockHost.takeWarmTaskLease.mockReturnValue({ + taskId: "warm-task", + runId: "warm-run", + }); + const warmActivatedTask = createTask({ + id: "warm-task", + latest_run: createRun({ id: "warm-run", task: "warm-task" }), + }); + const createTaskMock = vi.fn().mockResolvedValue(warmActivatedTask); + const createTaskRunMock = vi.fn(); + const saga = makeSaga({ + createTask: createTaskMock, + createTaskRun: createTaskRunMock, + }); + + const result = await saga.run({ + content: "Ship the fix", + repository: "posthog/posthog", + workspaceMode: "cloud", + branch: "main", + customImageId: "image-123", + }); + + expect(result.success).toBe(true); + expect(createTaskMock).toHaveBeenCalledWith( + expect.objectContaining({ + branch: "main", + custom_image_id: "image-123", + }), + ); + expect(createTaskRunMock).not.toHaveBeenCalled(); + }); + it("uses the selected user GitHub integration for cloud task creation", async () => { const createdTask = createTask({ github_user_integration: "user-integration-123", diff --git a/packages/core/src/task-detail/taskCreationSaga.ts b/packages/core/src/task-detail/taskCreationSaga.ts index 6c8be61d85..d74a341d35 100644 --- a/packages/core/src/task-detail/taskCreationSaga.ts +++ b/packages/core/src/task-detail/taskCreationSaga.ts @@ -685,10 +685,6 @@ export class TaskCreationSaga extends Saga< augmented, }; - if (input.sandboxEnvironmentId || input.customImageId) { - return { ...base, suppressWarmReuse: true }; - } - const lease = input.repository ? this.deps.host.takeWarmTaskLease({ repository: input.repository, @@ -696,13 +692,22 @@ export class TaskCreationSaga extends Saga< runtimeAdapter: input.adapter ?? null, model: input.model ?? null, reasoningEffort: input.reasoningLevel ?? null, + sandboxEnvironmentId: input.sandboxEnvironmentId ?? null, + customImageId: input.customImageId ?? null, }) : null; + const requiresConfiguredWarm = Boolean( + input.sandboxEnvironmentId || input.customImageId, + ); + const needsAttachments = transport.filePaths.length > 0 || transport.skillBundles.length > 0; if (!needsAttachments) { - return base; + return { + ...base, + suppressWarmReuse: requiresConfiguredWarm && !lease, + }; } if (!lease) { return { ...base, suppressWarmReuse: true }; @@ -792,6 +797,14 @@ export class TaskCreationSaga extends Saga< input.workspaceMode === "cloud" ? (input.reasoningLevel ?? null) : undefined, + sandbox_environment_id: + input.workspaceMode === "cloud" && !warmPayload?.suppressWarmReuse + ? input.sandboxEnvironmentId + : undefined, + custom_image_id: + input.workspaceMode === "cloud" && !warmPayload?.suppressWarmReuse + ? input.customImageId + : undefined, signal_report: input.signalReportId ?? undefined, channel: input.channelId ?? undefined, pending_user_message: warmPayload?.pendingUserMessage, diff --git a/packages/ui/src/features/task-detail/components/TaskInput.tsx b/packages/ui/src/features/task-detail/components/TaskInput.tsx index ac95290dca..2f90851f43 100644 --- a/packages/ui/src/features/task-detail/components/TaskInput.tsx +++ b/packages/ui/src/features/task-detail/components/TaskInput.tsx @@ -730,6 +730,8 @@ export function TaskInput({ runtimeAdapter: adapter ?? null, model: effectiveModel, reasoningEffort: effectiveReasoningLevel, + sandboxEnvironmentId: workspaceMode === "cloud" ? selectedCloudEnvId : null, + customImageId: workspaceMode === "cloud" ? selectedCustomImageId : null, }); const branchForTaskCreation = diff --git a/packages/ui/src/features/task-detail/hooks/useWarmTask.test.tsx b/packages/ui/src/features/task-detail/hooks/useWarmTask.test.tsx index 2d53cf82c3..aa30d8bd24 100644 --- a/packages/ui/src/features/task-detail/hooks/useWarmTask.test.tsx +++ b/packages/ui/src/features/task-detail/hooks/useWarmTask.test.tsx @@ -28,6 +28,8 @@ interface Props { runtimeAdapter?: string | null; model?: string | null; reasoningEffort?: string | null; + sandboxEnvironmentId?: string | null; + customImageId?: string | null; } const cloudTyping: Props = { @@ -201,6 +203,41 @@ describe("useWarmTask", () => { expect(mockClient.warmTask).toHaveBeenCalledTimes(2); }); + it("forwards sandbox configuration and re-warms when the image changes", async () => { + const { rerender } = renderHook((props: Props) => useWarmTask(props), { + initialProps: { + ...cloudTyping, + sandboxEnvironmentId: "environment-123", + customImageId: "image-123", + }, + }); + await flushDebounce(); + expect(mockClient.warmTask).toHaveBeenLastCalledWith({ + repository: "acme/repo", + github_integration: 42, + branch: "main", + ...NULL_RUNTIME, + sandbox_environment_id: "environment-123", + custom_image_id: "image-123", + }); + + rerender({ + ...cloudTyping, + sandboxEnvironmentId: "environment-123", + customImageId: "image-456", + }); + await flushDebounce(); + expect(mockClient.warmTask).toHaveBeenLastCalledWith({ + repository: "acme/repo", + github_integration: 42, + branch: "main", + ...NULL_RUNTIME, + sandbox_environment_id: "environment-123", + custom_image_id: "image-456", + }); + expect(mockClient.warmTask).toHaveBeenCalledTimes(2); + }); + it("warms again for a new selection after a failed warm", async () => { mockClient.warmTask.mockRejectedValueOnce(new Error("boom")); const { rerender } = renderHook((props: Props) => useWarmTask(props), { diff --git a/packages/ui/src/features/task-detail/hooks/useWarmTask.ts b/packages/ui/src/features/task-detail/hooks/useWarmTask.ts index 73a06dd16c..6033cc2381 100644 --- a/packages/ui/src/features/task-detail/hooks/useWarmTask.ts +++ b/packages/ui/src/features/task-detail/hooks/useWarmTask.ts @@ -21,6 +21,8 @@ interface UseWarmTaskOptions { runtimeAdapter?: string | null; model?: string | null; reasoningEffort?: string | null; + sandboxEnvironmentId?: string | null; + customImageId?: string | null; } export function useWarmTask({ @@ -32,6 +34,8 @@ export function useWarmTask({ runtimeAdapter, model, reasoningEffort, + sandboxEnvironmentId, + customImageId, }: UseWarmTaskOptions): void { const enabled = useFeatureFlag(TASKS_PREWARM_SANDBOX_FLAG); const client = useOptionalAuthenticatedClient(); @@ -44,6 +48,8 @@ export function useWarmTask({ const normalizedRuntimeAdapter = runtimeAdapter ?? null; const normalizedModel = model ?? null; const normalizedReasoningEffort = reasoningEffort ?? null; + const normalizedSandboxEnvironmentId = sandboxEnvironmentId ?? null; + const normalizedCustomImageId = customImageId ?? null; const eligible = enabled && isCloud && @@ -59,6 +65,8 @@ export function useWarmTask({ runtimeAdapter: normalizedRuntimeAdapter, model: normalizedModel, reasoningEffort: normalizedReasoningEffort, + sandboxEnvironmentId: normalizedSandboxEnvironmentId, + customImageId: normalizedCustomImageId, })}` : null; @@ -84,6 +92,8 @@ export function useWarmTask({ const warmRuntimeAdapter = normalizedRuntimeAdapter; const warmModel = normalizedModel; const warmReasoningEffort = normalizedReasoningEffort; + const warmSandboxEnvironmentId = normalizedSandboxEnvironmentId; + const warmCustomImageId = normalizedCustomImageId; debounceRef.current = setTimeout(() => { debounceRef.current = null; lastWarmedKeyRef.current = key; @@ -95,6 +105,10 @@ export function useWarmTask({ runtime_adapter: warmRuntimeAdapter, model: warmModel, reasoning_effort: warmReasoningEffort, + ...(warmSandboxEnvironmentId + ? { sandbox_environment_id: warmSandboxEnvironmentId } + : {}), + ...(warmCustomImageId ? { custom_image_id: warmCustomImageId } : {}), }) .then((warm) => { if (warm) { @@ -105,6 +119,8 @@ export function useWarmTask({ runtimeAdapter: warmRuntimeAdapter, model: warmModel, reasoningEffort: warmReasoningEffort, + sandboxEnvironmentId: warmSandboxEnvironmentId, + customImageId: warmCustomImageId, }), { taskId: warm.task_id, runId: warm.run_id }, ); @@ -127,5 +143,7 @@ export function useWarmTask({ normalizedRuntimeAdapter, normalizedModel, normalizedReasoningEffort, + normalizedSandboxEnvironmentId, + normalizedCustomImageId, ]); } diff --git a/packages/ui/src/features/task-detail/hooks/warmTaskLease.ts b/packages/ui/src/features/task-detail/hooks/warmTaskLease.ts index ae5628c5fc..86074fc47d 100644 --- a/packages/ui/src/features/task-detail/hooks/warmTaskLease.ts +++ b/packages/ui/src/features/task-detail/hooks/warmTaskLease.ts @@ -9,6 +9,8 @@ export interface WarmTaskLeaseKeyParts { runtimeAdapter?: string | null; model?: string | null; reasoningEffort?: string | null; + sandboxEnvironmentId?: string | null; + customImageId?: string | null; } export function buildWarmTaskLeaseKey(parts: WarmTaskLeaseKeyParts): string { @@ -18,6 +20,8 @@ export function buildWarmTaskLeaseKey(parts: WarmTaskLeaseKeyParts): string { parts.runtimeAdapter ?? "", parts.model ?? "", parts.reasoningEffort ?? "", + parts.sandboxEnvironmentId ?? "", + parts.customImageId ?? "", ].join(":"); } diff --git a/packages/ui/src/features/task-detail/taskCreationHostImpl.ts b/packages/ui/src/features/task-detail/taskCreationHostImpl.ts index af8c4078af..e4d99882e7 100644 --- a/packages/ui/src/features/task-detail/taskCreationHostImpl.ts +++ b/packages/ui/src/features/task-detail/taskCreationHostImpl.ts @@ -162,6 +162,8 @@ export class TrpcTaskCreationHost implements ITaskCreationHost { runtimeAdapter?: string | null; model?: string | null; reasoningEffort?: string | null; + sandboxEnvironmentId?: string | null; + customImageId?: string | null; }): { taskId: string; runId: string } | null { return takeWarmTaskLease(args); }