Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions apps/code/snapshots.yml
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,30 @@ snapshots:
hash: v1.k4693efd2.1d199b2c4bba8034cb18fb5b49866b64a6eb3311591add5f58ec288710dce2e9.FCc9egSaO1Onih417yciSYKNROe8zqDZyyS6xuXCZGw
billing-usagemeter--zero-spend-limit--light:
hash: v1.k4693efd2.b50c18736bbde45fc89f4c0ac7fd616286e5c1ecc2a56910fffca13eaa13d8a2.31z4F0JmvMeR4yqk5pjs_OQ3MypnM2miZI9al4wuc8c
channels-taskfeedrow--agent-origin--dark:
hash: v1.k4693efd2.82f8c70a399c9ea768201933e202fcc2fde74332c5153aeabf6835ace79beee6.-SVboDRIZ_-nh4PtMliCv7e7iCtdUm-1m2xQ9Xzcdfw
channels-taskfeedrow--agent-origin--light:
hash: v1.k4693efd2.4c1db3470d5ec9b4db9872d0e048314d0078979e8fbdb602b8c648ba70d7209f._KPUvj46p33u7A4Xndz-git-4rp7VKca1MpeTztlB6w
channels-taskfeedrow--human-email-only--dark:
hash: v1.k4693efd2.0f4dece4643b7375b77746347658f4fe65b37817cdfa9ad1ba697067e619f428.f0jKZ4OYqsmeJjXffjt-amK9cwE81JF0lgSMqtn37PA
channels-taskfeedrow--human-email-only--light:
hash: v1.k4693efd2.14db0510f7ea5c57985cf0cb60ab08f5e146c7243eeb1b0b963f74e85c7d60f4.ijHQLK0lZjjUlOQuzYzisImU9DWsNmZM7i5GYfWU4E0
channels-taskfeedrow--human-started--dark:
hash: v1.k4693efd2.e0abdae2e8ac2ef29793ea7afc2a2580c6610e07a3c640ad782af7fd3c12592a.YUgF_BTPzZoqnWXvyLBRs3w9C_P3Yfh4-cP4mUqs2NQ
channels-taskfeedrow--human-started--light:
hash: v1.k4693efd2.84c26deb1a587fe061238b3982b555167575893bacc9cd2667d4d3f74646261f.abqc0Voe6FIQFflzDJTv1KewcZ4XxcDz1a0mFlJu7oM
channels-taskfeedrow--long-prompt--dark:
hash: v1.k4693efd2.2547e88d76889aa7290c5221ff5ecb674cdb1cd9f1ae5efd211fb648a88037b6.4c9RO1upuz3VijYp2xU2MTNrW_87zWQLX5kRKCYABq4
channels-taskfeedrow--long-prompt--light:
hash: v1.k4693efd2.6b0f609e0155bc3b6e3149714b70d33ef83f7c2ca7b72e6d3a495b8db36828d0.eC5ZQDilDw7Y9EJqhfIdlk186GJjgGbYolZ9cm9Xtt0
channels-taskfeedrow--no-prompt--dark:
hash: v1.k4693efd2.9fa967f1a9acdeba0c50a9e45ae649f118ee26938037dbefd1bb0577067b03d4.va1lGsscqLW86-5yCKc3H6pQ8Az7_HBItkgGTnTQiYU
channels-taskfeedrow--no-prompt--light:
hash: v1.k4693efd2.a02339aecdb6fc327fdf6586e28ceeebf8490442fe217eb8bb46f1ce66bff78a.2l7XdvdW3D0GlLmPwDdTFgjZWdav68bZX_LYResVKwo
channels-taskfeedrow--no-starter--dark:
hash: v1.k4693efd2.66fff212f14afa9dd3bc532698a042383014a0cabd4331d30eff878148e1773d.RiectZxFTqk1husLYI-UV5ko1uZ4lpbjWstE7Hy3MJE
channels-taskfeedrow--no-starter--light:
hash: v1.k4693efd2.9ee4ad64ed7d2c5c62d0cd682d903b09da643d462d49aae0858afa500e92256b.OhGj64l-o7iWtxzJo1qt0A_CU9ME0ZRr_5e455RGUJQ
components-permissions-permissionselector--create-new-file--dark:
hash: v1.k4693efd2.c54203a4e636b83b3d24d7ed9c4ace8659db87cd8f231d9dc2ecc03320e31646.epDm7LebiLzlp0uuZBrE-Obt_anAn0xsE8bHFnm5vos
components-permissions-permissionselector--create-new-file--light:
Expand Down
111 changes: 111 additions & 0 deletions packages/ui/src/features/canvas/components/ChannelFeedView.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import type { Task, UserBasic } from "@posthog/shared/domain-types";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { TaskFeedRow } from "./ChannelFeedView";

