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

Commit dccfb47

Browse files
adamleithpclaudek11kirky
authored
feat(canvas): context onboarding with a slack-style channel intro (#3370)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Peter Kirkham <k11kirky@gmail.com>
1 parent e1977d1 commit dccfb47

18 files changed

Lines changed: 1036 additions & 443 deletions

packages/api-client/src/posthog-client.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ import type {
5252
ActionabilityJudgmentArtefact,
5353
AvailableSuggestedReviewer,
5454
AvailableSuggestedReviewersResponse,
55+
ChannelFeedMessage,
56+
ChannelFeedMessageEvent,
5557
CodeReferenceArtefact,
5658
CommitArtefact,
5759
CommitDiffResponse,
@@ -2525,6 +2527,56 @@ export class PostHogAPIClient {
25252527
return (await response.json()) as TaskChannel;
25262528
}
25272529

2530+
// A channel's system-announcement feed (context created, CONTEXT.md being
2531+
// built), chronological. Durable + team-visible, rendered alongside task cards.
2532+
async getChannelFeed(channelId: string): Promise<ChannelFeedMessage[]> {
2533+
const teamId = await this.getTeamId();
2534+
const urlPath = `/api/projects/${teamId}/task_channels/${channelId}/feed/`;
2535+
const response = await this.api.fetcher.fetch({
2536+
method: "get",
2537+
url: new URL(`${this.api.baseUrl}${urlPath}`),
2538+
path: urlPath,
2539+
});
2540+
if (!response.ok) {
2541+
throw new Error(`Failed to fetch channel feed: ${response.statusText}`);
2542+
}
2543+
return (await response.json()) as ChannelFeedMessage[];
2544+
}
2545+
2546+
// Post a system announcement into a channel's feed. The row is authored by the
2547+
// system; the server records the requester as `author` for "Adam …" rendering.
2548+
async postChannelFeedMessage(
2549+
channelId: string,
2550+
input: {
2551+
event: ChannelFeedMessageEvent;
2552+
payload?: Record<string, unknown>;
2553+
// Optional explicit timestamp (ISO) so a burst of announcements orders
2554+
// deterministically instead of racing on server insert time.
2555+
createdAt?: string;
2556+
},
2557+
): Promise<ChannelFeedMessage> {
2558+
const teamId = await this.getTeamId();
2559+
const urlPath = `/api/projects/${teamId}/task_channels/${channelId}/feed/`;
2560+
const response = await this.api.fetcher.fetch({
2561+
method: "post",
2562+
url: new URL(`${this.api.baseUrl}${urlPath}`),
2563+
path: urlPath,
2564+
overrides: {
2565+
body: JSON.stringify({
2566+
event: input.event,
2567+
payload: input.payload ?? {},
2568+
...(input.createdAt ? { created_at: input.createdAt } : {}),
2569+
}),
2570+
},
2571+
});
2572+
if (!response.ok) {
2573+
throw new Error(
2574+
`Failed to post channel feed message: ${response.statusText}`,
2575+
);
2576+
}
2577+
return (await response.json()) as ChannelFeedMessage;
2578+
}
2579+
25282580
// Mentions of the current user across task threads, newest first.
25292581
async getTaskMentions(options?: { since?: string }): Promise<TaskMention[]> {
25302582
const teamId = await this.getTeamId();

packages/shared/src/domain-types.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,27 @@ export interface TaskChannel {
7979
created_by?: UserBasic | null;
8080
}
8181

82+
/** Lifecycle events a client may post into a channel's feed. */
83+
export type ChannelFeedMessageEvent = "context_md_building";
84+
85+
/**
86+
* A durable, team-visible "PostHog agent" announcement in a channel's feed —
87+
* rendered alongside task cards (e.g. "Adam created this context"). `author` is
88+
* the user whose action produced the row; `author_kind` says who authored it.
89+
* `payload` carries structured event data (e.g. `{ context_name }`) so rendering
90+
* survives renames.
91+
*/
92+
export interface ChannelFeedMessage {
93+
id: string;
94+
channel: string;
95+
author?: UserBasic | null;
96+
author_kind: "human" | "system" | "agent";
97+
event: ChannelFeedMessageEvent | string;
98+
payload: Record<string, unknown>;
99+
content: string;
100+
created_at: string;
101+
}
102+
82103
/**
83104
* One human message in a task's thread. Thread messages never reach the agent
84105
* unless the task author forwards one, which stamps the forwarded_* fields.

packages/shared/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,7 @@ export type {
241241
export {
242242
formatRelativeTimeLong,
243243
formatRelativeTimeShort,
244+
getLocalDayDiff,
244245
getRelativeDateGroup,
245246
} from "./time";
246247
export {

packages/shared/src/time.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
22
import {
33
formatRelativeTimeLong,
44
formatRelativeTimeShort,
5+
getLocalDayDiff,
56
getRelativeDateGroup,
67
} from "./time";
78

@@ -67,6 +68,29 @@ describe("formatRelativeTimeLong", () => {
6768
});
6869
});
6970

71+
describe("getLocalDayDiff", () => {
72+
it("returns 0 for any moment on the same local day", () => {
73+
expect(getLocalDayDiff(NOW - 2 * HOUR)).toBe(0);
74+
});
75+
76+
it("counts calendar days, not 24h windows", () => {
77+
// 1h ago but across the local midnight boundary is still "yesterday".
78+
const justAfterMidnight = new Date(NOW);
79+
justAfterMidnight.setHours(0, 30, 0, 0);
80+
vi.setSystemTime(justAfterMidnight);
81+
expect(getLocalDayDiff(justAfterMidnight.getTime() - HOUR)).toBe(1);
82+
});
83+
84+
it("accepts an ISO string and an explicit now", () => {
85+
const now = new Date(NOW);
86+
expect(getLocalDayDiff(new Date(NOW - 3 * DAY).toISOString(), now)).toBe(3);
87+
});
88+
89+
it("returns negative for future days", () => {
90+
expect(getLocalDayDiff(NOW + 2 * DAY)).toBe(-2);
91+
});
92+
});
93+
7094
describe("getRelativeDateGroup", () => {
7195
it("returns null for today", () => {
7296
expect(getRelativeDateGroup(NOW - 2 * HOUR)).toBeNull();

packages/shared/src/time.ts

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -51,17 +51,25 @@ export function formatRelativeTimeLong(timestamp: number | string): string {
5151
});
5252
}
5353

54+
/**
55+
* Whole local calendar days between `timestamp` and `now` (0 = today,
56+
* 1 = yesterday, negative = future). Uses local-midnight boundaries so the
57+
* split lands on the viewer's midnight, not a UTC one.
58+
*/
59+
export function getLocalDayDiff(
60+
timestamp: number | string | Date,
61+
now: Date = new Date(),
62+
): number {
63+
const date = new Date(timestamp);
64+
const startOfDay = (d: Date) =>
65+
new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
66+
return Math.round((startOfDay(now) - startOfDay(date)) / 86_400_000);
67+
}
68+
5469
export function getRelativeDateGroup(
5570
timestamp: number | string,
5671
): string | null {
57-
const date = new Date(timestamp);
58-
const startOfToday = new Date();
59-
startOfToday.setHours(0, 0, 0, 0);
60-
const startOfDate = new Date(date);
61-
startOfDate.setHours(0, 0, 0, 0);
62-
const days = Math.round(
63-
(startOfToday.getTime() - startOfDate.getTime()) / 86_400_000,
64-
);
72+
const days = getLocalDayDiff(timestamp);
6573
if (days <= 0) return null;
6674
if (days === 1) return "Yesterday";
6775
if (days < 7) return "This week";

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

Lines changed: 109 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -34,12 +34,13 @@ import {
3434
ThreadItemRepliesMeta,
3535
ThreadItemTimestamp,
3636
} from "@posthog/quill";
37-
import { formatRelativeTimeShort } from "@posthog/shared";
37+
import { formatRelativeTimeShort, getLocalDayDiff } from "@posthog/shared";
3838
import type { Task, TaskRunStatus } from "@posthog/shared/domain-types";
3939
import { isTerminalStatus } from "@posthog/shared/domain-types";
4040
import { getUserInitials } from "@posthog/ui/features/auth/userInitials";
4141
import { TaskTabIcon } from "@posthog/ui/features/browser-tabs/TaskTabIcon";
4242
import { mentionChipClass } from "@posthog/ui/features/canvas/components/MentionText";
43+
import type { ChannelFeedSystemMessage } from "@posthog/ui/features/canvas/hooks/useChannelFeedMessages";
4344
import { useChannelTaskData } from "@posthog/ui/features/canvas/hooks/useChannelTaskData";
4445
import { useTaskThread } from "@posthog/ui/features/canvas/hooks/useTaskThread";
4546
import { userDisplayName } from "@posthog/ui/features/canvas/utils/userDisplay";
@@ -116,9 +117,7 @@ function ordinal(n: number): string {
116117
// year when it differs) further back so older separators stay unambiguous.
117118
function dayLabel(iso: string, now: Date): string {
118119
const date = new Date(iso);
119-
const startOfDay = (d: Date) =>
120-
new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
121-
const days = Math.round((startOfDay(now) - startOfDay(date)) / 86_400_000);
120+
const days = getLocalDayDiff(date, now);
122121
if (days <= 0) return "Today";
123122
if (days === 1) return "Yesterday";
124123
const weekday = date.toLocaleDateString(undefined, { weekday: "long" });
@@ -265,9 +264,9 @@ function TaskCard({ task, onOpen }: { task: Task; onOpen: () => void }) {
265264
nav never disagree (generating spinner, needs-permission, cloud
266265
status colors, PR state). */}
267266
<TaskTabIcon task={task} size={14} />
268-
<Text size="2" weight="medium" className="line-clamp-2">
267+
<span className="line-clamp-2 font-medium text-sm">
269268
{task.title || "Untitled task"}
270-
</Text>
269+
</span>
271270
</div>
272271
<TaskStatusBadge display={statusDisplay} />
273272
</div>
@@ -379,7 +378,7 @@ const FeedItem = memo(function FeedItem({
379378
onOpenThread: (task: Task) => void;
380379
}) {
381380
return (
382-
<ThreadItem className="rounded-none py-4 pr-8 hover:bg-fill-hover/50">
381+
<ThreadItem className="rounded-none py-1 pr-8 hover:bg-fill-hover/50">
383382
<ThreadItemGutter>
384383
<Avatar>
385384
<AvatarFallback>
@@ -472,31 +471,116 @@ function FeedRow({
472471
);
473472
}
474473

474+
// A card-less feed row for a synthetic announcement. Rows with an `author`
475+
// render as that user (initials avatar + name — e.g. "Adam L · joined mobile");
476+
// the rest render as "PostHog / Agent" (context lifecycle updates). Same chrome
477+
// as a task row, minus the task card and reply footer.
478+
function SystemFeedRow({ message }: { message: ChannelFeedSystemMessage }) {
479+
return (
480+
<ChatMessageScrollerItem messageId={message.id}>
481+
<ThreadItem className="rounded-none py-1 pr-8">
482+
<ThreadItemGutter>
483+
<Avatar>
484+
<AvatarFallback>
485+
{message.author ? (
486+
getUserInitials(message.author)
487+
) : (
488+
<RobotIcon size={16} />
489+
)}
490+
</AvatarFallback>
491+
</Avatar>
492+
</ThreadItemGutter>
493+
<ThreadItemContent className="min-w-0">
494+
<ThreadItemHeader>
495+
<ThreadItemAuthor>
496+
{message.author ? userDisplayName(message.author) : "PostHog"}
497+
</ThreadItemAuthor>
498+
{!message.author && <Badge variant="info">Agent</Badge>}
499+
<ThreadItemTimestamp dateTime={message.createdAt}>
500+
{formatRelativeTimeShort(message.createdAt)}
501+
</ThreadItemTimestamp>
502+
</ThreadItemHeader>
503+
<ThreadItemBody className="wrap-break-word text-muted-foreground">
504+
{message.text}
505+
</ThreadItemBody>
506+
</ThreadItemContent>
507+
</ThreadItem>
508+
</ChatMessageScrollerItem>
509+
);
510+
}
511+
512+
// A single feed entry, either a real task card or a synthetic system row, tagged
513+
// with the timestamp used to interleave the two.
514+
type FeedEntry =
515+
| { kind: "task"; id: string; createdAt: string; task: Task }
516+
| {
517+
kind: "system";
518+
id: string;
519+
createdAt: string;
520+
message: ChannelFeedSystemMessage;
521+
};
522+
475523
// The Slack-style channel feed: every task kicked off in the channel, oldest
476524
// first, rendered as a kickoff message + task card. Multiplayer — the list is
477-
// team-visible and polls for teammates' cards and status flips.
525+
// team-visible and polls for teammates' cards and status flips. Synthetic
526+
// "PostHog agent" system rows (context lifecycle) are interleaved by timestamp.
478527
export function ChannelFeedView({
479528
tasks,
529+
systemMessages,
480530
isLoading,
481531
emptyState,
532+
intro,
482533
onOpenTask,
483534
onOpenThread,
484535
}: {
485536
tasks: Task[];
537+
systemMessages?: ChannelFeedSystemMessage[];
486538
isLoading: boolean;
487539
emptyState?: React.ReactNode;
540+
/** Rendered pinned above the first entry — the Slack-style channel intro
541+
* (name, creation line, onboarding card). When set, the feed renders even
542+
* with no entries instead of falling back to `emptyState`. */
543+
intro?: ReactNode;
488544
onOpenTask: (task: Task) => void;
489545
onOpenThread: (task: Task) => void;
490546
}) {
491-
if (isLoading && tasks.length === 0) {
547+
// Merge tasks + system rows into one chronological list. ISO timestamps sort
548+
// lexically, so a plain string compare is chronological. Announcements are
549+
// posted 1ms before the task they describe; if the backend truncates that
550+
// sub-second offset the timestamps tie, so break ties system-row-first to
551+
// keep the announcement above its card.
552+
const entries = useMemo<FeedEntry[]>(() => {
553+
const merged: FeedEntry[] = [
554+
...tasks.map((task) => ({
555+
kind: "task" as const,
556+
id: task.id,
557+
createdAt: task.created_at,
558+
task,
559+
})),
560+
...(systemMessages ?? []).map((message) => ({
561+
kind: "system" as const,
562+
id: message.id,
563+
createdAt: message.createdAt,
564+
message,
565+
})),
566+
];
567+
merged.sort(
568+
(a, b) =>
569+
a.createdAt.localeCompare(b.createdAt) ||
570+
(a.kind === b.kind ? 0 : a.kind === "system" ? -1 : 1),
571+
);
572+
return merged;
573+
}, [tasks, systemMessages]);
574+
575+
if (isLoading && entries.length === 0) {
492576
return (
493577
<div className="flex flex-1 items-center justify-center">
494578
<Spinner />
495579
</div>
496580
);
497581
}
498582

499-
if (tasks.length === 0) {
583+
if (entries.length === 0 && !intro) {
500584
return <div className="flex-1 overflow-y-auto">{emptyState}</div>;
501585
}
502586

@@ -510,25 +594,30 @@ export function ChannelFeedView({
510594
the row's top-right corner (absolute, past the row edge). Without a
511595
gutter they hug the scroll container and get clipped. */}
512596
<ChatMessageScrollerContent className="mx-auto w-full gap-0 py-4">
513-
{tasks.map((task, index) => {
514-
const previous = tasks[index - 1];
597+
{intro}
598+
{entries.map((entry, index) => {
599+
const previous = entries[index - 1];
515600
const showDayMarker =
516601
!previous ||
517-
dayKey(previous.created_at) !== dayKey(task.created_at);
602+
dayKey(previous.createdAt) !== dayKey(entry.createdAt);
518603
return (
519-
<Fragment key={task.id}>
604+
<Fragment key={entry.id}>
520605
{showDayMarker && (
521606
<ChatMarker variant="separator">
522607
<ChatMarkerContent>
523-
{dayLabel(task.created_at, now)}
608+
{dayLabel(entry.createdAt, now)}
524609
</ChatMarkerContent>
525610
</ChatMarker>
526611
)}
527-
<FeedRow
528-
task={task}
529-
onOpenTask={onOpenTask}
530-
onOpenThread={onOpenThread}
531-
/>
612+
{entry.kind === "task" ? (
613+
<FeedRow
614+
task={entry.task}
615+
onOpenTask={onOpenTask}
616+
onOpenThread={onOpenThread}
617+
/>
618+
) : (
619+
<SystemFeedRow message={entry.message} />
620+
)}
532621
</Fragment>
533622
);
534623
})}

0 commit comments

Comments
 (0)