From 3d51286c8779898c28f082b4d75522e45414d9fe Mon Sep 17 00:00:00 2001 From: Raquel Smith Date: Tue, 21 Jul 2026 17:08:37 -0700 Subject: [PATCH] feat(canvas): keep task threads human-only Stop mirroring the agent's live conversation into the task thread panel. Threads now show human discussion only; the agent's turns, prompt echoes, and status line no longer post into the thread. Removes the live-session wiring in the panel that also pinned an agent process alive purely to feed the thread. The human @agent forward action is unchanged. Generated-By: PostHog Code Task-Id: 8261c40f-2fbf-4334-a4e7-e61bf1c332fc --- .../core/src/canvas/threadTimeline.test.ts | 149 ++--------- packages/core/src/canvas/threadTimeline.ts | 122 +-------- .../canvas/components/ThreadPanel.test.tsx | 80 +----- .../canvas/components/ThreadPanel.tsx | 245 ++---------------- .../canvas/components/threadAgentTurns.ts | 33 --- 5 files changed, 46 insertions(+), 583 deletions(-) delete mode 100644 packages/ui/src/features/canvas/components/threadAgentTurns.ts diff --git a/packages/core/src/canvas/threadTimeline.test.ts b/packages/core/src/canvas/threadTimeline.test.ts index a3b3b355f3..593a699adb 100644 --- a/packages/core/src/canvas/threadTimeline.test.ts +++ b/packages/core/src/canvas/threadTimeline.test.ts @@ -1,11 +1,5 @@ import { describe, expect, it } from "vitest"; -import { - buildThreadTimeline, - deriveThreadAgentStatus, - hasAgentMention, - normalizeAgentPromptText, - shouldSuspendThreadSession, -} from "./threadTimeline"; +import { buildThreadTimeline, hasAgentMention } from "./threadTimeline"; describe("hasAgentMention", () => { it.each([ @@ -19,151 +13,38 @@ 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("omits the session echo of a forwarded thread message", () => { + it("orders human messages chronologically", () => { 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, + id: "second", + content: "Second", + createdAt: "1970-01-01T00:00:00.200Z", }, - ], - 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, + id: "first", + content: "First", + createdAt: "1970-01-01T00:00:00.100Z", }, ], - humanMessages: [], - agentMessages: [], }); - expect(timeline.map((row) => row.kind)).toEqual(["prompt"]); + expect(timeline.map((row) => row.message.id)).toEqual(["first", "second"]); }); - it("interleaves prompts, human replies, and agent turns chronologically", () => { + it("keeps malformed timestamps at the end", () => { const timeline = buildThreadTimeline({ - prompts: [{ id: "prompt", text: "Start", timestamp: 100 }], humanMessages: [ + { id: "invalid", content: "Reply", createdAt: "invalid" }, { - id: "human", - content: "Reply", - createdAt: "1970-01-01T00:00:00.150Z", + id: "valid", + content: "First", + createdAt: "1970-01-01T00:00:00.100Z", }, ], - 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: "returns no status after work settles", - input: { hasActivity: true }, - expected: null, - }, - ])("$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); + expect(timeline.map((row) => row.message.id)).toEqual(["valid", "invalid"]); }); }); diff --git a/packages/core/src/canvas/threadTimeline.ts b/packages/core/src/canvas/threadTimeline.ts index a440d4e08d..52a857a408 100644 --- a/packages/core/src/canvas/threadTimeline.ts +++ b/packages/core/src/canvas/threadTimeline.ts @@ -1,9 +1,3 @@ -export interface ThreadAgentMessage { - id: string; - text: string; - timestamp?: number; -} - export interface ThreadHumanMessage { id: string; content: string; @@ -12,16 +6,11 @@ export interface ThreadHumanMessage { 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; -} +export type ThreadTimelineRow = { + kind: "human"; + timestamp: number; + message: ThreadHumanMessage; +}; function parsedTimestamp(timestamp: string): number { const parsed = Date.parse(timestamp); @@ -29,116 +18,23 @@ function parsedTimestamp(timestamp: string): number { } export function buildThreadTimeline({ - prompts, - agentMessages, humanMessages, }: { - prompts: ThreadAgentMessage[]; - 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 [ - ...visiblePrompts.map( - (message): ThreadTimelineRow => ({ - kind: "prompt", - timestamp: validTimestamp(message.timestamp), - message, - }), - ), - ...humanMessages.map( + return 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" | "error"; - -export interface ThreadAgentStatus { - phase: ThreadAgentPhase; - label: string; + ) + .sort((left, right) => left.timestamp - right.timestamp); } 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(); -} - -function isThreadCommentPrompt(content: string): boolean { - return THREAD_COMMENT_ATTRIBUTION_PATTERN.test(content.trim()); -} - -export function deriveThreadAgentStatus({ - hasActivity = false, - hasError = false, - cloudStatus, - errorTitle, - pendingPermissionCount = 0, - isPromptPending = false, - isInitializing = false, -}: { - hasActivity?: boolean; - hasError?: boolean; - cloudStatus?: string | null; - errorTitle?: string | null; - pendingPermissionCount?: number; - isPromptPending?: boolean; - isInitializing?: 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 null; -} - -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/ThreadPanel.test.tsx b/packages/ui/src/features/canvas/components/ThreadPanel.test.tsx index 89aca9895a..28ebd31887 100644 --- a/packages/ui/src/features/canvas/components/ThreadPanel.test.tsx +++ b/packages/ui/src/features/canvas/components/ThreadPanel.test.tsx @@ -1,53 +1,6 @@ -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, - ThreadMessageRow, - 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", () => { - render(); - - const status = screen.getByText("Working…"); - - expect(status.closest("article")).toBeNull(); - expect(status.closest('[data-slot="thread-item-body"]')).toBeNull(); - expect(status.closest("output")).not.toBeNull(); - }); -}); +import { ThreadMessageRow } from "./ThreadPanel"; describe("ThreadMessageRow", () => { it("renders backend-authored agent announcements as Agent", () => { @@ -126,34 +79,3 @@ describe("ThreadMessageRow", () => { ).toBeInTheDocument(); }); }); - -describe("UserPromptRow", () => { - it("prefixes direct task prompts with @agent", () => { - render( - , - ); - - 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 fba801b8f1..a11cd0e230 100644 --- a/packages/ui/src/features/canvas/components/ThreadPanel.tsx +++ b/packages/ui/src/features/canvas/components/ThreadPanel.tsx @@ -9,12 +9,7 @@ import { } from "@phosphor-icons/react"; import { buildThreadTimeline, - deriveThreadAgentStatus, hasAgentMention, - normalizeAgentPromptText, - shouldSuspendThreadSession, - type ThreadAgentMessage, - type ThreadAgentStatus, type ThreadTimelineRow, } from "@posthog/core/canvas/threadTimeline"; import { @@ -56,12 +51,8 @@ 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, - mentionChipClass, -} from "@posthog/ui/features/canvas/components/MentionText"; +import { MentionText } 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, @@ -71,16 +62,6 @@ import { useTaskThread, } 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"; @@ -184,109 +165,6 @@ export function ThreadMessageRow({ ); } -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; -} - -export function AgentStatusLine({ status }: { status: ThreadAgentStatus }) { - return ( - - {status.phase === "active" ? ( - - ) : ( - - )} - {status.label} - - ); -} - -export function AgentTurnRow({ - message, - streaming, -}: { - message: ThreadAgentMessage; - streaming: boolean; -}) { - return ( - - - - - - - - - - - Agent - {message.timestamp !== undefined && ( - - )} - - {message.text && ( - -
- {streaming ? ( - - ) : ( - - )} -
-
- )} -
-
- ); -} - -export function UserPromptRow({ - message, - author, -}: { - message: ThreadAgentMessage; - author: TaskThreadMessage["author"]; -}) { - const promptText = normalizeAgentPromptText(message.text); - - return ( - - - - {getUserInitials(author)} - - - - - {userDisplayName(author)} - {message.timestamp !== undefined && ( - - )} - - - @agent {promptText} - - - - ); -} - function ThreadLoadingState() { return ( @@ -351,25 +229,19 @@ function ThreadHeader({ 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; }) { @@ -383,9 +255,8 @@ function ThreadTimeline({ 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. + Discuss this task with your team. Messages stay between humans + unless the task author sends one to the agent. @@ -394,35 +265,21 @@ function ThreadTimeline({ return ( - {timeline.map((row) => - row.kind === "prompt" ? ( - - ) : row.kind === "human" ? ( - onSendToAgent(row.message.id)} - onDelete={() => onDelete(row.message.id)} - /> - ) : ( - - ), - )} + {timeline.map((row) => ( + onSendToAgent(row.message.id)} + onDelete={() => onDelete(row.message.id)} + /> + ))} ); } @@ -503,62 +360,9 @@ function ThreadConversation({ const isSendingToAgent = isPostingToAgent || isSending; 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 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, - }), - [ - events.length, - task.latest_run, - hasError, - cloudStatus, - errorTitle, - pendingPermissions.size, - isPromptPending, - isInitializing, - ], - ); - const timeline = useMemo( () => buildThreadTimeline({ - prompts: promptMsgs, - agentMessages: agentMsgs, humanMessages: messages.map((message) => ({ id: message.id, content: message.content, @@ -567,11 +371,9 @@ function ThreadConversation({ value: message, })), }), - [promptMsgs, messages, agentMsgs], + [messages], ); - const lastAgentId = agentMsgs[agentMsgs.length - 1]?.id; - const [draft, setDraft] = useState(""); const scrollRef = useRef(null); @@ -590,7 +392,7 @@ function ThreadConversation({ // biome-ignore lint/correctness/useExhaustiveDependencies: scroll when rendered thread content changes useEffect(() => { scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight }); - }, [timeline, agentStatus?.phase]); + }, [timeline]); const isTaskAuthor = !!currentUser?.uuid && currentUser.uuid === task.created_by?.uuid; @@ -649,7 +451,7 @@ function ThreadConversation({ }); }; - const isReady = !isInitializing && !isLoading; + const isReady = !isLoading; return (
@@ -668,20 +470,15 @@ function ThreadConversation({
- {agentStatus && } -