Skip to content

Commit bdb4d5c

Browse files
authored
feat(channels): ship production thread and feed UX (#3481)
1 parent 6f243a0 commit bdb4d5c

33 files changed

Lines changed: 1901 additions & 360 deletions

.claude/skills/quill-code/SKILL.md

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
---
2+
name: quill-code
3+
description: Edit the @posthog/quill design system locally and consume the change in this repo (posthog-code) before it is published to npm. Use when changing quill components/primitives/tokens, when a quill change must be tested inside the Code app, or when the user mentions quill, the design system, the .local-quill tarball, or the @posthog/quill pnpm override.
4+
---
5+
6+
# quill-code
7+
8+
`@posthog/quill` is **not** in this repo. It is a published catalog dependency whose
9+
source lives in the main PostHog monorepo at `../posthog/packages/quill`. To test an
10+
unpublished quill change inside this repo (posthog-code), you build quill, pack it to a
11+
tarball, and point a pnpm `overrides` entry at that tarball. This is a **temporary
12+
local-dev** state — revert before merging (see below).
13+
14+
## Quill layout (where to edit)
15+
16+
`../posthog/packages/quill` is the **workspace** (`@posthog/quill-workspace`). It
17+
contains sub-packages, each a layer of the design system:
18+
19+
- `packages/primitives/src` — base components (Card, Badge, Button, Progress, …)
20+
- `packages/components/src` — composed components (DataTable, DateTimePicker, Metric)
21+
- `packages/blocks/src`**product-level blocks** (e.g. `ExperimentCard`). Add the
22+
file here and export it from `packages/blocks/src/index.ts`.
23+
- `packages/quill/src` — the **aggregate** that re-exports all layers as `@posthog/quill`.
24+
25+
A new export in any sub-package flows to `@posthog/quill` automatically on build.
26+
27+
## The loop (every quill change)
28+
29+
1. Edit/add the component in the right sub-package (above) and export it from that
30+
package's `src/index.ts`.
31+
2. Re-sync into this repo manually — build → pack → point the override → reinstall:
32+
33+
```bash
34+
QUILL_DIR="${QUILL_DIR:-../posthog/packages/quill/packages/quill}"
35+
36+
# a. Build the WHOLE quill workspace (two levels up from the aggregate), so the
37+
# sub-packages rebuild BEFORE the aggregate bundles them.
38+
( cd "$QUILL_DIR/../.." && pnpm build )
39+
40+
# b. Pack into .local-quill/ under a UNIQUE filename. pnpm pins a tarball by
41+
# integrity, so a stable name caches stale across re-syncs — drop old local
42+
# tarballs first, then rename the packed file to a unique local name.
43+
rm -f .local-quill/posthog-quill-local-*.tgz
44+
( cd "$QUILL_DIR" && npm pack --pack-destination "$(git rev-parse --show-toplevel)/.local-quill" )
45+
mv .local-quill/posthog-quill-[0-9]*.tgz ".local-quill/posthog-quill-local-$(git rev-parse --short HEAD)-$$.tgz"
46+
47+
# c. Point the override at the new tarball, then reinstall.
48+
# Edit pnpm-workspace.yaml so overrides['@posthog/quill'] = file:./.local-quill/<new file>
49+
pnpm install
50+
```
51+
52+
> Building only the aggregate (`packages/quill/packages/quill`) re-bundles the
53+
> sub-packages' **stale** `dist/`, so edits to primitives/components/blocks are
54+
> silently dropped. Always build at the workspace root.
55+
3. Verify in the Code app (`pnpm dev`, or the `test-electron-app` skill). Repeat from 1.
56+
57+
After every quill edit you **must** re-run the sync — the app consumes the tarball, not
58+
the quill source, so unsynced edits are invisible here.
59+
60+
If quill lives elsewhere, set `QUILL_DIR=/abs/path/to/posthog/packages/quill/packages/quill`.
61+
62+
## Why a tarball, not `link:`
63+
64+
`link:` symlinks into the mono's `node_modules` and drags in its **React 18** types,
65+
colliding with this repo's **React 19** (dual-React → broken typecheck +
66+
invalid-hook-call at runtime). The tarball is copied into this repo's store and deduped
67+
against React 19. The filename is **content-hashed** because pnpm pins a tarball by
68+
integrity, so a stable filename gets cached stale across re-syncs.
69+
70+
## The override (what the script rewrites)
71+
72+
In `pnpm-workspace.yaml`, under `overrides:`:
73+
74+
```yaml
75+
'@posthog/quill': file:./.local-quill/posthog-quill-local-<hash>.tgz
76+
```
77+
78+
There is also a permanent pin you should leave alone:
79+
80+
```yaml
81+
'@posthog/quill>@base-ui/react': ^1.3.0 # quill ships a broken catalog: dep; do not remove
82+
```
83+
84+
## Reverting (before merge)
85+
86+
The override is local-dev only. Once the quill change is published to npm:
87+
88+
1. Bump the catalog version in `pnpm-workspace.yaml` (`'@posthog/quill': 0.3.0-beta.x`)
89+
to the published version.
90+
2. Restore the override line to point back at the catalog, or remove the `file:` override.
91+
3. `pnpm install`.
92+
93+
Do not commit a `file:./.local-quill/...` override or the `.local-quill/` tarballs.

