Skip to content
Open
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
52 changes: 52 additions & 0 deletions packages/api-client/src/posthog-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ import type {
ActionabilityJudgmentArtefact,
AvailableSuggestedReviewer,
AvailableSuggestedReviewersResponse,
ChannelFeedMessage,
ChannelFeedMessageEvent,
CodeReferenceArtefact,
CommitArtefact,
CommitDiffResponse,
Expand Down Expand Up @@ -2467,6 +2469,56 @@ export class PostHogAPIClient {
return (await response.json()) as TaskChannel;
}

// A channel's system-announcement feed (context created, CONTEXT.md being
// built), chronological. Durable + team-visible, rendered alongside task cards.
async getChannelFeed(channelId: string): Promise<ChannelFeedMessage[]> {
const teamId = await this.getTeamId();
const urlPath = `/api/projects/${teamId}/task_channels/${channelId}/feed/`;
const response = await this.api.fetcher.fetch({
method: "get",
url: new URL(`${this.api.baseUrl}${urlPath}`),
path: urlPath,
});
if (!response.ok) {
throw new Error(`Failed to fetch channel feed: ${response.statusText}`);
}
return (await response.json()) as ChannelFeedMessage[];
}

// Post a system announcement into a channel's feed. The row is authored by the
// system; the server records the requester as `author` for "Adam …" rendering.
async postChannelFeedMessage(
channelId: string,
input: {
event: ChannelFeedMessageEvent;
payload?: Record<string, unknown>;
// Optional explicit timestamp (ISO) so a burst of announcements orders
// deterministically instead of racing on server insert time.
createdAt?: string;
},
): Promise<ChannelFeedMessage> {
const teamId = await this.getTeamId();
const urlPath = `/api/projects/${teamId}/task_channels/${channelId}/feed/`;
const response = await this.api.fetcher.fetch({
method: "post",
url: new URL(`${this.api.baseUrl}${urlPath}`),
path: urlPath,
overrides: {
body: JSON.stringify({
event: input.event,
payload: input.payload ?? {},
...(input.createdAt ? { created_at: input.createdAt } : {}),
}),
},
});
if (!response.ok) {
throw new Error(
`Failed to post channel feed message: ${response.statusText}`,
);
}
return (await response.json()) as ChannelFeedMessage;
}

// Mentions of the current user across task threads, newest first.
async getTaskMentions(options?: { since?: string }): Promise<TaskMention[]> {
const teamId = await this.getTeamId();
Expand Down
21 changes: 21 additions & 0 deletions packages/shared/src/domain-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,27 @@ export interface TaskChannel {
created_by?: UserBasic | null;
}

/** Lifecycle events a client may post into a channel's feed. */
export type ChannelFeedMessageEvent = "context_md_building";

/**
* A durable, team-visible "PostHog agent" announcement in a channel's feed —
* rendered alongside task cards (e.g. "Adam created this context"). `author` is
* the user whose action produced the row; `author_kind` says who authored it.
* `payload` carries structured event data (e.g. `{ context_name }`) so rendering
* survives renames.
*/
export interface ChannelFeedMessage {
id: string;
channel: string;
author?: UserBasic | null;
author_kind: "human" | "system" | "agent";
event: ChannelFeedMessageEvent | string;
payload: Record<string, unknown>;
content: string;
created_at: string;
}

/**
* One human message in a task's thread. Thread messages never reach the agent
* unless the task author forwards one, which stamps the forwarded_* fields.
Expand Down
6 changes: 3 additions & 3 deletions packages/ui/src/features/browser-tabs/BrowserTabStrip.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import {
BrainIcon,
HashIcon,
HouseIcon,
PlugsConnectedIcon,
RobotIcon,
Expand Down Expand Up @@ -50,6 +49,7 @@ import {
useRouter,
useRouterState,
} from "@tanstack/react-router";
import { SquircleDashed } from "lucide-react";
import { type ReactNode, useEffect, useMemo } from "react";
import { useHotkeys } from "react-hotkeys-hook";
import {
Expand Down Expand Up @@ -519,8 +519,8 @@ export function BrowserTabStrip() {
const meta = channelSectionFor(section);
return {
id: t.id,
label: meta?.label ?? channel ?? "Channel",
icon: <HashIcon size={14} />,
label: meta?.label ?? channel ?? "Context",
icon: <SquircleDashed size={14} />,
channelName: channel,
// No section meta → the channel's index page.
isChannelHome: !meta,
Expand Down
2 changes: 1 addition & 1 deletion packages/ui/src/features/browser-tabs/TabStrip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,7 @@ function SortableTabPill({
repeat the channel-home name already shown above. */}
{tab.channelName ? (
<div className="text-muted">
#{tab.channelName}
{tab.channelName}
{tab.isChannelHome ? " / home" : null}
</div>
) : null}
Expand Down
4 changes: 2 additions & 2 deletions packages/ui/src/features/canvas/components/ActivityView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ function ActivityRow({
<>
{" in "}
<Text as="span" size="1" weight="medium">
#{item.channelName}
{item.channelName}
</Text>
</>
)}
Expand Down Expand Up @@ -176,7 +176,7 @@ export function ActivityView() {
Activity
</Text>
<Text size="2" className="block text-muted-foreground">
Mentions of you across channel threads.
Mentions of you across contexts.
</Text>
<div className="mt-4">
{isLoading && items.length === 0 ? (
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { HashIcon } from "@phosphor-icons/react";
import {
Button,
Tooltip,
Expand All @@ -8,6 +7,7 @@ import {
import { HeaderTitleEditor } from "@posthog/ui/features/task-detail/HeaderTitleEditor";
import { Flex, Text } from "@radix-ui/themes";
import { useNavigate } from "@tanstack/react-router";
import { SquircleDashed } from "lucide-react";
import { type ReactNode, useState } from "react";

interface ChannelBreadcrumbProps {
Expand Down Expand Up @@ -48,7 +48,10 @@ export function ChannelBreadcrumb({

const channelSegment = (
<>
<HashIcon size={12} className="mt-px shrink-0 text-muted-foreground/80" />
<SquircleDashed
size={12}
className="mt-px shrink-0 text-muted-foreground/80"
/>
<Text
className="min-w-0 truncate whitespace-nowrap font-medium text-[13px]"
title={channelName}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ describe("ChannelContextPanel", () => {
it.each([
{
channelName: "project-bluebird",
expectedTitle: "#project-bluebird CONTEXT.md",
expectedTitle: "project-bluebird CONTEXT.md",
},
{ channelName: undefined, expectedTitle: "CONTEXT.md" },
])(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export function ChannelContextPanel({
weight="medium"
className="min-w-0 truncate text-gray-12"
>
{channelName ? `#${channelName} ` : ""}CONTEXT.md
{channelName ? `${channelName} ` : ""}CONTEXT.md
</Text>
<Tooltip content="Close">
<button
Expand Down
97 changes: 84 additions & 13 deletions packages/ui/src/features/canvas/components/ChannelFeedView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import { isTerminalStatus } 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";
import { userDisplayName } from "@posthog/ui/features/canvas/utils/userDisplay";
Expand Down Expand Up @@ -472,31 +473,97 @@ function FeedRow({
);
}

// A card-less feed row for a synthetic "PostHog agent" announcement (context
// created, CONTEXT.md being built). Same chrome as a task row — Robot avatar,
// "PostHog / Agent" — minus the task card and reply footer.
function SystemFeedRow({ message }: { message: ChannelFeedSystemMessage }) {
return (
<ChatMessageScrollerItem messageId={message.id}>
<ThreadItem className="rounded-none py-4 pr-8">
<ThreadItemGutter>
<Avatar>
<AvatarFallback>
<RobotIcon size={16} />
</AvatarFallback>
</Avatar>
</ThreadItemGutter>
<ThreadItemContent className="min-w-0">
<ThreadItemHeader>
<ThreadItemAuthor>PostHog</ThreadItemAuthor>
<Badge variant="info">Agent</Badge>
<ThreadItemTimestamp dateTime={message.createdAt}>
{formatRelativeTimeShort(message.createdAt)}
</ThreadItemTimestamp>
</ThreadItemHeader>
<ThreadItemBody className="wrap-break-word text-muted-foreground">
{message.text}
</ThreadItemBody>
</ThreadItemContent>
</ThreadItem>
</ChatMessageScrollerItem>
);
}

// A single feed entry, either a real task card or a synthetic system row, tagged
// with the timestamp used to interleave the two.
type FeedEntry =
| { kind: "task"; id: string; createdAt: string; task: Task }
| {
kind: "system";
id: string;
createdAt: string;
message: ChannelFeedSystemMessage;
};

// The Slack-style channel feed: every task kicked off in the channel, oldest
// first, rendered as a kickoff message + task card. Multiplayer — the list is
// team-visible and polls for teammates' cards and status flips.
// team-visible and polls for teammates' cards and status flips. Synthetic
// "PostHog agent" system rows (context lifecycle) are interleaved by timestamp.
export function ChannelFeedView({
tasks,
systemMessages,
isLoading,
emptyState,
onOpenTask,
onOpenThread,
}: {
tasks: Task[];
systemMessages?: ChannelFeedSystemMessage[];
isLoading: boolean;
emptyState?: React.ReactNode;
onOpenTask: (task: Task) => void;
onOpenThread: (task: Task) => void;
}) {
if (isLoading && tasks.length === 0) {
// Merge tasks + system rows into one chronological list. ISO timestamps sort
// lexically, so a plain string compare is chronological.
const entries = useMemo<FeedEntry[]>(() => {
const merged: FeedEntry[] = [
...tasks.map((task) => ({
kind: "task" as const,
id: task.id,
createdAt: task.created_at,
task,
})),
...(systemMessages ?? []).map((message) => ({
kind: "system" as const,
id: message.id,
createdAt: message.createdAt,
message,
})),
];
merged.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
return merged;
}, [tasks, systemMessages]);

if (isLoading && entries.length === 0) {
return (
<div className="flex flex-1 items-center justify-center">
<Spinner />
</div>
);
}

if (tasks.length === 0) {
if (entries.length === 0) {
return <div className="flex-1 overflow-y-auto">{emptyState}</div>;
}

Expand All @@ -510,25 +577,29 @@ export function ChannelFeedView({
the row's top-right corner (absolute, past the row edge). Without a
gutter they hug the scroll container and get clipped. */}
<ChatMessageScrollerContent className="mx-auto w-full gap-0 py-4">
{tasks.map((task, index) => {
const previous = tasks[index - 1];
{entries.map((entry, index) => {
const previous = entries[index - 1];
const showDayMarker =
!previous ||
dayKey(previous.created_at) !== dayKey(task.created_at);
dayKey(previous.createdAt) !== dayKey(entry.createdAt);
return (
<Fragment key={task.id}>
<Fragment key={entry.id}>
{showDayMarker && (
<ChatMarker variant="separator">
<ChatMarkerContent>
{dayLabel(task.created_at, now)}
{dayLabel(entry.createdAt, now)}
</ChatMarkerContent>
</ChatMarker>
)}
<FeedRow
task={task}
onOpenTask={onOpenTask}
onOpenThread={onOpenThread}
/>
{entry.kind === "task" ? (
<FeedRow
task={entry.task}
onOpenTask={onOpenTask}
onOpenThread={onOpenThread}
/>
) : (
<SystemFeedRow message={entry.message} />
)}
</Fragment>
);
})}
Expand Down
15 changes: 6 additions & 9 deletions packages/ui/src/features/canvas/components/ChannelHeader.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { HashIcon } from "@phosphor-icons/react";
import { Button, cn } from "@posthog/quill";
import { ChannelTabs } from "@posthog/ui/features/canvas/components/ChannelTabs";
import { useChannels } from "@posthog/ui/features/canvas/hooks/useChannels";
import { Text } from "@radix-ui/themes";
import { useNavigate, useRouterState } from "@tanstack/react-router";
import { SquircleDashed } from "lucide-react";

// The shared channel header: a clickable "# channel" that doubles as the Home
// item — it routes to the channel home (`/website/$channelId`, like the sidebar
Expand All @@ -29,15 +29,12 @@ export function ChannelHeader({ channelId }: { channelId: string }) {
size="sm"
className={cn("min-w-0", isHome ? "bg-fill-selected" : "")}
>
<HashIcon
size={12}
className="mt-px shrink-0 text-muted-foreground/80"
<SquircleDashed
size={20}
className="shrink-0 text-muted-foreground/80"
/>
<Text
className="min-w-0 truncate font-medium text-[13px]"
title={channelName}
>
{channelName ?? "Channel"}
<Text className="min-w-0 truncate font-medium" title={channelName}>
{channelName ?? "Context"}
</Text>
</Button>
<ChannelTabs channelId={channelId} />
Expand Down
Loading