From 20c51fe263749445aff9807cd0a1a2a6c01774b3 Mon Sep 17 00:00:00 2001 From: Shy Alter Date: Mon, 27 Jul 2026 20:44:32 +0200 Subject: [PATCH 1/4] refactor(canvas): share thread and PR artifact logic Extract the task thread conversation behavior and PR artifact presentation used by existing channel surfaces. This is behavior-neutral groundwork for the structured Activity view. Generated-By: PostHog Code Task-Id: 6190e713-9b80-43d9-a05c-5e3b1ecdf297 --- .../canvas/components/ThreadPanel.tsx | 250 ++++-------------- .../components/WebsiteChannelArtifacts.tsx | 52 ++-- .../canvas/hooks/useThreadConversation.ts | 213 +++++++++++++++ .../features/git-interaction/usePrArtifact.ts | 38 +++ packages/ui/src/utils/posthogLinks.ts | 14 + 5 files changed, 333 insertions(+), 234 deletions(-) create mode 100644 packages/ui/src/features/canvas/hooks/useThreadConversation.ts create mode 100644 packages/ui/src/features/git-interaction/usePrArtifact.ts diff --git a/packages/ui/src/features/canvas/components/ThreadPanel.tsx b/packages/ui/src/features/canvas/components/ThreadPanel.tsx index 8b242509ee..6e54d9e7f8 100644 --- a/packages/ui/src/features/canvas/components/ThreadPanel.tsx +++ b/packages/ui/src/features/canvas/components/ThreadPanel.tsx @@ -7,19 +7,11 @@ import { TrashIcon, XIcon, } from "@phosphor-icons/react"; -import { - buildThreadTimeline, - deriveThreadAgentStatus, - hasAgentMention, - shouldSuspendThreadSession, - type ThreadAgentStatus, - type ThreadArtifact, - type ThreadTimelineRow, +import type { + ThreadAgentStatus, + ThreadArtifact, + ThreadTimelineRow, } from "@posthog/core/canvas/threadTimeline"; -import { - getPrVisualConfig, - parsePrNumber, -} from "@posthog/core/git-interaction/prStatus"; import { Avatar, AvatarFallback, @@ -47,44 +39,27 @@ import { ThreadItemGutter, ThreadItemHeader, } from "@posthog/quill"; -import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; import type { Task, TaskThreadMessage, UserBasic, } from "@posthog/shared/domain-types"; -import { isTerminalStatus } from "@posthog/shared/domain-types"; -import { useOptionalAuthenticatedClient } from "@posthog/ui/features/auth/authClient"; import { UserAvatar } from "@posthog/ui/features/auth/UserAvatar"; -import { useCurrentUser } from "@posthog/ui/features/auth/useCurrentUser"; import { TaskCard } from "@posthog/ui/features/canvas/components/ChannelFeedView"; import { iconForTemplate } from "@posthog/ui/features/canvas/components/canvasTemplateIcon"; 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 { - useDeleteTaskThreadMessage, - usePostTaskThreadMessage, - usePostTaskThreadMessageToAgent, - useSendTaskThreadMessageToAgent, - useTaskThread, -} from "@posthog/ui/features/canvas/hooks/useTaskThread"; +import { useThreadConversation } from "@posthog/ui/features/canvas/hooks/useThreadConversation"; import { userDisplayName } from "@posthog/ui/features/canvas/utils/userDisplay"; -import { getPrVisualIcon } from "@posthog/ui/features/git-interaction/prIcon"; -import { usePrDetails } from "@posthog/ui/features/git-interaction/usePrDetails"; -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 { usePrArtifact } from "@posthog/ui/features/git-interaction/usePrArtifact"; import { taskDetailQuery } from "@posthog/ui/features/tasks/queries"; -import { toast } from "@posthog/ui/primitives/toast"; -import { track } from "@posthog/ui/shell/analytics"; import { openExternalUrl } from "@posthog/ui/shell/openExternal"; -import { parseShareLink } from "@posthog/ui/utils/posthogLinks"; +import { parseHttpsUrl, parseShareLink } from "@posthog/ui/utils/posthogLinks"; import { navigateToShareTarget } from "@posthog/ui/utils/shareLinks"; import { getPostHogUrl } from "@posthog/ui/utils/urls"; import { useQuery } from "@tanstack/react-query"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useRef } from "react"; export function ThreadMessageRow({ message, @@ -215,15 +190,6 @@ function ArtifactCardButton({ ); } -function parseHttpsUrl(url: string): URL | null { - try { - const parsedUrl = new URL(url); - return parsedUrl.protocol === "https:" ? parsedUrl : null; - } catch { - return null; - } -} - function CanvasArtifactCard({ name, url, @@ -257,28 +223,19 @@ function CanvasArtifactCard({ } function PrArtifactCard({ url }: { url: string }) { - const parsedUrl = parseHttpsUrl(url); - const safeUrl = - parsedUrl?.origin === "https://github.com" ? parsedUrl.href : null; - const { - meta: { state, merged, draft }, - } = usePrDetails(safeUrl); - const config = getPrVisualConfig(state ?? "open", merged, draft); - const PrIcon = getPrVisualIcon(config.icon); - const prNumber = safeUrl ? parsePrNumber(safeUrl) : null; + const { safeUrl, title, stateLabel, Icon, iconColor } = usePrArtifact(url); return ( } - title={prNumber ? `Pull request #${prNumber}` : "Pull request"} - // Only show the resolved state once we have it, to avoid a flash of "Open". - detail={state ? config.label : null} + title={title} + detail={stateLabel} onOpen={safeUrl ? () => openExternalUrl(safeUrl) : undefined} /> ); @@ -319,7 +276,7 @@ export function ThreadArtifactRow({ ); } -function ThreadLoadingState() { +export function ThreadLoadingState() { return ( @@ -332,11 +289,15 @@ function ThreadLoadingState() { ); } -function ThreadHeader({ +/** The panel's title row and window controls. Shared with ActivityPanel, which + * is the same chrome under a different title. */ +export function ThreadPanelHeader({ + title, onClose, onToggleCollapsed, onOpenFull, }: { + title: string; onClose?: () => void; onToggleCollapsed?: () => void; onOpenFull?: () => void; @@ -344,7 +305,7 @@ function ThreadHeader({ return (
- Thread + {title}
{onOpenFull && (
); diff --git a/packages/ui/src/features/canvas/components/WebsiteChannelArtifacts.tsx b/packages/ui/src/features/canvas/components/WebsiteChannelArtifacts.tsx index d91d91d5b4..b55529a42a 100644 --- a/packages/ui/src/features/canvas/components/WebsiteChannelArtifacts.tsx +++ b/packages/ui/src/features/canvas/components/WebsiteChannelArtifacts.tsx @@ -1,10 +1,6 @@ import { CaretRightIcon } from "@phosphor-icons/react"; import type { ChannelTaskRecord } from "@posthog/core/canvas/channelTaskSchemas"; import type { DashboardSummary } from "@posthog/core/canvas/dashboardSchemas"; -import { - getPrVisualConfig, - parsePrNumber, -} from "@posthog/core/git-interaction/prStatus"; import { formatRelativeTimeShort } from "@posthog/shared"; import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; import { useArchivedTaskIds } from "@posthog/ui/features/archive/useArchivedTaskIds"; @@ -12,8 +8,7 @@ import { ChannelHeader } from "@posthog/ui/features/canvas/components/ChannelHea import { iconForTemplate } from "@posthog/ui/features/canvas/components/canvasTemplateIcon"; import { useChannelTasks } from "@posthog/ui/features/canvas/hooks/useChannelTasks"; import { useDashboards } from "@posthog/ui/features/canvas/hooks/useDashboards"; -import { getPrVisualIcon } from "@posthog/ui/features/git-interaction/prIcon"; -import { usePrDetails } from "@posthog/ui/features/git-interaction/usePrDetails"; +import { usePrArtifact } from "@posthog/ui/features/git-interaction/usePrArtifact"; import { useTasks } from "@posthog/ui/features/tasks/useTasks"; import { useSetHeaderContent } from "@posthog/ui/hooks/useSetHeaderContent"; import { track } from "@posthog/ui/shell/analytics"; @@ -159,7 +154,7 @@ export function WebsiteChannelArtifacts({ channelId }: { channelId: string }) { title={item.title} prUrl={item.prUrl} ts={item.ts} - onClick={() => openPr(item.prUrl)} + onClick={openPr} /> ), )} @@ -171,8 +166,8 @@ export function WebsiteChannelArtifacts({ channelId }: { channelId: string }) { } // A PR artifact row. The PR's lifecycle state (open / draft / merged / closed) -// is fetched per-URL (deduped + cached by usePrDetails) so the icon and label -// reflect the live state. +// comes from usePrArtifact, which also gates the URL — PR links come from run +// output, so a row must not fetch from whatever host that names. function PrArtifactRow({ title, prUrl, @@ -182,37 +177,28 @@ function PrArtifactRow({ title: string; prUrl: string; ts: number; - onClick: () => void; + onClick: (safeUrl: string) => void; }) { const { - meta: { state, merged, draft }, - } = usePrDetails(prUrl); - const config = getPrVisualConfig(state ?? "open", merged, draft); - const PrIcon = getPrVisualIcon(config.icon); - const prNumber = parsePrNumber(prUrl); + safeUrl, + title: prTitle, + stateLabel, + Icon, + iconColor, + accentColor, + } = usePrArtifact(prUrl); - const subtitle = [ - prNumber ? `Pull request #${prNumber}` : "Pull request", - // Only show the resolved state once we have it, to avoid a flash of "Open". - state ? config.label : null, - formatRelativeTimeShort(ts), - ] + const subtitle = [prTitle, stateLabel, formatRelativeTimeShort(ts)] .filter(Boolean) .join(" · "); return ( - } + accent={accentColor} + icon={} title={title} subtitle={subtitle} - onClick={onClick} + onClick={safeUrl ? () => onClick(safeUrl) : undefined} /> ); } @@ -228,13 +214,15 @@ function ArtifactRow({ accent: string; title: string; subtitle: string; - onClick: () => void; + /** Absent for a row with nowhere safe to go — a non-github PR link. */ + onClick?: () => void; }) { return ( - ), + TaskInput: (props: { onContextChipClick?: () => void }) => { + taskInputProps(props); + return ( + + ); + }, })); vi.mock("@posthog/ui/features/canvas/hooks/useChannels", () => ({ @@ -39,6 +43,11 @@ vi.mock("@posthog/ui/features/canvas/hooks/useChannels", () => ({ vi.mock("@posthog/ui/features/canvas/hooks/useChannelTasks", () => ({ useChannelTaskMutations: () => ({ fileTask: vi.fn() }), })); +vi.mock("@posthog/ui/features/canvas/hooks/useTaskChannels", () => ({ + useBackendChannel: () => ({ + channel: { id: "backend-channel-1", name: "project-bluebird" }, + }), +})); vi.mock("@posthog/ui/features/canvas/hooks/useFolderInstructions", () => ({ useFolderInstructions, })); @@ -77,6 +86,19 @@ describe("WebsiteNewTask context panel", () => { beforeEach(() => { track.mockReset(); useFolderInstructions.mockReset(); + taskInputProps.mockReset(); + }); + + it("creates the task in the channel's backend feed", () => { + useFolderInstructions.mockReturnValue({ data: undefined }); + renderNewTask(); + + expect(taskInputProps).toHaveBeenLastCalledWith( + expect.objectContaining({ + channelId: "backend-channel-1", + channelContextId: "chan-1", + }), + ); }); it("opens the context panel and tracks view_context when the chip is clicked", async () => { diff --git a/packages/ui/src/features/canvas/components/WebsiteNewTask.tsx b/packages/ui/src/features/canvas/components/WebsiteNewTask.tsx index 5f1009927f..8e17239f1f 100644 --- a/packages/ui/src/features/canvas/components/WebsiteNewTask.tsx +++ b/packages/ui/src/features/canvas/components/WebsiteNewTask.tsx @@ -6,6 +6,7 @@ import { ChannelContextPanel } from "@posthog/ui/features/canvas/components/Chan import { useChannels } from "@posthog/ui/features/canvas/hooks/useChannels"; import { useChannelTaskMutations } from "@posthog/ui/features/canvas/hooks/useChannelTasks"; import { useFolderInstructions } from "@posthog/ui/features/canvas/hooks/useFolderInstructions"; +import { useBackendChannel } from "@posthog/ui/features/canvas/hooks/useTaskChannels"; import { TaskInput } from "@posthog/ui/features/task-detail/components/TaskInput"; import { taskDetailQuery } from "@posthog/ui/features/tasks/queries"; import { useSetHeaderContent } from "@posthog/ui/hooks/useSetHeaderContent"; @@ -29,6 +30,7 @@ export function WebsiteNewTask({ channelId }: { channelId: string }) { const { fileTask } = useChannelTaskMutations(); const { channels } = useChannels(); const channelName = channels.find((c) => c.id === channelId)?.name; + const { channel: backendChannel } = useBackendChannel(channelName); // Surface the channel breadcrumb in the shared header, same as the other // channel scenes ("# channel / New task"). @@ -112,6 +114,7 @@ export function WebsiteNewTask({ channelId }: { channelId: string }) { onTaskCreated={onTaskCreated} channelContext={channelContext} channelName={channelName} + channelId={backendChannel?.id} channelContextId={channelId} allowNoRepo // So a prompt handed to openTaskInput survives routing into a channel. diff --git a/packages/ui/src/features/task-detail/components/TaskInput.tsx b/packages/ui/src/features/task-detail/components/TaskInput.tsx index 34626ee630..14fc8a1377 100644 --- a/packages/ui/src/features/task-detail/components/TaskInput.tsx +++ b/packages/ui/src/features/task-detail/components/TaskInput.tsx @@ -110,6 +110,8 @@ interface TaskInputProps { channelContext?: string; /** Display name of the channel the CONTEXT.md came from (for the chip). */ channelName?: string; + /** Backend channel UUID that owns the created task and feed entry. */ + channelId?: string; /** * Desktop file-system folder id that owns the channel's CONTEXT.md. When set, * the injected context lets the agent publish upkeep corrections addressed to @@ -154,6 +156,7 @@ export function TaskInput({ reportAssociation, channelContext, channelName, + channelId, channelContextId, allowNoRepo, suggestions, @@ -887,6 +890,7 @@ export function TaskInput({ signalReportId: activeReportAssociation?.reportId, channelContext: includeChannelContext ? channelContext : undefined, channelName, + channelId, channelContextId, allowNoRepo, });