Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 93 additions & 0 deletions .claude/skills/quill-code/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
---
name: quill-code
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.
---

# quill-code

`@posthog/quill` is **not** in this repo. It is a published catalog dependency whose
source lives in the main PostHog monorepo at `../posthog/packages/quill`. To test an
unpublished quill change inside this repo (posthog-code), you build quill, pack it to a
tarball, and point a pnpm `overrides` entry at that tarball. This is a **temporary
local-dev** state — revert before merging (see below).

## Quill layout (where to edit)

`../posthog/packages/quill` is the **workspace** (`@posthog/quill-workspace`). It
contains sub-packages, each a layer of the design system:

- `packages/primitives/src` — base components (Card, Badge, Button, Progress, …)
- `packages/components/src` — composed components (DataTable, DateTimePicker, Metric)
- `packages/blocks/src`**product-level blocks** (e.g. `ExperimentCard`). Add the
file here and export it from `packages/blocks/src/index.ts`.
- `packages/quill/src` — the **aggregate** that re-exports all layers as `@posthog/quill`.

A new export in any sub-package flows to `@posthog/quill` automatically on build.

## The loop (every quill change)

1. Edit/add the component in the right sub-package (above) and export it from that
package's `src/index.ts`.
2. Re-sync into this repo manually — build → pack → point the override → reinstall:

```bash
QUILL_DIR="${QUILL_DIR:-../posthog/packages/quill/packages/quill}"

# a. Build the WHOLE quill workspace (two levels up from the aggregate), so the
# sub-packages rebuild BEFORE the aggregate bundles them.
( cd "$QUILL_DIR/../.." && pnpm build )

# b. Pack into .local-quill/ under a UNIQUE filename. pnpm pins a tarball by
# integrity, so a stable name caches stale across re-syncs — drop old local
# tarballs first, then rename the packed file to a unique local name.
rm -f .local-quill/posthog-quill-local-*.tgz
( cd "$QUILL_DIR" && npm pack --pack-destination "$(git rev-parse --show-toplevel)/.local-quill" )
mv .local-quill/posthog-quill-[0-9]*.tgz ".local-quill/posthog-quill-local-$(git rev-parse --short HEAD)-$$.tgz"

# c. Point the override at the new tarball, then reinstall.
# Edit pnpm-workspace.yaml so overrides['@posthog/quill'] = file:./.local-quill/<new file>
pnpm install
```

> Building only the aggregate (`packages/quill/packages/quill`) re-bundles the
> sub-packages' **stale** `dist/`, so edits to primitives/components/blocks are
> silently dropped. Always build at the workspace root.
3. Verify in the Code app (`pnpm dev`, or the `test-electron-app` skill). Repeat from 1.

After every quill edit you **must** re-run the sync — the app consumes the tarball, not
the quill source, so unsynced edits are invisible here.

If quill lives elsewhere, set `QUILL_DIR=/abs/path/to/posthog/packages/quill/packages/quill`.

## Why a tarball, not `link:`

`link:` symlinks into the mono's `node_modules` and drags in its **React 18** types,
colliding with this repo's **React 19** (dual-React → broken typecheck +
invalid-hook-call at runtime). The tarball is copied into this repo's store and deduped
against React 19. The filename is **content-hashed** because pnpm pins a tarball by
integrity, so a stable filename gets cached stale across re-syncs.

## The override (what the script rewrites)

In `pnpm-workspace.yaml`, under `overrides:`:

```yaml
'@posthog/quill': file:./.local-quill/posthog-quill-local-<hash>.tgz
```
There is also a permanent pin you should leave alone:
```yaml
'@posthog/quill>@base-ui/react': ^1.3.0 # quill ships a broken catalog: dep; do not remove
```
## Reverting (before merge)
The override is local-dev only. Once the quill change is published to npm:
1. Bump the catalog version in `pnpm-workspace.yaml` (`'@posthog/quill': 0.3.0-beta.x`)
to the published version.
2. Restore the override line to point back at the catalog, or remove the `file:` override.
3. `pnpm install`.

