diff --git a/packages/shared/src/analytics-events.ts b/packages/shared/src/analytics-events.ts
index c6b6c42644..99ddc02757 100644
--- a/packages/shared/src/analytics-events.ts
+++ b/packages/shared/src/analytics-events.ts
@@ -872,6 +872,7 @@ export type ChannelsSurface =
| "canvas"
| "context"
| "thread_panel"
+ | "activity_panel"
| "activity";
export type ChannelActionType =
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 (
["events"];
+
+export interface ThreadConversation {
+ timeline: ThreadTimelineRow[];
+ agentStatus: ThreadAgentStatus | null;
+ events: SessionEvents;
+ isPromptPending: boolean;
+ isReady: boolean;
+ members: UserBasic[];
+ currentUser: { uuid?: string; email?: string } | undefined;
+ isTaskAuthor: boolean;
+ canForward: boolean;
+ draft: string;
+ setDraft: (value: string) => void;
+ isSubmitDisabled: boolean;
+ submit: () => Promise;
+ sendMessageToAgent: (messageId: string) => void;
+ deleteMessage: (messageId: string) => void;
+ onMentionInsert: (member: UserBasic) => void;
+}
+
+export function useThreadConversation(
+ task: Task,
+ { surface }: { surface: ThreadSurface },
+): ThreadConversation {
+ const taskId = task.id;
+ const client = useOptionalAuthenticatedClient();
+ const { data: currentUser } = useCurrentUser({ client });
+
+ const { messages, isLoading } = useTaskThread(taskId);
+ const { postMessage, isPosting } = usePostTaskThreadMessage(taskId);
+ const { postMessageToAgent, isPostingToAgent } =
+ usePostTaskThreadMessageToAgent(taskId);
+ const { deleteMessage } = useDeleteTaskThreadMessage(taskId);
+ const { sendToAgent, isSending } = useSendTaskThreadMessageToAgent(taskId);
+ const isSendingToAgent = isPostingToAgent || isSending;
+ const { members } = useOrgMembers();
+
+ const {
+ 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 pendingPermissions = usePendingPermissionsForTask(taskId);
+
+ 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(messages), [messages]);
+
+ const [draft, setDraft] = useState("");
+
+ const onMentionInsert = useCallback(
+ (member: UserBasic) => {
+ track(ANALYTICS_EVENTS.CHANNEL_ACTION, {
+ action_type: "mention_member",
+ surface,
+ task_id: taskId,
+ mentioned_user_id: member.uuid,
+ });
+ },
+ [taskId, surface],
+ );
+
+ const isTaskAuthor =
+ !!currentUser?.uuid && currentUser.uuid === task.created_by?.uuid;
+ const canForward =
+ !!task.latest_run &&
+ !isTerminalStatus(task.latest_run.status) &&
+ !isSendingToAgent;
+
+ const submit = async () => {
+ const content = draft.trim();
+ 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("");
+ 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) {
+ // Hand the draft back rather than losing what they typed.
+ setDraft(content);
+ toast.error("Couldn't post message", {
+ description: error instanceof Error ? error.message : String(error),
+ });
+ }
+ };
+
+ const sendMessageToAgent = (messageId: string) => {
+ sendToAgent(messageId).catch((error: unknown) => {
+ toast.error("Couldn't send message to agent", {
+ description: error instanceof Error ? error.message : String(error),
+ });
+ });
+ };
+
+ const handleDelete = (messageId: string) => {
+ deleteMessage(messageId).catch((error: unknown) => {
+ toast.error("Couldn't delete message", {
+ description: error instanceof Error ? error.message : String(error),
+ });
+ });
+ };
+
+ return {
+ timeline,
+ agentStatus,
+ events,
+ isPromptPending,
+ isReady: !isInitializing && !isLoading,
+ members,
+ currentUser,
+ isTaskAuthor,
+ canForward,
+ draft,
+ setDraft,
+ isSubmitDisabled: !draft.trim() || isPosting || isSendingToAgent,
+ submit,
+ sendMessageToAgent,
+ deleteMessage: handleDelete,
+ onMentionInsert,
+ };
+}
diff --git a/packages/ui/src/features/git-interaction/usePrArtifact.ts b/packages/ui/src/features/git-interaction/usePrArtifact.ts
new file mode 100644
index 0000000000..9c8f3299d4
--- /dev/null
+++ b/packages/ui/src/features/git-interaction/usePrArtifact.ts
@@ -0,0 +1,38 @@
+import type { Icon } from "@phosphor-icons/react";
+import {
+ getPrVisualConfig,
+ parsePrNumber,
+} from "@posthog/core/git-interaction/prStatus";
+import { getPrVisualIcon } from "@posthog/ui/features/git-interaction/prIcon";
+import { usePrDetails } from "@posthog/ui/features/git-interaction/usePrDetails";
+import { parseHttpsUrl } from "@posthog/ui/utils/posthogLinks";
+
+export function usePrArtifact(url: string | null): {
+ safeUrl: string | null;
+ prNumber: string | undefined;
+ title: string;
+ stateLabel: string | null;
+ Icon: Icon;
+ iconColor: string;
+ accentColor: string;
+} {
+ const parsed = url ? parseHttpsUrl(url) : null;
+ const safeUrl = parsed?.origin === "https://github.com" ? parsed.href : null;
+ const {
+ meta: { state, merged, draft },
+ } = usePrDetails(safeUrl);
+
+ const config = getPrVisualConfig(state ?? "open", merged, draft);
+ const prNumber = safeUrl ? parsePrNumber(safeUrl) : undefined;
+
+ return {
+ safeUrl,
+ prNumber,
+ title: prNumber ? `Pull request #${prNumber}` : "Pull request",
+ // Only once the state has resolved, to avoid a flash of "Open".
+ stateLabel: state ? config.label : null,
+ Icon: getPrVisualIcon(config.icon),
+ iconColor: `var(--${config.color}-9)`,
+ accentColor: config.color,
+ };
+}
diff --git a/packages/ui/src/utils/posthogLinks.ts b/packages/ui/src/utils/posthogLinks.ts
index 4db4fb3aec..1b2227fcdd 100644
--- a/packages/ui/src/utils/posthogLinks.ts
+++ b/packages/ui/src/utils/posthogLinks.ts
@@ -121,6 +121,20 @@ export function channelShareUrl(
);
}
+/**
+ * Parse a URL, rejecting anything that isn't https. The gate every surface that
+ * renders a backend-supplied link goes through before fetching from it or
+ * handing it to the host's external-link opener.
+ */
+export function parseHttpsUrl(url: string): URL | null {
+ try {
+ const parsed = new URL(url);
+ return parsed.protocol === "https:" ? parsed : null;
+ } catch {
+ return null;
+ }
+}
+
export type ShareLinkTarget =
| { kind: "canvas"; channelId: string; dashboardId: string }
| { kind: "channel"; channelId: string; taskId?: string };