From b35e91800cf178d434332a979ddd371d365d10b6 Mon Sep 17 00:00:00 2001 From: Peter Kirkham Date: Wed, 15 Jul 2026 16:28:23 +0100 Subject: [PATCH 01/16] 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/16] 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/16] 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 2bcea3294f341ba9cbdc19b40fa7598e78281b35 Mon Sep 17 00:00:00 2001 From: Alessandro Pogliaghi Date: Thu, 16 Jul 2026 11:06:56 +0100 Subject: [PATCH 04/16] feat(agent): instruction-level rtk adoption for codex sessions The Claude adapter routes eligible commands through rtk deterministically via a PreToolUse hook, but the Codex app-server protocol has no command-rewrite channel, so cloud Codex runs never used the rtk binary installed in the sandbox. Append rtk usage guidance to the Codex developer instructions instead, mirroring the Claude hook's eligibility sets and gated on resolveRtkPrefix so the per-run POSTHOG_RTK=0 kill switch also disables it. Generated-By: PostHog Code Task-Id: b005dede-2677-4364-94a2-c2a7e90e376a --- .../agent/src/adapters/claude/session/rtk.ts | 9 +- .../agent/src/adapters/rtk-guidance.test.ts | 97 +++++++++++++++++++ packages/agent/src/adapters/rtk-guidance.ts | 59 +++++++++++ packages/agent/src/server/agent-server.ts | 9 +- 4 files changed, 168 insertions(+), 6 deletions(-) create mode 100644 packages/agent/src/adapters/rtk-guidance.test.ts create mode 100644 packages/agent/src/adapters/rtk-guidance.ts diff --git a/packages/agent/src/adapters/claude/session/rtk.ts b/packages/agent/src/adapters/claude/session/rtk.ts index c609d7ee4d..0b7d256826 100644 --- a/packages/agent/src/adapters/claude/session/rtk.ts +++ b/packages/agent/src/adapters/claude/session/rtk.ts @@ -15,7 +15,8 @@ import { gitSubcommand } from "../git-command"; // Commands RTK compresses faithfully and that have no side effects, so wrapping // them changes only how much output reaches the model, never what runs. -const RTK_PLAIN_COMMANDS = new Set(["grep", "find", "ls"]); +// Exported so the instruction-level Codex guidance advertises the same set. +export const RTK_PLAIN_COMMANDS = new Set(["grep", "find", "ls"]); // Git subcommands whose output is worth compressing and that RTK handles // faithfully. The criterion is compressible output, NOT read-only: RTK never @@ -23,7 +24,8 @@ const RTK_PLAIN_COMMANDS = new Set(["grep", "find", "ls"]); // `git reflog expire`) still executes its write — its output is just shorter. // Excludes commit/push: negligible output to compress, and the cloud // signed-commit guard keys on a leading `git` token that `rtk git …` would hide. -const GIT_COMPRESSIBLE_SUBCOMMANDS = new Set([ +// Exported so the instruction-level Codex guidance advertises the same set. +export const GIT_COMPRESSIBLE_SUBCOMMANDS = new Set([ "status", "diff", "log", @@ -44,7 +46,8 @@ const GIT_COMPRESSIBLE_SUBCOMMANDS = new Set([ // wrapping only its head would change the meaning of the rest. const SHELL_OPERATORS = /[|&;<>`\n]|\$\(/; -function shQuote(value: string): string { +// Exported so the instruction-level Codex guidance quotes the prefix the same way. +export function shQuote(value: string): string { if (/^[\w./-]+$/.test(value)) return value; return `'${value.replace(/'/g, `'\\''`)}'`; } diff --git a/packages/agent/src/adapters/rtk-guidance.test.ts b/packages/agent/src/adapters/rtk-guidance.test.ts new file mode 100644 index 0000000000..1664ca1a1c --- /dev/null +++ b/packages/agent/src/adapters/rtk-guidance.test.ts @@ -0,0 +1,97 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { afterAll, beforeAll, describe, expect, test } from "vitest"; +import { + GIT_COMPRESSIBLE_SUBCOMMANDS, + RTK_PLAIN_COMMANDS, +} from "./claude/session/rtk"; +import { appendRtkGuidanceForCodex, buildRtkGuidance } from "./rtk-guidance"; + +describe("rtk guidance for codex", () => { + let dir: string; + let binary: string; + + beforeAll(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), "rtk-guidance-test-")); + binary = path.join(dir, "rtk"); + fs.writeFileSync(binary, "#!/bin/sh\n"); + }); + + afterAll(() => { + fs.rmSync(dir, { recursive: true, force: true }); + }); + + describe("buildRtkGuidance", () => { + // The guidance must advertise exactly the Claude hook's eligibility sets, + // so the token-usage cohorts stay comparable across adapters. + test("advertises every command the Claude hook rewrites", () => { + const guidance = buildRtkGuidance("/usr/local/bin/rtk"); + for (const command of RTK_PLAIN_COMMANDS) { + expect(guidance).toContain(command); + } + for (const sub of GIT_COMPRESSIBLE_SUBCOMMANDS) { + expect(guidance).toContain(sub); + } + }); + + test("uses the resolved binary path in the examples", () => { + const guidance = buildRtkGuidance("/usr/local/bin/rtk"); + expect(guidance).toContain("`/usr/local/bin/rtk git status`"); + }); + + // A desktop install can resolve a path with spaces; unquoted it would + // split into multiple shell tokens and every guided command would fail. + test("shell-quotes a binary path containing spaces", () => { + const guidance = buildRtkGuidance("/Apps/PostHog Code/rtk"); + expect(guidance).toContain("`'/Apps/PostHog Code/rtk' git status`"); + expect(guidance).not.toContain("`/Apps/PostHog Code/rtk git status`"); + }); + + // Parity with the Claude hook's exclusion: prefixing commit/push would + // hide the leading `git` token from the cloud signed-commit guard. + test("forbids prefixing git commit and git push", () => { + const guidance = buildRtkGuidance("rtk"); + expect(guidance).toContain("Never prefix `git commit`, `git push`"); + }); + }); + + describe("appendRtkGuidanceForCodex", () => { + test("appends guidance when rtk is on PATH", () => { + const result = appendRtkGuidanceForCodex("base instructions", { + PATH: dir, + }); + expect(result.startsWith("base instructions\n\n")).toBe(true); + expect(result).toContain("rtk command-output compression"); + expect(result).toContain(binary); + }); + + // POSTHOG_RTK=0 is set per run from the cloud kill-switch flag; it must + // silence the guidance too, which is why the gate is resolveRtkPrefix + // rather than detectRtkBinary. + test.each([["0"], ["false"]])( + "returns instructions unchanged when POSTHOG_RTK is %s", + (value) => { + expect( + appendRtkGuidanceForCodex("base instructions", { + POSTHOG_RTK: value, + PATH: dir, + }), + ).toBe("base instructions"); + }, + ); + + test("returns instructions unchanged when rtk is not installed", () => { + expect( + appendRtkGuidanceForCodex("base instructions", { + PATH: "/nonexistent", + }), + ).toBe("base instructions"); + }); + + test("does not leave a leading separator when instructions are empty", () => { + const result = appendRtkGuidanceForCodex("", { PATH: dir }); + expect(result.startsWith("## rtk")).toBe(true); + }); + }); +}); diff --git a/packages/agent/src/adapters/rtk-guidance.ts b/packages/agent/src/adapters/rtk-guidance.ts new file mode 100644 index 0000000000..962913a32c --- /dev/null +++ b/packages/agent/src/adapters/rtk-guidance.ts @@ -0,0 +1,59 @@ +import { + GIT_COMPRESSIBLE_SUBCOMMANDS, + RTK_PLAIN_COMMANDS, + resolveRtkPrefix, + shQuote, +} from "./claude/session/rtk"; + +/** + * Instruction-level RTK integration for Codex sessions. + * + * The Claude adapter routes eligible commands through RTK deterministically + * with a PreToolUse hook that rewrites the Bash input. Codex executes shell + * commands internally over JSON-RPC and its app-server protocol has no + * command-rewrite channel — the adapter can only approve or deny — so the + * only integration point is the developer instructions: tell the model to + * prefix eligible commands itself. + * + * The advertised command set and rules mirror the Claude hook exactly + * (RTK_PLAIN_COMMANDS + GIT_COMPRESSIBLE_SUBCOMMANDS, bare invocations only, + * never commit/push), so token-usage cohorts stay comparable across adapters. + */ +export function buildRtkGuidance(rtkPrefix: string): string { + // Same quoting as the Claude rewrite hook: a resolved path containing + // spaces must stay one shell token in the commands the model copies. + const prefix = shQuote(rtkPrefix); + const plainCommands = [...RTK_PLAIN_COMMANDS].join("`, `"); + const gitSubcommands = [...GIT_COMPRESSIBLE_SUBCOMMANDS].join(", "); + + return `## rtk command-output compression + +\`${prefix}\` is installed. It runs a command unchanged and compresses its output before you read it, so prefixed commands cost far less context. When you execute one of these as a single, bare command, prefix it with \`${prefix}\`: + +- \`${plainCommands}\` +- these git subcommands: ${gitSubcommands} + +Examples: \`${prefix} git status\`, \`${prefix} grep -rn "foo" src\`, \`${prefix} ls -la\`. + +Rules: +- Only prefix a single bare invocation. Never use it when the command is part of a pipe, uses \`&&\`, \`;\`, or redirection, or when another program parses the output — compression would corrupt what the consumer reads. +- Never prefix \`git commit\`, \`git push\`, or any other command not listed above. +- Skip the prefix when you need the exact, complete output (for example, copying a diff verbatim).`; +} + +/** + * Appends the RTK guidance to Codex developer instructions when an RTK binary + * is usable. Gated on `resolveRtkPrefix` — not `detectRtkBinary` — so the + * per-run `POSTHOG_RTK=0` opt-out (the cloud kill-switch flag) disables the + * guidance along with everything else. + */ +export function appendRtkGuidanceForCodex( + instructions: string, + env: NodeJS.ProcessEnv = process.env, +): string { + const rtkPrefix = resolveRtkPrefix(env); + if (!rtkPrefix) return instructions; + return [instructions, buildRtkGuidance(rtkPrefix)] + .filter(Boolean) + .join("\n\n"); +} diff --git a/packages/agent/src/server/agent-server.ts b/packages/agent/src/server/agent-server.ts index 7de8e1eb78..55a88fe26d 100644 --- a/packages/agent/src/server/agent-server.ts +++ b/packages/agent/src/server/agent-server.ts @@ -43,6 +43,7 @@ import { classifyAgentError, isPromptTooLongError, } from "../adapters/error-classification"; +import { appendRtkGuidanceForCodex } from "../adapters/rtk-guidance"; import { SIGNED_COMMIT_QUALIFIED_TOOL_NAME, SIGNED_MERGE_QUALIFIED_TOOL_NAME, @@ -2941,9 +2942,11 @@ export class AgentServer { private buildCodexInstructions( systemPrompt: string | { append: string }, ): string { - return typeof systemPrompt === "string" - ? systemPrompt - : systemPrompt.append; + const instructions = + typeof systemPrompt === "string" ? systemPrompt : systemPrompt.append; + // Codex has no command-rewrite hook (see rtk-guidance.ts), so RTK is + // adopted through the developer instructions instead. + return appendRtkGuidanceForCodex(instructions); } /** From 7ffce8a6feb3673c7dff6ccd3d641a2cdb3b0431 Mon Sep 17 00:00:00 2001 From: Peter Kirkham Date: Thu, 16 Jul 2026 11:50:35 +0100 Subject: [PATCH 05/16] 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 06/16] 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 45298c3c726e7b6a9f5a0193058f3cbeb8f1b106 Mon Sep 17 00:00:00 2001 From: Alessandro Pogliaghi Date: Thu, 16 Jul 2026 13:04:12 +0100 Subject: [PATCH 07/16] fix(agent): persist Codex prompt token usage --- .../codex-app-server-agent.test.ts | 65 +++++++++++++++-- .../codex-app-server-agent.ts | 70 ++++++++++++++----- .../codex-app-server/ext-notifications.ts | 32 ++++----- .../codex-app-server/turn-controller.test.ts | 14 ++-- .../codex-app-server/turn-controller.ts | 16 ++--- .../codex-app-server/usage-tracker.test.ts | 13 ++-- .../codex-app-server/usage-tracker.ts | 30 ++++---- 7 files changed, 162 insertions(+), 78 deletions(-) diff --git a/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.test.ts b/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.test.ts index 3e567b09c7..23355aee71 100644 --- a/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.test.ts +++ b/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.test.ts @@ -1961,9 +1961,23 @@ describe("CodexAppServerAgent", () => { } as unknown as PromptRequest); // The single turn/completed resolves both the original and the folded prompt. + stub.emit("thread/tokenUsage/updated", { + tokenUsage: { + last: { + totalTokens: 45, + inputTokens: 30, + cachedInputTokens: 5, + outputTokens: 10, + }, + }, + }); stub.emit("turn/completed", { turn: { status: "completed" } }); - expect((await first).stopReason).toBe("end_turn"); - expect((await second).stopReason).toBe("end_turn"); + const [firstResult, secondResult] = await Promise.all([first, second]); + expect(firstResult).toMatchObject({ + stopReason: "end_turn", + usage: { totalTokens: 45 }, + }); + expect(secondResult).toEqual({ stopReason: "end_turn" }); const steer = stub.requests.find((r) => r.method === "turn/steer"); expect(steer?.params).toMatchObject({ @@ -2126,7 +2140,19 @@ describe("CodexAppServerAgent", () => { }, }); stub.emit("turn/completed", { turn: { status: "completed" } }); - await done; + const result = await done; + + expect(result).toEqual({ + stopReason: "end_turn", + usage: { + inputTokens: 60, + outputTokens: 30, + cachedReadTokens: 10, + cachedWriteTokens: 0, + thoughtTokens: 5, + totalTokens: 100, + }, + }); const turnComplete = extNotifications.find( (n) => n.method === "_posthog/turn_complete", @@ -2818,6 +2844,16 @@ describe("CodexAppServerAgent", () => { text: "The implementation plan is ready.", }, }); + stub.emit("thread/tokenUsage/updated", { + tokenUsage: { + last: { + totalTokens: 30, + inputTokens: 20, + outputTokens: 10, + reasoningOutputTokens: 2, + }, + }, + }); stub.emit("turn/completed", { turn: { id: "turn_1", status: "completed" }, }); @@ -2826,10 +2862,31 @@ describe("CodexAppServerAgent", () => { await waitUntil( () => stub.requests.filter((r) => r.method === "turn/start").length >= 2, ); + stub.emit("thread/tokenUsage/updated", { + tokenUsage: { + last: { + totalTokens: 50, + inputTokens: 35, + cachedInputTokens: 5, + outputTokens: 10, + reasoningOutputTokens: 3, + }, + }, + }); stub.emit("turn/completed", { turn: { id: "turn_2", status: "completed" }, }); - expect((await done).stopReason).toBe("end_turn"); + expect(await done).toEqual({ + stopReason: "end_turn", + usage: { + inputTokens: 55, + outputTokens: 20, + cachedReadTokens: 5, + cachedWriteTokens: 0, + thoughtTokens: 5, + totalTokens: 80, + }, + }); // The approval renders as the plan-approval UI (switch_mode + the plan text). expect(permissionRequests).toHaveLength(1); diff --git a/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.ts b/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.ts index 4c4035a53b..8b1ce61b89 100644 --- a/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.ts +++ b/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.ts @@ -52,7 +52,6 @@ import { } from "./app-server-client"; import { handleServerRequest } from "./approvals"; import { - type AccumulatedUsage, buildSdkSessionParams, buildTurnCompleteParams, buildUsageBreakdownParams, @@ -162,6 +161,31 @@ function parseGoalCommand(prompt: PromptRequest["prompt"]): GoalCommand | null { } } +function mergePromptUsage( + left: PromptResponse["usage"], + right: PromptResponse["usage"], +): PromptResponse["usage"] { + if (!left) return right; + if (!right) return left; + return { + inputTokens: left.inputTokens + right.inputTokens, + outputTokens: left.outputTokens + right.outputTokens, + cachedReadTokens: + (left.cachedReadTokens ?? 0) + (right.cachedReadTokens ?? 0), + cachedWriteTokens: + (left.cachedWriteTokens ?? 0) + (right.cachedWriteTokens ?? 0), + thoughtTokens: (left.thoughtTokens ?? 0) + (right.thoughtTokens ?? 0), + totalTokens: left.totalTokens + right.totalTokens, + }; +} + +function mergePromptResponses( + left: PromptResponse, + right: PromptResponse, +): PromptResponse { + return { ...right, usage: mergePromptUsage(left.usage, right.usage) }; +} + // The native app-server owns its config; BaseAcpAgent only calls dispose() on this. class NoopSettingsManager implements BaseSettingsManager { constructor(private cwd: string) {} @@ -219,7 +243,7 @@ export class CodexAppServerAgent extends BaseAcpAgent { /** The in-flight turn's , streamed or completed (drives the implement handoff). */ private planProposal?: { itemId: string; text: string }; /** Idle signal deferred while the plan handoff keeps this prompt busy. */ - private deferredTurnComplete?: { usage: AccumulatedUsage }; + private deferredTurnComplete?: { usage: PromptResponse["usage"] }; /** Settles the pending plan-approval race on cancel/close/preempting prompt. */ private planHandoffCancel?: () => void; private readonly mcp = new McpManager(); @@ -727,15 +751,16 @@ export class CodexAppServerAgent extends BaseAcpAgent { return undefined; }); this.turns.onSteered(steerRes?.turnId); - return { stopReason: await this.turns.awaitCompletion() }; + const response = await this.turns.awaitCompletion(); + return { stopReason: response.stopReason }; } if (this.turns.isPending) { // A turn is pending but has no turnId yet, so we can't steer; fail fast. throw new Error("prompt() called while a turn is already in progress"); } - const stopReason = await this.runTurn(input); - return { stopReason: await this.maybeOfferPlanImplementation(stopReason) }; + const response = await this.runTurn(input); + return this.maybeOfferPlanImplementation(response); } private async handleGoalCommand(command: GoalCommand): Promise { @@ -850,7 +875,7 @@ export class CodexAppServerAgent extends BaseAcpAgent { } /** Start one codex turn and await its completion. */ - private async runTurn(input: CodexUserInput[]): Promise { + private async runTurn(input: CodexUserInput[]): Promise { this.lastAgentMessage = ""; this.resetUsage(); this.planProposal = undefined; @@ -895,12 +920,12 @@ export class CodexAppServerAgent extends BaseAcpAgent { * back into another plan turn, whose revised plan prompts again. */ private async maybeOfferPlanImplementation( - stopReason: StopReason, - ): Promise { - let reason = stopReason; + response: PromptResponse, + ): Promise { + let result = response; try { while ( - reason === "end_turn" && + result.stopReason === "end_turn" && this.config.mode === "plan" && this.planProposal && !this.session.cancelled @@ -911,7 +936,7 @@ export class CodexAppServerAgent extends BaseAcpAgent { // Re-check after the await: a cancel that raced the response wins, so a // late accept can never start implementation on a cancelled prompt. if (this.session.cancelled) { - reason = "cancelled"; + result = { ...result, stopReason: "cancelled" }; break; } // A picker change while approval was open owns the mode. Never let a @@ -921,19 +946,25 @@ export class CodexAppServerAgent extends BaseAcpAgent { this.config.setOption("mode", outcome.mode); this.emitCurrentMode(outcome.mode); this.emitConfigOptions(); - reason = await this.runFollowUpTurn(IMPLEMENT_PLAN_MESSAGE); + result = mergePromptResponses( + result, + await this.runFollowUpTurn(IMPLEMENT_PLAN_MESSAGE), + ); break; } if (outcome.kind === "feedback") { - reason = await this.runFollowUpTurn(outcome.feedback); + result = mergePromptResponses( + result, + await this.runFollowUpTurn(outcome.feedback), + ); continue; } break; } } finally { - await this.flushDeferredTurnComplete(reason); + await this.flushDeferredTurnComplete(result.stopReason); } - return reason; + return result; } /** @@ -949,7 +980,7 @@ export class CodexAppServerAgent extends BaseAcpAgent { } /** Run an adapter-initiated turn, echoed as a user message like a host prompt. */ - private async runFollowUpTurn(text: string): Promise { + private async runFollowUpTurn(text: string): Promise { this.broadcastUserInput([{ type: "text", text }]); return this.runTurn(toCodexInput([{ type: "text", text }])); } @@ -1448,7 +1479,10 @@ export class CodexAppServerAgent extends BaseAcpAgent { await this.emitTurnCompleteSignal(reason, usage); await this.emitUsageBreakdown(contextUsed); } - pending.resolve(reason); + pending.resolve({ + stopReason: reason, + ...(usage ? { usage } : {}), + }); } /** Whether maybeOfferPlanImplementation will run for a turn that ended this way. */ @@ -1464,7 +1498,7 @@ export class CodexAppServerAgent extends BaseAcpAgent { /** Emit the cloud idle signal `_posthog/turn_complete` (only with a taskRunId). */ private async emitTurnCompleteSignal( reason: StopReason, - usage: AccumulatedUsage, + usage: PromptResponse["usage"], ): Promise { if (!this.sessionId || !this.taskRunId) return; await this.client diff --git a/packages/agent/src/adapters/codex-app-server/ext-notifications.ts b/packages/agent/src/adapters/codex-app-server/ext-notifications.ts index a692eaa421..f397285f21 100644 --- a/packages/agent/src/adapters/codex-app-server/ext-notifications.ts +++ b/packages/agent/src/adapters/codex-app-server/ext-notifications.ts @@ -50,13 +50,12 @@ export interface TurnCompleteParams { usage: TurnCompleteUsage; } -/** The four component counts the caller accumulates; total is computed here. */ -export interface AccumulatedUsage { - inputTokens: number; - outputTokens: number; - cachedReadTokens: number; - cachedWriteTokens: number; -} +type TurnCompleteUsageInput = { + inputTokens?: number | null; + outputTokens?: number | null; + cachedReadTokens?: number | null; + cachedWriteTokens?: number | null; +}; /** * `_posthog/turn_complete` — fired when a prompt turn finishes. `totalTokens` is the @@ -65,21 +64,22 @@ export interface AccumulatedUsage { export function buildTurnCompleteParams( sessionId: string, stopReason: StopReason, - usage: AccumulatedUsage, + usage?: TurnCompleteUsageInput | null, ): TurnCompleteParams { + const inputTokens = usage?.inputTokens ?? 0; + const outputTokens = usage?.outputTokens ?? 0; + const cachedReadTokens = usage?.cachedReadTokens ?? 0; + const cachedWriteTokens = usage?.cachedWriteTokens ?? 0; return { sessionId, stopReason, usage: { - inputTokens: usage.inputTokens, - outputTokens: usage.outputTokens, - cachedReadTokens: usage.cachedReadTokens, - cachedWriteTokens: usage.cachedWriteTokens, + inputTokens, + outputTokens, + cachedReadTokens, + cachedWriteTokens, totalTokens: - usage.inputTokens + - usage.outputTokens + - usage.cachedReadTokens + - usage.cachedWriteTokens, + inputTokens + outputTokens + cachedReadTokens + cachedWriteTokens, }, }; } diff --git a/packages/agent/src/adapters/codex-app-server/turn-controller.test.ts b/packages/agent/src/adapters/codex-app-server/turn-controller.test.ts index 9e7398ffe0..e8d1f6ab8f 100644 --- a/packages/agent/src/adapters/codex-app-server/turn-controller.test.ts +++ b/packages/agent/src/adapters/codex-app-server/turn-controller.test.ts @@ -18,8 +18,8 @@ describe("TurnController", () => { expect(turns.activeTurnId).toBeUndefined(); expect(turns.claim()).toBeUndefined(); - pending?.resolve("end_turn"); - await expect(completion).resolves.toBe("end_turn"); + pending?.resolve({ stopReason: "end_turn" }); + await expect(completion).resolves.toEqual({ stopReason: "end_turn" }); }); it("finishPrompt for an older turn does not wipe a newer turn's pending state", async () => { @@ -36,15 +36,15 @@ describe("TurnController", () => { turns.onStarted("turn-b"); // Turn A's prompt() resolves and its finally runs; it must not clear turn B. - claimedA?.resolve("end_turn"); - await expect(a.completion).resolves.toBe("end_turn"); + claimedA?.resolve({ stopReason: "end_turn" }); + await expect(a.completion).resolves.toEqual({ stopReason: "end_turn" }); turns.finishPrompt(a.turn); expect(turns.isRunning).toBe(true); const claimedB = turns.claim(); expect(claimedB).toBeDefined(); - claimedB?.resolve("end_turn"); - await expect(b.completion).resolves.toBe("end_turn"); + claimedB?.resolve({ stopReason: "end_turn" }); + await expect(b.completion).resolves.toEqual({ stopReason: "end_turn" }); }); it("finishPrompt with the current turn token clears the pending slot", () => { @@ -84,7 +84,7 @@ describe("TurnController", () => { turns.markInterrupted(); turns.close("cancelled"); - await expect(completion).resolves.toBe("cancelled"); + await expect(completion).resolves.toEqual({ stopReason: "cancelled" }); expect(turns.isPending).toBe(false); expect(turns.activeTurnId).toBeUndefined(); expect(turns.shouldDropCompletion("turn-1")).toBe(false); diff --git a/packages/agent/src/adapters/codex-app-server/turn-controller.ts b/packages/agent/src/adapters/codex-app-server/turn-controller.ts index 350ba0b1ba..e6f6483a9a 100644 --- a/packages/agent/src/adapters/codex-app-server/turn-controller.ts +++ b/packages/agent/src/adapters/codex-app-server/turn-controller.ts @@ -1,7 +1,7 @@ -import type { StopReason } from "@agentclientprotocol/sdk"; +import type { PromptResponse, StopReason } from "@agentclientprotocol/sdk"; interface PendingTurn { - resolve: (reason: StopReason) => void; + resolve: (response: PromptResponse) => void; reject: (err: Error) => void; } @@ -13,13 +13,13 @@ interface PendingTurn { export class TurnController { private turnId?: string; private pending?: PendingTurn; - private completion?: Promise; + private completion?: Promise; private generation = 0; private readonly cancelled = new Set(); - begin(): { completion: Promise; turn: number } { + begin(): { completion: Promise; turn: number } { const turn = ++this.generation; - this.completion = new Promise((resolve, reject) => { + this.completion = new Promise((resolve, reject) => { this.pending = { resolve, reject }; }); return { completion: this.completion, turn }; @@ -49,8 +49,8 @@ export class TurnController { } /** Await the in-flight turn's completion (the steer path reuses the original). */ - awaitCompletion(): Promise { - return this.completion ?? Promise.resolve("end_turn"); + awaitCompletion(): Promise { + return this.completion ?? Promise.resolve({ stopReason: "end_turn" }); } /** Atomically claim the pending turn (clears the slot + turnId synchronously), or undefined if already claimed. */ @@ -96,7 +96,7 @@ export class TurnController { /** Resolve and clear everything on session close. */ close(reason: StopReason): void { this.turnId = undefined; - this.pending?.resolve(reason); + this.pending?.resolve({ stopReason: reason }); this.pending = undefined; this.completion = undefined; this.cancelled.clear(); diff --git a/packages/agent/src/adapters/codex-app-server/usage-tracker.test.ts b/packages/agent/src/adapters/codex-app-server/usage-tracker.test.ts index aba636c727..d8c579bd18 100644 --- a/packages/agent/src/adapters/codex-app-server/usage-tracker.test.ts +++ b/packages/agent/src/adapters/codex-app-server/usage-tracker.test.ts @@ -46,6 +46,8 @@ describe("UsageTracker", () => { outputTokens: 80, cachedReadTokens: 50, cachedWriteTokens: 0, + thoughtTokens: 20, + totalTokens: 500, }); }); @@ -54,7 +56,7 @@ describe("UsageTracker", () => { const update = tracker.ingest(payload({ last: undefined })); expect(update?.used).toBe(1200); - expect(tracker.perTurnUsage().inputTokens).toBe(900); + expect(tracker.perTurnUsage()?.inputTokens).toBe(900); }); it("derives `used` from inputTokens when totalTokens is absent (same order as the gauge)", () => { @@ -78,17 +80,12 @@ describe("UsageTracker", () => { expect(tracker.contextTokens()).toBe(500); }); - it("resetForTurn zeroes the per-turn view so a token-less turn reports 0, not stale data", () => { + it("resetForTurn clears stale per-turn usage", () => { const tracker = new UsageTracker(); tracker.ingest(payload()); tracker.resetForTurn(); expect(tracker.contextTokens()).toBeUndefined(); - expect(tracker.perTurnUsage()).toEqual({ - inputTokens: 0, - outputTokens: 0, - cachedReadTokens: 0, - cachedWriteTokens: 0, - }); + expect(tracker.perTurnUsage()).toBeUndefined(); }); }); diff --git a/packages/agent/src/adapters/codex-app-server/usage-tracker.ts b/packages/agent/src/adapters/codex-app-server/usage-tracker.ts index 154d7d1159..fd107a7c13 100644 --- a/packages/agent/src/adapters/codex-app-server/usage-tracker.ts +++ b/packages/agent/src/adapters/codex-app-server/usage-tracker.ts @@ -1,8 +1,8 @@ +import type { Usage } from "@agentclientprotocol/sdk"; import { type ContextBreakdownBaseline, emptyBaseline, } from "../claude/context-breakdown"; -import type { AccumulatedUsage } from "./ext-notifications"; import { readTokenUsage } from "./token-usage"; /** The live `_posthog/usage_update` fields (context-window occupancy). */ @@ -25,7 +25,7 @@ export interface UsageUpdate { */ export class UsageTracker { private baseline: ContextBreakdownBaseline = emptyBaseline(); - private lastTurn?: AccumulatedUsage; + private lastTurn?: Usage; private contextUsed?: number; setBaseline(baseline: ContextBreakdownBaseline): void { @@ -36,7 +36,6 @@ export class UsageTracker { return this.baseline; } - /** Zero the per-turn view at turn start so a token-less turn reports 0. */ resetForTurn(): void { this.lastTurn = undefined; this.contextUsed = undefined; @@ -49,12 +48,17 @@ export class UsageTracker { const { context, used, size } = reading; // Drives the per-source breakdown's "conversation" bucket on turn complete. this.contextUsed = used; + const inputTokens = context.inputTokens ?? 0; + const outputTokens = context.outputTokens ?? 0; + const cachedReadTokens = context.cachedInputTokens ?? 0; this.lastTurn = { - inputTokens: context.inputTokens ?? 0, - outputTokens: context.outputTokens ?? 0, - cachedReadTokens: context.cachedInputTokens ?? 0, - // codex's TokenUsageBreakdown has no cache-write field; 0 is authoritative. + inputTokens, + outputTokens, + cachedReadTokens, cachedWriteTokens: 0, + thoughtTokens: context.reasoningOutputTokens, + totalTokens: + context.totalTokens ?? inputTokens + outputTokens + cachedReadTokens, }; return { used, @@ -69,16 +73,8 @@ export class UsageTracker { }; } - /** Per-turn usage for `_posthog/turn_complete` — codex's `last`, not a delta. */ - perTurnUsage(): AccumulatedUsage { - return ( - this.lastTurn ?? { - inputTokens: 0, - outputTokens: 0, - cachedReadTokens: 0, - cachedWriteTokens: 0, - } - ); + perTurnUsage(): Usage | undefined { + return this.lastTurn ? { ...this.lastTurn } : undefined; } /** Live context occupancy (same derivation as the renderer gauge), or undefined pre-usage. */ From 83fc1f94715e9a61e13048605a7922e41235359c Mon Sep 17 00:00:00 2001 From: Peter Kirkham Date: Thu, 16 Jul 2026 13:35:05 +0100 Subject: [PATCH 08/16] 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 09/16] 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 10/16] 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 927acb0c7461bc00907c5ff153209a2cce89b5ab Mon Sep 17 00:00:00 2001 From: Adam Bowker Date: Thu, 16 Jul 2026 09:34:25 -0400 Subject: [PATCH 11/16] fix(billing): explain the merged usage limit as included allowance + spend limit A subscribed org's ai_credits.limit_usd arrives from the gateway as one merged number: the $20 included monthly allowance plus the configured spend limit. The plans-page meter and title-bar card rendered it raw, so an org on the default limit saw a total matching nothing it configured, and the billing announcement modal misquoted the merged number as the org's spend limit. Derive the breakdown in codeUsageMeter (via codeOrgSpendLimitUsd), spell it out under both meters ("$20 included + $50 org spend limit"), notch the plans-page bar where the included allowance ends, and quote the recovered spend limit in the announcement modal. Generated-By: PostHog Code Task-Id: b24f71e2-3751-4a6b-b5d8-a24147c4c067 --- .../core/src/billing/usageDisplay.test.ts | 78 +++++++++++++++++++ packages/core/src/billing/usageDisplay.ts | 51 ++++++++++++ .../billing/UsageBillingAnnouncementModal.tsx | 20 ++--- .../ui/src/features/billing/UsageButton.tsx | 10 ++- .../features/billing/UsageMeter.stories.tsx | 61 +++++++++++++++ .../ui/src/features/billing/UsageMeter.tsx | 25 ++++-- .../settings/sections/PlanUsageSettings.tsx | 18 ++++- 7 files changed, 247 insertions(+), 16 deletions(-) create mode 100644 packages/ui/src/features/billing/UsageMeter.stories.tsx diff --git a/packages/core/src/billing/usageDisplay.test.ts b/packages/core/src/billing/usageDisplay.test.ts index d7c3f882fd..7ae0c2c050 100644 --- a/packages/core/src/billing/usageDisplay.test.ts +++ b/packages/core/src/billing/usageDisplay.test.ts @@ -1,8 +1,10 @@ import { describe, expect, it } from "vitest"; import type { UsageOutput } from "../usage/schemas"; import { + codeOrgSpendLimitUsd, codeUsageMeter, formatResetTime, + formatUsageBreakdown, formatUsdAmount, isCodeUsageFreeTier, isUsageExceeded, @@ -92,9 +94,32 @@ describe("codeUsageMeter", () => { percent: 25, exceeded: false, resetAt: "2026-06-01T00:00:00.000Z", + breakdown: { includedUsd: 20, spendLimitUsd: 30 }, }); }); + it("splits a default-settings subscribed limit into $20 included + $50 spend limit", () => { + const meter = codeUsageMeter({ + ...makeUsage(), + code_usage_subscribed: true, + ai_credits: { exhausted: false, used_usd: 5, limit_usd: 70 }, + }); + expect(meter).toMatchObject({ + kind: "dollars", + limitUsd: 70, + breakdown: { includedUsd: 20, spendLimitUsd: 50 }, + }); + }); + + it("keeps a free org's dollars meter breakdown-free — its limit is just the allowance", () => { + const meter = codeUsageMeter({ + ...makeUsage(), + code_usage_subscribed: false, + ai_credits: { exhausted: false, used_usd: 5, limit_usd: 20 }, + }); + expect(meter).toMatchObject({ kind: "dollars", breakdown: null }); + }); + it("marks the dollars meter exceeded from the org bucket and falls back to the sustained reset", () => { const meter = codeUsageMeter({ ...makeUsage(), @@ -134,6 +159,59 @@ describe("codeUsageMeter", () => { }); }); +describe("codeOrgSpendLimitUsd", () => { + const subscribedWithLimit = (limitUsd: number | null) => ({ + code_usage_subscribed: true, + ai_credits: { exhausted: false, used_usd: 0, limit_usd: limitUsd }, + }); + + it.each([ + ["default settings", 70, 50], + ["custom limit", 120.5, 100.5], + ["a $0 spend limit is a real answer", 20, 0], + ])("recovers the configured limit with %s", (_name, limitUsd, expected) => { + expect(codeOrgSpendLimitUsd(subscribedWithLimit(limitUsd))).toBe(expected); + }); + + it("returns null when the merged limit is below the allowance", () => { + expect(codeOrgSpendLimitUsd(subscribedWithLimit(15))).toBeNull(); + }); + + it("returns null without a limit number", () => { + expect(codeOrgSpendLimitUsd(subscribedWithLimit(null))).toBeNull(); + expect( + codeOrgSpendLimitUsd({ + code_usage_subscribed: true, + ai_credits: undefined, + }), + ).toBeNull(); + }); + + it("returns null for free, unknown, or missing orgs", () => { + expect( + codeOrgSpendLimitUsd({ + code_usage_subscribed: false, + ai_credits: { exhausted: false, used_usd: 0, limit_usd: 20 }, + }), + ).toBeNull(); + expect( + codeOrgSpendLimitUsd({ + code_usage_subscribed: undefined, + ai_credits: { exhausted: false, used_usd: 0, limit_usd: 70 }, + }), + ).toBeNull(); + expect(codeOrgSpendLimitUsd(null)).toBeNull(); + }); +}); + +describe("formatUsageBreakdown", () => { + it("phrases the merged limit as included + spend limit", () => { + expect(formatUsageBreakdown({ includedUsd: 20, spendLimitUsd: 50 })).toBe( + "$20 included + $50 org spend limit", + ); + }); +}); + describe("formatUsdAmount", () => { it.each([ [50, "$50"], diff --git a/packages/core/src/billing/usageDisplay.ts b/packages/core/src/billing/usageDisplay.ts index fcf22434c7..3d32bf3f62 100644 --- a/packages/core/src/billing/usageDisplay.ts +++ b/packages/core/src/billing/usageDisplay.ts @@ -1,5 +1,16 @@ import type { UsageBucket, UsageOutput } from "../usage/schemas"; +/** + * The monthly usage allowance included for every org under Code usage + * billing — the "first $20 each month is included" from the billing product + * config. The gateway has no field for it: a subscribed org's + * `ai_credits.limit_usd` arrives as one merged number (allowance + configured + * spend limit), so display code peels the allowance back off with this + * constant. If billing ever changes the allowance or starts sending a + * breakdown, this constant (and the copy quoting $20) is the seam. + */ +export const CODE_INCLUDED_USAGE_USD = 20; + /** Confirmed free tier only — an absent `code_usage_subscribed` is unknown, never free. */ export function isCodeUsageFreeTier( usage: Pick | null | undefined, @@ -7,6 +18,32 @@ export function isCodeUsageFreeTier( return usage?.code_usage_subscribed === false; } +/** + * The spend limit the org actually configured in billing settings ($50 by + * default), recovered from the merged `limit_usd`. Null when it can't be + * recovered: unconfirmed/free orgs (their limit IS the allowance), missing + * numbers, or a merged limit below the allowance. Zero is a real answer — a + * subscribed org whose merged limit equals the allowance set its spend limit + * to $0. + */ +export function codeOrgSpendLimitUsd( + usage: + | Pick + | null + | undefined, +): number | null { + if (usage?.code_usage_subscribed !== true) return null; + const limitUsd = usage.ai_credits?.limit_usd; + if (limitUsd == null || limitUsd < CODE_INCLUDED_USAGE_USD) return null; + // Round away float dust — the wire amounts are already cent-rounded. + return Math.round((limitUsd - CODE_INCLUDED_USAGE_USD) * 100) / 100; +} + +export interface CodeUsageBreakdown { + includedUsd: number; + spendLimitUsd: number; +} + export type CodeUsageMeter = | { kind: "dollars"; @@ -15,6 +52,10 @@ export type CodeUsageMeter = percent: number; exceeded: boolean; resetAt: string; + // How the merged limit decomposes for a subscribed org, so the meter + // can explain "$70" as $20 included + $50 spend limit. Null when the + // split is unknown (free tier, or a limit below the allowance). + breakdown: CodeUsageBreakdown | null; } | { kind: "bucket"; bucket: UsageBucket } | { kind: "hidden" }; @@ -32,6 +73,7 @@ export function codeUsageMeter( const usedUsd = usage.ai_credits?.used_usd; const limitUsd = usage.ai_credits?.limit_usd; if (usedUsd != null && limitUsd != null && limitUsd > 0) { + const spendLimitUsd = codeOrgSpendLimitUsd(usage); return { kind: "dollars", usedUsd, @@ -39,6 +81,10 @@ export function codeUsageMeter( percent: Math.min(100, Math.round((usedUsd / limitUsd) * 100)), exceeded: usage.ai_credits?.exhausted === true, resetAt: usage.billing_period_end ?? usage.sustained.reset_at, + breakdown: + spendLimitUsd != null + ? { includedUsd: CODE_INCLUDED_USAGE_USD, spendLimitUsd } + : null, }; } if (isCodeUsageFreeTier(usage)) { @@ -51,6 +97,11 @@ export function formatUsdAmount(amount: number): string { return Number.isInteger(amount) ? `$${amount}` : `$${amount.toFixed(2)}`; } +/** One shared phrasing for the merged-limit explanation, e.g. "$20 included + $50 org spend limit". */ +export function formatUsageBreakdown(breakdown: CodeUsageBreakdown): string { + return `${formatUsdAmount(breakdown.includedUsd)} included + ${formatUsdAmount(breakdown.spendLimitUsd)} org spend limit`; +} + export function isUsageExceeded(usage: UsageOutput): boolean { return ( usage.is_rate_limited || usage.sustained.exceeded || usage.burst.exceeded diff --git a/packages/ui/src/features/billing/UsageBillingAnnouncementModal.tsx b/packages/ui/src/features/billing/UsageBillingAnnouncementModal.tsx index b2484be203..5982865af2 100644 --- a/packages/ui/src/features/billing/UsageBillingAnnouncementModal.tsx +++ b/packages/ui/src/features/billing/UsageBillingAnnouncementModal.tsx @@ -1,5 +1,8 @@ import { ArrowSquareOut, CreditCard } from "@phosphor-icons/react"; -import { formatUsdAmount } from "@posthog/core/billing/usageDisplay"; +import { + codeOrgSpendLimitUsd, + formatUsdAmount, +} from "@posthog/core/billing/usageDisplay"; import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; import { Button, Dialog, Flex, Text } from "@radix-ui/themes"; import { useEffect } from "react"; @@ -22,12 +25,11 @@ export function UsageBillingAnnouncementModal() { const cloudRegion = useAuthStateValue((state) => state.cloudRegion); const { usage } = useUsage({ enabled: isOpen }); - // A free org's limit_usd is its included allocation (the first bullet's - // $20), not a spend limit — the org-specific line is for subscribed orgs. - const limitUsd = - usage?.code_usage_subscribed === true - ? (usage.ai_credits?.limit_usd ?? null) - : null; + // A subscribed org's limit_usd merges the included $20 with the configured + // spend limit — quote the recovered spend limit, the number the org set. + // Free orgs (whose limit_usd is just the first bullet's $20) get the + // default-limit line instead. + const spendLimitUsd = codeOrgSpendLimitUsd(usage); useEffect(() => { if (isOpen) { @@ -83,10 +85,10 @@ export function UsageBillingAnnouncementModal() { • Premium models need a payment method; an open model stays free. - {limitUsd != null ? ( + {spendLimitUsd != null ? ( <> • Your organization's spend limit is{" "} - {`${formatUsdAmount(limitUsd)}/month`}{" "} + {`${formatUsdAmount(spendLimitUsd)}/month`}{" "} — adjust it any time in billing settings. ) : ( diff --git a/packages/ui/src/features/billing/UsageButton.tsx b/packages/ui/src/features/billing/UsageButton.tsx index 8f470b5b8b..893a461d75 100644 --- a/packages/ui/src/features/billing/UsageButton.tsx +++ b/packages/ui/src/features/billing/UsageButton.tsx @@ -1,6 +1,7 @@ import { Circle } from "@phosphor-icons/react"; import { formatResetTime, + formatUsageBreakdown, formatUsdAmount, } from "@posthog/core/billing/usageDisplay"; import { @@ -70,6 +71,13 @@ export function UsageButton() { const resetLabel = formatResetTime( meter.kind === "dollars" ? meter.resetAt : meter.bucket.reset_at, ); + // The subscribed meter's limit is a merged number (included allowance + + // configured spend limit); without the split, "$70" matches nothing the + // user ever set. + const breakdownLabel = + meter.kind === "dollars" && meter.breakdown + ? formatUsageBreakdown(meter.breakdown) + : null; // Upgrade-prompt analytics only apply to free-tier orgs — a subscribed // org's meter is not an upgrade prompt. @@ -158,7 +166,7 @@ export function UsageButton() { variant={blocked ? "destructive" : "default"} />
- {resetLabel} + {breakdownLabel ? `${breakdownLabel} · ${resetLabel}` : resetLabel}
diff --git a/packages/ui/src/features/billing/UsageMeter.stories.tsx b/packages/ui/src/features/billing/UsageMeter.stories.tsx new file mode 100644 index 0000000000..0d37009bea --- /dev/null +++ b/packages/ui/src/features/billing/UsageMeter.stories.tsx @@ -0,0 +1,61 @@ +import { UsageMeter } from "@posthog/ui/features/billing/UsageMeter"; +import type { Meta, StoryObj } from "@storybook/react-vite"; + +const meta: Meta = { + title: "Billing/UsageMeter", + component: UsageMeter, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +// A subscribed org on default settings: the $70 limit is $20 included +// allowance + the $50 default spend limit, spelled out in the detail line +// with the notch marking where the included allowance ends. +export const SubscribedWithBreakdown: Story = { + args: { + label: "Usage this period", + percent: 18, + valueLabel: "$12.40 of $70", + detail: "$20 included + $50 org spend limit. Resets Jul 31 at 2:00 PM PDT", + markerPercent: (20 / 70) * 100, + }, +}; + +export const SubscribedPastIncluded: Story = { + args: { + label: "Usage this period", + percent: 66, + valueLabel: "$46.20 of $70", + detail: "$20 included + $50 org spend limit. Resets Jul 31 at 2:00 PM PDT", + markerPercent: (20 / 70) * 100, + }, +}; + +export const FreeTier: Story = { + args: { + label: "Monthly free usage", + percent: 62, + valueLabel: "$12.40 of $20 included", + detail: "Resets Jul 31 at 2:00 PM PDT", + }, +}; + +export const Exceeded: Story = { + args: { + label: "Usage this period", + percent: 100, + valueLabel: "$70 of $70", + detail: + "Limit exceeded. $20 included + $50 org spend limit. Resets Jul 31 at 2:00 PM PDT", + markerPercent: (20 / 70) * 100, + color: "red", + }, +}; diff --git a/packages/ui/src/features/billing/UsageMeter.tsx b/packages/ui/src/features/billing/UsageMeter.tsx index 9ea1c91a11..43ce470d23 100644 --- a/packages/ui/src/features/billing/UsageMeter.tsx +++ b/packages/ui/src/features/billing/UsageMeter.tsx @@ -6,6 +6,9 @@ interface UsageMeterProps { valueLabel: string; detail: string; color?: "red"; + // Where a boundary inside the limit falls (e.g. the end of the included + // allowance), as a percent of the bar. Rendered as a notch over the track. + markerPercent?: number; } export function UsageMeter({ @@ -14,8 +17,11 @@ export function UsageMeter({ valueLabel, detail, color, + markerPercent, }: UsageMeterProps) { const borderColor = color === "red" ? "var(--red-7)" : "var(--gray-5)"; + const showMarker = + markerPercent != null && markerPercent > 0 && markerPercent < 100; return ( {label}
{valueLabel} - +
+ + {showMarker && ( +
+ )} +
{detail} ); diff --git a/packages/ui/src/features/settings/sections/PlanUsageSettings.tsx b/packages/ui/src/features/settings/sections/PlanUsageSettings.tsx index 09d0db0884..014d7595cf 100644 --- a/packages/ui/src/features/settings/sections/PlanUsageSettings.tsx +++ b/packages/ui/src/features/settings/sections/PlanUsageSettings.tsx @@ -6,6 +6,7 @@ import { import { codeUsageMeter, formatResetTime, + formatUsageBreakdown, formatUsdAmount, isCodeUsageFreeTier, } from "@posthog/core/billing/usageDisplay"; @@ -178,7 +179,22 @@ export function PlanUsageSettings() { label={freeTier ? "Monthly free usage" : "Usage this period"} percent={meter.percent} valueLabel={`${formatUsdAmount(meter.usedUsd)} of ${formatUsdAmount(meter.limitUsd)}${freeTier ? " included" : ""}`} - detail={`${meter.exceeded ? "Limit exceeded. " : ""}${formatResetTime(meter.resetAt)}`} + detail={[ + meter.exceeded ? "Limit exceeded." : null, + // Spell out how the merged limit is composed — "$70" on its + // own reads as a mystery number to an org that set $50. + meter.breakdown + ? `${formatUsageBreakdown(meter.breakdown)}.` + : null, + formatResetTime(meter.resetAt), + ] + .filter(Boolean) + .join(" ")} + markerPercent={ + meter.breakdown + ? (meter.breakdown.includedUsd / meter.limitUsd) * 100 + : undefined + } color={meter.exceeded ? "red" : undefined} /> ) : meter.kind === "bucket" ? ( From eb76666d94c5b9d2c70eb0a080d4da429b635c08 Mon Sep 17 00:00:00 2001 From: Peter Kirkham Date: Thu, 16 Jul 2026 15:02:21 +0100 Subject: [PATCH 12/16] 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 13/16] 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 09:57:10 -0400 Subject: [PATCH 14/16] feat(billing): segment the usage bar into included vs spend-limit portions The 1px notch marking where the included allowance ends was barely visible in either theme. Replace it with a physically segmented bar: the $20 included allowance and the org spend limit render as separate gap-divided segments, usage fills the included segment green before pouring into the accent spend-limit segment, and a dot legend names each amount. Exceeded turns both segments and dots red; a $0 spend limit collapses to the included segment alone; the free tier keeps the plain single-track bar. Generated-By: PostHog Code Task-Id: b24f71e2-3751-4a6b-b5d8-a24147c4c067 --- packages/core/src/billing/usageDisplay.ts | 22 ---- .../billing/UsageBillingAnnouncementModal.tsx | 4 - .../ui/src/features/billing/UsageButton.tsx | 3 - .../features/billing/UsageMeter.stories.tsx | 36 ++++-- .../ui/src/features/billing/UsageMeter.tsx | 104 ++++++++++++++++-- .../settings/sections/PlanUsageSettings.tsx | 17 +-- 6 files changed, 121 insertions(+), 65 deletions(-) diff --git a/packages/core/src/billing/usageDisplay.ts b/packages/core/src/billing/usageDisplay.ts index 3d32bf3f62..1207cf9d91 100644 --- a/packages/core/src/billing/usageDisplay.ts +++ b/packages/core/src/billing/usageDisplay.ts @@ -1,14 +1,5 @@ import type { UsageBucket, UsageOutput } from "../usage/schemas"; -/** - * The monthly usage allowance included for every org under Code usage - * billing — the "first $20 each month is included" from the billing product - * config. The gateway has no field for it: a subscribed org's - * `ai_credits.limit_usd` arrives as one merged number (allowance + configured - * spend limit), so display code peels the allowance back off with this - * constant. If billing ever changes the allowance or starts sending a - * breakdown, this constant (and the copy quoting $20) is the seam. - */ export const CODE_INCLUDED_USAGE_USD = 20; /** Confirmed free tier only — an absent `code_usage_subscribed` is unknown, never free. */ @@ -18,14 +9,6 @@ export function isCodeUsageFreeTier( return usage?.code_usage_subscribed === false; } -/** - * The spend limit the org actually configured in billing settings ($50 by - * default), recovered from the merged `limit_usd`. Null when it can't be - * recovered: unconfirmed/free orgs (their limit IS the allowance), missing - * numbers, or a merged limit below the allowance. Zero is a real answer — a - * subscribed org whose merged limit equals the allowance set its spend limit - * to $0. - */ export function codeOrgSpendLimitUsd( usage: | Pick @@ -35,7 +18,6 @@ export function codeOrgSpendLimitUsd( if (usage?.code_usage_subscribed !== true) return null; const limitUsd = usage.ai_credits?.limit_usd; if (limitUsd == null || limitUsd < CODE_INCLUDED_USAGE_USD) return null; - // Round away float dust — the wire amounts are already cent-rounded. return Math.round((limitUsd - CODE_INCLUDED_USAGE_USD) * 100) / 100; } @@ -52,9 +34,6 @@ export type CodeUsageMeter = percent: number; exceeded: boolean; resetAt: string; - // How the merged limit decomposes for a subscribed org, so the meter - // can explain "$70" as $20 included + $50 spend limit. Null when the - // split is unknown (free tier, or a limit below the allowance). breakdown: CodeUsageBreakdown | null; } | { kind: "bucket"; bucket: UsageBucket } @@ -97,7 +76,6 @@ export function formatUsdAmount(amount: number): string { return Number.isInteger(amount) ? `$${amount}` : `$${amount.toFixed(2)}`; } -/** One shared phrasing for the merged-limit explanation, e.g. "$20 included + $50 org spend limit". */ export function formatUsageBreakdown(breakdown: CodeUsageBreakdown): string { return `${formatUsdAmount(breakdown.includedUsd)} included + ${formatUsdAmount(breakdown.spendLimitUsd)} org spend limit`; } diff --git a/packages/ui/src/features/billing/UsageBillingAnnouncementModal.tsx b/packages/ui/src/features/billing/UsageBillingAnnouncementModal.tsx index 5982865af2..984ebee993 100644 --- a/packages/ui/src/features/billing/UsageBillingAnnouncementModal.tsx +++ b/packages/ui/src/features/billing/UsageBillingAnnouncementModal.tsx @@ -25,10 +25,6 @@ export function UsageBillingAnnouncementModal() { const cloudRegion = useAuthStateValue((state) => state.cloudRegion); const { usage } = useUsage({ enabled: isOpen }); - // A subscribed org's limit_usd merges the included $20 with the configured - // spend limit — quote the recovered spend limit, the number the org set. - // Free orgs (whose limit_usd is just the first bullet's $20) get the - // default-limit line instead. const spendLimitUsd = codeOrgSpendLimitUsd(usage); useEffect(() => { diff --git a/packages/ui/src/features/billing/UsageButton.tsx b/packages/ui/src/features/billing/UsageButton.tsx index 893a461d75..f6fce7457e 100644 --- a/packages/ui/src/features/billing/UsageButton.tsx +++ b/packages/ui/src/features/billing/UsageButton.tsx @@ -71,9 +71,6 @@ export function UsageButton() { const resetLabel = formatResetTime( meter.kind === "dollars" ? meter.resetAt : meter.bucket.reset_at, ); - // The subscribed meter's limit is a merged number (included allowance + - // configured spend limit); without the split, "$70" matches nothing the - // user ever set. const breakdownLabel = meter.kind === "dollars" && meter.breakdown ? formatUsageBreakdown(meter.breakdown) diff --git a/packages/ui/src/features/billing/UsageMeter.stories.tsx b/packages/ui/src/features/billing/UsageMeter.stories.tsx index 0d37009bea..290d0fb41d 100644 --- a/packages/ui/src/features/billing/UsageMeter.stories.tsx +++ b/packages/ui/src/features/billing/UsageMeter.stories.tsx @@ -16,29 +16,46 @@ const meta: Meta = { export default meta; type Story = StoryObj; -// A subscribed org on default settings: the $70 limit is $20 included -// allowance + the $50 default spend limit, spelled out in the detail line -// with the notch marking where the included allowance ends. +// A subscribed org on default settings: the $70 limit renders as two +// segments — the $20 included allowance (green) and the $50 default spend +// limit (accent) — with a dot legend naming each amount. Usage still inside +// the included allowance only fills the green segment. export const SubscribedWithBreakdown: Story = { args: { label: "Usage this period", percent: 18, valueLabel: "$12.40 of $70", - detail: "$20 included + $50 org spend limit. Resets Jul 31 at 2:00 PM PDT", - markerPercent: (20 / 70) * 100, + detail: "Resets Jul 31 at 2:00 PM PDT", + breakdown: { includedUsd: 20, spendLimitUsd: 50, usedUsd: 12.4 }, }, }; +// Past the allowance: the green segment is full and billable usage fills the +// accent segment. export const SubscribedPastIncluded: Story = { args: { label: "Usage this period", percent: 66, valueLabel: "$46.20 of $70", - detail: "$20 included + $50 org spend limit. Resets Jul 31 at 2:00 PM PDT", - markerPercent: (20 / 70) * 100, + detail: "Resets Jul 31 at 2:00 PM PDT", + breakdown: { includedUsd: 20, spendLimitUsd: 50, usedUsd: 46.2 }, + }, +}; + +// A subscribed org that set its spend limit to $0: only the included segment +// (and its legend entry) renders. +export const ZeroSpendLimit: Story = { + args: { + label: "Usage this period", + percent: 65, + valueLabel: "$13 of $20", + detail: "Resets Jul 31 at 2:00 PM PDT", + breakdown: { includedUsd: 20, spendLimitUsd: 0, usedUsd: 13 }, }, }; +// Free tier has no breakdown — its limit IS the allowance, so the plain +// single-track bar renders. export const FreeTier: Story = { args: { label: "Monthly free usage", @@ -53,9 +70,8 @@ export const Exceeded: Story = { label: "Usage this period", percent: 100, valueLabel: "$70 of $70", - detail: - "Limit exceeded. $20 included + $50 org spend limit. Resets Jul 31 at 2:00 PM PDT", - markerPercent: (20 / 70) * 100, + detail: "Limit exceeded. Resets Jul 31 at 2:00 PM PDT", + breakdown: { includedUsd: 20, spendLimitUsd: 50, usedUsd: 70 }, color: "red", }, }; diff --git a/packages/ui/src/features/billing/UsageMeter.tsx b/packages/ui/src/features/billing/UsageMeter.tsx index 43ce470d23..5b708bad12 100644 --- a/packages/ui/src/features/billing/UsageMeter.tsx +++ b/packages/ui/src/features/billing/UsageMeter.tsx @@ -1,27 +1,34 @@ +import { formatUsdAmount } from "@posthog/core/billing/usageDisplay"; +import { cn } from "@posthog/quill"; import { Flex, Progress, Text } from "@radix-ui/themes"; +interface UsageMeterBreakdown { + includedUsd: number; + spendLimitUsd: number; + usedUsd: number; +} + interface UsageMeterProps { label: string; percent: number; valueLabel: string; detail: string; color?: "red"; - // Where a boundary inside the limit falls (e.g. the end of the included - // allowance), as a percent of the bar. Rendered as a notch over the track. - markerPercent?: number; + breakdown?: UsageMeterBreakdown; } +const clampPercent = (value: number): number => + Math.min(100, Math.max(0, value)); + export function UsageMeter({ label, percent, valueLabel, detail, color, - markerPercent, + breakdown, }: UsageMeterProps) { const borderColor = color === "red" ? "var(--red-7)" : "var(--gray-5)"; - const showMarker = - markerPercent != null && markerPercent > 0 && markerPercent < 100; return ( {label} {valueLabel} -
+ {breakdown ? ( + + ) : ( - {showMarker && ( + )} + {detail} + + ); +} + +function SegmentedUsageBar({ + percent, + breakdown, + exceeded, +}: { + percent: number; + breakdown: UsageMeterBreakdown; + exceeded: boolean; +}) { + const { includedUsd, spendLimitUsd, usedUsd } = breakdown; + const totalUsd = includedUsd + spendLimitUsd; + const hasPaidSegment = spendLimitUsd > 0 && totalUsd > 0; + const includedFill = + includedUsd > 0 ? clampPercent((usedUsd / includedUsd) * 100) : 100; + const paidFill = hasPaidSegment + ? clampPercent(((usedUsd - includedUsd) / spendLimitUsd) * 100) + : 0; + const includedDot = exceeded ? "bg-(--red-9)" : "bg-(--green-9)"; + const paidDot = exceeded ? "bg-(--red-9)" : "bg-(--accent-9)"; + + return ( + +
+
+
+ {hasPaidSegment && ( +
+
+
)}
- {detail} + + + + + {formatUsdAmount(includedUsd)} included + + + {hasPaidSegment && ( + + + + {formatUsdAmount(spendLimitUsd)} org spend limit + + + )} + ); } diff --git a/packages/ui/src/features/settings/sections/PlanUsageSettings.tsx b/packages/ui/src/features/settings/sections/PlanUsageSettings.tsx index 014d7595cf..e794e5afd1 100644 --- a/packages/ui/src/features/settings/sections/PlanUsageSettings.tsx +++ b/packages/ui/src/features/settings/sections/PlanUsageSettings.tsx @@ -6,7 +6,6 @@ import { import { codeUsageMeter, formatResetTime, - formatUsageBreakdown, formatUsdAmount, isCodeUsageFreeTier, } from "@posthog/core/billing/usageDisplay"; @@ -179,20 +178,10 @@ export function PlanUsageSettings() { label={freeTier ? "Monthly free usage" : "Usage this period"} percent={meter.percent} valueLabel={`${formatUsdAmount(meter.usedUsd)} of ${formatUsdAmount(meter.limitUsd)}${freeTier ? " included" : ""}`} - detail={[ - meter.exceeded ? "Limit exceeded." : null, - // Spell out how the merged limit is composed — "$70" on its - // own reads as a mystery number to an org that set $50. + detail={`${meter.exceeded ? "Limit exceeded. " : ""}${formatResetTime(meter.resetAt)}`} + breakdown={ meter.breakdown - ? `${formatUsageBreakdown(meter.breakdown)}.` - : null, - formatResetTime(meter.resetAt), - ] - .filter(Boolean) - .join(" ")} - markerPercent={ - meter.breakdown - ? (meter.breakdown.includedUsd / meter.limitUsd) * 100 + ? { ...meter.breakdown, usedUsd: meter.usedUsd } : undefined } color={meter.exceeded ? "red" : undefined} From 2a85d33bf1bcd7edc48293219ea0880a65b3efa8 Mon Sep 17 00:00:00 2001 From: "posthog[bot]" <206114724+posthog[bot]@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:28:39 +0000 Subject: [PATCH 15/16] chore(visual): update storybook baselines 10 updated Run: 31ffb0f9-8ac6-42a7-9d0d-21c01935161b Co-authored-by: adboio <23323033+adboio@users.noreply.github.com> --- apps/code/snapshots.yml | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/apps/code/snapshots.yml b/apps/code/snapshots.yml index f006547cc7..cfbdd290b1 100644 --- a/apps/code/snapshots.yml +++ b/apps/code/snapshots.yml @@ -96,6 +96,26 @@ snapshots: hash: v1.k4693efd2.b76d39ed8142d4449cb5befcde538bb11a6bffcbed66e81bdc3aa8e2746b7e06.GSIfBsgE-3RYuqlLUpluZ87C6-KjBUaTj2eu_a5elZ8 autoresearch-runtime-stats--with-context-usage--light: hash: v1.k4693efd2.18889b5eda942cd19b0f7be00a87925d632529ca4111fac180d6339f7d34d1ca.X3s9IXrx2-TdQaJEjf1td3hwvNzeIp3Xq3uQh9Gwt5A + billing-usagemeter--exceeded--dark: + hash: v1.k4693efd2.dc026c5cace4e44c4cc713b95654e278d7bf0439a4862e9ceea39a033974815d.m5Be4dAOwwmt-UlEnhbhrKiym1rlGUiGZ4oyUonUoVg + billing-usagemeter--exceeded--light: + hash: v1.k4693efd2.850bf1e451a18981d9e3be653837dd282c6be289ac9c0826ff6a981ac67e1e38.1GwpfJ_4dUlMO7TXp8AOM5YmJuItGGB50ihKkkeIFgg + billing-usagemeter--free-tier--dark: + hash: v1.k4693efd2.164d54cac22cfa05dbbf25fae1a62e77c496b1981dcaff6b8e1ab07fc802f5e0.Gh75uI1vHbGWAgKun41BYoX6LZBYX_FVI7Qu-rFlDV4 + billing-usagemeter--free-tier--light: + hash: v1.k4693efd2.5e220fe66fac4c54b4cb9f5ac6ec1054e0b709374fee2c58fdba782f5b2888df.W5la88YA6uOSc1tg30raKaRy12plPGxXl5o7uowL2gc + billing-usagemeter--subscribed-past-included--dark: + hash: v1.k4693efd2.ac987b7184aaa4f8895d9b75c6b7e1177efcb9a21cca0668caf533ee0d5eaab3.2Mq2GLXV83ebM8LKhTQY8csv6BeskWv5yPu8NSAQ20Y + billing-usagemeter--subscribed-past-included--light: + hash: v1.k4693efd2.0c4a37beaceb37baab57e6da98321b236c20127731fd5eaeb61677de4580c204.fZ2eDGlkEijZjHQyatiHtP39EFYT6EO1Vn_f8Tz7b0E + billing-usagemeter--subscribed-with-breakdown--dark: + hash: v1.k4693efd2.f74f8f76e51ee14e427423e1757859e3f001b13e6a43d1e7310b5b0cbbb570b5.eMwK2csA86oBarnAya99W87MfP6Z6PMfdjwbCmnPrw4 + billing-usagemeter--subscribed-with-breakdown--light: + hash: v1.k4693efd2.1335dba8bf16980c987882641619e114584343ec50bb69b3c01e63507a13756b._v7_6tsVMmW3WixSeFN-puHQVeIxf9AAGxFZ8Ni97YQ + billing-usagemeter--zero-spend-limit--dark: + hash: v1.k4693efd2.1d199b2c4bba8034cb18fb5b49866b64a6eb3311591add5f58ec288710dce2e9.FCc9egSaO1Onih417yciSYKNROe8zqDZyyS6xuXCZGw + billing-usagemeter--zero-spend-limit--light: + hash: v1.k4693efd2.b50c18736bbde45fc89f4c0ac7fd616286e5c1ecc2a56910fffca13eaa13d8a2.31z4F0JmvMeR4yqk5pjs_OQ3MypnM2miZI9al4wuc8c components-permissions-permissionselector--create-new-file--dark: hash: v1.k4693efd2.c54203a4e636b83b3d24d7ed9c4ace8659db87cd8f231d9dc2ecc03320e31646.epDm7LebiLzlp0uuZBrE-Obt_anAn0xsE8bHFnm5vos components-permissions-permissionselector--create-new-file--light: From b6faccc979dc62f2a9ff0a69a643e85a74bdb5d6 Mon Sep 17 00:00:00 2001 From: Peter Kirkham Date: Thu, 16 Jul 2026 15:34:03 +0100 Subject: [PATCH 16/16] 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(