Skip to content

Commit d0b8bc7

Browse files
authored
Add Storybook story for channel task feed row
Extract the pure presentational TaskFeedRow (avatar + attribution header + body) out of FeedItem so it can be storied; the data-fetching TaskCard and ReplyFooter stay in the FeedItem container and are passed in as children. Adds ChannelFeedView.stories.tsx covering the attribution states so the human-vs-agent sender rendering is easy to validate: human-started, human-with-email-only (initials/name fallback), non-user origin (stays agent-attributed), and user_created with no starter. Generated-By: PostHog Code Task-Id: 4b3b11b7-5066-448f-a721-fe8532ce15d1
1 parent 13322a1 commit d0b8bc7

2 files changed

Lines changed: 176 additions & 35 deletions

File tree

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import type { Task, UserBasic } from "@posthog/shared/domain-types";
2+
import type { Meta, StoryObj } from "@storybook/react-vite";
3+
import { TaskFeedRow } from "./ChannelFeedView";
4+
5+
// A stand-in for the real TaskCard, which fetches its own status/PR data and so
6+
// renders empty in Storybook. The story only needs something card-shaped under
7+
// the attribution for the row to read realistically.
8+
function MockTaskCard({ title }: { title: string }) {
9+
return (
10+
<div className="mt-1.5 rounded-sm border border-border-primary px-3 py-2.5">
11+
<div className="flex items-center justify-between gap-2">
12+
<span className="font-medium text-sm">{title}</span>
13+
<span className="rounded-full bg-fill-secondary px-2 py-0.5 text-muted-foreground text-xs">
14+
Ready
15+
</span>
16+
</div>
17+
</div>
18+
);
19+
}
20+
21+
const user = (overrides: Partial<UserBasic> = {}): UserBasic => ({
22+
id: 1,
23+
uuid: "user-1",
24+
email: "adam@posthog.com",
25+
first_name: "Adam",
26+
last_name: "Bowker",
27+
...overrides,
28+
});
29+
30+
const task = (overrides: Partial<Task> = {}): Task => ({
31+
id: "task-1",
32+
task_number: 1,
33+
slug: "task-1",
34+
title: "Add feedback modal to channels view",
35+
description: "",
36+
// A fixed timestamp keeps the relative-time label stable for visual review.
37+
created_at: "2026-07-17T12:00:00.000Z",
38+
updated_at: "2026-07-17T12:00:00.000Z",
39+
origin_product: "user_created",
40+
created_by: user(),
41+
...overrides,
42+
});
43+
44+
const meta: Meta<typeof TaskFeedRow> = {
45+
title: "Channels/TaskFeedRow",
46+
component: TaskFeedRow,
47+
decorators: [
48+
(Story) => (
49+
<div className="max-w-xl">
50+
<Story />
51+
</div>
52+
),
53+
],
54+
};
55+
56+
export default meta;
57+
type Story = StoryObj<typeof TaskFeedRow>;
58+
59+
/**
60+
* A channel-started task, attributed to the human who started it: initials
61+
* avatar, their display name, no "Agent" badge, and a plain "started a new
62+
* task" body.
63+
*/
64+
export const HumanStarted: Story = {
65+
args: {
66+
task: task(),
67+
children: <MockTaskCard title="Add feedback modal to channels view" />,
68+
},
69+
};
70+
71+
/**
72+
* A human with no name set falls back to the email-derived initials in the
73+
* avatar and the email as the display name.
74+
*/
75+
export const HumanEmailOnly: Story = {
76+
args: {
77+
task: task({
78+
created_by: user({ first_name: undefined, last_name: undefined }),
79+
}),
80+
children: <MockTaskCard title="Make background color configurable" />,
81+
},
82+
};
83+
84+
/**
85+
* A non-user origin (e.g. Slack) stays agent-attributed — robot avatar,
86+
* "PostHog" name, "Agent" badge — even though created_by is set, because that
87+
* person didn't start the task in the channel.
88+
*/
89+
export const AgentOrigin: Story = {
90+
args: {
91+
task: task({ origin_product: "slack", title: "Investigate signup drop-off" }),
92+
children: <MockTaskCard title="Investigate signup drop-off" />,
93+
},
94+
};
95+
96+
/**
97+
* A user_created task with no created_by has no human to attribute, so it also
98+
* falls back to the agent identity.
99+
*/
100+
export const NoStarter: Story = {
101+
args: {
102+
task: task({ created_by: null, title: "Untitled task" }),
103+
children: <MockTaskCard title="Untitled task" />,
104+
},
105+
};

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

Lines changed: 71 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,11 @@ import {
3737
useChatMessageScroller,
3838
} from "@posthog/quill";
3939
import { formatRelativeTimeShort, getLocalDayDiff } from "@posthog/shared";
40-
import type { Task, TaskRunStatus } from "@posthog/shared/domain-types";
40+
import type {
41+
Task,
42+
TaskRunStatus,
43+
UserBasic,
44+
} from "@posthog/shared/domain-types";
4145
import { getUserInitials } from "@posthog/ui/features/auth/userInitials";
4246
import { TaskTabIcon } from "@posthog/ui/features/browser-tabs/TaskTabIcon";
4347
import type { ChannelFeedSystemMessage } from "@posthog/ui/features/canvas/hooks/useChannelFeedMessages";
@@ -410,24 +414,31 @@ function ReplyFooter({
410414
);
411415
}
412416

