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

Commit 5903d4d

Browse files
authored
feat(channels): add the structured Activity view
Replace the flag-gated task thread with Timeline, Artifacts, and Comments views. Include multi-run artifacts and selected historical pull request review while leaving flag-off behavior unchanged. Generated-By: PostHog Code Task-Id: 6190e713-9b80-43d9-a05c-5e3b1ecdf297
1 parent b5a9f27 commit 5903d4d

14 files changed

Lines changed: 1051 additions & 13 deletions
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { describe, expect, it } from "vitest";
2+
import { parseRunPlans } from "./runArtifactSchemas";
3+
4+
describe("parseRunPlans", () => {
5+
it.each([
6+
{ name: "undefined", raw: undefined },
7+
{ name: "null", raw: null },
8+
{ name: "an object", raw: { artifacts: [] } },
9+
{ name: "a string", raw: "plan" },
10+
])("reads $name as no artifacts", ({ raw }) => {
11+
expect(parseRunPlans(raw)).toEqual([]);
12+
});
13+
14+
it("keeps only plan artifacts", () => {
15+
const plans = parseRunPlans([
16+
{ id: "a", type: "plan", name: "Plan A" },
17+
{ id: "b", type: "upload", name: "internal blob" },
18+
]);
19+
expect(plans).toEqual([{ id: "a", type: "plan", name: "Plan A" }]);
20+
});
21+
22+
// One bad entry shouldn't take the Artifacts tab down with it.
23+
it("drops entries that don't match the shape", () => {
24+
const plans = parseRunPlans([
25+
{ id: 42, type: "plan" },
26+
null,
27+
"not an object",
28+
{ type: "plan", storage_path: "runs/1/plan.md" },
29+
]);
30+
expect(plans).toEqual([{ type: "plan", storage_path: "runs/1/plan.md" }]);
31+
});
32+
33+
it("ignores an artifact with no type", () => {
34+
expect(parseRunPlans([{ id: "a", name: "mystery" }])).toEqual([]);
35+
});
36+
});
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { z } from "zod";
2+
3+
export const runArtifactSchema = z.object({
4+
id: z.string().optional(),
5+
name: z.string().optional(),
6+
type: z.string().optional(),
7+
storage_path: z.string().optional(),
8+
});
9+
export type RunArtifact = z.infer<typeof runArtifactSchema>;
10+
11+
export function parseRunPlans(raw: unknown): RunArtifact[] {
12+
if (!Array.isArray(raw)) return [];
13+
return raw.flatMap((entry) => {
14+
const parsed = runArtifactSchema.safeParse(entry);
15+
return parsed.success && parsed.data.type === "plan" ? [parsed.data] : [];
16+
});
17+
}
Lines changed: 265 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,265 @@
1+
import { CaretRightIcon } from "@phosphor-icons/react";
2+
import { Button, cn, Tabs, TabsList, TabsTrigger } from "@posthog/quill";
3+
import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events";
4+
import type { Task } from "@posthog/shared/domain-types";
5+
import { ActivityTimeline } from "@posthog/ui/features/canvas/components/ActivityTimeline";
6+
import { TaskCard } from "@posthog/ui/features/canvas/components/ChannelFeedView";
7+
import { TaskArtifactsList } from "@posthog/ui/features/canvas/components/TaskArtifactsList";
8+
import {
9+
AgentStatusLine,
10+
ThreadLoadingState,
11+
ThreadPanelHeader,
12+
ThreadReplyComposer,
13+
ThreadTimeline,
14+
} from "@posthog/ui/features/canvas/components/ThreadPanel";
15+
import { useThreadConversation } from "@posthog/ui/features/canvas/hooks/useThreadConversation";
16+
import { buildConversationItems } from "@posthog/ui/features/sessions/components/buildConversationItems";
17+
import { taskDetailQuery } from "@posthog/ui/features/tasks/queries";
18+
import { track } from "@posthog/ui/shell/analytics";
19+
import { useQuery } from "@tanstack/react-query";
20+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
21+
22+
type ActivityTab = "timeline" | "artifacts" | "comments";
23+
24+
const ACTIVITY_TABS: readonly { key: ActivityTab; label: string }[] = [
25+
{ key: "timeline", label: "Timeline" },
26+
{ key: "artifacts", label: "Artifacts" },
27+
{ key: "comments", label: "Comments" },
28+
] as const;
29+
30+
const TABS_WITH_COMPOSER: ReadonlySet<ActivityTab> = new Set([
31+
"timeline",
32+
"comments",
33+
]);
34+
35+
const TIMESTAMP_END_CLASS =
36+
"[&_[data-slot=thread-item-timestamp]]:ml-auto [&_[data-slot=thread-item-timestamp]]:shrink-0 [&_[data-slot=thread-item-timestamp]]:pl-2";
37+
38+
function ActivityTabsRow({
39+
tab,
40+
onTabChange,
41+
}: {
42+
tab: ActivityTab;
43+
onTabChange: (tab: ActivityTab) => void;
44+
}) {
45+
return (
46+
<div className="shrink-0 border-border border-b px-2 py-1.5">
47+
<Tabs
48+
value={tab}
49+
onValueChange={(value: string) => onTabChange(value as ActivityTab)}
50+
>
51+
<TabsList variant="line" className="h-auto gap-0.5">
52+
{ACTIVITY_TABS.map((t) => (
53+
<TabsTrigger key={t.key} value={t.key} className="px-2.5 py-1">
54+
<span className="font-medium text-[13px]">{t.label}</span>
55+
</TabsTrigger>
56+
))}
57+
</TabsList>
58+
</Tabs>
59+
</div>
60+
);
61+
}
62+
63+
function ActivityConversation({
64+
task,
65+
channelId,
66+
onClose,
67+
onToggleCollapsed,
68+
onOpenFull,
69+
showTaskSummary,
70+
}: {
71+
task: Task;
72+
channelId: string;
73+
onClose?: () => void;
74+
onToggleCollapsed?: () => void;
75+
onOpenFull?: () => void;
76+
showTaskSummary: boolean;
77+
}) {
78+
const taskId = task.id;
79+
const {
80+
timeline,
81+
agentStatus,
82+
events,
83+
isPromptPending,
84+
isReady,
85+
members,
86+
currentUser,
87+
isTaskAuthor,
88+
canForward,
89+
draft,
90+
setDraft,
91+
isSubmitDisabled,
92+
submit,
93+
sendMessageToAgent,
94+
deleteMessage,
95+
onMentionInsert,
96+
} = useThreadConversation(task, { surface: "activity_panel" });
97+
98+
const [tab, setTab] = useState<ActivityTab>("timeline");
99+
const handleTabChange = useCallback(
100+
(next: ActivityTab) => {
101+
setTab(next);
102+
track(ANALYTICS_EVENTS.CHANNEL_ACTION, {
103+
action_type: "activity_tab_change",
104+
surface: "activity_panel",
105+
task_id: taskId,
106+
tab: next,
107+
});
108+
},
109+
[taskId],
110+
);
111+
112+
const commentRows = useMemo(
113+
() => timeline.filter((row) => row.kind === "human"),
114+
[timeline],
115+
);
116+
const conversationItems = useMemo(
117+
() =>
118+
tab === "timeline"
119+
? buildConversationItems(events, isPromptPending).items
120+
: [],
121+
[tab, events, isPromptPending],
122+
);
123+
124+
const scrollRef = useRef<HTMLDivElement>(null);
125+
// biome-ignore lint/correctness/useExhaustiveDependencies: scroll when rendered thread content changes
126+
useEffect(() => {
127+
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight });
128+
}, [timeline, events.length, agentStatus?.phase, tab]);
129+
130+
const showComposer = TABS_WITH_COMPOSER.has(tab);
131+
132+
const body = () => {
133+
if (tab === "artifacts") {
134+
return <TaskArtifactsList task={task} timeline={timeline} />;
135+
}
136+
if (tab === "comments") {
137+
return (
138+
<ThreadTimeline
139+
timeline={commentRows}
140+
isReady={isReady}
141+
currentUserUuid={currentUser?.uuid}
142+
currentUserEmail={currentUser?.email}
143+
isTaskAuthor={isTaskAuthor}
144+
canForward={canForward}
145+
onSendToAgent={sendMessageToAgent}
146+
onDelete={deleteMessage}
147+
/>
148+
);
149+
}
150+
if (!isReady) return <ThreadLoadingState />;
151+
return (
152+
<ActivityTimeline
153+
task={task}
154+
timeline={timeline}
155+
conversationItems={conversationItems}
156+
currentUserUuid={currentUser?.uuid}
157+
currentUserEmail={currentUser?.email}
158+
isTaskAuthor={isTaskAuthor}
159+
canForward={canForward}
160+
onSendToAgent={sendMessageToAgent}
161+
onDelete={deleteMessage}
162+
/>
163+
);
164+
};
165+
166+
return (
167+
<div
168+
className={cn(
169+
"flex h-full min-w-0 flex-col bg-gray-1",
170+
TIMESTAMP_END_CLASS,
171+
)}
172+
>
173+
<ThreadPanelHeader
174+
title="Activity"
175+
onOpenFull={onOpenFull}
176+
onToggleCollapsed={onToggleCollapsed}
177+
onClose={onClose}
178+
/>
179+
<ActivityTabsRow tab={tab} onTabChange={handleTabChange} />
180+
181+
{showTaskSummary && (
182+
<div className="z-10 px-2">
183+
<TaskCard task={task} channelId={channelId} inThread />
184+
</div>
185+
)}
186+
<div
187+
ref={scrollRef}
188+
aria-busy={!isReady}
189+
className="flex-1 overflow-y-auto"
190+
>
191+
{body()}
192+
</div>
193+
194+
{showComposer && agentStatus && <AgentStatusLine status={agentStatus} />}
195+
196+
{showComposer && (
197+
<ThreadReplyComposer
198+
draft={draft}
199+
onDraftChange={setDraft}
200+
onSubmit={submit}
201+
members={members}
202+
allowAgentMention={isTaskAuthor && canForward}
203+
onMentionInsert={onMentionInsert}
204+
disabled={isSubmitDisabled}
205+
/>
206+
)}
207+
</div>
208+
);
209+
}
210+
211+
export function ActivityPanel({
212+
taskId,
213+
channelId,
214+
task: taskProp,
215+
onClose,
216+
collapsed,
217+
onToggleCollapsed,
218+
onOpenFull,
219+
showTaskSummary = true,
220+
}: {
221+
taskId: string;
222+
channelId: string;
223+
task?: Task;
224+
onClose?: () => void;
225+
collapsed?: boolean;
226+
onToggleCollapsed?: () => void;
227+
onOpenFull?: () => void;
228+
showTaskSummary?: boolean;
229+
}) {
230+
const { data: fetchedTask } = useQuery({
231+
...taskDetailQuery(taskId),
232+
enabled: !taskProp && !collapsed,
233+
});
234+
const task = taskProp ?? fetchedTask;
235+
236+
if (collapsed) {
237+
return (
238+
<div className="flex h-full w-9 flex-col items-center border-border border-l bg-gray-1 py-2">
239+
<Button
240+
variant="default"
241+
size="icon-sm"
242+
aria-label="Expand activity"
243+
onClick={onToggleCollapsed}
244+
>
245+
<CaretRightIcon size={14} className="rotate-180" />
246+
</Button>
247+
</div>
248+
);
249+
}
250+
251+
if (!task) {
252+
return <ThreadLoadingState />;
253+
}
254+
255+
return (
256+
<ActivityConversation
257+
task={task}
258+
channelId={channelId}
259+
onClose={onClose}
260+
onToggleCollapsed={onToggleCollapsed}
261+
onOpenFull={onOpenFull}
262+
showTaskSummary={showTaskSummary}
263+
/>
264+
);
265+
}

0 commit comments

Comments
 (0)