From b35e91800cf178d434332a979ddd371d365d10b6 Mon Sep 17 00:00:00 2001 From: Peter Kirkham Date: Wed, 15 Jul 2026 16:28:23 +0100 Subject: [PATCH 1/5] 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 2/5] 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 3/5] fix(channels): satisfy thread scroll dependencies Drive the thread auto-scroll effect from the memoized timeline and agent phase so React Doctor sees complete dependencies without maintaining partial message fields. Generated-By: PostHog Code Task-Id: 87a79e23-20c4-440b-818b-28154924ffbc --- .../ui/src/features/canvas/components/ThreadPanel.tsx | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/packages/ui/src/features/canvas/components/ThreadPanel.tsx b/packages/ui/src/features/canvas/components/ThreadPanel.tsx index 6826c6577e..e872045efc 100644 --- a/packages/ui/src/features/canvas/components/ThreadPanel.tsx +++ b/packages/ui/src/features/canvas/components/ThreadPanel.tsx @@ -425,16 +425,10 @@ function ThreadConversation({ [taskId], ); - // biome-ignore lint/correctness/useExhaustiveDependencies: scroll on new content + // biome-ignore lint/correctness/useExhaustiveDependencies: scroll when rendered thread content changes useEffect(() => { scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight }); - }, [ - messages.length, - promptMsgs.length, - agentMsgs.length, - agentMsgs[agentMsgs.length - 1]?.text, - agentStatus?.phase, - ]); + }, [timeline, agentStatus?.phase]); const isTaskAuthor = !!currentUser?.uuid && currentUser.uuid === task.created_by?.uuid; From 7ffce8a6feb3673c7dff6ccd3d641a2cdb3b0431 Mon Sep 17 00:00:00 2001 From: Peter Kirkham Date: Thu, 16 Jul 2026 11:50:35 +0100 Subject: [PATCH 4/5] 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 5/5] 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[] = [];