Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 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
84 changes: 84 additions & 0 deletions .claude/skills/quill-code/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
---
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:

```bash
.claude/skills/quill-code/scripts/sync-quill.sh
```

This builds the **whole quill workspace** (recursive `pnpm build` at the workspace
root — it rebuilds the sub-packages BEFORE the aggregate bundles them), packs it
into `.local-quill/` as a content-hashed `posthog-quill-local-<hash>.tgz`, rewrites
the override line in `pnpm-workspace.yaml`, deletes stale tarballs, and runs
`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 — the script does this.
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.
49 changes: 49 additions & 0 deletions .claude/skills/quill-code/scripts/sync-quill.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
#!/usr/bin/env bash
# Build local @posthog/quill, pack it to a content-hashed tarball, point the
# pnpm override at it, and reinstall. See SKILL.md for the why.
set -euo pipefail

# code repo root = dir containing pnpm-workspace.yaml, walking up from this script
CODE_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && while [ ! -f pnpm-workspace.yaml ] && [ "$PWD" != / ]; do cd ..; done && pwd)"
[ -f "$CODE_ROOT/pnpm-workspace.yaml" ] || { echo "could not find pnpm-workspace.yaml above $0" >&2; exit 1; }

QUILL_DIR="${QUILL_DIR:-$CODE_ROOT/../posthog/packages/quill/packages/quill}"
[ -d "$QUILL_DIR" ] || { echo "quill source not found: $QUILL_DIR (set QUILL_DIR=...)" >&2; exit 1; }

# The quill workspace root (@posthog/quill-workspace) is two levels up from the
# aggregate package. We MUST build there: its `pnpm build` runs the recursive
# `pnpm -r --filter '@posthog/quill-*' --filter '@posthog/quill' build`, which
# rebuilds the sub-packages (primitives, components, blocks, ...) BEFORE the
# aggregate bundles them. Building inside QUILL_DIR alone only re-bundles the
# sub-packages' STALE dist, so edits to blocks/primitives/etc. are silently lost.
QUILL_WS="${QUILL_WS:-$(cd "$QUILL_DIR/../.." && pwd)}"

DEST="$CODE_ROOT/.local-quill"
WS="$CODE_ROOT/pnpm-workspace.yaml"

echo "==> building quill workspace in $QUILL_WS"
( cd "$QUILL_WS" && pnpm build )

echo "==> packing tarball -> $DEST"
mkdir -p "$DEST"
( cd "$QUILL_DIR" && npm pack --pack-destination "$DEST" >/dev/null )

RAW="$(ls -t "$DEST"/posthog-quill-*.tgz | grep -v -- '-local-' | head -1)"
[ -n "$RAW" ] || { echo "npm pack produced no posthog-quill-*.tgz" >&2; exit 1; }

HASH="$(md5 -q "$RAW" | cut -c1-8)"
HASHED="posthog-quill-local-$HASH.tgz"
cp "$RAW" "$DEST/$HASHED"
rm -f "$RAW"
# drop stale local tarballs so the pnpm store can't resolve an old integrity
find "$DEST" -name 'posthog-quill-local-*.tgz' ! -name "$HASHED" -delete

echo "==> pointing override at .local-quill/$HASHED"
# single override line: '@posthog/quill': file:./.local-quill/...
sed -i '' -E "s#('@posthog/quill': file:\./\.local-quill/)[^']*#\1$HASHED#" "$WS"
grep -q "$HASHED" "$WS" || { echo "failed to rewrite override line in $WS" >&2; exit 1; }

echo "==> pnpm install"
( cd "$CODE_ROOT" && pnpm install )

echo "==> done. @posthog/quill now resolves to .local-quill/$HASHED"
Original file line number Diff line number Diff line change
Expand Up @@ -335,7 +335,34 @@ function handlePromptRequest(
turnComplete: false,
};

b.currentTurnStartIndex = b.items.length;
// The orchestrator emits its setup progress ("Started agent") before the
// prompt it responds to is replayed onto the stream, so the card would sit
// above the user's message. Open the turn before any trailing progress cards
// so the transcript reads user message → setup → work.
let insertIndex = b.items.length;
while (insertIndex > 0) {
const prev = b.items[insertIndex - 1];
if (
prev.type === "session_update" &&
prev.update.sessionUpdate === "progress_group"
) {
insertIndex--;
} else {
break;
}
}
if (insertIndex < b.items.length) {
for (const card of b.progressCards.values()) {
if (card.itemIndex >= insertIndex) card.itemIndex++;
}
// The shifted cards may live inside a turn the incremental builder already
// froze; flag the mutation so it falls back to a full rebuild.
if (insertIndex < b.lowestTouchedProgressIndex) {
b.lowestTouchedProgressIndex = insertIndex;
}
}

b.currentTurnStartIndex = insertIndex;
b.currentTurn = {
id: turnId,
promptId: msg.id,
Expand All @@ -350,19 +377,19 @@ function handlePromptRequest(
b.pendingPrompts.set(msg.id, b.currentTurn);

if (gitAction.isGitAction && gitAction.actionType) {
b.items.push({
b.items.splice(insertIndex, 0, {
type: "git_action",
id: `${turnId}-git-action`,
actionType: gitAction.actionType,
});
} else if (skillButtonId) {
b.items.push({
b.items.splice(insertIndex, 0, {
type: "skill_button_action",
id: `${turnId}-skill-action`,
buttonId: skillButtonId,
});
} else {
b.items.push({
b.items.splice(insertIndex, 0, {
type: "user_message",
id: `${turnId}-user`,
content: userContent,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,12 @@ import {
TableRow,
Text,
} from "@posthog/quill";
import {
parseOpenFence,
splitMarkdownBlocks,
} from "@posthog/ui/features/editor/components/splitMarkdownBlocks";
import { HighlightedCode } from "@posthog/ui/primitives/HighlightedCode";
import { memo } from "react";
import { memo, useMemo } from "react";
import Markdown, { type Components } from "react-markdown";
import rehypeSanitize from "rehype-sanitize";
import remarkGfm from "remark-gfm";
Expand Down Expand Up @@ -119,3 +123,44 @@ export const ChatMarkdown = memo(function ChatMarkdown({
</div>
);
});

/**
* Streaming variant of {@link ChatMarkdown}: splits the message into top-level blocks so completed
* blocks keep a stable string and their memoized parse is reused — each streamed frame re-parses
* only the growing tail block, O(last block) instead of O(message).
*
* While the tail sits inside an unterminated code fence it renders as plain monospace in the same
* `pre` box the finished block will use — no per-frame Shiki highlight, no layout shift when the
* fence closes. Completed messages should render through {@link ChatMarkdown} directly for a
* single, fully-correct parse.
*/
export const ChatStreamingMarkdown = memo(function ChatStreamingMarkdown({
content,
}: {
content: string;
}) {
const blocks = useMemo(() => splitMarkdownBlocks(content), [content]);
const lastIndex = blocks.length - 1;

return (
<div className="flex flex-col gap-3 [&>*:first-child]:mt-0 [&>*:last-child]:mb-0">
{blocks.map((block, index) => {
const key = `b${index}`;
const openFence = index === lastIndex ? parseOpenFence(block) : null;
if (openFence) {
return (
<div key={key} className="flex flex-col gap-3">
{openFence.before.trim() ? (
<ChatMarkdown content={openFence.before} />
) : null}
<pre className="overflow-x-auto rounded-lg border border-border bg-muted/50 p-3 text-sm leading-[1.5]">
<code className="font-mono text-xs">{openFence.code}</code>
</pre>
</div>
);
}
return <ChatMarkdown key={key} content={block} />;
})}
</div>
);
});
Loading
Loading