Do not commit a `file:./.local-quill/...` override or the `.local-quill/` tarballs.
2 changes: 2 additions & 0 deletions apps/code/src/renderer/desktop-contributions.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { agentChatCoreModule } from "@posthog/core/agent-chat/agentChat.module";
import { autoresearchCoreModule } from "@posthog/core/autoresearch/autoresearch.module";
import { billingCoreModule } from "@posthog/core/billing/billing.module";
import { taskThreadCoreModule } from "@posthog/core/canvas/taskThread.module";
import { inboxCoreModule } from "@posthog/core/inbox/inbox.module";
import { githubConnectModule } from "@posthog/core/integrations/githubConnect.module";
import { onboardingModule } from "@posthog/core/onboarding/onboarding.module";
Expand Down Expand Up @@ -36,6 +37,7 @@ export function registerDesktopContributions(): void {
autoresearchCoreModule,
billingUiModule,
billingCoreModule,
taskThreadCoreModule,
browserTabsUiModule,
cloneUiModule,
connectivityUiModule,
Expand Down
3 changes: 3 additions & 0 deletions apps/web/src/web-container.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import "reflect-metadata";
import { TypedContainer } from "@inversifyjs/strongly-typed";
import { taskThreadCoreModule } from "@posthog/core/canvas/taskThread.module";
import { setRootContainer } from "@posthog/di/container";
import { ROOT_LOGGER, type RootLogger } from "@posthog/di/logger";
import {
Expand Down Expand Up @@ -96,4 +97,6 @@ container.bind(MCP_SANDBOX_PROXY_URL).toConstantValue(() => {
return sandboxProxyUrl;
});

container.load(taskThreadCoreModule);

setRootContainer(container);
28 changes: 28 additions & 0 deletions packages/core/src/canvas/channelFeed.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { taskFeedRunStatus } from "./channelFeed";

describe("taskFeedRunStatus", () => {
it.each(["queued", "in_progress"] as const)(
"preserves a cloud %s status even when local session activity has settled",
(status) => {
expect(
taskFeedRunStatus({
status,
environment: "cloud",
}),
).toBe(status);
},
);

it("hides an unreliable non-terminal local status", () => {
expect(
taskFeedRunStatus({ status: "queued", environment: "local" }),
).toBeNull();
});

it("keeps a terminal local status", () => {
expect(
taskFeedRunStatus({ status: "completed", environment: "local" }),
).toBe("completed");
});
});
23 changes: 23 additions & 0 deletions packages/core/src/canvas/channelFeed.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import type { TaskRunStatus } from "@posthog/shared/domain-types";
import { isTerminalStatus } from "@posthog/shared/domain-types";

const PERMANENT_CHANNEL_FEED_FAILURES = new Set([401, 403, 404]);

export function shouldPollChannelFeed(error: unknown): boolean {
if (!error || typeof error !== "object" || !("status" in error)) return true;
const status = (error as { status?: unknown }).status;
return (
typeof status !== "number" || !PERMANENT_CHANNEL_FEED_FAILURES.has(status)
);
}

export function taskFeedRunStatus({
status,
environment,
}: {
status: TaskRunStatus | null | undefined;
environment: string | null | undefined;
}): TaskRunStatus | null {
if (!status) return null;
return environment === "cloud" || isTerminalStatus(status) ? status : null;
}
7 changes: 7 additions & 0 deletions packages/core/src/canvas/taskThread.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { ContainerModule } from "inversify";
import { TASK_THREAD_SERVICE, TaskThreadService } from "./taskThreadService";

export const taskThreadCoreModule = new ContainerModule(({ bind }) => {
bind(TaskThreadService).toSelf().inSingletonScope();
bind(TASK_THREAD_SERVICE).toService(TaskThreadService);
});
66 changes: 66 additions & 0 deletions packages/core/src/canvas/taskThreadService.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import type { PostHogAPIClient } from "@posthog/api-client/posthog-client";
import type { TaskThreadMessage } from "@posthog/shared/domain-types";
import { describe, expect, it, vi } from "vitest";
import { TaskThreadService } from "./taskThreadService";

function message(
overrides: Partial<TaskThreadMessage> = {},
): TaskThreadMessage {
return {
id: "message-id",
task: "task-id",
content: "@agent investigate this",
created_at: "2026-07-16T00:00:00Z",
...overrides,
};
}

function client(overrides: Partial<PostHogAPIClient> = {}): PostHogAPIClient {
return overrides as PostHogAPIClient;
}

describe("TaskThreadService", () => {
it("creates a message before forwarding it to the agent", async () => {
const createTaskThreadMessage = vi.fn().mockResolvedValue(message());
const sendTaskThreadMessageToAgent = vi
.fn()
.mockResolvedValue(
message({ forwarded_to_agent_at: "2026-07-16T00:00:01Z" }),
);
const service = new TaskThreadService();

await service.postMessageToAgent(
client({ createTaskThreadMessage, sendTaskThreadMessageToAgent }),
"task-id",
"@agent investigate this",
);

expect(createTaskThreadMessage).toHaveBeenCalledWith(
"task-id",
"@agent investigate this",
);
expect(sendTaskThreadMessageToAgent).toHaveBeenCalledWith(
"task-id",
"message-id",
);
expect(createTaskThreadMessage.mock.invocationCallOrder[0]).toBeLessThan(
sendTaskThreadMessageToAgent.mock.invocationCallOrder[0],
);
});

it("returns the created message when forwarding fails", async () => {
const sendError = new Error("No active run");
const service = new TaskThreadService();

await expect(
service.postMessageToAgent(
client({
createTaskThreadMessage: vi.fn().mockResolvedValue(message()),
sendTaskThreadMessageToAgent: vi.fn().mockRejectedValue(sendError),
}),
"task-id",
"@agent investigate this",
),
).resolves.toEqual({ message: message(), sendError });
});
});
31 changes: 31 additions & 0 deletions packages/core/src/canvas/taskThreadService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import type { PostHogAPIClient } from "@posthog/api-client/posthog-client";
import type { TaskThreadMessage } from "@posthog/shared/domain-types";
import { injectable } from "inversify";

export const TASK_THREAD_SERVICE = Symbol.for(
"posthog.core.canvas.taskThreadService",
);

export interface PostMessageToAgentResult {
message: TaskThreadMessage;
sendError: unknown | null;
}

@injectable()
export class TaskThreadService {
async postMessageToAgent(
client: PostHogAPIClient,
taskId: string,
content: string,
): Promise<PostMessageToAgentResult> {
const message = await client.createTaskThreadMessage(taskId, content);
try {
return {
message: await client.sendTaskThreadMessageToAgent(taskId, message.id),
sendError: null,
};
} catch (sendError) {
return { message, sendError };
}
}
}
Loading
Loading