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
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -73,4 +73,6 @@ posthog-sym
.claude/settings.local.json
CLAUDE.local.md

apps/mobile/ROADMAP.md
apps/mobile/ROADMAP.md
# Local quill tarball for ChatX thread testing (not committed)
.local-quill/
95 changes: 95 additions & 0 deletions docs/chat-rebuild-spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
# ChatX Thread Rebuild — Build Spec

Status: in progress. Source of decisions: grilling session 2026-06-27.

## Thesis

Replace the virtualized thread (`ConversationView` + `VirtualizedList`) with shadcn
**Base-UI** chat primitives, renamed `ChatX`. Non-virtualized, `content-visibility: auto`,
minimal DOM, close to the shadcn examples. Primitives are built in **quill**; the app-side
thread is rebuilt in the **code** repo.

## Provenance

The primitives are vendored from the shadcn `base-mira` registry:

- `https://ui.shadcn.com/r/styles/base-mira/{message-scroller,message,bubble,marker,attachment}.json`
- The scroll **engine** is the published headless package `@shadcn/react` (MIT, exports
`./message-scroller`). The styled file is a thin wrapper — same shape as quill's
`Badge` wrapping `@base-ui/react`. We take `@shadcn/react` as a runtime dep of the
quill primitives package and wrap it in quill conventions.

## Where

- **Primitives**: `posthog/packages/quill/packages/primitives/src/chat-*.tsx`
(+ colocated `chat-*.css`, `chat-*.stories.tsx`), exported from `index.ts`.
Conventions: `useRender` / `mergeProps` / `cva` / `data-quill` / `cn` from `./lib/utils`,
4-space indent, single quotes.
- **App thread**: code repo `packages/ui` — new `<ChatThread>` replacing `ConversationView`.
- Flow: build in quill → local-link → integrate → publish beta → bump catalog.

## Primitives (v1 = four)

1. **ChatMessageScroller** (Provider / Root / Viewport / Content / Item / Button + 3 hooks).
Thin wrap of `@shadcn/react/message-scroller`. Non-virtualized. `autoScroll`,
`defaultScrollPosition="end"`, `scrollPreviousItemPeek≈64`, `scrollAnchor` on
turn-start items. Imperative scroll state via data-attrs; no React state on scroll.
2. **ChatMessage** (Group / Avatar / Content / Header / Footer). `align="start|end"`.
Avatar first-class, omitted in our render.
3. **ChatBubble** (Content / Reactions / Group). Assistant = `ghost` (no bg, full-width);
user = filled.
4. **ChatMarker** (Icon / Content) — the recursive one. **Diverges from stock shadcn
Marker**: when given a `body`, renders as a Base-UI Collapsible — hover shows a chevron
+ `bg-fill-hover`, click toggles, expanded renders the body. No body → flat status line
(stock shadcn). Uncontrolled `defaultOpen` + optional `open` / `onOpenChange`.
Pairs with `Spinner` + shimmer for live state.

Deferred: `ChatAttachment` (except trivial mention chips), `BubbleReactions`.

## Mapping (app layer)

- **User turn** → `ChatMessage align="end"` → filled `ChatBubble` → text (+ mention chips).
- **Assistant turn** → `ChatMessage align="start"` → `ghost ChatBubble` → bare markdown.
- **Single tool** → `ChatMarker` with `body` = injected detail (`<ReadContent/>`,
`<EditDiff/>` — reused existing renderers; primitive is chrome-only).
- **Tool group (completed turn)** → summary `ChatMarker` ("Read 3 files · Edited 1") whose
`body` = the per-tool child `ChatMarker`s.
- **Status / thought / error / compact / cancelled** → `ChatMarker`
(`default` / `border` / `separator`).
- **MCP app iframes** → top-level rows, outside grouping, IntersectionObserver
mount/unmount (no state preservation — acceptable).

## Grouping / collapse

- `mode={all|partial|none}` on **`<ChatThread>`** (app), not the primitive. App maps
mode → per-marker `defaultOpen`: `none`→true, `all`→false, `partial`→`isActiveTurn`.
- **Live→complete**: live turn renders expanded markers (Spinner/shimmer); on completion
the grouper **swaps row identity** → one collapsed summary marker mounts fresh. Keep
`createIncrementalThreadGrouper`. No controlled-state flipping.

## Escape hatch (heavy rows)

Heavy rows opt into IntersectionObserver mount/unmount + placeholder. Off-screen MCP apps
unmount. `content-visibility: auto` saves paint, not React mount cost — this caps live
iframe processes.

## Anchoring / streaming

- Anchor = user message when present, else turn's first assistant row; `scrollAnchor` on
those items only.
- `content-visibility: auto` + tuned `contain-intrinsic-size` per row type. Accept-and-tune
for v1; leave a seam for a measured-size cache if the 1000-turn stress shows drift.

## Build order