function MockTaskCard({ title }: { title: string }) {
return (
<div className="mt-1.5 rounded-sm border border-border-primary px-3 py-2.5">
<div className="flex items-center justify-between gap-2">
<span className="font-medium text-sm">{title}</span>
<span className="rounded-full bg-fill-secondary px-2 py-0.5 text-muted-foreground text-xs">
Ready
</span>
</div>
</div>
);
}

const user = (overrides: Partial<UserBasic> = {}): UserBasic => ({
id: 1,
uuid: "user-1",
email: "adam@posthog.com",
first_name: "Adam",
last_name: "Bowker",
...overrides,
});

const task = (overrides: Partial<Task> = {}): Task => ({
id: "task-1",
task_number: 1,
slug: "task-1",
title: "Add feedback modal to channels view",
description:
"Add a feedback modal to the channels view so people can share thoughts without leaving the feed",
created_at: "2026-07-17T12:00:00.000Z",
updated_at: "2026-07-17T12:00:00.000Z",
origin_product: "user_created",
created_by: user(),
...overrides,
});

const meta: Meta<typeof TaskFeedRow> = {
title: "Channels/TaskFeedRow",
component: TaskFeedRow,
decorators: [
(Story) => (
<div className="max-w-xl">
<Story />
</div>
),
],
};

export default meta;
type Story = StoryObj<typeof TaskFeedRow>;

export const HumanStarted: Story = {
args: {
task: task(),
children: <MockTaskCard title="Add feedback modal to channels view" />,
},
};

export const HumanEmailOnly: Story = {
args: {
task: task({
created_by: user({ first_name: undefined, last_name: undefined }),
title: "Make background color configurable",
description: "Make the channel background color configurable in settings",
}),
children: <MockTaskCard title="Make background color configurable" />,
},
};

export const AgentOrigin: Story = {
args: {
task: task({
origin_product: "slack",
title: "Investigate signup drop-off",
description: "Investigate the signup drop-off we saw over the weekend",
}),
children: <MockTaskCard title="Investigate signup drop-off" />,
},
};

export const LongPrompt: Story = {
args: {
task: task({
description:
"Rework the channel feed so each row reads as the person who started the task rather than the agent, show a preview of their prompt under the header, keep the task card below, and make sure long prompts truncate cleanly instead of pushing the card down the feed",
}),
children: <MockTaskCard title="Rework the channel feed attribution" />,
},
};

export const NoPrompt: Story = {
args: {
task: task({ description: "", title: "Untitled task" }),
children: <MockTaskCard title="Untitled task" />,
},
};