apps/code/src/renderer/desktop-contributions.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { agentChatCoreModule } from "@posthog/core/agent-chat/agentChat.module";
22
import { autoresearchCoreModule } from "@posthog/core/autoresearch/autoresearch.module";
33
import { billingCoreModule } from "@posthog/core/billing/billing.module";
4+
import { taskThreadCoreModule } from "@posthog/core/canvas/taskThread.module";
45
import { inboxCoreModule } from "@posthog/core/inbox/inbox.module";
56
import { githubConnectModule } from "@posthog/core/integrations/githubConnect.module";
67
import { localMcpCoreModule } from "@posthog/core/local-mcp/local-mcp.module";
@@ -37,6 +38,7 @@ export function registerDesktopContributions(): void {
3738
autoresearchCoreModule,
3839
billingUiModule,
3940
billingCoreModule,
41+
taskThreadCoreModule,
4042
browserTabsUiModule,
4143
cloneUiModule,
4244
connectivityUiModule,

apps/web/src/web-container.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import "reflect-metadata";
22
import { TypedContainer } from "@inversifyjs/strongly-typed";
3+
import { taskThreadCoreModule } from "@posthog/core/canvas/taskThread.module";
34
import { setRootContainer } from "@posthog/di/container";
45
import { ROOT_LOGGER, type RootLogger } from "@posthog/di/logger";
56
import {
@@ -96,4 +97,6 @@ container.bind(MCP_SANDBOX_PROXY_URL).toConstantValue(() => {
9697
return sandboxProxyUrl;
9798
});
9899

100+
container.load(taskThreadCoreModule);
101+
99102
setRootContainer(container);
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { describe, expect, it } from "vitest";
2+
import { taskFeedRunStatus } from "./channelFeed";
3+
4+
describe("taskFeedRunStatus", () => {
5+
it.each(["queued", "in_progress"] as const)(
6+
"preserves a cloud %s status even when local session activity has settled",
7+
(status) => {
8+
expect(
9+
taskFeedRunStatus({
10+
status,
11+
environment: "cloud",
12+
}),
13+
).toBe(status);
14+
},
15+
);
16+
17+
it("hides an unreliable non-terminal local status", () => {
18+
expect(
19+
taskFeedRunStatus({ status: "queued", environment: "local" }),
20+
).toBeNull();
21+
});
22+
23+
it("keeps a terminal local status", () => {
24+
expect(
25+
taskFeedRunStatus({ status: "completed", environment: "local" }),
26+
).toBe("completed");
27+
});
28+
});
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import type { TaskRunStatus } from "@posthog/shared/domain-types";
2+
import { isTerminalStatus } from "@posthog/shared/domain-types";
3+
4+
const PERMANENT_CHANNEL_FEED_FAILURES = new Set([401, 403, 404]);
5+
6+
export function shouldPollChannelFeed(error: unknown): boolean {
7+
if (!error || typeof error !== "object" || !("status" in error)) return true;
8+
const status = (error as { status?: unknown }).status;
9+
return (
10+
typeof status !== "number" || !PERMANENT_CHANNEL_FEED_FAILURES.has(status)
11+
);
12+
}
13+
14+
export function taskFeedRunStatus({
15+
status,
16+
environment,
17+
}: {
18+
status: TaskRunStatus | null | undefined;
19+
environment: string | null | undefined;
20+
}): TaskRunStatus | null {
21+
if (!status) return null;
22+
return environment === "cloud" || isTerminalStatus(status) ? status : null;
23+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { ContainerModule } from "inversify";
2+
import { TASK_THREAD_SERVICE, TaskThreadService } from "./taskThreadService";
3+
4+
export const taskThreadCoreModule = new ContainerModule(({ bind }) => {
5+
bind(TaskThreadService).toSelf().inSingletonScope();
6+
bind(TASK_THREAD_SERVICE).toService(TaskThreadService);
7+
});
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import type { PostHogAPIClient } from "@posthog/api-client/posthog-client";
2+
import type { TaskThreadMessage } from "@posthog/shared/domain-types";
3+
import { describe, expect, it, vi } from "vitest";
4+
import { TaskThreadService } from "./taskThreadService";
5+
6+
function message(
7+
overrides: Partial<TaskThreadMessage> = {},
8+
): TaskThreadMessage {
9+
return {
10+
id: "message-id",
11+
task: "task-id",
12+
content: "@agent investigate this",
13+
created_at: "2026-07-16T00:00:00Z",
14+
...overrides,
15+
};
16+
}
17+
18+
function client(overrides: Partial<PostHogAPIClient> = {}): PostHogAPIClient {
19+
return overrides as PostHogAPIClient;
20+
}
21+
22+
describe("TaskThreadService", () => {
23+
it("creates a message before forwarding it to the agent", async () => {
24+
const createTaskThreadMessage = vi.fn().mockResolvedValue(message());
25+
const sendTaskThreadMessageToAgent = vi
26+
.fn()
27+
.mockResolvedValue(
28+
message({ forwarded_to_agent_at: "2026-07-16T00:00:01Z" }),
29+
);
30+
const service = new TaskThreadService();
31+
32+
await service.postMessageToAgent(
33+
client({ createTaskThreadMessage, sendTaskThreadMessageToAgent }),
34+
"task-id",
35+
"@agent investigate this",
36+
);
37+
38+
expect(createTaskThreadMessage).toHaveBeenCalledWith(
39+
"task-id",
40+
"@agent investigate this",
41+
);
42+
expect(sendTaskThreadMessageToAgent).toHaveBeenCalledWith(
43+
"task-id",
44+
"message-id",
45+
);
46+
expect(createTaskThreadMessage.mock.invocationCallOrder[0]).toBeLessThan(
47+
sendTaskThreadMessageToAgent.mock.invocationCallOrder[0],
48+
);
49+
});
50+
51+
it("returns the created message when forwarding fails", async () => {
52+
const sendError = new Error("No active run");
53+
const service = new TaskThreadService();
54+
55+
await expect(
56+
service.postMessageToAgent(
57+
client({
58+
createTaskThreadMessage: vi.fn().mockResolvedValue(message()),
59+
sendTaskThreadMessageToAgent: vi.fn().mockRejectedValue(sendError),
60+
}),
61+
"task-id",
62+
"@agent investigate this",
63+
),
64+
).resolves.toEqual({ message: message(), sendError });
65+
});
66+
});
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import type { PostHogAPIClient } from "@posthog/api-client/posthog-client";
2+
import type { TaskThreadMessage } from "@posthog/shared/domain-types";
3+
import { injectable } from "inversify";
4+
5+
export const TASK_THREAD_SERVICE = Symbol.for(
6+
"posthog.core.canvas.taskThreadService",
7+
);
8+
9+
export interface PostMessageToAgentResult {
10+
message: TaskThreadMessage;
11+
sendError: unknown | null;
12+
}
13+
14+
@injectable()
15+
export class TaskThreadService {
16+
async postMessageToAgent(
17+
client: PostHogAPIClient,
18+
taskId: string,
19+
content: string,
20+
): Promise<PostMessageToAgentResult> {
21+
const message = await client.createTaskThreadMessage(taskId, content);
22+
try {
23+
return {
24+
message: await client.sendTaskThreadMessageToAgent(taskId, message.id),
25+
sendError: null,
26+
};
27+
} catch (sendError) {
28+
return { message, sendError };
29+
}
30+
}
31+
}

0 commit comments

Comments
 (0)