Skip to content

Commit b35e918

Browse files
authored
feat(channels): ship production thread and feed UX
Generated-By: PostHog Code Task-Id: 87a79e23-20c4-440b-818b-28154924ffbc
1 parent 73b695c commit b35e918

14 files changed

Lines changed: 968 additions & 225 deletions
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import { describe, expect, it } from "vitest";
2+
import {
3+
buildThreadTimeline,
4+
deriveThreadAgentStatus,
5+
shouldSuspendThreadSession,
6+
} from "./threadTimeline";
7+
8+
describe("buildThreadTimeline", () => {
9+
it("interleaves prompts, human replies, and agent turns chronologically", () => {
10+
const timeline = buildThreadTimeline({
11+
prompts: [{ id: "prompt", text: "Start", timestamp: 100 }],
12+
humanMessages: [
13+
{
14+
id: "human",
15+
content: "Reply",
16+
createdAt: "1970-01-01T00:00:00.150Z",
17+
},
18+
],
19+
agentMessages: [{ id: "agent", text: "Done", timestamp: 200 }],
20+
});
21+
22+
expect(timeline.map((row) => row.kind)).toEqual([
23+
"prompt",
24+
"human",
25+
"agent",
26+
]);
27+
});
28+
29+
it("keeps malformed timestamps at the end", () => {
30+
const timeline = buildThreadTimeline({
31+
prompts: [{ id: "prompt", text: "Start", timestamp: 100 }],
32+
humanMessages: [{ id: "human", content: "Reply", createdAt: "invalid" }],
33+
agentMessages: [{ id: "agent", text: "Done", timestamp: 200 }],
34+
});
35+
36+
expect(timeline.map((row) => row.kind)).toEqual([
37+
"prompt",
38+
"agent",
39+
"human",
40+
]);
41+
});
42+
});
43+
44+
describe("deriveThreadAgentStatus", () => {
45+
it.each([
46+
{
47+
name: "returns no status before activity",
48+
input: {},
49+
expected: null,
50+
},
51+
{
52+
name: "prioritizes failures",
53+
input: { hasActivity: true, hasError: true, errorTitle: "Run failed" },
54+
expected: { phase: "error", label: "Run failed" },
55+
},
56+
{
57+
name: "prioritizes pending permissions over active work",
58+
input: {
59+
hasActivity: true,
60+
pendingPermissionCount: 1,
61+
isPromptPending: true,
62+
},
63+
expected: { phase: "needs_input", label: "Needs input" },
64+
},
65+
{
66+
name: "reports active work",
67+
input: { hasActivity: true, isPromptPending: true },
68+
expected: { phase: "active", label: "Working…" },
69+
},
70+
{
71+
name: "reports shipped work",
72+
input: { hasActivity: true, hasPullRequest: true },
73+
expected: { phase: "complete", label: "Shipped" },
74+
},
75+
])("$name", ({ input, expected }) => {
76+
expect(deriveThreadAgentStatus(input)).toEqual(expected);
77+
});
78+
});
79+
80+
describe("shouldSuspendThreadSession", () => {
81+
it("suspends a local runless task so reading cannot start work", () => {
82+
expect(
83+
shouldSuspendThreadSession({
84+
isCloud: false,
85+
hasRun: false,
86+
hasSession: false,
87+
}),
88+
).toBe(true);
89+
});
90+
91+
it.each([
92+
{ isCloud: true, hasRun: false, hasSession: false },
93+
{ isCloud: false, hasRun: true, hasSession: false },
94+
{ isCloud: false, hasRun: false, hasSession: true },
95+
])("keeps an existing or cloud session attached", (input) => {
96+
expect(shouldSuspendThreadSession(input)).toBe(false);
97+
});
98+
});
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
export interface ThreadAgentMessage {
2+
id: string;
3+
text: string;
4+
timestamp?: number;
5+
}
6+
7+
export interface ThreadHumanMessage<T = unknown> {
8+
id: string;
9+
content: string;
10+
createdAt: string;
11+
value?: T;
12+
}
13+
14+
export type ThreadTimelineRow<T = unknown> =
15+
| { kind: "prompt"; timestamp: number; message: ThreadAgentMessage }
16+
| { kind: "agent"; timestamp: number; message: ThreadAgentMessage }
17+
| { kind: "human"; timestamp: number; message: ThreadHumanMessage<T> };
18+
19+
function validTimestamp(timestamp: number | undefined): number {
20+
return timestamp !== undefined && Number.isFinite(timestamp)
21+
? timestamp
22+
: Number.MAX_SAFE_INTEGER;
23+
}
24+
25+
function parsedTimestamp(timestamp: string): number {
26+
const parsed = Date.parse(timestamp);
27+
return Number.isFinite(parsed) ? parsed : Number.MAX_SAFE_INTEGER;
28+
}
29+
30+
export function buildThreadTimeline<T>({
31+
prompts,
32+
agentMessages,
33+
humanMessages,
34+
}: {
35+
prompts: ThreadAgentMessage[];
36+
agentMessages: ThreadAgentMessage[];
37+
humanMessages: ThreadHumanMessage<T>[];
38+
}): ThreadTimelineRow<T>[] {
39+
return [
40+
...prompts.map(
41+
(message): ThreadTimelineRow<T> => ({
42+
kind: "prompt",
43+
timestamp: validTimestamp(message.timestamp),
44+
message,
45+
}),
46+
),
47+
...humanMessages.map(
48+
(message): ThreadTimelineRow<T> => ({
49+
kind: "human",
50+
timestamp: parsedTimestamp(message.createdAt),
51+
message,
52+
}),
53+
),
54+
...agentMessages.map(
55+
(message): ThreadTimelineRow<T> => ({
56+
kind: "agent",
57+
timestamp: validTimestamp(message.timestamp),
58+
message,
59+
}),
60+
),
61+
].sort((left, right) => left.timestamp - right.timestamp);
62+
}
63+
64+
export type ThreadAgentPhase = "active" | "needs_input" | "complete" | "error";
65+
66+
export interface ThreadAgentStatus {
67+
phase: ThreadAgentPhase;
68+
label: string;
69+
}
70+
71+
export function deriveThreadAgentStatus({
72+
hasActivity = false,
73+
hasError = false,
74+
cloudStatus,
75+
errorTitle,
76+
pendingPermissionCount = 0,
77+
isPromptPending = false,
78+
isInitializing = false,
79+
hasPullRequest = false,
80+
}: {
81+
hasActivity?: boolean;
82+
hasError?: boolean;
83+
cloudStatus?: string | null;
84+
errorTitle?: string | null;
85+
pendingPermissionCount?: number;
86+
isPromptPending?: boolean;
87+
isInitializing?: boolean;
88+
hasPullRequest?: boolean;
89+
}): ThreadAgentStatus | null {
90+
if (!hasActivity) return null;
91+
if (hasError || cloudStatus === "failed") {
92+
return { phase: "error", label: errorTitle ?? "Failed" };
93+
}
94+
if (pendingPermissionCount > 0) {
95+
return { phase: "needs_input", label: "Needs input" };
96+
}
97+
if (isPromptPending || isInitializing) {
98+
return { phase: "active", label: "Working…" };
99+
}
100+
return {
101+
phase: "complete",
102+
label: hasPullRequest ? "Shipped" : "Ready to ship",
103+
};
104+
}
105+
106+
export function shouldSuspendThreadSession({
107+
isCloud,
108+
hasRun,
109+
hasSession,
110+
}: {
111+
isCloud: boolean;
112+
hasRun: boolean;
113+
hasSession: boolean;
114+
}): boolean {
115+
return !isCloud && !hasRun && !hasSession;
116+
}

0 commit comments

Comments
 (0)