export const NoStarter: Story = {
args: {
task: task({
created_by: null,
title: "Untitled task",
description: "Summarize this week's shipped changes",
}),
children: <MockTaskCard title="Summarize this week's shipped changes" />,
},
};
125 changes: 76 additions & 49 deletions packages/ui/src/features/canvas/components/ChannelFeedView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
RobotIcon,
} from "@phosphor-icons/react";
import { taskFeedRunStatus } from "@posthog/core/canvas/channelFeed";
import { xmlToPlainText } from "@posthog/core/message-editor/content";
import {
Avatar,
AvatarFallback,
Expand Down Expand Up @@ -37,10 +38,13 @@ import {
useChatMessageScroller,
} from "@posthog/quill";
import { formatRelativeTimeShort, getLocalDayDiff } from "@posthog/shared";
import type { Task, TaskRunStatus } from "@posthog/shared/domain-types";
import type {
Task,
TaskRunStatus,
UserBasic,
} from "@posthog/shared/domain-types";
import { getUserInitials } from "@posthog/ui/features/auth/userInitials";
import { TaskTabIcon } from "@posthog/ui/features/browser-tabs/TaskTabIcon";
import { mentionChipClass } from "@posthog/ui/features/canvas/components/MentionText";
import type { ChannelFeedSystemMessage } from "@posthog/ui/features/canvas/hooks/useChannelFeedMessages";
import { useChannelTaskData } from "@posthog/ui/features/canvas/hooks/useChannelTaskData";
import { useTaskThread } from "@posthog/ui/features/canvas/hooks/useTaskThread";
Expand Down Expand Up @@ -411,81 +415,104 @@ function ReplyFooter({
);
}

const FeedItem = memo(function FeedItem({
function channelTaskStarter(task: Task): UserBasic | null {
return task.origin_product === "user_created"
? (task.created_by ?? null)
: null;
}

export function TaskFeedRow({
task,
channelId,
inView,
onOpenTask,
onOpenThread,
actions,
children,
}: {
task: Task;
channelId: string;
inView: boolean;
onOpenTask: (task: Task) => void;
onOpenThread: (task: Task) => void;
actions?: ReactNode;
children?: ReactNode;
}) {
const starter = channelTaskStarter(task);
const prompt = useMemo(
() => xmlToPlainText(task.description ?? "").trim(),
[task.description],
);

return (
<ThreadItem className="rounded-none py-1 pr-8 hover:bg-fill-hover/50">
<ThreadItemGutter>
<Avatar>
<AvatarFallback>
<RobotIcon size={16} />
{starter ? getUserInitials(starter) : <RobotIcon size={16} />}
</AvatarFallback>
</Avatar>
</ThreadItemGutter>

<ThreadItemContent className="min-w-0">
<ThreadItemHeader>
<ThreadItemAuthor>PostHog</ThreadItemAuthor>
<Badge variant="info">Agent</Badge>
<ThreadItemAuthor>
{starter ? userDisplayName(starter) : "PostHog"}
</ThreadItemAuthor>
{!starter && <Badge variant="info">Agent</Badge>}
Comment on lines +452 to +454

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Missing Creator Changes Task Origin

When a user_created task has no created_by value, starter becomes null and the row labels it as a PostHog agent task. Deleted users, imported tasks, or incomplete API records can therefore turn a human-origin task into an agent-origin message instead of showing an unknown human fallback.

<ThreadItemTimestamp
dateTime={new Date(task.created_at).toISOString()}
>
{formatRelativeTimeShort(task.created_at)}
</ThreadItemTimestamp>
</ThreadItemHeader>

<ThreadItemBody className="wrap-break-word">
{/* Only attribute channel-started tasks: other origins (Slack,
automations) carry a created_by who didn't start it here. */}
{task.origin_product === "user_created" && task.created_by ? (
<>
{/* Mention-styled but rendered inert: the starter shouldn't be
notified about their own task. */}
<span className={mentionChipClass}>
@{userDisplayName(task.created_by)}
</span>{" "}
started a new task
</>
) : (
"A new task was started"
)}
<ThreadItemBody className="wrap-break-word line-clamp-2 whitespace-pre-wrap">
{prompt ||
(starter ? "started a new task" : "A new task was started")}
</ThreadItemBody>

<TaskCard
task={task}
channelId={channelId}
onOpen={() => onOpenThread(task)}
/>
<ReplyFooter
taskId={task.id}
inView={inView}
onOpenThread={() => onOpenThread(task)}
/>
{children}
</ThreadItemContent>

{/* Replying now lives in the always-visible ReplyFooter, so the hover
toolbar only carries the distinct "Open task" action. Actions anchor
to the row's top-right corner; a top tooltip there overhangs the panel
edge and gets clipped by the scroll container, so open tooltips toward
the content instead. */}
<ThreadItemActions aria-label="Message actions" className="inset-bs-2">
<ThreadItemAction label="Open task" onClick={() => onOpenTask(task)}>
<ArrowSquareOutIcon size={15} />
</ThreadItemAction>
</ThreadItemActions>
{actions}
</ThreadItem>
);
}

const FeedItem = memo(function FeedItem({
task,
channelId,
inView,
onOpenTask,
onOpenThread,
}: {
task: Task;
channelId: string;
inView: boolean;
onOpenTask: (task: Task) => void;
onOpenThread: (task: Task) => void;
}) {
return (
<TaskFeedRow
task={task}
actions={
// Replying now lives in the always-visible ReplyFooter, so the hover
// toolbar only carries the distinct "Open task" action. Actions anchor
// to the row's top-right corner; a top tooltip there overhangs the panel
// edge and gets clipped by the scroll container, so open tooltips toward
// the content instead.
<ThreadItemActions aria-label="Message actions" className="inset-bs-2">
<ThreadItemAction label="Open task" onClick={() => onOpenTask(task)}>
<ArrowSquareOutIcon size={15} />
</ThreadItemAction>
</ThreadItemActions>
}
>
<TaskCard
task={task}
channelId={channelId}
onOpen={() => onOpenThread(task)}
/>
<ReplyFooter
taskId={task.id}
inView={inView}
onOpenThread={() => onOpenThread(task)}
/>
</TaskFeedRow>
);
});

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