413-
const FeedItem = memo(function FeedItem({
417+
// The human who kicked a task off in the channel, or null when the row should
418+
// stay agent-attributed. Only channel-started tasks (user_created) are a
419+
// person's doing; other origins (Slack, automations) carry a created_by who
420+
// didn't start it here, so they keep the agent identity — which also leaves
421+
// that identity free for genuine agent-authored messages in the future.
422+
function channelTaskStarter(task: Task): UserBasic | null {
423+
return task.origin_product === "user_created"
424+
? (task.created_by ?? null)
425+
: null;
426+
}
427+
428+
// The presentational feed row: avatar + attribution header + body. The task
429+
// card and reply footer are supplied as `children` (they fetch their own data,
430+
// so keeping them out of here leaves the row pure and storyable); `actions` is
431+
// the hover toolbar.
432+
export function TaskFeedRow({
414433
task,
415-
channelId,
416-
inView,
417-
onOpenTask,
418-
onOpenThread,
434+
actions,
435+
children,
419436
}: {
420437
task: Task;
421-
channelId: string;
422-
inView: boolean;
423-
onOpenTask: (task: Task) => void;
424-
onOpenThread: (task: Task) => void;
438+
actions?: ReactNode;
439+
children?: ReactNode;
425440
}) {
426-
// Only attribute channel-started tasks to a human: other origins (Slack,
427-
// automations) carry a created_by who didn't start it here, so those stay
428-
// agent-attributed to leave room for genuine agent-authored messages.
429-
const starter =
430-
task.origin_product === "user_created" ? task.created_by : null;
441+
const starter = channelTaskStarter(task);
431442

432443
return (
433444
<ThreadItem className="rounded-none py-1 pr-8 hover:bg-fill-hover/50">
@@ -456,30 +467,55 @@ const FeedItem = memo(function FeedItem({
456467
{starter ? "started a new task" : "A new task was started"}
457468
</ThreadItemBody>
458469

459-
<TaskCard
460-
task={task}
461-
channelId={channelId}
462-
onOpen={() => onOpenThread(task)}
463-
/>
464-
<ReplyFooter
465-
taskId={task.id}
466-
inView={inView}
467-
onOpenThread={() => onOpenThread(task)}
468-
/>
470+
{children}
469471
</ThreadItemContent>
470472

471-
{/* Replying now lives in the always-visible ReplyFooter, so the hover
472-
toolbar only carries the distinct "Open task" action. Actions anchor
473-
to the row's top-right corner; a top tooltip there overhangs the panel
474-
edge and gets clipped by the scroll container, so open tooltips toward
475-
the content instead. */}
476-
<ThreadItemActions aria-label="Message actions" className="inset-bs-2">
477-
<ThreadItemAction label="Open task" onClick={() => onOpenTask(task)}>
478-
<ArrowSquareOutIcon size={15} />
479-
</ThreadItemAction>
480-
</ThreadItemActions>
473+
{actions}
481474
</ThreadItem>
482475
);
476+
}
477+
478+
const FeedItem = memo(function FeedItem({
479+
task,
480+
channelId,
481+
inView,
482+
onOpenTask,
483+
onOpenThread,
484+
}: {
485+
task: Task;
486+
channelId: string;
487+
inView: boolean;
488+
onOpenTask: (task: Task) => void;
489+
onOpenThread: (task: Task) => void;
490+
}) {
491+
return (
492+
<TaskFeedRow
493+
task={task}
494+
actions={
495+
// Replying now lives in the always-visible ReplyFooter, so the hover
496+
// toolbar only carries the distinct "Open task" action. Actions anchor
497+
// to the row's top-right corner; a top tooltip there overhangs the panel
498+
// edge and gets clipped by the scroll container, so open tooltips toward
499+
// the content instead.
500+
<ThreadItemActions aria-label="Message actions" className="inset-bs-2">
501+
<ThreadItemAction label="Open task" onClick={() => onOpenTask(task)}>
502+
<ArrowSquareOutIcon size={15} />
503+
</ThreadItemAction>
504+
</ThreadItemActions>
505+
}
506+
>
507+
<TaskCard
508+
task={task}
509+
channelId={channelId}
510+
onOpen={() => onOpenThread(task)}
511+
/>
512+
<ReplyFooter
513+
taskId={task.id}
514+
inView={inView}
515+
onOpenThread={() => onOpenThread(task)}
516+
/>
517+
</TaskFeedRow>
518+
);
483519
});
484520

485521
// One feed row: owns the scroller item (the `content-visibility` boundary, so

0 commit comments

Comments
 (0)