1. Scroller + dummy rows + 1000-turn stress story (prove thesis).
2. `ChatMarker` (all variants / fill levels).
3. `ChatMessage` + `ChatBubble`.
4. Link → `<ChatThread>` (parse→group→primitives, `mode`, MCP mounting) behind existing composer.
5. Stress real thread, tune sizes.

Each primitive ships `*.stories.tsx` with shadcn-shaped fixtures.

## Kept untouched

Existing composer; event→item parse; optimistic merge; incremental grouper; tool detail renderers.
34 changes: 22 additions & 12 deletions packages/agent/src/adapters/claude/claude-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -672,25 +672,35 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
},
},
});
// Clear the "Compacting…" spinner. On success a `compact_boundary`
// usually also clears it, but a no-op success carries none, so
// signal completion explicitly.
await this.client.extNotification(
POSTHOG_NOTIFICATIONS.STATUS,
{
sessionId: params.sessionId,
status: "compacting",
isComplete: true,
},
);
break;
} else if (
message.compact_result === "failed" &&
compactionInProgress
) {
compactionInProgress = false;
const reason = message.compact_error
? `: ${message.compact_error}`
: ".";
await this.client.sessionUpdate({
sessionId: params.sessionId,
update: {
sessionUpdate: "agent_message_chunk",
content: {
type: "text",
text: `\n\nCompacting failed${reason}`,
},
// A failed compaction never emits a `compact_boundary`, so emit a
// structured failure status: the renderer clears the "Compacting…"
// spinner and reports the outcome as its own status row (a separator
// marker in the new thread), not as assistant prose.
await this.client.extNotification(
POSTHOG_NOTIFICATIONS.STATUS,
{
sessionId: params.sessionId,
status: "compacting_failed",
error: message.compact_error ?? undefined,
},
});
);
break;
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
InputGroupTextarea,
} from "@posthog/quill";
import type { AcpMessage } from "@posthog/shared";
import { ConversationView } from "@posthog/ui/features/sessions/components/ConversationView";
import { ThreadView } from "@posthog/ui/features/sessions/components/ThreadView";
import { Flex, Text, Tooltip } from "@radix-ui/themes";
import { type KeyboardEvent, type ReactNode, useState } from "react";

Expand Down Expand Up @@ -65,7 +65,7 @@ export function AgentChatSurface({
</div>
))
) : (
<ConversationView
<ThreadView
events={messages}
isPromptPending={isStreaming}
collapseMode="none"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { AgentApplicationSessionDetail } from "@posthog/shared/agent-platform-types";
import { ConversationView } from "@posthog/ui/features/sessions/components/ConversationView";
import { ThreadView } from "@posthog/ui/features/sessions/components/ThreadView";
import { Badge } from "@posthog/ui/primitives/Badge";
import { Flex, Text } from "@radix-ui/themes";
import { type ReactNode, useMemo, useState } from "react";
Expand Down Expand Up @@ -230,7 +230,7 @@ export function AgentSessionDetailBody({
/>
</Centered>
) : (
<ConversationView events={events} isPromptPending={null} />
<ThreadView events={events} isPromptPending={null} />
)}
</div>
</Flex>
Expand Down
26 changes: 19 additions & 7 deletions packages/ui/src/features/mcp-apps/components/McpToolView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
getPostHogExecDisplay,
isPostHogExecTool,
} from "../../posthog-mcp/utils/posthog-exec-display";
import { useChatThreadChrome } from "../../sessions/components/chat-thread/chatThreadChrome";
import { ToolRow } from "../../sessions/components/session-update/ToolRow";
import {
ContentPre,
Expand Down Expand Up @@ -36,6 +37,9 @@ export function McpToolView({
turnCancelled,
turnComplete,
);
// New thread restyles the MCP header/output; the legacy thread keeps its original colours + the
// input/output divider so ConversationView is unchanged when the chat thread is toggled off.
const chatChrome = useChatThreadChrome();

const { serverName: defaultServerName, toolName: defaultToolName } =
parseMcpToolKey(mcpToolName);
Expand Down Expand Up @@ -64,14 +68,22 @@ export function McpToolView({
fullInput || showOutput ? (
<>
{fullInput && <ContentPre>{fullInput}</ContentPre>}
{showOutput && (
<div className={fullInput ? "border-gray-6 border-t" : undefined}>
{showOutput &&
(chatChrome ? (
<ContentPre>{output}</ContentPre>
</div>
)}
) : (
<div className={fullInput ? "border-gray-6 border-t" : undefined}>
<ContentPre>{output}</ContentPre>
</div>
))}
</>
) : undefined;

const labelClass = chatChrome ? "text-muted-foreground" : "text-gray-10";
const previewClass = chatChrome
? "text-muted-foreground/50"
: "text-accent-11";

return (
<ToolRow
icon={Plugs}
Expand All @@ -82,14 +94,14 @@ export function McpToolView({
content={body}
>
<ToolTitle>
<span className="text-gray-10">{serverName}</span>
<span className={labelClass}>{serverName}</span>
{" - "}
{toolName}
<span className="text-gray-10">{" (MCP)"}</span>
<span className={labelClass}>{" (MCP)"}</span>
</ToolTitle>
{inputPreview && (
<ToolTitle>
<span className="text-accent-11">{inputPreview}</span>
<span className={previewClass}>{inputPreview}</span>
</ToolTitle>
)}
</ToolRow>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ const DIFFS_HIGHLIGHTER_OPTIONS = {
theme: { dark: "github-dark" as const, light: "github-light" as const },
};

interface ConversationViewProps {
export interface ConversationViewProps {
events: AcpMessage[];
isPromptPending: boolean | null;
promptStartedAt?: number | null;
Expand Down
14 changes: 9 additions & 5 deletions packages/ui/src/features/sessions/components/SessionView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ import { useAutoFocusOnTyping } from "@posthog/ui/features/message-editor/useAut
import { resolveAndAttachDroppedFiles } from "@posthog/ui/features/message-editor/utils/persistFile";
import { PermissionSelector } from "@posthog/ui/features/permissions/PermissionSelector";
import { CloudInitializingView } from "@posthog/ui/features/sessions/components/CloudInitializingView";
import { ConversationView } from "@posthog/ui/features/sessions/components/ConversationView";
import {
copyFromContextMenu,
getGithubRefUrlFromEventTarget,
Expand All @@ -31,6 +30,7 @@ import { ReasoningLevelSelector } from "@posthog/ui/features/sessions/components
import { RawLogsView } from "@posthog/ui/features/sessions/components/raw-logs/RawLogsView";
import { SessionResourcesBar } from "@posthog/ui/features/sessions/components/SessionResourcesBar";
import { SteerQueueToggle } from "@posthog/ui/features/sessions/components/SteerQueueToggle";
import { ThreadView } from "@posthog/ui/features/sessions/components/ThreadView";
import { CHAT_CONTENT_MAX_WIDTH } from "@posthog/ui/features/sessions/constants";
import { useToggleMessagingMode } from "@posthog/ui/features/sessions/hooks/useToggleMessagingMode";
import {
Expand Down Expand Up @@ -173,6 +173,7 @@ export function SessionView({
const adapter = useAdapterForTask(taskId);
const toggleMessagingMode = useToggleMessagingMode(taskId);
const { allowBypassPermissions } = useSettingsStore();
const useNewChatThread = useSettingsStore((s) => s.useNewChatThread);
const { isOnline } = useConnectivity();
const currentModeId = modeOption?.currentValue;
const handoffInProgress =
Expand Down Expand Up @@ -421,7 +422,7 @@ export function SessionView({
>
{isSuspended ? (
<>
<ConversationView
<ThreadView
events={events}
isPromptPending={isPromptPending}
promptStartedAt={promptStartedAt}
Expand Down Expand Up @@ -505,7 +506,7 @@ export function SessionView({
onRetry={onRetry}
/>
)}
<ConversationView
<ThreadView
events={events}
isPromptPending={isPromptPending}
promptStartedAt={promptStartedAt}
Expand All @@ -517,7 +518,7 @@ export function SessionView({
scrollX={false}
/>

<SessionResourcesBar events={events} />
{!useNewChatThread && <SessionResourcesBar events={events} />}

<PlanStatusBar plan={latestPlan} />

Expand Down Expand Up @@ -565,7 +566,10 @@ export function SessionView({
</Flex>
</Flex>
) : hideInput ? null : firstPendingPermission ? (
<Box className="min-h-0 overflow-y-auto">
// This box replaces the composer while a permission is pending, so it's an input
// region: `shrink-0` keeps it from being compressed by the scroller above, and
// `min-h-0 overflow-y-auto` lets a tall permission prompt scroll inside itself.
<Box className="min-h-0 shrink-0 overflow-y-auto">
Comment thread
adamleithp marked this conversation as resolved.
<Box
className={compact ? "p-1" : "mx-auto px-2 pb-3"}
style={
Expand Down
21 changes: 21 additions & 0 deletions packages/ui/src/features/sessions/components/ThreadView.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import {
ConversationView,
type ConversationViewProps,
} from "@posthog/ui/features/sessions/components/ConversationView";
import { ChatThread } from "@posthog/ui/features/sessions/components/chat-thread/ChatThread";
import { useSettingsStore } from "@posthog/ui/features/settings/settingsStore";

/**
* Picks the conversation renderer at the mount boundary: the experimental ChatX thread when
* `useNewChatThread` is on, otherwise the production `ConversationView`. Switching at the parent
* (rather than early-returning inside `ConversationView`) keeps both components' hook order stable
* across toggles. Flip it in Settings → Experimental.
*/
export function ThreadView(props: ConversationViewProps) {
const useNewChatThread = useSettingsStore((s) => s.useNewChatThread);
return useNewChatThread ? (
<ChatThread {...props} />
) : (
<ConversationView {...props} />
);
}
Loading
Loading