Skip to content
This repository was archived by the owner on Aug 6, 2026. It is now read-only.

Commit 621b6da

Browse files
authored
refactor(canvas): share thread and PR artifact logic (1/4) (#3812)
1 parent 2161a6a commit 621b6da

6 files changed

Lines changed: 334 additions & 234 deletions

File tree

packages/shared/src/analytics-events.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -872,6 +872,7 @@ export type ChannelsSurface =
872872
| "canvas"
873873
| "context"
874874
| "thread_panel"
875+
| "activity_panel"
875876
| "activity";
876877

877878
export type ChannelActionType =

packages/ui/src/features/canvas/components/ThreadPanel.tsx

Lines changed: 48 additions & 202 deletions
Original file line numberDiff line numberDiff line change
@@ -7,19 +7,11 @@ import {
77
TrashIcon,
88
XIcon,
99
} from "@phosphor-icons/react";
10-
import {
11-
buildThreadTimeline,
12-
deriveThreadAgentStatus,
13-
hasAgentMention,
14-
shouldSuspendThreadSession,
15-
type ThreadAgentStatus,
16-
type ThreadArtifact,
17-
type ThreadTimelineRow,
10+
import type {
11+
ThreadAgentStatus,
12+
ThreadArtifact,
13+
ThreadTimelineRow,
1814
} from "@posthog/core/canvas/threadTimeline";
19-
import {
20-
getPrVisualConfig,
21-
parsePrNumber,
22-
} from "@posthog/core/git-interaction/prStatus";
2315
import {
2416
Avatar,
2517
AvatarFallback,
@@ -47,44 +39,27 @@ import {
4739
ThreadItemGutter,
4840
ThreadItemHeader,
4941
} from "@posthog/quill";
50-
import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events";
5142
import type {
5243
Task,
5344
TaskThreadMessage,
5445
UserBasic,
5546
} from "@posthog/shared/domain-types";
56-
import { isTerminalStatus } from "@posthog/shared/domain-types";
57-
import { useOptionalAuthenticatedClient } from "@posthog/ui/features/auth/authClient";
5847
import { UserAvatar } from "@posthog/ui/features/auth/UserAvatar";
59-
import { useCurrentUser } from "@posthog/ui/features/auth/useCurrentUser";
6048
import { TaskCard } from "@posthog/ui/features/canvas/components/ChannelFeedView";
6149
import { iconForTemplate } from "@posthog/ui/features/canvas/components/canvasTemplateIcon";
6250
import { MentionComposer } from "@posthog/ui/features/canvas/components/MentionComposer";
6351
import { MentionText } from "@posthog/ui/features/canvas/components/MentionText";
6452
import { ThreadTimestamp } from "@posthog/ui/features/canvas/components/ThreadTimestamp";
65-
import { useOrgMembers } from "@posthog/ui/features/canvas/hooks/useOrgMembers";
66-
import {
67-
useDeleteTaskThreadMessage,
68-
usePostTaskThreadMessage,
69-
usePostTaskThreadMessageToAgent,
70-
useSendTaskThreadMessageToAgent,
71-
useTaskThread,
72-
} from "@posthog/ui/features/canvas/hooks/useTaskThread";
53+
import { useThreadConversation } from "@posthog/ui/features/canvas/hooks/useThreadConversation";
7354
import { userDisplayName } from "@posthog/ui/features/canvas/utils/userDisplay";
74-
import { getPrVisualIcon } from "@posthog/ui/features/git-interaction/prIcon";
75-
import { usePrDetails } from "@posthog/ui/features/git-interaction/usePrDetails";
76-
import { useSessionConnection } from "@posthog/ui/features/sessions/hooks/useSessionConnection";
77-
import { useSessionViewState } from "@posthog/ui/features/sessions/hooks/useSessionViewState";
78-
import { usePendingPermissionsForTask } from "@posthog/ui/features/sessions/sessionStore";
55+
import { usePrArtifact } from "@posthog/ui/features/git-interaction/usePrArtifact";
7956
import { taskDetailQuery } from "@posthog/ui/features/tasks/queries";
80-
import { toast } from "@posthog/ui/primitives/toast";
81-
import { track } from "@posthog/ui/shell/analytics";
8257
import { openExternalUrl } from "@posthog/ui/shell/openExternal";
83-
import { parseShareLink } from "@posthog/ui/utils/posthogLinks";
58+
import { parseHttpsUrl, parseShareLink } from "@posthog/ui/utils/posthogLinks";
8459
import { navigateToShareTarget } from "@posthog/ui/utils/shareLinks";
8560
import { getPostHogUrl } from "@posthog/ui/utils/urls";
8661
import { useQuery } from "@tanstack/react-query";
87-
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
62+
import { useEffect, useRef } from "react";
8863

8964
export function ThreadMessageRow({
9065
message,
@@ -215,15 +190,6 @@ function ArtifactCardButton({
215190
);
216191
}
217192

218-
function parseHttpsUrl(url: string): URL | null {
219-
try {
220-
const parsedUrl = new URL(url);
221-
return parsedUrl.protocol === "https:" ? parsedUrl : null;
222-
} catch {
223-
return null;
224-
}
225-
}
226-
227193
function CanvasArtifactCard({
228194
name,
229195
url,
@@ -257,28 +223,19 @@ function CanvasArtifactCard({
257223
}
258224

259225
function PrArtifactCard({ url }: { url: string }) {
260-
const parsedUrl = parseHttpsUrl(url);
261-
const safeUrl =
262-
parsedUrl?.origin === "https://github.com" ? parsedUrl.href : null;
263-
const {
264-
meta: { state, merged, draft },
265-
} = usePrDetails(safeUrl);
266-
const config = getPrVisualConfig(state ?? "open", merged, draft);
267-
const PrIcon = getPrVisualIcon(config.icon);
268-
const prNumber = safeUrl ? parsePrNumber(safeUrl) : null;
226+
const { safeUrl, title, stateLabel, Icon, iconColor } = usePrArtifact(url);
269227
return (
270228
<ArtifactCardButton
271229
icon={
272-
<PrIcon
230+
<Icon
273231
size={14}
274232
weight="bold"
275233
className="shrink-0"
276-
style={{ color: `var(--${config.color}-9)` }}
234+
style={{ color: iconColor }}
277235
/>
278236
}
279-
title={prNumber ? `Pull request #${prNumber}` : "Pull request"}
280-
// Only show the resolved state once we have it, to avoid a flash of "Open".
281-
detail={state ? config.label : null}
237+
title={title}
238+
detail={stateLabel}
282239
onOpen={safeUrl ? () => openExternalUrl(safeUrl) : undefined}
283240
/>
284241
);
@@ -319,7 +276,7 @@ export function ThreadArtifactRow({
319276
);
320277
}
321278

322-
function ThreadLoadingState() {
279+
export function ThreadLoadingState() {
323280
return (
324281
<Empty className="h-full border-0">
325282
<EmptyHeader>
@@ -332,19 +289,23 @@ function ThreadLoadingState() {
332289
);
333290
}
334291

335-
function ThreadHeader({
292+
/** The panel's title row and window controls. Shared with ActivityPanel, which
293+
* is the same chrome under a different title. */
294+
export function ThreadPanelHeader({
295+
title,
336296
onClose,
337297
onToggleCollapsed,
338298
onOpenFull,
339299
}: {
300+
title: string;
340301
onClose?: () => void;
341302
onToggleCollapsed?: () => void;
342303
onOpenFull?: () => void;
343304
}) {
344305
return (
345306
<div className="flex items-center gap-1 border-border border-b px-3 py-2">
346307
<div className="min-w-0 flex-1">
347-
<span className="block font-medium text-sm">Thread</span>
308+
<span className="block font-medium text-sm">{title}</span>
348309
</div>
349310
{onOpenFull && (
350311
<Button
@@ -360,7 +321,7 @@ function ThreadHeader({
360321
<Button
361322
variant="default"
362323
size="icon-sm"
363-
aria-label="Collapse thread"
324+
aria-label={`Collapse ${title.toLowerCase()}`}
364325
onClick={onToggleCollapsed}
365326
>
366327
<CaretRightIcon size={14} />
@@ -370,7 +331,7 @@ function ThreadHeader({
370331
<Button
371332
variant="default"
372333
size="icon-sm"
373-
aria-label="Close thread"
334+
aria-label={`Close ${title.toLowerCase()}`}
374335
onClick={onClose}
375336
>
376337
<XIcon size={14} />
@@ -380,7 +341,7 @@ function ThreadHeader({
380341
);
381342
}
382343

383-
function ThreadTimeline({
344+
export function ThreadTimeline({
384345
timeline,
385346
isReady,
386347
currentUserUuid,
@@ -446,7 +407,7 @@ function ThreadTimeline({
446407
);
447408
}
448409

449-
function ThreadReplyComposer({
410+
export function ThreadReplyComposer({
450411
draft,
451412
onDraftChange,
452413
onSubmit,
@@ -509,151 +470,36 @@ function ThreadConversation({
509470
onOpenFull?: () => void;
510471
showTaskSummary: boolean;
511472
}) {
512-
const taskId = task.id;
513-
const client = useOptionalAuthenticatedClient();
514-
const { data: currentUser } = useCurrentUser({ client });
515-
516-
const { messages, isLoading } = useTaskThread(taskId);
517-
const { postMessage, isPosting } = usePostTaskThreadMessage(taskId);
518-
const { postMessageToAgent, isPostingToAgent } =
519-
usePostTaskThreadMessageToAgent(taskId);
520-
const { deleteMessage } = useDeleteTaskThreadMessage(taskId);
521-
const { sendToAgent, isSending } = useSendTaskThreadMessageToAgent(taskId);
522-
const isSendingToAgent = isPostingToAgent || isSending;
523-
const { members } = useOrgMembers();
524-
525-
const {
526-
session,
527-
repoPath,
528-
isCloud,
529-
events,
530-
cloudStatus,
531-
isPromptPending,
532-
isInitializing,
533-
hasError,
534-
errorTitle,
535-
} = useSessionViewState(taskId, task);
536-
useSessionConnection({
537-
taskId,
538-
task,
539-
session,
540-
repoPath,
541-
isCloud,
542-
isSuspended: shouldSuspendThreadSession({
543-
isCloud,
544-
hasRun: Boolean(task.latest_run?.id),
545-
hasSession: Boolean(session),
546-
}),
473+
const conversation = useThreadConversation(task, {
474+
surface: "thread_panel",
547475
});
548-
const pendingPermissions = usePendingPermissionsForTask(taskId);
549-
550-
const agentStatus = useMemo(
551-
() =>
552-
deriveThreadAgentStatus({
553-
hasActivity: events.length > 0 || !!task.latest_run,
554-
hasError,
555-
cloudStatus,
556-
errorTitle,
557-
pendingPermissionCount: pendingPermissions.size,
558-
isPromptPending,
559-
isInitializing,
560-
}),
561-
[
562-
events.length,
563-
task.latest_run,
564-
hasError,
565-
cloudStatus,
566-
errorTitle,
567-
pendingPermissions.size,
568-
isPromptPending,
569-
isInitializing,
570-
],
571-
);
572-
573-
const timeline = useMemo(() => buildThreadTimeline(messages), [messages]);
476+
const {
477+
timeline,
478+
agentStatus,
479+
isReady,
480+
members,
481+
currentUser,
482+
isTaskAuthor,
483+
canForward,
484+
draft,
485+
setDraft,
486+
isSubmitDisabled,
487+
submit,
488+
sendMessageToAgent,
489+
deleteMessage,
490+
onMentionInsert,
491+
} = conversation;
574492

575-
const [draft, setDraft] = useState("");
576493
const scrollRef = useRef<HTMLDivElement>(null);
577-
578-
const handleMentionInsert = useCallback(
579-
(member: UserBasic) => {
580-
track(ANALYTICS_EVENTS.CHANNEL_ACTION, {
581-
action_type: "mention_member",
582-
surface: "thread_panel",
583-
task_id: taskId,
584-
mentioned_user_id: member.uuid,
585-
});
586-
},
587-
[taskId],
588-
);
589-
590494
// biome-ignore lint/correctness/useExhaustiveDependencies: scroll when rendered thread content changes
591495
useEffect(() => {
592496
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight });
593497
}, [timeline, agentStatus?.phase]);
594498

595-
const isTaskAuthor =
596-
!!currentUser?.uuid && currentUser.uuid === task.created_by?.uuid;
597-
const canForward =
598-
!!task.latest_run &&
599-
!isTerminalStatus(task.latest_run.status) &&
600-
!isSendingToAgent;
601-
602-
const submit = async () => {
603-
const content = draft.trim();
604-
if (!content || isPosting || isSendingToAgent) return;
605-
const sendToAgentRequested = hasAgentMention(content);
606-
if (sendToAgentRequested && (!isTaskAuthor || !canForward)) {
607-
toast.error("Couldn't send to agent", {
608-
description:
609-
"Only the task author can @agent while the task has an active run.",
610-
});
611-
return;
612-
}
613-
setDraft("");
614-
try {
615-
if (sendToAgentRequested) {
616-
const { sendError } = await postMessageToAgent(content);
617-
if (sendError) {
618-
toast.error("Message posted, but couldn't send it to the agent", {
619-
description:
620-
sendError instanceof Error
621-
? sendError.message
622-
: String(sendError),
623-
});
624-
}
625-
} else {
626-
await postMessage(content);
627-
}
628-
} catch (error) {
629-
setDraft(content);
630-
toast.error("Couldn't post message", {
631-
description: error instanceof Error ? error.message : String(error),
632-
});
633-
}
634-
};
635-
636-
const handleSendToAgent = (messageId: string) => {
637-
sendToAgent(messageId).catch((error: unknown) => {
638-
toast.error("Couldn't send message to agent", {
639-
description: error instanceof Error ? error.message : String(error),
640-
});
641-
});
642-
};
643-
644-
const handleDelete = (messageId: string) => {
645-
deleteMessage(messageId).catch((error: unknown) => {
646-
toast.error("Couldn't delete message", {
647-
description: error instanceof Error ? error.message : String(error),
648-
});
649-
});
650-
};
651-
652-
const isReady = !isInitializing && !isLoading;
653-
654499
return (
655500
<div className="flex h-full min-w-0 flex-col bg-gray-1">
656-
<ThreadHeader
501+
<ThreadPanelHeader
502+
title="Thread"
657503
onOpenFull={onOpenFull}
658504
onToggleCollapsed={onToggleCollapsed}
659505
onClose={onClose}
@@ -672,8 +518,8 @@ function ThreadConversation({
672518
currentUserEmail={currentUser?.email}
673519
isTaskAuthor={isTaskAuthor}
674520
canForward={canForward}
675-
onSendToAgent={handleSendToAgent}
676-
onDelete={handleDelete}
521+
onSendToAgent={sendMessageToAgent}
522+
onDelete={deleteMessage}
677523
/>
678524
</div>
679525

@@ -685,8 +531,8 @@ function ThreadConversation({
685531
onSubmit={submit}
686532
members={members}
687533
allowAgentMention={isTaskAuthor && canForward}
688-
onMentionInsert={handleMentionInsert}
689-
disabled={!draft.trim() || isPosting || isSendingToAgent}
534+
onMentionInsert={onMentionInsert}
535+
disabled={isSubmitDisabled}
690536
/>
691537
</div>
692538
);

0 commit comments

Comments
 (0)