Skip to content

Commit 320dd0e

Browse files
authored
Merge branch 'main' into posthog-code/per-task-cost-estimate
2 parents d3368ab + 3d788e3 commit 320dd0e

28 files changed

Lines changed: 970 additions & 133 deletions

packages/core/src/archive/archiveOrchestration.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ class Harness {
2929
stopCloudRun: vi.fn().mockResolvedValue(true),
3030
disconnectFromTask: vi.fn().mockResolvedValue(undefined),
3131
archive: vi.fn().mockResolvedValue(undefined),
32+
clearViewedState: vi.fn(),
3233
logError: vi.fn(),
3334
cache: {
3435
cancelPathFilter: vi.fn().mockResolvedValue(undefined),
@@ -59,10 +60,19 @@ describe("archiveTask", () => {
5960

6061
expect(harness.deps.archive).toHaveBeenCalledWith(TASK_ID);
6162
expect(harness.deps.disconnectFromTask).toHaveBeenCalledWith(TASK_ID);
63+
expect(harness.deps.clearViewedState).toHaveBeenCalledWith(TASK_ID);
6264
expect(harness.ids).toContain(TASK_ID);
6365
expect(harness.list.some((a) => a.taskId === TASK_ID)).toBe(true);
6466
});
6567

68+
it("does not clear read state when the archive request fails", async () => {
69+
harness.deps.archive = vi.fn().mockRejectedValue(new Error("boom"));
70+
71+
await expect(archiveTask(TASK_ID, harness.deps)).rejects.toThrow("boom");
72+
73+
expect(harness.deps.clearViewedState).not.toHaveBeenCalled();
74+
});
75+
6676
it("with optimistic:false, defers cache writes until archive resolves", async () => {
6777
let idsWhenArchiveCalled: string[] = ["sentinel"];
6878
harness.deps.archive = vi.fn().mockImplementation(async () => {

packages/core/src/archive/archiveOrchestration.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ export interface ArchiveOrchestrationDeps {
3939
stopCloudRun(taskId: string, runId?: string): Promise<boolean>;
4040
disconnectFromTask(taskId: string): Promise<void>;
4141
archive(taskId: string): Promise<void>;
42+
clearViewedState(taskId: string): void;
4243
logError(message: string, error: unknown): void;
4344
cache: ArchiveCacheWriter;
4445
}
@@ -103,9 +104,8 @@ export async function archiveTask(
103104
try {
104105
await deps.disconnectFromTask(taskId);
105106
await deps.archive(taskId);
106-
// Destroying terminals is irreversible, so it waits for the archive to
107-
// commit; a failed archive keeps its live terminals.
108107
deps.clearTerminalStates(taskId);
108+
deps.clearViewedState(taskId);
109109
// Non-optimistic flows keep the row visible during the request, then remove
110110
// it the moment the archive succeeds.
111111
if (!optimistic) {

packages/core/src/git/router-schemas.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ export const changedFileSchema = z.object({
4242
linesRemoved: z.number().optional(),
4343
staged: z.boolean().optional(),
4444
patch: z.string().optional(),
45+
sha: z.string().optional(),
4546
});
4647

4748
export type ChangedFile = z.infer<typeof changedFileSchema>;

packages/shared/src/domain-types.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,12 @@ export interface ChannelFeedMessage {
107107
export interface TaskThreadMessage {
108108
id: string;
109109
task: string;
110+
/** Who authored the row; agent rows are server-emitted announcements. Absent on older backends. */
111+
author_kind?: "human" | "system" | "agent";
112+
/** Stable event key for non-human rows (e.g. "canvas_created", "turn_complete"). */
113+
event?: string;
114+
/** Structured event payload; turn_complete carries `{ run_id }` so a client rendering a run's live agent turns can dedupe the durable row. */
115+
payload?: Record<string, unknown>;
110116
content: string;
111117
created_at: string;
112118
author?: UserBasic | null;
@@ -364,6 +370,7 @@ export interface ChangedFile {
364370
linesRemoved?: number;
365371
staged?: boolean;
366372
patch?: string; // Unified diff patch from GitHub API
373+
sha?: string;
367374
}
368375

369376
// External apps detection types

packages/ui/src/features/archive/useArchiveTask.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
type HostTrpcClient,
1717
} from "@posthog/host-router/client";
1818
import { useHostTRPC } from "@posthog/host-router/react";
19+
import { useReviewViewedStore } from "@posthog/ui/features/code-review/reviewViewedStore";
1920
import { useCommandCenterStore } from "@posthog/ui/features/command-center/commandCenterStore";
2021
import { useFocusStore } from "@posthog/ui/features/focus/focusStore";
2122
import { pinnedTasksApi } from "@posthog/ui/features/sidebar/taskMetaApi";
@@ -127,6 +128,8 @@ function makeOrchestrationDeps(
127128
),
128129
archive: (taskId) =>
129130
hostClient.archive.archive.mutate({ taskId }).then(() => undefined),
131+
clearViewedState: (taskId) =>
132+
useReviewViewedStore.getState().clearTasks([taskId]),
130133
logError: (message, error) => log.error(message, error),
131134
cache: makeCacheWriter(queryClient, keys),
132135
};

packages/ui/src/features/canvas/components/MentionText.test.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,17 @@ describe("MentionText", () => {
3131
expect(screen.queryByText("@agent")).not.toBeInTheDocument();
3232
});
3333

34+
it("keeps agent text inside a markdown link label", () => {
35+
render(
36+
<MentionText content="[My @agent report](https://posthog.com/report) has been created" />,
37+
);
38+
39+
expect(
40+
screen.getByRole("link", { name: "My @agent report" }),
41+
).toHaveAttribute("href", "https://posthog.com/report");
42+
expect(screen.queryByText("@agent")).not.toBeInTheDocument();
43+
});
44+
3445
it("inherits the surrounding message text size", () => {
3546
render(<MentionText content="A thread reply" />);
3647

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

Lines changed: 24 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -36,30 +36,40 @@ export function MentionText({
3636
entries.push({ segment, key: `${offset}` });
3737
offset += length;
3838
};
39-
const pushText = (text: string) => {
39+
const pushAgentMentions = (text: string) => {
4040
let cursor = 0;
4141
for (const match of text.matchAll(/(^|\s)(@agent)\b/gi)) {
4242
const mentionStart = (match.index ?? 0) + match[1].length;
43-
for (const part of splitLinkSegments(
44-
text.slice(cursor, mentionStart),
45-
)) {
46-
push(part, part.text.length);
43+
if (mentionStart > cursor) {
44+
push(
45+
{ type: "text", text: text.slice(cursor, mentionStart) },
46+
mentionStart - cursor,
47+
);
4748
}
4849
push({ type: "agent", text: match[2] }, match[2].length);
4950
cursor = mentionStart + match[2].length;
5051
}
51-
for (const part of splitLinkSegments(text.slice(cursor))) {
52-
push(part, part.text.length);
52+
if (cursor < text.length) {
53+
push({ type: "text", text: text.slice(cursor) }, text.length - cursor);
54+
}
55+
};
56+
const pushMentions = (text: string) => {
57+
for (const segment of splitMentionSegments(text)) {
58+
if (segment.type === "mention") {
59+
push(
60+
{ type: "mention", name: segment.name, email: segment.email },
61+
segment.text.length,
62+
);
63+
} else {
64+
pushAgentMentions(segment.text);
65+
}
5366
}
5467
};
55-
for (const segment of splitMentionSegments(content)) {
56-
if (segment.type === "mention") {
57-
push(
58-
{ type: "mention", name: segment.name, email: segment.email },
59-
segment.text.length,
60-
);
68+
for (const segment of splitLinkSegments(content)) {
69+
if (segment.type === "link") {
70+
push(segment, segment.text.length);
6171
} else {
62-
pushText(segment.text);
72+
pushMentions(segment.text);
6373
}
6474
}
6575
return entries;

packages/ui/src/features/canvas/components/ThreadPanel.test.tsx

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
import type { ConversationItem } from "@posthog/ui/features/sessions/components/buildConversationItems";
22
import { render, screen } from "@testing-library/react";
33
import { describe, expect, it } from "vitest";
4-
import { AgentStatusLine, UserPromptRow } from "./ThreadPanel";
4+
import {
5+
AgentStatusLine,
6+
ThreadMessageRow,
7+
UserPromptRow,
8+
} from "./ThreadPanel";
59
import { agentTurns } from "./threadAgentTurns";
610

711
describe("agentTurns", () => {
@@ -45,6 +49,84 @@ describe("AgentStatusLine", () => {
4549
});
4650
});
4751

52+
describe("ThreadMessageRow", () => {
53+
it("renders backend-authored agent announcements as Agent", () => {
54+
render(
55+
<ThreadMessageRow
56+
message={{
57+
id: "announcement",
58+
task: "task",
59+
author_kind: "agent",
60+
event: "canvas_created",
61+
payload: {},
62+
content: "Canvas created",
63+
created_at: "2026-07-17T00:00:00Z",
64+
author: null,
65+
}}
66+
isTaskAuthor={false}
67+
isOwnMessage={false}
68+
canForward={false}
69+
onSendToAgent={() => {}}
70+
onDelete={() => {}}
71+
/>,
72+
);
73+
74+
expect(screen.getByText("Agent")).toBeInTheDocument();
75+
expect(screen.queryByText("Unknown")).not.toBeInTheDocument();
76+
});
77+
78+
it("renders system announcements as System without human actions", () => {
79+
render(
80+
<ThreadMessageRow
81+
message={{
82+
id: "system-announcement",
83+
task: "task",
84+
author_kind: "system",
85+
event: "status_changed",
86+
payload: {},
87+
content: "Status changed",
88+
created_at: "2026-07-17T00:00:00Z",
89+
author: null,
90+
}}
91+
isTaskAuthor
92+
isOwnMessage={false}
93+
canForward
94+
onSendToAgent={() => {}}
95+
onDelete={() => {}}
96+
/>,
97+
);
98+
99+
expect(screen.getByText("System")).toBeInTheDocument();
100+
expect(
101+
screen.queryByRole("button", { name: "Message actions" }),
102+
).not.toBeInTheDocument();
103+
});
104+
105+
it("keeps legacy authorless rows as human messages", () => {
106+
render(
107+
<ThreadMessageRow
108+
message={{
109+
id: "legacy-message",
110+
task: "task",
111+
content: "Author removed",
112+
created_at: "2026-07-17T00:00:00Z",
113+
author: null,
114+
}}
115+
isTaskAuthor
116+
isOwnMessage={false}
117+
canForward
118+
onSendToAgent={() => {}}
119+
onDelete={() => {}}
120+
/>,
121+
);
122+
123+
expect(screen.getByText("Unknown")).toBeInTheDocument();
124+
expect(
125+
screen.getByRole("button", { name: "Message actions" }),
126+
).toBeInTheDocument();
127+
});
128+
});
129+
48130
describe("UserPromptRow", () => {
49131
it("prefixes direct task prompts with @agent", () => {
50132
render(

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

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ import { track } from "@posthog/ui/shell/analytics";
8787
import { useQuery } from "@tanstack/react-query";
8888
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
8989

90-
function ThreadMessageRow({
90+
export function ThreadMessageRow({
9191
message,
9292
isTaskAuthor,
9393
isOwnMessage,
@@ -105,18 +105,36 @@ function ThreadMessageRow({
105105
onDelete: () => void;
106106
}) {
107107
const forwarded = !!message.forwarded_to_agent_at;
108-
const showMenu = (isTaskAuthor && !forwarded) || isOwnMessage;
108+
const authorKind = message.author_kind ?? "human";
109+
const isAgent = authorKind === "agent";
110+
const isSystem = authorKind === "system";
111+
const showMenu =
112+
authorKind === "human" && ((isTaskAuthor && !forwarded) || isOwnMessage);
109113

110114
return (
111115
<ThreadItem>
112116
<ThreadItemGutter>
113117
<Avatar size="lg" className="sticky top-2">
114-
<AvatarFallback>{getUserInitials(message.author)}</AvatarFallback>
118+
<AvatarFallback>
119+
{isAgent ? (
120+
<RobotIcon size={14} />
121+
) : isSystem ? (
122+
"S"
123+
) : (
124+
getUserInitials(message.author)
125+
)}
126+
</AvatarFallback>
115127
</Avatar>
116128
</ThreadItemGutter>
117129
<ThreadItemContent>
118130
<ThreadItemHeader>
119-
<ThreadItemAuthor>{userDisplayName(message.author)}</ThreadItemAuthor>
131+
<ThreadItemAuthor>
132+
{isAgent
133+
? "Agent"
134+
: isSystem
135+
? "System"
136+
: userDisplayName(message.author)}
137+
</ThreadItemAuthor>
120138
<ThreadTimestamp dateTime={message.created_at} />
121139
</ThreadItemHeader>
122140
<ThreadItemBody>

packages/ui/src/features/canvas/utils/linkify.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,33 @@ describe("splitLinkSegments", () => {
5454
],
5555
["not a url scheme", "ftp://example.com", [text("ftp://example.com")]],
5656
["empty string", "", []],
57+
[
58+
"markdown link uses its label",
59+
"[Signups](https://us.posthog.com/code/canvas/c/d) has been created",
60+
[
61+
{
62+
type: "link",
63+
text: "Signups",
64+
href: "https://us.posthog.com/code/canvas/c/d",
65+
},
66+
text(" has been created"),
67+
],
68+
],
69+
[
70+
"markdown link alongside a bare url",
71+
"see [docs](https://a.com) or https://b.com",
72+
[
73+
text("see "),
74+
{ type: "link", text: "docs", href: "https://a.com" },
75+
text(" or "),
76+
link("https://b.com"),
77+
],
78+
],
79+
[
80+
"markdown label without a url stays prose",
81+
"[not a link](just text)",
82+
[text("[not a link](just text)")],
83+
],
5784
])("%s", (_label, input, expected) => {
5885
expect(splitLinkSegments(input)).toEqual(expected);
5986
});

0 commit comments

Comments
 (0)