Skip to content

Commit 889554a

Browse files
authored
feat(bluebird): Slack-style channel feed with task cards and threads (#3129)
1 parent a88f688 commit 889554a

19 files changed

Lines changed: 1367 additions & 49 deletions

File tree

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

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,8 +71,10 @@ import type {
7171
SuggestedReviewersArtefact,
7272
SuggestedReviewerWriteEntry,
7373
Task,
74+
TaskChannel,
7475
TaskRun,
7576
TaskRunArtefact,
77+
TaskThreadMessage,
7678
UserBasic,
7779
} from "@posthog/shared/domain-types";
7880
import {
@@ -2120,6 +2122,7 @@ export class PostHogAPIClient {
21202122
createdBy?: number;
21212123
originProduct?: string;
21222124
internal?: boolean;
2125+
channel?: string;
21232126
}) {
21242127
const teamId = await this.getTeamId();
21252128
const params: Record<string, string | number | boolean> = {
@@ -2142,6 +2145,10 @@ export class PostHogAPIClient {
21422145
params.internal = true;
21432146
}
21442147

2148+
if (options?.channel) {
2149+
params.channel = options.channel;
2150+
}
2151+
21452152
const data = await this.api.get(`/api/projects/{project_id}/tasks/`, {
21462153
path: { project_id: teamId.toString() },
21472154
query: params,
@@ -2210,6 +2217,7 @@ export class PostHogAPIClient {
22102217
runtime_adapter?: string | null;
22112218
model?: string | null;
22122219
reasoning_effort?: string | null;
2220+
channel?: string | null;
22132221
},
22142222
) {
22152223
const teamId = await this.getTeamId();
@@ -2259,6 +2267,121 @@ export class PostHogAPIClient {
22592267
});
22602268
}
22612269

2270+
// Task channels + threads. Not in the generated OpenAPI client yet, so these
2271+
// go through the raw fetcher like the desktop file-system endpoints above.
2272+
2273+
// List backend task channels: all public channels plus the requester's
2274+
// personal "#me" channel (provisioned lazily server-side on first list).
2275+
async getTaskChannels(): Promise<TaskChannel[]> {
2276+
const teamId = await this.getTeamId();
2277+
const urlPath = `/api/projects/${teamId}/task_channels/`;
2278+
const response = await this.api.fetcher.fetch({
2279+
method: "get",
2280+
url: new URL(`${this.api.baseUrl}${urlPath}`),
2281+
path: urlPath,
2282+
});
2283+
if (!response.ok) {
2284+
throw new Error(`Failed to fetch task channels: ${response.statusText}`);
2285+
}
2286+
return (await response.json()) as TaskChannel[];
2287+
}
2288+
2289+
// Resolve-or-create a public channel by name (idempotent server-side).
2290+
async resolveTaskChannel(name: string): Promise<TaskChannel> {
2291+
const teamId = await this.getTeamId();
2292+
const urlPath = `/api/projects/${teamId}/task_channels/`;
2293+
const response = await this.api.fetcher.fetch({
2294+
method: "post",
2295+
url: new URL(`${this.api.baseUrl}${urlPath}`),
2296+
path: urlPath,
2297+
overrides: { body: JSON.stringify({ name }) },
2298+
});
2299+
if (!response.ok) {
2300+
throw new Error(`Failed to resolve task channel: ${response.statusText}`);
2301+
}
2302+
return (await response.json()) as TaskChannel;
2303+
}
2304+
2305+
async getTaskThreadMessages(taskId: string): Promise<TaskThreadMessage[]> {
2306+
const teamId = await this.getTeamId();
2307+
const urlPath = `/api/projects/${teamId}/tasks/${taskId}/thread_messages/`;
2308+
const response = await this.api.fetcher.fetch({
2309+
method: "get",
2310+
url: new URL(`${this.api.baseUrl}${urlPath}`),
2311+
path: urlPath,
2312+
});
2313+
if (!response.ok) {
2314+
throw new Error(
2315+
`Failed to fetch thread messages: ${response.statusText}`,
2316+
);
2317+
}
2318+
return (await response.json()) as TaskThreadMessage[];
2319+
}
2320+
2321+
async createTaskThreadMessage(
2322+
taskId: string,
2323+
content: string,
2324+
): Promise<TaskThreadMessage> {
2325+
const teamId = await this.getTeamId();
2326+
const urlPath = `/api/projects/${teamId}/tasks/${taskId}/thread_messages/`;
2327+
const response = await this.api.fetcher.fetch({
2328+
method: "post",
2329+
url: new URL(`${this.api.baseUrl}${urlPath}`),
2330+
path: urlPath,
2331+
overrides: { body: JSON.stringify({ content }) },
2332+
});
2333+
if (!response.ok) {
2334+
throw new Error(`Failed to post thread message: ${response.statusText}`);
2335+
}
2336+
return (await response.json()) as TaskThreadMessage;
2337+
}
2338+
2339+
async deleteTaskThreadMessage(
2340+
taskId: string,
2341+
messageId: string,
2342+
): Promise<void> {
2343+
const teamId = await this.getTeamId();
2344+
const urlPath = `/api/projects/${teamId}/tasks/${taskId}/thread_messages/${encodeURIComponent(messageId)}/`;
2345+
const response = await this.api.fetcher.fetch({
2346+
method: "delete",
2347+
url: new URL(`${this.api.baseUrl}${urlPath}`),
2348+
path: urlPath,
2349+
});
2350+
if (!response.ok && response.status !== 404) {
2351+
throw new Error(
2352+
`Failed to delete thread message: ${response.statusText}`,
2353+
);
2354+
}
2355+
}
2356+
2357+
// Forward a thread message into the task's live run. Task author only; the
2358+
// backend rejects with 400/403 otherwise (surfaced via the error body detail).
2359+
async sendTaskThreadMessageToAgent(
2360+
taskId: string,
2361+
messageId: string,
2362+
): Promise<TaskThreadMessage> {
2363+
const teamId = await this.getTeamId();
2364+
const urlPath = `/api/projects/${teamId}/tasks/${taskId}/thread_messages/${encodeURIComponent(messageId)}/send_to_agent/`;
2365+
const response = await this.api.fetcher.fetch({
2366+
method: "post",
2367+
url: new URL(`${this.api.baseUrl}${urlPath}`),
2368+
path: urlPath,
2369+
overrides: { body: JSON.stringify({}) },
2370+
});
2371+
if (!response.ok) {
2372+
const errorText = await response.text().catch(() => "");
2373+
let message = `Failed to send message to agent: ${response.statusText}`;
2374+
try {
2375+
const parsed = JSON.parse(errorText) as { detail?: string };
2376+
if (parsed.detail) message = parsed.detail;
2377+
} catch {
2378+
if (errorText) message = errorText;
2379+
}
2380+
throw new Error(message);
2381+
}
2382+
return (await response.json()) as TaskThreadMessage;
2383+
}
2384+
22622385
async sendRunCommand(
22632386
taskId: string,
22642387
runId: string,

packages/core/src/task-detail/taskCreationSaga.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -645,6 +645,7 @@ export class TaskCreationSaga extends Saga<
645645
? (input.reasoningLevel ?? null)
646646
: undefined,
647647
signal_report: input.signalReportId ?? undefined,
648+
channel: input.channelId ?? undefined,
648649
});
649650
return result as unknown as Task;
650651
},

packages/core/src/task-detail/taskInput.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ export interface PrepareTaskInputOptions {
2121
additionalDirectories?: string[];
2222
channelContext?: string;
2323
channelName?: string;
24+
channelId?: string;
2425
customInstructions?: string;
2526
allowNoRepo?: boolean;
2627
}
@@ -59,6 +60,7 @@ export function prepareTaskInput(
5960
additionalDirectories: isCloud ? undefined : options.additionalDirectories,
6061
channelContext: options.channelContext,
6162
channelName: options.channelName,
63+
channelId: options.channelId,
6264
customInstructions: isCloud ? options.customInstructions : undefined,
6365
allowNoRepo: options.allowNoRepo,
6466
};

packages/shared/src/domain-types.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,9 +53,39 @@ export interface Task {
5353
json_schema?: Record<string, unknown> | null;
5454
signal_report?: string | null;
5555
internal?: boolean;
56+
/** Backend channel (tasks product Channel UUID) this task is owned by. */
57+
channel?: string | null;
5658
latest_run?: TaskRun;
5759
}
5860

61+
/**
62+
* A backend task channel — the shared feed a task is kicked off in. Distinct
63+
* from the desktop file-system "channel" folders: those carry CONTEXT.md and
64+
* artifacts, while this owns the task feed and threads. `personal` is the
65+
* user's private "#me" channel.
66+
*/
67+
export interface TaskChannel {
68+
id: string;
69+
name: string;
70+
channel_type: "public" | "personal";
71+
created_at: string;
72+
created_by?: UserBasic | null;
73+
}
74+
75+
/**
76+
* One human message in a task's thread. Thread messages never reach the agent
77+
* unless the task author forwards one, which stamps the forwarded_* fields.
78+
*/
79+
export interface TaskThreadMessage {
80+
id: string;
81+
task: string;
82+
content: string;
83+
created_at: string;
84+
author?: UserBasic | null;
85+
forwarded_to_agent_at?: string | null;
86+
forwarded_by?: UserBasic | null;
87+
}
88+
5989
export type TaskRunStatus =
6090
| "not_started"
6191
| "queued"

packages/shared/src/task-creation-domain.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ export interface TaskCreationInput {
4646
channelContext?: string;
4747
/** Display name of that channel, embedded in the context block for the UI. */
4848
channelName?: string;
49+
/** Backend channel UUID the created task is owned by (its feed home). */
50+
channelId?: string;
4951
/**
5052
* The user's saved personalization (Settings → Personalization custom
5153
* instructions). Cloud-only: local tasks already receive these through the

0 commit comments

Comments
 (0)