From dcb5f20eda364b3e4ad458b7247239610151a5a1 Mon Sep 17 00:00:00 2001 From: Adam Leith Date: Sun, 28 Jun 2026 12:43:25 +0100 Subject: [PATCH 1/6] new markdown, new UI based on quill (publish it first) --- .gitignore | 4 +- docs/chat-rebuild-spec.md | 95 ++++++ .../components/AgentChatSurface.tsx | 4 +- .../components/AgentSessionDetailBody.tsx | 4 +- .../sessions/components/ConversationView.tsx | 2 +- .../sessions/components/SessionView.tsx | 6 +- .../sessions/components/ThreadView.tsx | 21 ++ .../components/chat-thread/ChatMarkdown.tsx | 121 +++++++ .../components/chat-thread/ChatThread.tsx | 319 ++++++++++++++++++ .../chat-thread/chatThreadChrome.tsx | 17 + .../components/session-update/ToolRow.tsx | 37 +- .../settings/sections/AdvancedSettings.tsx | 14 +- .../ui/src/features/settings/settingsStore.ts | 7 + .../ui/src/primitives/HighlightedCode.tsx | 11 +- pnpm-workspace.yaml | 13 + 15 files changed, 661 insertions(+), 14 deletions(-) create mode 100644 docs/chat-rebuild-spec.md create mode 100644 packages/ui/src/features/sessions/components/ThreadView.tsx create mode 100644 packages/ui/src/features/sessions/components/chat-thread/ChatMarkdown.tsx create mode 100644 packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx create mode 100644 packages/ui/src/features/sessions/components/chat-thread/chatThreadChrome.tsx diff --git a/.gitignore b/.gitignore index 8373d4d549..fa269ba709 100644 --- a/.gitignore +++ b/.gitignore @@ -73,4 +73,6 @@ posthog-sym .claude/settings.local.json CLAUDE.local.md -apps/mobile/ROADMAP.md \ No newline at end of file +apps/mobile/ROADMAP.md +# Local quill tarball for ChatX thread testing (not committed) +.local-quill/ diff --git a/docs/chat-rebuild-spec.md b/docs/chat-rebuild-spec.md new file mode 100644 index 0000000000..9c953019fc --- /dev/null +++ b/docs/chat-rebuild-spec.md @@ -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 `` 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 (``, + `` — 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 **``** (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 → `` (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. diff --git a/packages/ui/src/features/agent-applications/components/AgentChatSurface.tsx b/packages/ui/src/features/agent-applications/components/AgentChatSurface.tsx index a177ecc953..368c683f6e 100644 --- a/packages/ui/src/features/agent-applications/components/AgentChatSurface.tsx +++ b/packages/ui/src/features/agent-applications/components/AgentChatSurface.tsx @@ -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"; @@ -65,7 +65,7 @@ export function AgentChatSurface({ )) ) : ( - ) : ( - + )} diff --git a/packages/ui/src/features/sessions/components/ConversationView.tsx b/packages/ui/src/features/sessions/components/ConversationView.tsx index d15543515e..275aff3b8c 100644 --- a/packages/ui/src/features/sessions/components/ConversationView.tsx +++ b/packages/ui/src/features/sessions/components/ConversationView.tsx @@ -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; diff --git a/packages/ui/src/features/sessions/components/SessionView.tsx b/packages/ui/src/features/sessions/components/SessionView.tsx index 68ca6650ae..9564696b6a 100644 --- a/packages/ui/src/features/sessions/components/SessionView.tsx +++ b/packages/ui/src/features/sessions/components/SessionView.tsx @@ -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, @@ -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 { @@ -421,7 +421,7 @@ export function SessionView({ > {isSuspended ? ( <> - )} - s.useNewChatThread); + return useNewChatThread ? ( + + ) : ( + + ); +} diff --git a/packages/ui/src/features/sessions/components/chat-thread/ChatMarkdown.tsx b/packages/ui/src/features/sessions/components/chat-thread/ChatMarkdown.tsx new file mode 100644 index 0000000000..c1d9fa3684 --- /dev/null +++ b/packages/ui/src/features/sessions/components/chat-thread/ChatMarkdown.tsx @@ -0,0 +1,121 @@ +import { + Heading, + Separator, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, + Text, +} from "@posthog/quill"; +import { HighlightedCode } from "@posthog/ui/primitives/HighlightedCode"; +import { memo } from "react"; +import Markdown, { type Components } from "react-markdown"; +import rehypeSanitize from "rehype-sanitize"; +import remarkGfm from "remark-gfm"; + +/** + * The chat thread's own markdown renderer — intentionally separate from the app-wide + * `MarkdownRenderer` (which carries PostHog deeplink handling, Radix Text wrappers, and other + * product baggage). This one is a thin, generic react-markdown setup for chat bubble content: + * GFM + sanitized HTML, minimal prose styling. Restyle the element map below per product. + */ +const components: Components = { + p: ({ children }) => ( + {children} + ), + a: ({ children, href }) => ( + + {children} + + ), + ul: ({ children }) => ( +
    {children}
+ ), + ol: ({ children }) => ( +
    {children}
+ ), + li: ({ children }) =>
  • {children}
  • , + code: ({ className, children }) => { + const match = /language-(\w+)/.exec(className ?? ""); + if (match) { + // Fenced block with a language → Shiki-highlighted (theme-aware). The `pre` renderer + // below provides the box; HighlightedCode renders the colored inside it. + return ( + + ); + } + return ( + + {children} + + ); + }, + pre: ({ children }) => ( +
    +      {children}
    +    
    + ), + h1: ({ children }) => ( + + {children} + + ), + h2: ({ children }) => ( + + {children} + + ), + h3: ({ children }) => ( + + {children} + + ), + blockquote: ({ children }) => ( +
    + {children} +
    + ), + hr: () => , + table: ({ children }) => ( + + {children} +
    + ), + thead: ({ children }) => {children}, + th: ({ children }) => {children}, + tbody: ({ children }) => {children}, + tr: ({ children }) => {children}, + td: ({ children }) => {children}, +}; + +const remarkPlugins = [remarkGfm]; +const rehypePlugins = [rehypeSanitize]; + +export const ChatMarkdown = memo(function ChatMarkdown({ + content, +}: { + content: string; +}) { + return ( +
    + + {content} + +
    + ); +}); diff --git a/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx b/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx new file mode 100644 index 0000000000..cd0d5db01f --- /dev/null +++ b/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx @@ -0,0 +1,319 @@ +import { ChatCircle } from "@phosphor-icons/react"; +import { WorkerPoolContextProvider } from "@pierre/diffs/react"; +import { useService } from "@posthog/di/react"; +import { + Button, + ChatBubble, + ChatBubbleContent, + ChatMessage, + ChatMessageContent, + ChatMessageScroller, + ChatMessageScrollerButton, + ChatMessageScrollerContent, + ChatMessageScrollerItem, + ChatMessageScrollerProvider, + ChatMessageScrollerViewport, + useChatMessageScroller, + useChatMessageScrollerVisibility, +} from "@posthog/quill"; +import type { ConversationItem } from "@posthog/ui/features/sessions/components/buildConversationItems"; +import { ChatMarkdown } from "@posthog/ui/features/sessions/components/chat-thread/ChatMarkdown"; +import { ChatThreadChromeProvider } from "@posthog/ui/features/sessions/components/chat-thread/chatThreadChrome"; +import { GitActionMessage } from "@posthog/ui/features/sessions/components/GitActionMessage"; +import { GitActionResult } from "@posthog/ui/features/sessions/components/GitActionResult"; +import { mergeConversationItems } from "@posthog/ui/features/sessions/components/mergeConversationItems"; +import { SessionUpdateView } from "@posthog/ui/features/sessions/components/session-update/SessionUpdateView"; +import { UserShellExecuteView } from "@posthog/ui/features/sessions/components/session-update/UserShellExecuteView"; +import { CHAT_CONTENT_MAX_WIDTH } from "@posthog/ui/features/sessions/constants"; +import { useConversationItems } from "@posthog/ui/features/sessions/hooks/useConversationItems"; +import { + useOptimisticItemsForTask, + useSessionForTask, +} from "@posthog/ui/features/sessions/sessionStore"; +import { SessionTaskIdProvider } from "@posthog/ui/features/sessions/useSessionTaskId"; +import { useSettingsStore } from "@posthog/ui/features/settings/settingsStore"; +import { SkillButtonActionMessage } from "@posthog/ui/features/skill-buttons/components/SkillButtonActionMessage"; +import { + DIFF_WORKER_FACTORY, + type DiffWorkerFactory, +} from "@posthog/ui/shell/diffWorkerHost"; +import { AnimatePresence, motion, useReducedMotion } from "framer-motion"; +import { + memo, + type ReactNode, + useCallback, + useEffect, + useMemo, + useState, +} from "react"; + +import type { ConversationViewProps } from "../ConversationView"; + +const DIFFS_HIGHLIGHTER_OPTIONS = { + theme: { dark: "github-dark" as const, light: "github-light" as const }, +}; + +/** Plain end-aligned user bubble. Full content — the pinned preview is the separate overlay. */ +function UserBubble({ content }: { content: string }) { + return ( + + + + + + + + + + ); +} + +/** + * "Fake sticky" header. A real `position: sticky` row can't hand off in this flat list (every row + * shares one containing block, so they'd pile at the top) and sticking causes reflow. Instead we + * overlay a single header, out of flow, pinned over the viewport top — showing the current turn's + * user message (the engine's anchor) once the real one has scrolled off. Click to scroll back to it. + * + * Only this small component subscribes to the engine's per-scroll visibility state, so the rows + * themselves never re-render on scroll. + */ +function StickyHeaderOverlay({ items }: { items: ConversationItem[] }) { + const { currentAnchorId, visibleMessageIds } = + useChatMessageScrollerVisibility(); + const { scrollToMessage } = useChatMessageScroller(); + const shouldReduceMotion = useReducedMotion(); + const [dismissedId, setDismissedId] = useState(null); + + const active = items.find( + (i): i is Extract => + i.id === currentAnchorId && i.type === "user_message", + ); + const offscreen = active != null && !visibleMessageIds.includes(active.id); + + // Once the real message is back on screen, clear the dismissal so the header can return later. + useEffect(() => { + if (!offscreen) setDismissedId(null); + }, [offscreen]); + + const dismiss = (id: string) => { + // Hide immediately on click (don't wait for the scroll to bring the message into view), then + // jump to it. + setDismissedId(id); + scrollToMessage(id); + }; + + return ( + + {active != null && offscreen && active.id !== dismissedId && ( + + {/* Align to the content column's right edge (matches the message rows) rather than the + viewport edge, so the button reads in-context with the conversation. */} +
    + +
    +
    + )} +
    + ); +} + +/** + * One transcript row. Memoized and scroll-state-free, so rows never re-render while scrolling — the + * non-virtualized thread stays cheap. The pinned header is the separate overlay, not the rows. + */ +const ThreadRow = memo(function ThreadRow({ + item, + renderItem, +}: { + item: ConversationItem; + renderItem: (item: ConversationItem) => ReactNode; +}) { + return ( + + {item.type === "user_message" ? ( + + ) : ( + renderItem(item) + )} + + ); +}); + +/** The scroll body, under the Provider so the overlay + scroll-button hooks can read engine state. */ +function ThreadScrollBody({ + items, + renderItem, +}: { + items: ConversationItem[]; + renderItem: (item: ConversationItem) => ReactNode; +}) { + return ( + + + + + {items.map((item) => ( + + ))} + + + + + ); +} + +/** + * Experimental thread renderer built on the new ChatX (quill) primitives. + * + * Reuses the existing parse pipeline (`useConversationItems`) and the non-virtualized + * `ChatMessageScroller` (`content-visibility: auto`). User + assistant turns render through + * `ChatMessage`/`ChatBubble` (end-aligned filled / start-aligned ghost) with our own `ChatMarkdown`. + * Tool calls render as `ChatMarker` — `ChatThreadChromeProvider` flips the shared `ToolRow` chrome + * to the ChatX primitive, so every tool view is mapped without forking. Still TODO: per-turn tool + * grouping, and mention chips / attachments / timestamps on user messages. + * + * Swapped in behind `settingsStore.useNewChatThread` via `ThreadView`. + */ +export function ChatThread({ + events, + isPromptPending, + repoPath, + taskId, +}: ConversationViewProps) { + const diffWorkerFactory = useService(DIFF_WORKER_FACTORY); + const diffsPoolOptions = useMemo( + () => ({ + workerFactory: () => diffWorkerFactory(), + totalASTLRUCacheSize: 200, + }), + [diffWorkerFactory], + ); + + const showDebugLogs = useSettingsStore((s) => s.debugLogsCloudRuns); + + const { items: conversationItems } = useConversationItems( + events, + isPromptPending, + { showDebugLogs }, + ); + + const optimisticItems = useOptimisticItemsForTask(taskId); + const isCloud = useSessionForTask(taskId)?.isCloud ?? false; + + const items = useMemo( + () => + mergeConversationItems({ conversationItems, optimisticItems, isCloud }), + [conversationItems, optimisticItems, isCloud], + ); + + const renderItem = useCallback( + (item: ConversationItem) => { + switch (item.type) { + // user_message is rendered by ThreadScrollBody (it needs the active-anchor state for sticky). + // NOTE: mention chips / attachments / timestamp are dropped in this slice — just the bubble + // surface + markdown. Re-add via ChatAttachment + ChatMessageFooter later. + case "user_message": + return null; + case "git_action": + return ; + case "skill_button_action": + return ; + case "session_update": { + const update = item.update; + // Assistant prose → start-aligned ghost bubble. Everything else (tool calls, thoughts, + // console, status) keeps the existing renderer for now — ChatMarker mapping is next. + if ( + update.sessionUpdate === "agent_message_chunk" && + update.content.type === "text" + ) { + return ( + + + + + + + + + + ); + } + return ( + + ); + } + case "git_action_result": + return repoPath ? ( + + ) : null; + case "turn_cancelled": + return null; + case "user_shell_execute": + return ; + } + }, + [repoPath], + ); + + return ( + + + + + + + + + + ); +} diff --git a/packages/ui/src/features/sessions/components/chat-thread/chatThreadChrome.tsx b/packages/ui/src/features/sessions/components/chat-thread/chatThreadChrome.tsx new file mode 100644 index 0000000000..914983a260 --- /dev/null +++ b/packages/ui/src/features/sessions/components/chat-thread/chatThreadChrome.tsx @@ -0,0 +1,17 @@ +import { createContext, useContext } from "react"; + +/** + * When true, shared session-update components (notably `ToolRow`) render their chrome with the new + * ChatX primitives (`ChatMarker`) instead of the legacy Radix chrome. The experimental `ChatThread` + * turns this on; the production `ConversationView` never provides it, so its rendering is unchanged. + * + * This lets one shared `ToolRow` serve both threads — the new thread swaps chrome via context + * rather than forking every per-tool view. + */ +const ChatThreadChromeContext = createContext(false); + +export const ChatThreadChromeProvider = ChatThreadChromeContext.Provider; + +export function useChatThreadChrome(): boolean { + return useContext(ChatThreadChromeContext); +} diff --git a/packages/ui/src/features/sessions/components/session-update/ToolRow.tsx b/packages/ui/src/features/sessions/components/session-update/ToolRow.tsx index 47b27fbe34..6b82f08f31 100644 --- a/packages/ui/src/features/sessions/components/session-update/ToolRow.tsx +++ b/packages/ui/src/features/sessions/components/session-update/ToolRow.tsx @@ -1,7 +1,14 @@ import { Collapsible } from "@base-ui/react/collapsible"; import { type Icon, WrenchIcon } from "@phosphor-icons/react"; -import { cn } from "@posthog/quill"; +import { + ChatMarker, + ChatMarkerContent, + ChatMarkerIcon, + cn, + Spinner, +} from "@posthog/quill"; import { type ReactNode, useState } from "react"; +import { useChatThreadChrome } from "../chat-thread/chatThreadChrome"; import { ExpandableIcon, LoadingIcon, @@ -69,8 +76,36 @@ export function ToolRow({ onOpenChange?.(next); }; + const chatChrome = useChatThreadChrome(); + const isCollapsible = collapsible || content != null; + // New thread: render the tool as a ChatMarker (icon + title row, collapsible detail body). + // Old thread (no provider) skips this and uses the Radix chrome below. + if (chatChrome) { + const IconComp = icon ?? WrenchIcon; + const iconNode = leading ?? (isLoading ? : ); + return ( + + {iconNode} + + {typeof children === "string" ? ( + {children} + ) : ( + children + )} + + {trailing} + + + ); + } + const leadingNode = leading ?? ( {isCollapsible ? ( diff --git a/packages/ui/src/features/settings/sections/AdvancedSettings.tsx b/packages/ui/src/features/settings/sections/AdvancedSettings.tsx index 34f2c21ad4..7bc06941fd 100644 --- a/packages/ui/src/features/settings/sections/AdvancedSettings.tsx +++ b/packages/ui/src/features/settings/sections/AdvancedSettings.tsx @@ -15,6 +15,8 @@ export function AdvancedSettings() { const setDebugLogsCloudRuns = useSettingsStore( (s) => s.setDebugLogsCloudRuns, ); + const useNewChatThread = useSettingsStore((s) => s.useNewChatThread); + const setUseNewChatThread = useSettingsStore((s) => s.setUseNewChatThread); return ( @@ -53,7 +55,6 @@ export function AdvancedSettings() { )} + + + ); } diff --git a/packages/ui/src/features/settings/settingsStore.ts b/packages/ui/src/features/settings/settingsStore.ts index ef46a9c0d9..6ceb69d6ef 100644 --- a/packages/ui/src/features/settings/settingsStore.ts +++ b/packages/ui/src/features/settings/settingsStore.ts @@ -163,6 +163,10 @@ interface SettingsStore { mcpAppsDisabledServers: string[]; downloadUpdatesAutomatically: boolean; lastSeenChangelogVersion: string | null; + // Renders the conversation with the new ChatX (quill) primitives instead of + // the virtualized ConversationView. Local A/B toggle while the rebuild bakes. + useNewChatThread: boolean; + setUseNewChatThread: (enabled: boolean) => void; setHedgehogMode: (enabled: boolean) => void; setSlotMachineMode: (enabled: boolean) => void; setMcpAppsDisabledServers: (servers: string[]) => void; @@ -320,6 +324,8 @@ export const useSettingsStore = create()( mcpAppsDisabledServers: [], downloadUpdatesAutomatically: true, lastSeenChangelogVersion: null, + useNewChatThread: false, + setUseNewChatThread: (enabled) => set({ useNewChatThread: enabled }), setHedgehogMode: (enabled) => set({ hedgehogMode: enabled }), setSlotMachineMode: (enabled) => set({ slotMachineMode: enabled }), setDownloadUpdatesAutomatically: (enabled) => @@ -416,6 +422,7 @@ export const useSettingsStore = create()( mcpAppsDisabledServers: state.mcpAppsDisabledServers, downloadUpdatesAutomatically: state.downloadUpdatesAutomatically, lastSeenChangelogVersion: state.lastSeenChangelogVersion, + useNewChatThread: state.useNewChatThread, // Onboarding hints hints: state.hints, diff --git a/packages/ui/src/primitives/HighlightedCode.tsx b/packages/ui/src/primitives/HighlightedCode.tsx index 73f5cbb965..c65cf03a8f 100644 --- a/packages/ui/src/primitives/HighlightedCode.tsx +++ b/packages/ui/src/primitives/HighlightedCode.tsx @@ -5,9 +5,14 @@ import { highlightSyntax } from "../utils/syntax-highlight"; interface HighlightedCodeProps { code: string; language: string; + className?: string; } -export function HighlightedCode({ code, language }: HighlightedCodeProps) { +export function HighlightedCode({ + code, + language, + className, +}: HighlightedCodeProps) { const isDarkMode = useThemeStore((s) => s.isDarkMode); const segments = useMemo( () => highlightSyntax(code, language, isDarkMode), @@ -15,11 +20,11 @@ export function HighlightedCode({ code, language }: HighlightedCodeProps) { ); if (!segments) { - return {code}; + return {code}; } return ( - + {segments.map((segment, i) => segment.color ? ( // biome-ignore lint/suspicious/noArrayIndexKey: stable parse output, never reorders diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 66b687fc40..dc9b9d4945 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -99,6 +99,19 @@ overrides: # undici 8.4.1 dropped that assertion and handles the paused case gracefully; # node-gyp only uses stable public exports (fetch/Agent), so 8.x is safe. 'node-gyp>undici': 8.4.1 + # LOCAL DEV ONLY — point @posthog/quill at a packed tarball of the local mono build to + # test the ChatX thread primitives before publishing. A tarball (not link:) is used on + # purpose: 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). + # The tarball is copied into this repo's store and deduped against React 19. + # Re-sync after a quill change: + # (cd ../posthog/packages/quill/packages/quill && pnpm build && npm pack --pack-destination /Users/adamleithp/Dev/code/.local-quill) + # then rename the tgz to a content-hashed name and point the override at it (pnpm pins the + # tarball by integrity, so a stable filename gets cached stale across re-syncs): + # cd .local-quill && cp posthog-quill-*.tgz posthog-quill-local-$(md5 -q posthog-quill-*.tgz | cut -c1-8).tgz + # update the line below to the new name, then `pnpm install`. + # Revert (back to catalog 0.3.0-beta.x) once the ChatX primitives are published to npm. + '@posthog/quill': file:./.local-quill/posthog-quill-local-d4357f58.tgz onlyBuiltDependencies: - '@parcel/watcher' From e4e91a38e7ef4a2fe583574e1ddaba0750cc23c9 Mon Sep 17 00:00:00 2001 From: Adam Leith Date: Mon, 29 Jun 2026 12:56:29 +0100 Subject: [PATCH 2/6] wip --- .../mcp-apps/components/McpToolView.tsx | 26 ++- .../components/chat-thread/ChatThread.tsx | 159 +++++++++++++++++- .../components/chat-thread/ToolGroup.tsx | 117 +++++++++++++ .../session-update/ExecuteToolView.tsx | 7 +- .../session-update/SubagentToolView.tsx | 93 ++++------ .../components/session-update/ThoughtView.tsx | 6 +- .../session-update/ToolCallBlock.tsx | 6 +- .../session-update/ToolCallView.tsx | 78 ++++----- .../components/session-update/ToolRow.tsx | 4 +- .../session-update/toolCallUtils.tsx | 78 ++++++++- pnpm-workspace.yaml | 2 +- 11 files changed, 444 insertions(+), 132 deletions(-) create mode 100644 packages/ui/src/features/sessions/components/chat-thread/ToolGroup.tsx diff --git a/packages/ui/src/features/mcp-apps/components/McpToolView.tsx b/packages/ui/src/features/mcp-apps/components/McpToolView.tsx index cd75f22b99..2a40dc99bc 100644 --- a/packages/ui/src/features/mcp-apps/components/McpToolView.tsx +++ b/packages/ui/src/features/mcp-apps/components/McpToolView.tsx @@ -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, @@ -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); @@ -64,14 +68,22 @@ export function McpToolView({ fullInput || showOutput ? ( <> {fullInput && {fullInput}} - {showOutput && ( -
    + {showOutput && + (chatChrome ? ( {output} -
    - )} + ) : ( +
    + {output} +
    + ))} ) : undefined; + const labelClass = chatChrome ? "text-muted-foreground" : "text-gray-10"; + const previewClass = chatChrome + ? "text-muted-foreground/50" + : "text-accent-11"; + return ( - {serverName} + {serverName} {" - "} {toolName} - {" (MCP)"} + {" (MCP)"} {inputPreview && ( - {inputPreview} + {inputPreview} )} diff --git a/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx b/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx index cd0d5db01f..66a2039900 100644 --- a/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx +++ b/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx @@ -1,4 +1,4 @@ -import { ChatCircle } from "@phosphor-icons/react"; +import { CaretDown, ChatCircle } from "@phosphor-icons/react"; import { WorkerPoolContextProvider } from "@pierre/diffs/react"; import { useService } from "@posthog/di/react"; import { @@ -13,12 +13,17 @@ import { ChatMessageScrollerItem, ChatMessageScrollerProvider, ChatMessageScrollerViewport, + cn, useChatMessageScroller, useChatMessageScrollerVisibility, } from "@posthog/quill"; import type { ConversationItem } from "@posthog/ui/features/sessions/components/buildConversationItems"; import { ChatMarkdown } from "@posthog/ui/features/sessions/components/chat-thread/ChatMarkdown"; import { ChatThreadChromeProvider } from "@posthog/ui/features/sessions/components/chat-thread/chatThreadChrome"; +import { + ToolGroup, + type ToolGroupItem, +} from "@posthog/ui/features/sessions/components/chat-thread/ToolGroup"; import { GitActionMessage } from "@posthog/ui/features/sessions/components/GitActionMessage"; import { GitActionResult } from "@posthog/ui/features/sessions/components/GitActionResult"; import { mergeConversationItems } from "@posthog/ui/features/sessions/components/mergeConversationItems"; @@ -43,7 +48,9 @@ import { type ReactNode, useCallback, useEffect, + useLayoutEffect, useMemo, + useRef, useState, } from "react"; @@ -53,14 +60,140 @@ const DIFFS_HIGHLIGHTER_OPTIONS = { theme: { dark: "github-dark" as const, light: "github-light" as const }, }; -/** Plain end-aligned user bubble. Full content — the pinned preview is the separate overlay. */ +/** A row is either a parsed conversation item or a synthesized group of tool calls. */ +type ThreadItem = ConversationItem | ToolGroupItem; + +type SessionUpdateItem = Extract; + +function isToolCallItem(item: ConversationItem): item is SessionUpdateItem { + return ( + item.type === "session_update" && item.update.sessionUpdate === "tool_call" + ); +} + +/** + * Session-updates that `SessionUpdateView` always renders as `null`. They produce no row, so they + * must not break a contiguous tool run. + */ +const INVISIBLE_UPDATES = new Set([ + "user_message_chunk", + "tool_call_update", + "plan", + "available_commands_update", + "config_option_update", +]); + +/** + * True when an item renders nothing, so it should be transparent to tool grouping. Besides the + * always-null updates, this covers text chunks the stream emits with empty/whitespace or non-text + * content (a stray empty `agent_message_chunk` between two tool calls is hidden via `empty:hidden` + * but would otherwise split the run into two ungrouped markers). + */ +function isInvisibleItem(item: ConversationItem): boolean { + if (item.type !== "session_update") return false; + const update = item.update; + if (INVISIBLE_UPDATES.has(update.sessionUpdate)) return true; + if ( + update.sessionUpdate === "agent_message_chunk" || + update.sessionUpdate === "agent_thought_chunk" + ) { + return update.content.type !== "text" || update.content.text.trim() === ""; + } + return false; +} + +/** + * Collapse each contiguous run of ≥2 tool-call updates into a single `ToolGroupItem`. A run is + * broken by any *visible* non-tool item (prose, thought, status) so groups follow reading order; + * invisible updates (see {@link INVISIBLE_UPDATES}) are transparent and don't split a run. A lone + * tool call passes through untouched — it stays a single marker, matching the legacy thread. + */ +function groupToolRuns(items: ConversationItem[]): ThreadItem[] { + const out: ThreadItem[] = []; + // The buffer holds the active run: tool items plus any invisible items interleaved with them. + let buffer: ConversationItem[] = []; + let toolCount = 0; + + const flush = () => { + if (toolCount >= 2) { + const tools = buffer.filter(isToolCallItem); + out.push({ type: "tool_group", id: `tool-group-${tools[0].id}`, tools }); + } else { + out.push(...buffer); + } + buffer = []; + toolCount = 0; + }; + + for (const item of items) { + if (isToolCallItem(item)) { + buffer.push(item); + toolCount++; + } else if (isInvisibleItem(item)) { + // Don't break the run; carry it along (it renders nothing wherever it lands). + buffer.push(item); + } else { + flush(); + out.push(item); + } + } + flush(); + return out; +} + +/** + * End-aligned user bubble. The text is clamped to two lines (`max-height: 2lh` + `overflow-hidden`, + * which — unlike `-webkit-line-clamp` — reliably clamps markdown's block `

    ` children); a "Show + * more" toggle appears only when the content actually exceeds the clamp. Overflow can't be known + * from character count (it depends on wrapping width), so we measure `scrollHeight` against the + * clamped `clientHeight` — which holds even while clamped — and re-measure on resize. + */ function UserBubble({ content }: { content: string }) { + const [isExpanded, setIsExpanded] = useState(false); + const [isOverflowing, setIsOverflowing] = useState(false); + const textRef = useRef(null); + + // Only meaningful while collapsed: expanding removes the clamp so scrollHeight === clientHeight. + // We keep the prior result when expanded so the "Show less" trigger stays put. + // biome-ignore lint/correctness/useExhaustiveDependencies: re-measure when the message text changes. + useLayoutEffect(() => { + if (isExpanded) return; + const el = textRef.current; + if (!el) return; + const measure = () => + setIsOverflowing(el.scrollHeight - el.clientHeight > 1); + measure(); + const observer = new ResizeObserver(measure); + observer.observe(el); + return () => observer.disconnect(); + }, [content, isExpanded]); + return ( - +

    + +
    + {isOverflowing && ( + + )} @@ -152,7 +285,7 @@ const ThreadRow = memo(function ThreadRow({ item, renderItem, }: { - item: ConversationItem; + item: ThreadItem; renderItem: (item: ConversationItem) => ReactNode; }) { return ( @@ -162,7 +295,9 @@ const ThreadRow = memo(function ThreadRow({ className="mx-auto w-full px-2 empty:hidden" style={{ maxWidth: CHAT_CONTENT_MAX_WIDTH }} > - {item.type === "user_message" ? ( + {item.type === "tool_group" ? ( + + ) : item.type === "user_message" ? ( ) : ( renderItem(item) @@ -174,17 +309,19 @@ const ThreadRow = memo(function ThreadRow({ /** The scroll body, under the Provider so the overlay + scroll-button hooks can read engine state. */ function ThreadScrollBody({ items, + rows, renderItem, }: { items: ConversationItem[]; + rows: ThreadItem[]; renderItem: (item: ConversationItem) => ReactNode; }) { return ( - - {items.map((item) => ( + + {rows.map((item) => ( ))} @@ -238,6 +375,8 @@ export function ChatThread({ [conversationItems, optimisticItems, isCloud], ); + const rows = useMemo(() => groupToolRuns(items), [items]); + const renderItem = useCallback( (item: ConversationItem) => { switch (item.type) { @@ -310,7 +449,11 @@ export function ChatThread({ defaultScrollPosition="end" scrollPreviousItemPeek={64} > - + diff --git a/packages/ui/src/features/sessions/components/chat-thread/ToolGroup.tsx b/packages/ui/src/features/sessions/components/chat-thread/ToolGroup.tsx new file mode 100644 index 0000000000..0824e1cee2 --- /dev/null +++ b/packages/ui/src/features/sessions/components/chat-thread/ToolGroup.tsx @@ -0,0 +1,117 @@ +import { Wrench } from "@phosphor-icons/react"; +import { + ChatMarker, + ChatMarkerContent, + ChatMarkerIcon, + cn, +} from "@posthog/quill"; +import type { ToolCall } from "@posthog/ui/features/sessions/types"; +import { memo } from "react"; +import type { ConversationItem } from "../buildConversationItems"; +import { SessionUpdateView } from "../session-update/SessionUpdateView"; +import { iconForToolCall } from "../session-update/toolCallUtils"; + +/** A contiguous run (≥2) of `tool_call` session-updates from one assistant turn. */ +export type ToolGroupItem = { + type: "tool_group"; + id: string; + tools: Extract[]; +}; + +/** Pull the resolved ToolCall + agent tool name from a `tool_call` session-update item. */ +function resolveTool(item: ToolGroupItem["tools"][number]): { + toolCall: ToolCall; + toolName?: string; +} { + const update = item.update as Extract< + ConversationItem, + { type: "session_update" } + >["update"] & { toolCallId?: string }; + const fromMap = + (update.toolCallId && item.turnContext.toolCalls.get(update.toolCallId)) || + (update as unknown as ToolCall); + const meta = fromMap._meta as + | { claudeCode?: { toolName?: string } } + | undefined; + return { toolCall: fromMap, toolName: meta?.claudeCode?.toolName }; +} + +/** Identity used to decide if a group is "all the same tool". */ +function toolKey(item: ToolGroupItem["tools"][number]): string { + const { toolCall, toolName } = resolveTool(item); + return toolName ?? toolCall.kind ?? "tool"; +} + +/** Human label for a uniform group, e.g. `ToolSearch` → "Toolsearch", `mcp__x__run` → "Run". */ +function friendlyName(key: string): string { + const last = key.includes("__") ? (key.split("__").pop() ?? key) : key; + return last.charAt(0).toUpperCase() + last.slice(1).toLowerCase(); +} + +function isToolActive(item: ToolGroupItem["tools"][number]): boolean { + const { toolCall } = resolveTool(item); + const incomplete = + toolCall.status === "pending" || toolCall.status === "in_progress"; + return ( + incomplete && + !item.turnContext.turnCancelled && + !item.turnContext.turnComplete + ); +} + +/** + * Summary `ChatMarker` for a batch of consecutive tool calls. The trigger row reads as natural + * language — "Using Toolsearch" while a tool is still running, "Used Toolsearch" once done, or + * "Used N tools" when the batch mixes tools — with a single representative leading icon. The + * collapsible body holds each tool's own marker via `SessionUpdateView` (which dispatches through + * `ToolCallBlock` → `ToolRow` → `ChatMarker`). + * + * Expanded by default while the turn is still running (live visibility), collapsed once complete. + */ +export const ToolGroup = memo(function ToolGroup({ + tools, +}: { + tools: ToolGroupItem["tools"]; +}) { + const turnComplete = tools[0]?.turnContext.turnComplete ?? false; + const isActive = tools.some(isToolActive); + + // Uniform when every tool in the run shares the same name/kind — then we can name it. + const keys = tools.map(toolKey); + const uniform = keys.every((k) => k === keys[0]); + + const verb = isActive ? "Using" : "Used"; + const object = uniform ? friendlyName(keys[0]) : `${tools.length} tools`; + + const first = resolveTool(tools[0]); + const LeadIcon = uniform + ? iconForToolCall(first.toolCall, first.toolName) + : Wrench; + + return ( + ( + + ))} + className="opacity-50 hover:opacity-100" + > + + + + + {verb} {object} + + + ); +}); diff --git a/packages/ui/src/features/sessions/components/session-update/ExecuteToolView.tsx b/packages/ui/src/features/sessions/components/session-update/ExecuteToolView.tsx index 2217f02bdb..e52869bffb 100644 --- a/packages/ui/src/features/sessions/components/session-update/ExecuteToolView.tsx +++ b/packages/ui/src/features/sessions/components/session-update/ExecuteToolView.tsx @@ -1,5 +1,6 @@ import { Terminal } from "@phosphor-icons/react"; import { compactHomePath } from "@posthog/shared"; +import { useChatThreadChrome } from "../chat-thread/chatThreadChrome"; import { ToolRow } from "./ToolRow"; import { ContentPre, @@ -37,6 +38,10 @@ export function ExecuteToolView({ const description = executeInput?.description ?? (command ? undefined : title); + // New thread hides the inline command chip (the ChatMarker title carries it); the legacy thread + // keeps showing it so ConversationView is unchanged when the chat thread is toggled off. + const chatChrome = useChatThreadChrome(); + const output = stripCodeFences(getContentText(content) ?? "").replace( ANSI_REGEX, "", @@ -53,7 +58,7 @@ export function ExecuteToolView({ content={hasOutput ? {output} : undefined} > {description && {description}} - {command && ( + {!chatChrome && command && ( 0; + const childContent = + childItems.length > 0 + ? childItems.map((child) => + child.type === "session_update" ? ( + + ) : null, + ) + : undefined; return ( - - - - {isExpanded && hasChildren && ( - // [&_.tool-row-collapsible]:pl-1 so that inner ToolRow triggers have some more spacing on left - - {childItems.map((child) => { - if (child.type !== "session_update") return null; - return ( - - ); - })} - - )} - + {title || "Subagent"} + + ); } diff --git a/packages/ui/src/features/sessions/components/session-update/ThoughtView.tsx b/packages/ui/src/features/sessions/components/session-update/ThoughtView.tsx index b1d9b0cc22..9b3d917bfa 100644 --- a/packages/ui/src/features/sessions/components/session-update/ThoughtView.tsx +++ b/packages/ui/src/features/sessions/components/session-update/ThoughtView.tsx @@ -1,5 +1,6 @@ import { Brain } from "@phosphor-icons/react"; import { memo } from "react"; +import { useChatThreadChrome } from "../chat-thread/chatThreadChrome"; import { ToolRow } from "./ToolRow"; import { ContentPre } from "./toolCallUtils"; @@ -13,6 +14,9 @@ export const ThoughtView = memo(function ThoughtView({ isLoading, }: ThoughtViewProps) { const hasContent = content.trim().length > 0; + // New thread reads back in past tense once the thought is done; the legacy thread keeps "Thinking" + // so ConversationView is unchanged when the chat thread is toggled off. + const chatChrome = useChatThreadChrome(); // An empty thought that's done streaming is pure noise — a bare "Thinking" // header with nothing under it. Only show it while content is still arriving. @@ -25,7 +29,7 @@ export const ThoughtView = memo(function ThoughtView({ isLoading={isLoading} content={hasContent ? {content} : undefined} > - Thinking + {chatChrome && !isLoading ? "Thought" : "Thinking"} ); diff --git a/packages/ui/src/features/sessions/components/session-update/ToolCallBlock.tsx b/packages/ui/src/features/sessions/components/session-update/ToolCallBlock.tsx index 7ce8651f95..6b8f014a96 100644 --- a/packages/ui/src/features/sessions/components/session-update/ToolCallBlock.tsx +++ b/packages/ui/src/features/sessions/components/session-update/ToolCallBlock.tsx @@ -14,6 +14,7 @@ import type { ToolViewProps } from "@posthog/ui/features/sessions/components/ses import type { ToolCall } from "@posthog/ui/features/sessions/types"; import { Box } from "@radix-ui/themes"; import type { ConversationItem, TurnContext } from "../buildConversationItems"; +import { useChatThreadChrome } from "../chat-thread/chatThreadChrome"; import { MCP_TOOL_BLOCK_COMPONENT, type McpToolBlockComponent, @@ -39,6 +40,7 @@ export function ToolCallBlock({ | { claudeCode?: { toolName?: string } } | undefined; const toolName = meta?.claudeCode?.toolName; + const chatChrome = useChatThreadChrome(); if (toolName === "EnterPlanMode") { return null; @@ -70,7 +72,9 @@ export function ToolCallBlock({ if (toolName?.startsWith("mcp__")) { return ( - + // New thread pulls the MCP block left to align with the ChatMarker vertical rule; the legacy + // thread keeps its original left padding so ConversationView is unchanged when toggled off. + {McpToolBlock ? ( ) : ( diff --git a/packages/ui/src/features/sessions/components/session-update/ToolCallView.tsx b/packages/ui/src/features/sessions/components/session-update/ToolCallView.tsx index d6912b7b11..967d52339e 100644 --- a/packages/ui/src/features/sessions/components/session-update/ToolCallView.tsx +++ b/packages/ui/src/features/sessions/components/session-update/ToolCallView.tsx @@ -1,20 +1,5 @@ -import { - ArrowsClockwise, - ArrowsLeftRight, - Brain, - ChatCircle, - Command, - FileText, - Globe, - type Icon, - MagnifyingGlass, - PencilSimple, - Terminal, - Trash, - Wrench, -} from "@phosphor-icons/react"; import { compactHomePath } from "@posthog/shared"; -import type { CodeToolKind } from "@posthog/ui/features/sessions/types"; +import { useChatThreadChrome } from "../chat-thread/chatThreadChrome"; import { ToolRow } from "./ToolRow"; import { ContentPre, @@ -22,37 +7,29 @@ import { formatInput, getContentText, getFilename, + iconForToolCall, stripCodeFences, ToolTitle, type ToolViewProps, useToolCallStatus, } from "./toolCallUtils"; -const kindIcons: Record = { - read: FileText, - edit: PencilSimple, - delete: Trash, - move: ArrowsLeftRight, - search: MagnifyingGlass, - execute: Terminal, - think: Brain, - fetch: Globe, - switch_mode: ArrowsClockwise, - question: ChatCircle, - other: Wrench, -}; - -const toolNameIcons: Record = { - ToolSearch: MagnifyingGlass, - Skill: Command, -}; - const toolNameDisplays: Record< string, - { prefix: string; suffix: string; inputKey: string } + { prefix: string; pastPrefix: string; suffix: string; inputKey: string } > = { - Skill: { prefix: "Reading", suffix: "skill", inputKey: "skill" }, - ToolSearch: { prefix: "Searching", suffix: "tools", inputKey: "query" }, + Skill: { + prefix: "Reading", + pastPrefix: "Read", + suffix: "skill", + inputKey: "skill", + }, + ToolSearch: { + prefix: "Searching", + pastPrefix: "Searched", + suffix: "tools", + inputKey: "query", + }, }; interface ToolCallViewProps extends ToolViewProps { @@ -72,10 +49,10 @@ export function ToolCallView({ turnCancelled, turnComplete, ); - const KindIcon = - (agentToolName && toolNameIcons[agentToolName]) || - (kind && kindIcons[kind]) || - Wrench; + const KindIcon = iconForToolCall(toolCall, agentToolName); + // New thread drops the input/output divider (ContentPre carries its own border); the legacy thread + // keeps it so ConversationView is unchanged when the chat thread is toggled off. + const chatChrome = useChatThreadChrome(); const filePath = kind === "read" && locations?.[0]?.path; const toolDisplay = agentToolName @@ -90,8 +67,12 @@ export function ToolCallView({ ? { ...toolDisplay, value: highlightValue } : undefined; + // New thread reads back in past tense once the tool has finished ("Reading" → "Read"); the legacy + // thread keeps the original present-tense prefix so ConversationView is unchanged when toggled off. const displayText = specialDisplay - ? specialDisplay.prefix + ? chatChrome && !isLoading + ? specialDisplay.pastPrefix + : specialDisplay.prefix : filePath ? `Read ${getFilename(filePath)}` : title @@ -111,11 +92,14 @@ export function ToolCallView({ fullInput || showOutput ? ( <> {fullInput && {fullInput}} - {showOutput && ( -
    + {showOutput && + (chatChrome ? ( {output} -
    - )} + ) : ( +
    + {output} +
    + ))} ) : undefined; diff --git a/packages/ui/src/features/sessions/components/session-update/ToolRow.tsx b/packages/ui/src/features/sessions/components/session-update/ToolRow.tsx index 6b82f08f31..f02a672962 100644 --- a/packages/ui/src/features/sessions/components/session-update/ToolRow.tsx +++ b/packages/ui/src/features/sessions/components/session-update/ToolRow.tsx @@ -91,9 +91,11 @@ export function ToolRow({ defaultOpen={defaultOpen} open={open} onOpenChange={onOpenChange} + className="opacity-50 hover:opacity-100 data-panel-open:bg-fill-selected data-panel-open:opacity-100" > {iconNode} - + + {/* Example: posthog - insight-create(... */} {typeof children === "string" ? ( {children} ) : ( diff --git a/packages/ui/src/features/sessions/components/session-update/toolCallUtils.tsx b/packages/ui/src/features/sessions/components/session-update/toolCallUtils.tsx index ab24add53a..d975c37f49 100644 --- a/packages/ui/src/features/sessions/components/session-update/toolCallUtils.tsx +++ b/packages/ui/src/features/sessions/components/session-update/toolCallUtils.tsx @@ -1,7 +1,57 @@ -import { type Icon, Minus, Plus } from "@phosphor-icons/react"; +import { + ArrowsClockwise, + ArrowsLeftRight, + Brain, + ChatCircle, + Command, + FileText, + Globe, + type Icon, + MagnifyingGlass, + Minus, + PencilSimple, + Plus, + Terminal, + Trash, + Wrench, +} from "@phosphor-icons/react"; import { DotsCircleSpinner } from "@posthog/ui/primitives/DotsCircleSpinner"; import { Box, Text } from "@radix-ui/themes"; -import type { ToolCall, ToolCallContent } from "../../types"; +import type { CodeToolKind, ToolCall, ToolCallContent } from "../../types"; +import { useChatThreadChrome } from "../chat-thread/chatThreadChrome"; + +/** Tool icon by `ToolCall.kind`. Shared by the per-tool views and the tool-group icon strip. */ +export const kindIcons: Record = { + read: FileText, + edit: PencilSimple, + delete: Trash, + move: ArrowsLeftRight, + search: MagnifyingGlass, + execute: Terminal, + think: Brain, + fetch: Globe, + switch_mode: ArrowsClockwise, + question: ChatCircle, + other: Wrench, +}; + +/** Tool icon by agent tool name, for tools without a generic `kind`. */ +export const toolNameIcons: Record = { + ToolSearch: MagnifyingGlass, + Skill: Command, +}; + +/** Resolve the leading icon for a tool call: name override → kind → Wrench fallback. */ +export function iconForToolCall( + toolCall: ToolCall, + agentToolName?: string, +): Icon { + return ( + (agentToolName && toolNameIcons[agentToolName]) || + (toolCall.kind && kindIcons[toolCall.kind]) || + Wrench + ); +} export function ToolTitle({ children, @@ -10,10 +60,14 @@ export function ToolTitle({ children: React.ReactNode; className?: string; }) { + // New thread (ChatX marker chrome) uses the muted, truncating title; the legacy thread keeps its + // original styling so toggling the chat thread off leaves ConversationView pixel-identical. + const chatChrome = useChatThreadChrome(); + const base = chatChrome + ? "text-sm text-muted-foreground truncate shrink-0 max-w-[calc(100%-1.5rem)]" + : "text-[13px] text-gray-11"; return ( - + {children} ); @@ -244,6 +298,20 @@ export function ExpandableIcon({ } export function ContentPre({ children }: { children: React.ReactNode }) { + // New thread wraps output in a bordered, muted box (it sits inside a ChatMarker panel); the legacy + // thread keeps the original borderless scroll box so ConversationView is unchanged when toggled off. + const chatChrome = useChatThreadChrome(); + if (chatChrome) { + return ( + + +
    +            {children}
    +          
    +
    +
    + ); + } return ( diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index dc9b9d4945..951510ab42 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -111,7 +111,7 @@ overrides: # cd .local-quill && cp posthog-quill-*.tgz posthog-quill-local-$(md5 -q posthog-quill-*.tgz | cut -c1-8).tgz # update the line below to the new name, then `pnpm install`. # Revert (back to catalog 0.3.0-beta.x) once the ChatX primitives are published to npm. - '@posthog/quill': file:./.local-quill/posthog-quill-local-d4357f58.tgz + '@posthog/quill': file:./.local-quill/posthog-quill-local-5d2e0b61.tgz onlyBuiltDependencies: - '@parcel/watcher' From 14be10809ef4f7e3e8d0d9d5aabe85205f26a498 Mon Sep 17 00:00:00 2001 From: Adam Leith Date: Tue, 30 Jun 2026 10:48:14 +0100 Subject: [PATCH 3/6] session view stuff --- .../sessions/components/SessionView.tsx | 2 +- .../components/chat-thread/ChatThread.tsx | 123 ++++++++++++------ .../session-update/ProgressGroupView.tsx | 83 +++++------- pnpm-lock.yaml | 23 ++-- 4 files changed, 130 insertions(+), 101 deletions(-) diff --git a/packages/ui/src/features/sessions/components/SessionView.tsx b/packages/ui/src/features/sessions/components/SessionView.tsx index 9564696b6a..6a86fe667d 100644 --- a/packages/ui/src/features/sessions/components/SessionView.tsx +++ b/packages/ui/src/features/sessions/components/SessionView.tsx @@ -565,7 +565,7 @@ export function SessionView({ ) : hideInput ? null : firstPendingPermission ? ( - + @@ -211,17 +217,51 @@ function UserBubble({ content }: { content: string }) { * themselves never re-render on scroll. */ function StickyHeaderOverlay({ items }: { items: ConversationItem[] }) { - const { currentAnchorId, visibleMessageIds } = - useChatMessageScrollerVisibility(); + const { currentAnchorId } = useChatMessageScrollerVisibility(); const { scrollToMessage } = useChatMessageScroller(); const shouldReduceMotion = useReducedMotion(); const [dismissedId, setDismissedId] = useState(null); + const [offscreen, setOffscreen] = useState(false); + // Anchor element used only to locate the enclosing scroller/viewport in the DOM. + const probeRef = useRef(null); const active = items.find( (i): i is Extract => i.id === currentAnchorId && i.type === "user_message", ); - const offscreen = active != null && !visibleMessageIds.includes(active.id); + const activeId = active?.id ?? null; + + // The engine's `visibleMessageIds` can't be used here: its IntersectionObserver excludes a band of + // `scrollPreviousItemPeek` px at the viewport top, which is exactly where a freshly-anchored turn + // message lands — so it reads as "not visible" while plainly on screen. Measure real geometry + // instead: the message is off-screen only once its bottom scrolls above the viewport top. + useEffect(() => { + if (activeId == null) { + setOffscreen(false); + return; + } + const viewport = probeRef.current + ?.closest('[data-slot="chat-message-scroller"]') + ?.querySelector('[data-slot="chat-message-scroller-viewport"]'); + if (!viewport) return; + + const measure = () => { + const el = viewport.querySelector( + `[data-message-id="${CSS.escape(activeId)}"]`, + ); + if (!el) { + setOffscreen(false); + return; + } + const messageBottom = el.getBoundingClientRect().bottom; + const viewportTop = viewport.getBoundingClientRect().top; + setOffscreen(messageBottom <= viewportTop + 4); + }; + + measure(); + viewport.addEventListener("scroll", measure, { passive: true }); + return () => viewport.removeEventListener("scroll", measure); + }, [activeId]); // Once the real message is back on screen, clear the dismissal so the header can return later. useEffect(() => { @@ -236,44 +276,47 @@ function StickyHeaderOverlay({ items }: { items: ConversationItem[] }) { }; return ( - - {active != null && offscreen && active.id !== dismissedId && ( - - {/* Align to the content column's right edge (matches the message rows) rather than the - viewport edge, so the button reads in-context with the conversation. */} -
    +
    -
    - )} -
    + + + + )} + + ); } @@ -292,7 +335,7 @@ const ThreadRow = memo(function ThreadRow({ {item.type === "tool_group" ? ( diff --git a/packages/ui/src/features/sessions/components/session-update/ProgressGroupView.tsx b/packages/ui/src/features/sessions/components/session-update/ProgressGroupView.tsx index cae43b783c..f7d4ebbc90 100644 --- a/packages/ui/src/features/sessions/components/session-update/ProgressGroupView.tsx +++ b/packages/ui/src/features/sessions/components/session-update/ProgressGroupView.tsx @@ -1,8 +1,8 @@ -import { CaretDown, CaretRight } from "@phosphor-icons/react"; +import { ListChecks } from "@phosphor-icons/react"; import { type Step, StepList } from "@posthog/ui/primitives/StepList"; -import * as Collapsible from "@radix-ui/react-collapsible"; -import { Box, Text } from "@radix-ui/themes"; +import { Box } from "@radix-ui/themes"; import { useEffect, useState } from "react"; +import { ToolRow } from "./ToolRow"; interface ProgressGroupViewProps { steps: Step[]; @@ -27,12 +27,12 @@ export function ProgressGroupView({ isActive, turnComplete, }: ProgressGroupViewProps) { - // Multi-step groups always render a collapsible header (caret + summary). - // While the turn is still running the trigger is disabled and forced open, - // so the user sees progress stream in without a flicker between consecutive - // step transitions. Once the turn completes, the header auto-collapses and - // becomes interactive. Single-step groups have no header at all — the one - // step row IS the whole view. + // Multi-step groups render through the shared `ToolRow` (caret + summary, + // collapsible body), so they match every tool call. While the turn is still + // running the row is forced open via the controlled `open` so progress + // streams in without a flicker between step transitions; once the turn + // completes it auto-collapses and honours the user toggle. Single-step groups + // have no header at all — the one step row IS the whole view. const [userToggledOpen, setUserToggledOpen] = useState(null); useEffect(() => { @@ -44,48 +44,35 @@ export function ProgressGroupView({ if (steps.length === 0) return null; const hasHeader = steps.length > 1; + // Single-step groups have no header, so their body must stay expanded — - // collapsing with no header would leave nothing on screen. Multi-step groups - // stay open while the turn is running, then honour the user toggle once the - // turn completes (default: collapsed). - const isOpen = !hasHeader - ? true - : !turnComplete - ? true - : (userToggledOpen ?? true); + // collapsing with no header would leave nothing on screen. + if (!hasHeader) { + return ( + + + + ); + } + + // Multi-step groups stay open while the turn is running, then honour the user + // toggle once the turn completes (default: collapsed). + const isOpen = !turnComplete ? true : (userToggledOpen ?? false); const summaryLabel = resolveHeaderLabel(steps) ?? ""; return ( - - { - if (hasHeader && turnComplete) setUserToggledOpen(next); - }} - > - {hasHeader && ( - - - - )} - - - - - - - + { + // Only the user's choice (after the turn finishes) sticks; while running + // the row is controlled open, so a stray toggle is ignored. + if (turnComplete) setUserToggledOpen(next); + }} + content={} + > + {summaryLabel} + ); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 195247715e..559032863c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,9 +18,6 @@ catalogs: '@phosphor-icons/react': specifier: ^2.1.10 version: 2.1.10 - '@posthog/quill': - specifier: 0.3.0-beta.19 - version: 0.3.0-beta.19 '@radix-ui/themes': specifier: ^3.2.1 version: 3.3.0 @@ -80,6 +77,7 @@ overrides: '@types/react-dom': ^19.2.3 '@posthog/quill>@base-ui/react': ^1.3.0 node-gyp>undici: 8.4.1 + '@posthog/quill': file:./.local-quill/posthog-quill-local-5d2e0b61.tgz patchedDependencies: node-pty: @@ -187,8 +185,8 @@ importers: specifier: workspace:* version: link:../../packages/platform '@posthog/quill': - specifier: 'catalog:' - version: 0.3.0-beta.19(@base-ui/react@1.3.0(@types/react@19.2.17)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(tailwindcss@4.3.1) + specifier: file:../../.local-quill/posthog-quill-local-5d2e0b61.tgz + version: file:.local-quill/posthog-quill-local-5d2e0b61.tgz(@base-ui/react@1.3.0(@types/react@19.2.17)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(tailwindcss@4.3.1) '@posthog/shared': specifier: workspace:* version: link:../../packages/shared @@ -1179,6 +1177,9 @@ importers: '@posthog/platform': specifier: workspace:* version: link:../platform + '@posthog/quill': + specifier: file:../../.local-quill/posthog-quill-local-5d2e0b61.tgz + version: file:.local-quill/posthog-quill-local-5d2e0b61.tgz(@base-ui/react@1.3.0(@types/react@19.2.17)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(tailwindcss@4.2.2) '@posthog/quill-charts': specifier: 0.3.0-beta.19 version: 0.3.0-beta.19(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -1333,9 +1334,6 @@ importers: '@phosphor-icons/react': specifier: 'catalog:' version: 2.1.10(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@posthog/quill': - specifier: 'catalog:' - version: 0.3.0-beta.19(@base-ui/react@1.3.0(@types/react@19.2.17)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(tailwindcss@4.2.2) '@posthog/tsconfig': specifier: workspace:* version: link:../../tooling/typescript @@ -4859,8 +4857,9 @@ packages: react: 19.2.6 react-dom: 19.2.6 - '@posthog/quill@0.3.0-beta.19': - resolution: {integrity: sha512-KMk/TQeuyYpC0GpNTViVkSqGVIniCEeyEravDoKn6LDbLw0jYdXI9kpovvy70KLjMlUilKmkg9jbJbebPKShjA==} + '@posthog/quill@file:.local-quill/posthog-quill-local-5d2e0b61.tgz': + resolution: {integrity: sha512-Y/i8mxYWvw0/1GDRr9TQDKJ3AYqRmWnKY9YOAi/KfjY56IWnanpYFGjUH6isgrb4NGG21TVQoBe2OPMT4ikvQg==, tarball: file:.local-quill/posthog-quill-local-5d2e0b61.tgz} + version: 0.3.0-beta.16 engines: {node: '>=20'} peerDependencies: '@base-ui/react': ^1.3.0 @@ -17079,7 +17078,7 @@ snapshots: react-dom: 19.2.6(react@19.2.6) simple-statistics: 7.8.9 - '@posthog/quill@0.3.0-beta.19(@base-ui/react@1.3.0(@types/react@19.2.17)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(tailwindcss@4.2.2)': + '@posthog/quill@file:.local-quill/posthog-quill-local-5d2e0b61.tgz(@base-ui/react@1.3.0(@types/react@19.2.17)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(tailwindcss@4.2.2)': dependencies: '@base-ui/react': 1.3.0(@types/react@19.2.17)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) class-variance-authority: 0.7.1 @@ -17091,7 +17090,7 @@ snapshots: tailwind-merge: 2.6.1 tailwindcss: 4.2.2 - '@posthog/quill@0.3.0-beta.19(@base-ui/react@1.3.0(@types/react@19.2.17)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(tailwindcss@4.3.1)': + '@posthog/quill@file:.local-quill/posthog-quill-local-5d2e0b61.tgz(@base-ui/react@1.3.0(@types/react@19.2.17)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(tailwindcss@4.3.1)': dependencies: '@base-ui/react': 1.3.0(@types/react@19.2.17)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) class-variance-authority: 0.7.1 From 1fa8296fe38a576fb80c6d5a5c122a359f19368f Mon Sep 17 00:00:00 2001 From: Adam Leith Date: Tue, 30 Jun 2026 13:49:14 +0100 Subject: [PATCH 4/6] feat(thread): restore user-message chrome, gate shared changes, fix autoscroll scrollbar Polish pass on the experimental ChatX thread (behind settingsStore.useNewChatThread): - UserBubble: restore the channel CONTEXT.md / canvas-instructions context chips (ChatMessageHeader) and the hover send-timestamp (ChatMessageFooter), and render stripped content so the injected XML blocks never leak. File/attachment mentions restored too. - Hide SessionResourcesBar in the new thread (the chips live on the user message now). - Gate the shared session-update changes so the legacy ConversationView is unchanged when the flag is off: ProgressGroupView and SubagentToolView keep their bespoke rendering for the legacy thread and only route through ToolRow under the chat-thread chrome context; ToolCallView's input-preview restyle is gated too. - globals.css: keep the chat scroller's scrollbar visible while auto-scrolling, so it no longer flickers away on every collapsible toggle. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../sessions/components/SessionView.tsx | 3 +- .../components/chat-thread/ChatThread.tsx | 165 ++++++++++++++++-- .../session-update/ProgressGroupView.tsx | 66 +++++-- .../session-update/SubagentToolView.tsx | 93 ++++++++-- .../session-update/ToolCallBlock.tsx | 4 +- .../session-update/ToolCallView.tsx | 10 +- packages/ui/src/styles/globals.css | 18 ++ 7 files changed, 313 insertions(+), 46 deletions(-) diff --git a/packages/ui/src/features/sessions/components/SessionView.tsx b/packages/ui/src/features/sessions/components/SessionView.tsx index 6a86fe667d..c9f563c9b1 100644 --- a/packages/ui/src/features/sessions/components/SessionView.tsx +++ b/packages/ui/src/features/sessions/components/SessionView.tsx @@ -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 = @@ -517,7 +518,7 @@ export function SessionView({ scrollX={false} /> - + {!useNewChatThread && } diff --git a/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx b/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx index 6bb2967c60..b727cf8166 100644 --- a/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx +++ b/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx @@ -1,4 +1,4 @@ -import { CaretDown, ChatCircle } from "@phosphor-icons/react"; +import { CaretDown, ChatCircle, FileText, Scroll } from "@phosphor-icons/react"; import { WorkerPoolContextProvider } from "@pierre/diffs/react"; import { useService } from "@posthog/di/react"; import { @@ -7,6 +7,8 @@ import { ChatBubbleContent, ChatMessage, ChatMessageContent, + ChatMessageFooter, + ChatMessageHeader, ChatMessageScroller, ChatMessageScrollerButton, ChatMessageScrollerContent, @@ -17,6 +19,9 @@ import { useChatMessageScroller, useChatMessageScrollerVisibility, } from "@posthog/quill"; +import { PROJECT_BLUEBIRD_FLAG } from "@posthog/shared"; +import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; +import { usePanelLayoutStore } from "@posthog/ui/features/panels/panelLayoutStore"; import type { ConversationItem } from "@posthog/ui/features/sessions/components/buildConversationItems"; import { ChatMarkdown } from "@posthog/ui/features/sessions/components/chat-thread/ChatMarkdown"; import { ChatThreadChromeProvider } from "@posthog/ui/features/sessions/components/chat-thread/chatThreadChrome"; @@ -27,6 +32,14 @@ import { import { GitActionMessage } from "@posthog/ui/features/sessions/components/GitActionMessage"; import { GitActionResult } from "@posthog/ui/features/sessions/components/GitActionResult"; import { mergeConversationItems } from "@posthog/ui/features/sessions/components/mergeConversationItems"; +import { extractCanvasInstructions } from "@posthog/ui/features/sessions/components/session-update/canvasInstructions"; +import { extractChannelContext } from "@posthog/ui/features/sessions/components/session-update/channelContext"; +import { extractCustomInstructions } from "@posthog/ui/features/sessions/components/session-update/customInstructions"; +import { + hasFileMentions, + MentionChip, + parseFileMentions, +} from "@posthog/ui/features/sessions/components/session-update/parseFileMentions"; import { SessionUpdateView } from "@posthog/ui/features/sessions/components/session-update/SessionUpdateView"; import { UserShellExecuteView } from "@posthog/ui/features/sessions/components/session-update/UserShellExecuteView"; import { CHAT_CONTENT_MAX_WIDTH } from "@posthog/ui/features/sessions/constants"; @@ -35,7 +48,11 @@ import { useOptimisticItemsForTask, useSessionForTask, } from "@posthog/ui/features/sessions/sessionStore"; -import { SessionTaskIdProvider } from "@posthog/ui/features/sessions/useSessionTaskId"; +import type { UserMessageAttachment } from "@posthog/ui/features/sessions/userMessageTypes"; +import { + SessionTaskIdProvider, + useSessionTaskId, +} from "@posthog/ui/features/sessions/useSessionTaskId"; import { useSettingsStore } from "@posthog/ui/features/settings/settingsStore"; import { SkillButtonActionMessage } from "@posthog/ui/features/skill-buttons/components/SkillButtonActionMessage"; import { @@ -141,14 +158,77 @@ function groupToolRuns(items: ConversationItem[]): ThreadItem[] { return out; } +function formatTimestamp(ts: number): string { + return new Date(ts).toLocaleString([], { + month: "short", + day: "numeric", + year: "numeric", + hour: "numeric", + minute: "2-digit", + second: "2-digit", + }); +} + /** * End-aligned user bubble. The text is clamped to two lines (`max-height: 2lh` + `overflow-hidden`, * which — unlike `-webkit-line-clamp` — reliably clamps markdown's block `

    ` children); a "Show * more" toggle appears only when the content actually exceeds the clamp. Overflow can't be known * from character count (it depends on wrapping width), so we measure `scrollHeight` against the * clamped `clientHeight` — which holds even while clamped — and re-measure on resize. + * + * A channel's CONTEXT.md and the canvas generation instructions, if injected into this prompt, are + * collapsed into a clickable `ChatMessageHeader` chip above the bubble (opening the snapshot as a + * split tab) rather than rendered inline — a project-bluebird feature. The blocks are always stripped + * (along with the always-on personalization block) so the raw XML never leaks for flag-off viewers. + * The send timestamp sits in a `ChatMessageFooter` revealed on hover. */ -function UserBubble({ content }: { content: string }) { +function UserBubble({ + content, + timestamp, + attachments = [], +}: { + content: string; + timestamp?: number; + attachments?: UserMessageAttachment[]; +}) { + const bluebirdEnabled = useFeatureFlag( + PROJECT_BLUEBIRD_FLAG, + import.meta.env.DEV, + ); + const channelContext = useMemo( + () => extractChannelContext(content), + [content], + ); + const afterChannelContext = channelContext + ? channelContext.stripped + : content; + const canvasInstructions = useMemo( + () => extractCanvasInstructions(afterChannelContext), + [afterChannelContext], + ); + const afterCanvasInstructions = canvasInstructions + ? canvasInstructions.stripped + : afterChannelContext; + const customInstructions = useMemo( + () => extractCustomInstructions(afterCanvasInstructions), + [afterCanvasInstructions], + ); + const displayContent = customInstructions + ? customInstructions.stripped + : afterCanvasInstructions; + const showChannelContextTag = !!channelContext && bluebirdEnabled; + const showCanvasInstructionsTag = !!canvasInstructions && bluebirdEnabled; + const showHeaderChips = showChannelContextTag || showCanvasInstructionsTag; + const taskId = useSessionTaskId(); + const openChannelContextInSplit = usePanelLayoutStore( + (s) => s.openChannelContextInSplit, + ); + const openCanvasInstructionsInSplit = usePanelLayoutStore( + (s) => s.openCanvasInstructionsInSplit, + ); + + const containsFileMentions = hasFileMentions(displayContent); + const [isExpanded, setIsExpanded] = useState(false); const [isOverflowing, setIsOverflowing] = useState(false); const textRef = useRef(null); @@ -166,11 +246,48 @@ function UserBubble({ content }: { content: string }) { const observer = new ResizeObserver(measure); observer.observe(el); return () => observer.disconnect(); - }, [content, isExpanded]); + }, [displayContent, isExpanded]); return ( - + + {showHeaderChips && ( + + {showChannelContextTag && channelContext && ( + } + label={`${ + channelContext.mention.name + ? `#${channelContext.mention.name} ` + : "" + }CONTEXT.md`} + onClick={ + taskId + ? () => + openChannelContextInSplit(taskId, { + channelName: channelContext.mention.name, + body: channelContext.mention.body, + }) + : undefined + } + /> + )} + {showCanvasInstructionsTag && canvasInstructions && ( + } + label="Canvas instructions" + onClick={ + taskId + ? () => + openCanvasInstructionsInSplit(taskId, { + body: canvasInstructions.body, + }) + : undefined + } + /> + )} + + )}

    - + {containsFileMentions ? ( + parseFileMentions(displayContent) + ) : ( + + )}
    + {attachments.length > 0 && !containsFileMentions && ( +
    + {attachments.map((attachment) => ( + } + label={attachment.label} + /> + ))} +
    + )} {isOverflowing && ( + + )} + + + + + + +
    + ); + } + + // New thread: single-step groups have no header, so their body must stay expanded — collapsing with + // no header would leave nothing on screen. if (!hasHeader) { return ( diff --git a/packages/ui/src/features/sessions/components/session-update/SubagentToolView.tsx b/packages/ui/src/features/sessions/components/session-update/SubagentToolView.tsx index 6d333d66df..28d57c0508 100644 --- a/packages/ui/src/features/sessions/components/session-update/SubagentToolView.tsx +++ b/packages/ui/src/features/sessions/components/session-update/SubagentToolView.tsx @@ -1,9 +1,18 @@ -import { Robot } from "@phosphor-icons/react"; import { + ArrowsInSimple as ArrowsInSimpleIcon, + ArrowsOutSimple as ArrowsOutSimpleIcon, + Robot, +} from "@phosphor-icons/react"; +import { + LoadingIcon, + StatusIndicators, type ToolViewProps, useToolCallStatus, } from "@posthog/ui/features/sessions/components/session-update/toolCallUtils"; +import { Box, Flex, IconButton, Text } from "@radix-ui/themes"; +import { useState } from "react"; import type { ConversationItem, TurnContext } from "../buildConversationItems"; +import { useChatThreadChrome } from "../chat-thread/chatThreadChrome"; import { SessionUpdateView } from "./SessionUpdateView"; import { ToolRow } from "./ToolRow"; @@ -13,10 +22,9 @@ interface SubagentToolViewProps extends ToolViewProps { } /** - * A subagent (Task/Agent) call: same minimal shape as {@link ThoughtView} — a single `ToolRow` - * whose collapsible body holds the subagent's own child tool calls (rendered through - * `SessionUpdateView`). `ToolRow` supplies the chrome for both threads (ChatMarker / Radix - * collapsible), so there's no bespoke box or expand button here. + * A subagent (Task/Agent) call. The new thread renders it as a single `ToolRow` (ChatMarker chrome) + * whose collapsible body holds the subagent's own child tool calls. The legacy thread keeps its + * bespoke bordered box + expand button so ConversationView is unchanged when the chat thread is off. */ export function SubagentToolView({ toolCall, @@ -31,23 +39,70 @@ export function SubagentToolView({ turnCancelled, turnComplete, ); + const chatChrome = useChatThreadChrome(); + const [isExpanded, setIsExpanded] = useState(false); + + const hasChildren = childItems.length > 0; + const childContent = hasChildren + ? childItems.map((child) => + child.type === "session_update" ? ( + + ) : null, + ) + : undefined; - const childContent = - childItems.length > 0 - ? childItems.map((child) => - child.type === "session_update" ? ( - + + + {isExpanded && hasChildren && ( + // [&_.tool-row-collapsible]:pl-1 so that inner ToolRow triggers have some more spacing on left + + {childContent} + + )} + + ); + } + // New thread: same minimal shape as ThoughtView — a single ToolRow whose collapsible body holds the + // subagent's child tool calls. ToolRow supplies the ChatMarker chrome, so no bespoke box here. return (
    + {McpToolBlock ? ( ) : ( diff --git a/packages/ui/src/features/sessions/components/session-update/ToolCallView.tsx b/packages/ui/src/features/sessions/components/session-update/ToolCallView.tsx index 967d52339e..c2cbd38383 100644 --- a/packages/ui/src/features/sessions/components/session-update/ToolCallView.tsx +++ b/packages/ui/src/features/sessions/components/session-update/ToolCallView.tsx @@ -115,7 +115,15 @@ export function ToolCallView({ {displayText && {displayText}} {inputPreview && ( - {inputPreview} + + {inputPreview} + )} {specialDisplay && {specialDisplay.suffix}} diff --git a/packages/ui/src/styles/globals.css b/packages/ui/src/styles/globals.css index e5b187d00a..cc978dc965 100644 --- a/packages/ui/src/styles/globals.css +++ b/packages/ui/src/styles/globals.css @@ -59,6 +59,24 @@ */ @source "../../../../node_modules/@posthog/quill/dist/**/*.js"; +/* + * Keep the chat scroller's scrollbar visible while it auto-scrolls. + * + * Quill hides the scrollbar (`scrollbar-width: none` / `::-webkit-scrollbar + * display: none`) whenever the viewport carries `data-autoscrolling`, to stop + * the thumb jittering during programmatic scroll. But the autoscroll engine + * also fires `scrollToEnd` on any content-height change while pinned to the + * bottom — so toggling a tool collapsible flips `data-autoscrolling` on for the + * settle timeout and the scrollbar flickers away on every toggle. We'd rather + * keep a stable scrollbar; pinned-to-bottom streaming doesn't visibly jitter. + */ +.quill-chat-message-scroller__viewport[data-autoscrolling] { + scrollbar-width: auto; +} +.quill-chat-message-scroller__viewport[data-autoscrolling]::-webkit-scrollbar { + display: revert; +} + /* * Indeterminate "section loading" bar — a thin accent bar that swoops across the * top of a container while work is in flight (e.g. a canvas agent turn). Mirrors From ab9a44495d70e5535d0bd0ba8735e25df942bb60 Mon Sep 17 00:00:00 2001 From: Adam Leith Date: Tue, 30 Jun 2026 17:40:28 +0100 Subject: [PATCH 5/6] feat(thread): footer + status markers in new thread, fix stuck compaction spinner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New ChatX thread polish (behind settingsStore.useNewChatThread) plus a shared compaction fix: - Move SessionFooter back into the thread (last row) instead of under the composer; add group/thread so its hover-reveal works. New ChatThreadFooter wrapper. - Render turn_cancelled ("Interrupted by user") and compaction boundary ("Conversation compacted") as ChatMarker separator rows in the new thread; the legacy thread keeps its existing rendering (gated via chat-thread chrome). - Fix the "Compacting…" spinner spinning forever after a failed compaction: a failed compaction emits no compact_boundary, so the agent now sends a structured compacting_failed status that clears the spinner and renders the outcome (a separator marker in the new thread, a status row in the legacy thread) instead of assistant prose. - Drop the now-redundant globals.css scrollbar override (quill no longer hides the scrollbar on data-autoscrolling). - Point @posthog/quill at the published 0.3.0-beta.21 (drops the local-tarball override) now that the ChatX primitives are on npm. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../agent/src/adapters/claude/claude-agent.ts | 34 +++++---- .../components/buildConversationItems.test.ts | 69 +++++++++++++++++++ .../components/buildConversationItems.ts | 24 ++++++- .../components/chat-thread/ChatThread.tsx | 41 ++++++++++- .../chat-thread/ChatThreadFooter.tsx | 68 ++++++++++++++++++ .../session-update/CompactBoundaryView.tsx | 17 +++++ .../session-update/SessionUpdateView.tsx | 3 + .../session-update/StatusNotificationView.tsx | 32 ++++++++- packages/ui/src/styles/globals.css | 18 ----- pnpm-lock.yaml | 23 ++++--- pnpm-workspace.yaml | 15 +--- 11 files changed, 283 insertions(+), 61 deletions(-) create mode 100644 packages/ui/src/features/sessions/components/chat-thread/ChatThreadFooter.tsx diff --git a/packages/agent/src/adapters/claude/claude-agent.ts b/packages/agent/src/adapters/claude/claude-agent.ts index 00921a8db2..7f2db920f0 100644 --- a/packages/agent/src/adapters/claude/claude-agent.ts +++ b/packages/agent/src/adapters/claude/claude-agent.ts @@ -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; } } diff --git a/packages/ui/src/features/sessions/components/buildConversationItems.test.ts b/packages/ui/src/features/sessions/components/buildConversationItems.test.ts index 92fbd00666..83194fa8c9 100644 --- a/packages/ui/src/features/sessions/components/buildConversationItems.test.ts +++ b/packages/ui/src/features/sessions/components/buildConversationItems.test.ts @@ -106,6 +106,23 @@ function resourcesUsedMsg( }; } +function statusMsg( + ts: number, + status: string, + isComplete?: boolean, + error?: string, +): AcpMessage { + return { + type: "acp_message", + ts, + message: { + jsonrpc: "2.0", + method: "_posthog/status", + params: { sessionId: "session-1", status, isComplete, error }, + }, + }; +} + describe("buildConversationItems", () => { it("extracts cloud prompt attachments into user messages", () => { const uri = makeAttachmentUri("/tmp/hello world.txt"); @@ -153,6 +170,58 @@ describe("buildConversationItems", () => { ]); }); + it("clears the compacting spinner on a successful completion status, without duplicating the row", () => { + // A successful compaction sends a terminal `status: compacting, isComplete: + // true`. It must flip the existing status row, not append a second one. + const result = buildConversationItems( + [ + userPromptMsg(1, 1, "hi"), + statusMsg(2, "compacting"), + statusMsg(3, "compacting", true), + ], + null, + ); + + const statusItems = result.items.filter( + (i): i is Extract => + i.type === "session_update" && i.update.sessionUpdate === "status", + ); + expect(statusItems).toHaveLength(1); + expect((statusItems[0].update as { isComplete?: boolean }).isComplete).toBe( + true, + ); + expect(result.isCompacting).toBe(false); + }); + + it("renders a failed compaction as a compacting_failed status row and clears the spinner", () => { + // A failed compaction emits no compact_boundary, so the agent sends a + // structured `compacting_failed` status: it clears the spinner (the original + // compacting row goes complete) and adds the outcome row with the error. + const result = buildConversationItems( + [ + userPromptMsg(1, 1, "hi"), + statusMsg(2, "compacting"), + statusMsg(3, "compacting_failed", undefined, "Not enough messages."), + ], + null, + ); + + const statusItems = result.items.filter( + (i): i is Extract => + i.type === "session_update" && i.update.sessionUpdate === "status", + ); + // Spinner row (now complete) + the failure row. + expect(statusItems.map((i) => i.update)).toEqual([ + { sessionUpdate: "status", status: "compacting", isComplete: true }, + { + sessionUpdate: "status", + status: "compacting_failed", + error: "Not enough messages.", + }, + ]); + expect(result.isCompacting).toBe(false); + }); + it("marks cloud turns complete from structured turn completion notifications", () => { const result = buildConversationItems( [userPromptMsg(10, 42, "hello"), turnCompleteMsg(25)], diff --git a/packages/ui/src/features/sessions/components/buildConversationItems.ts b/packages/ui/src/features/sessions/components/buildConversationItems.ts index 34da5d7a7a..bb7b55100f 100644 --- a/packages/ui/src/features/sessions/components/buildConversationItems.ts +++ b/packages/ui/src/features/sessions/components/buildConversationItems.ts @@ -531,9 +531,29 @@ function handleNotification( if (isNotification(msg.method, POSTHOG_NOTIFICATIONS.STATUS)) { if (!b.currentTurn) ensureImplicitTurn(b, ts); - const params = msg.params as { status: string; isComplete?: boolean }; - if (params.status === "compacting" && !params.isComplete) { + const params = msg.params as { + status: string; + isComplete?: boolean; + error?: string; + }; + if (params.status === "compacting") { + if (params.isComplete) { + // Successful compaction — flip the existing "Compacting…" status to + // complete instead of pushing a second item, so the spinner stops. + markCompactingStatusComplete(b); + return; + } b.isCompacting = true; + } else if (params.status === "compacting_failed") { + // A failed compaction emits no `compact_boundary`, so clear the spinner + // and render the outcome as its own status row. + markCompactingStatusComplete(b); + pushItem(b, { + sessionUpdate: "status", + status: "compacting_failed", + error: params.error, + }); + return; } pushItem(b, { sessionUpdate: "status", diff --git a/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx b/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx index b727cf8166..a49eac63a3 100644 --- a/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx +++ b/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx @@ -5,6 +5,8 @@ import { Button, ChatBubble, ChatBubbleContent, + ChatMarker, + ChatMarkerContent, ChatMessage, ChatMessageContent, ChatMessageFooter, @@ -24,6 +26,7 @@ import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFla import { usePanelLayoutStore } from "@posthog/ui/features/panels/panelLayoutStore"; import type { ConversationItem } from "@posthog/ui/features/sessions/components/buildConversationItems"; import { ChatMarkdown } from "@posthog/ui/features/sessions/components/chat-thread/ChatMarkdown"; +import { ChatThreadFooter } from "@posthog/ui/features/sessions/components/chat-thread/ChatThreadFooter"; import { ChatThreadChromeProvider } from "@posthog/ui/features/sessions/components/chat-thread/chatThreadChrome"; import { ToolGroup, @@ -495,19 +498,32 @@ function ThreadScrollBody({ items, rows, renderItem, + footer, }: { items: ConversationItem[]; rows: ThreadItem[]; renderItem: (item: ConversationItem) => ReactNode; + /** Status row (duration / context usage) pinned as the last item in the thread. */ + footer?: ReactNode; }) { + // `group/thread` so the footer's hover-reveal (opacity-50 → 100 on group-hover) tracks the thread, + // mirroring the legacy ConversationView container. return ( - + - + {rows.map((item) => ( ))} + {footer && ( +
    + {footer} +
    + )}
    @@ -531,7 +547,9 @@ function ThreadScrollBody({ export function ChatThread({ events, isPromptPending, + promptStartedAt, repoPath, + task, taskId, }: ConversationViewProps) { const diffWorkerFactory = useService(DIFF_WORKER_FACTORY); @@ -613,7 +631,15 @@ export function ChatThread({ /> ) : null; case "turn_cancelled": - return null; + return ( + + + {item.interruptReason === "moving_to_worktree" + ? "Paused while worktree is focused" + : "Interrupted by user"} + + + ); case "user_shell_execute": return ; } @@ -637,6 +663,15 @@ export function ChatThread({ items={items} rows={rows} renderItem={renderItem} + footer={ + + } /> diff --git a/packages/ui/src/features/sessions/components/chat-thread/ChatThreadFooter.tsx b/packages/ui/src/features/sessions/components/chat-thread/ChatThreadFooter.tsx new file mode 100644 index 0000000000..cabb5777e3 --- /dev/null +++ b/packages/ui/src/features/sessions/components/chat-thread/ChatThreadFooter.tsx @@ -0,0 +1,68 @@ +import type { AcpMessage } from "@posthog/shared"; +import type { Task } from "@posthog/shared/domain-types"; +import { SessionFooter } from "@posthog/ui/features/sessions/components/SessionFooter"; +import { useContextUsage } from "@posthog/ui/features/sessions/hooks/useContextUsage"; +import { useConversationItems } from "@posthog/ui/features/sessions/hooks/useConversationItems"; +import { + usePendingPermissionsForTask, + useQueuedMessagesForTask, + useSessionForTask, +} from "@posthog/ui/features/sessions/sessionStore"; +import { useSettingsStore } from "@posthog/ui/features/settings/settingsStore"; + +interface ChatThreadFooterProps { + events: AcpMessage[]; + isPromptPending: boolean | null; + promptStartedAt?: number | null; + task?: Task; + taskId?: string; +} + +/** + * The session status footer (duration / queued / context usage / diff stats) for the new chat + * thread, rendered UNDER the composer. The legacy `ConversationView` renders the same + * `SessionFooter` at the bottom of the thread instead; here it lives under the input. + * + * Re-derives the turn / usage / queue state from `events` with the same hooks `ConversationView` + * uses — `ChatThread` runs its own `useConversationItems`, so this is a second (incremental, + * memoized) parse pass, acceptable for a flag-gated surface. Gated behind + * `settingsStore.useNewChatThread` at the call site. + */ +export function ChatThreadFooter({ + events, + isPromptPending, + promptStartedAt, + task, + taskId, +}: ChatThreadFooterProps) { + const showDebugLogs = useSettingsStore((s) => s.debugLogsCloudRuns); + const contextUsage = useContextUsage(events); + const { lastTurnInfo, isCompacting, completedToolCallCount } = + useConversationItems(events, isPromptPending, { showDebugLogs }); + const pendingPermissions = usePendingPermissionsForTask(taskId ?? ""); + const queuedCount = useQueuedMessagesForTask(taskId).length; + const session = useSessionForTask(taskId); + const pausedDurationMs = session?.pausedDurationMs ?? 0; + + return ( +
    + 0} + pausedDurationMs={pausedDurationMs} + isCompacting={isCompacting} + usage={contextUsage} + completedToolCallCount={completedToolCallCount} + /> +
    + ); +} diff --git a/packages/ui/src/features/sessions/components/session-update/CompactBoundaryView.tsx b/packages/ui/src/features/sessions/components/session-update/CompactBoundaryView.tsx index 7d70577455..6629be9134 100644 --- a/packages/ui/src/features/sessions/components/session-update/CompactBoundaryView.tsx +++ b/packages/ui/src/features/sessions/components/session-update/CompactBoundaryView.tsx @@ -1,5 +1,7 @@ import { Lightning } from "@phosphor-icons/react"; +import { ChatMarker, ChatMarkerContent } from "@posthog/quill"; import { Badge, Box, Flex, Text } from "@radix-ui/themes"; +import { useChatThreadChrome } from "../chat-thread/chatThreadChrome"; interface CompactBoundaryViewProps { trigger: "manual" | "auto"; @@ -17,6 +19,21 @@ export function CompactBoundaryView({ contextSize && contextSize > 0 ? Math.round((preTokens / contextSize) * 100) : null; + // New thread renders the boundary as a centered separator marker; the legacy thread keeps its + // bordered badge row so ConversationView is unchanged when the chat thread is off. + const chatChrome = useChatThreadChrome(); + + if (chatChrome) { + return ( + + + {`Conversation compacted · ${trigger} · ${ + percent !== null ? `${percent}% of context` : `~${tokensK}K tokens` + }`} + + + ); + } return ( diff --git a/packages/ui/src/features/sessions/components/session-update/SessionUpdateView.tsx b/packages/ui/src/features/sessions/components/session-update/SessionUpdateView.tsx index f9fc407248..aa87f4bfd6 100644 --- a/packages/ui/src/features/sessions/components/session-update/SessionUpdateView.tsx +++ b/packages/ui/src/features/sessions/components/session-update/SessionUpdateView.tsx @@ -33,6 +33,8 @@ export type RenderItem = sessionUpdate: "status"; status: string; isComplete?: boolean; + /** Set when a status ends in failure (e.g. a failed compaction) so the row renders the error. */ + error?: string; } | { sessionUpdate: "error"; @@ -122,6 +124,7 @@ export const SessionUpdateView = memo(function SessionUpdateView({ ); case "error": diff --git a/packages/ui/src/features/sessions/components/session-update/StatusNotificationView.tsx b/packages/ui/src/features/sessions/components/session-update/StatusNotificationView.tsx index 278580ee6f..d4ff94afde 100644 --- a/packages/ui/src/features/sessions/components/session-update/StatusNotificationView.tsx +++ b/packages/ui/src/features/sessions/components/session-update/StatusNotificationView.tsx @@ -1,15 +1,45 @@ -import { Spinner } from "@phosphor-icons/react"; +import { Spinner, XCircle } from "@phosphor-icons/react"; +import { ChatMarker, ChatMarkerContent } from "@posthog/quill"; import { Box, Flex, Text } from "@radix-ui/themes"; +import { useChatThreadChrome } from "../chat-thread/chatThreadChrome"; interface StatusNotificationViewProps { status: string; isComplete?: boolean; + /** Failure reason, set on a `compacting_failed` status. */ + error?: string; } export function StatusNotificationView({ status, isComplete, + error, }: StatusNotificationViewProps) { + // New thread renders status notes as centered separator markers; the legacy thread keeps its + // bordered rows so ConversationView is unchanged when the chat thread is off. + const chatChrome = useChatThreadChrome(); + + // A failed compaction (e.g. "Not enough messages to compact"). The matching `compacting` spinner + // is cleared separately; this row reports the outcome. + if (status === "compacting_failed") { + const message = error ? `Compacting failed: ${error}` : "Compacting failed"; + if (chatChrome) { + return ( + + {message} + + ); + } + return ( + + + + {message} + + + ); + } + if (status === "compacting") { if (isComplete) { return null; diff --git a/packages/ui/src/styles/globals.css b/packages/ui/src/styles/globals.css index cc978dc965..e5b187d00a 100644 --- a/packages/ui/src/styles/globals.css +++ b/packages/ui/src/styles/globals.css @@ -59,24 +59,6 @@ */ @source "../../../../node_modules/@posthog/quill/dist/**/*.js"; -/* - * Keep the chat scroller's scrollbar visible while it auto-scrolls. - * - * Quill hides the scrollbar (`scrollbar-width: none` / `::-webkit-scrollbar - * display: none`) whenever the viewport carries `data-autoscrolling`, to stop - * the thumb jittering during programmatic scroll. But the autoscroll engine - * also fires `scrollToEnd` on any content-height change while pinned to the - * bottom — so toggling a tool collapsible flips `data-autoscrolling` on for the - * settle timeout and the scrollbar flickers away on every toggle. We'd rather - * keep a stable scrollbar; pinned-to-bottom streaming doesn't visibly jitter. - */ -.quill-chat-message-scroller__viewport[data-autoscrolling] { - scrollbar-width: auto; -} -.quill-chat-message-scroller__viewport[data-autoscrolling]::-webkit-scrollbar { - display: revert; -} - /* * Indeterminate "section loading" bar — a thin accent bar that swoops across the * top of a container while work is in flight (e.g. a canvas agent turn). Mirrors diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 559032863c..bedefd3802 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,6 +18,9 @@ catalogs: '@phosphor-icons/react': specifier: ^2.1.10 version: 2.1.10 + '@posthog/quill': + specifier: 0.3.0-beta.21 + version: 0.3.0-beta.21 '@radix-ui/themes': specifier: ^3.2.1 version: 3.3.0 @@ -77,7 +80,6 @@ overrides: '@types/react-dom': ^19.2.3 '@posthog/quill>@base-ui/react': ^1.3.0 node-gyp>undici: 8.4.1 - '@posthog/quill': file:./.local-quill/posthog-quill-local-5d2e0b61.tgz patchedDependencies: node-pty: @@ -185,8 +187,8 @@ importers: specifier: workspace:* version: link:../../packages/platform '@posthog/quill': - specifier: file:../../.local-quill/posthog-quill-local-5d2e0b61.tgz - version: file:.local-quill/posthog-quill-local-5d2e0b61.tgz(@base-ui/react@1.3.0(@types/react@19.2.17)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(tailwindcss@4.3.1) + specifier: 'catalog:' + version: 0.3.0-beta.21(@base-ui/react@1.3.0(@types/react@19.2.17)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(tailwindcss@4.3.1) '@posthog/shared': specifier: workspace:* version: link:../../packages/shared @@ -1177,9 +1179,6 @@ importers: '@posthog/platform': specifier: workspace:* version: link:../platform - '@posthog/quill': - specifier: file:../../.local-quill/posthog-quill-local-5d2e0b61.tgz - version: file:.local-quill/posthog-quill-local-5d2e0b61.tgz(@base-ui/react@1.3.0(@types/react@19.2.17)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(tailwindcss@4.2.2) '@posthog/quill-charts': specifier: 0.3.0-beta.19 version: 0.3.0-beta.19(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -1334,6 +1333,9 @@ importers: '@phosphor-icons/react': specifier: 'catalog:' version: 2.1.10(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@posthog/quill': + specifier: 'catalog:' + version: 0.3.0-beta.21(@base-ui/react@1.3.0(@types/react@19.2.17)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(tailwindcss@4.2.2) '@posthog/tsconfig': specifier: workspace:* version: link:../../tooling/typescript @@ -4857,9 +4859,8 @@ packages: react: 19.2.6 react-dom: 19.2.6 - '@posthog/quill@file:.local-quill/posthog-quill-local-5d2e0b61.tgz': - resolution: {integrity: sha512-Y/i8mxYWvw0/1GDRr9TQDKJ3AYqRmWnKY9YOAi/KfjY56IWnanpYFGjUH6isgrb4NGG21TVQoBe2OPMT4ikvQg==, tarball: file:.local-quill/posthog-quill-local-5d2e0b61.tgz} - version: 0.3.0-beta.16 + '@posthog/quill@0.3.0-beta.21': + resolution: {integrity: sha512-cA+9aERFpplPXhDn/zMywKR/LXqqI/CnA/IK6MDUZDP/HvebIzzXUvd40TiQN4hCoLrEhXwT5HGzQzHGNWTMdg==} engines: {node: '>=20'} peerDependencies: '@base-ui/react': ^1.3.0 @@ -17078,7 +17079,7 @@ snapshots: react-dom: 19.2.6(react@19.2.6) simple-statistics: 7.8.9 - '@posthog/quill@file:.local-quill/posthog-quill-local-5d2e0b61.tgz(@base-ui/react@1.3.0(@types/react@19.2.17)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(tailwindcss@4.2.2)': + '@posthog/quill@0.3.0-beta.21(@base-ui/react@1.3.0(@types/react@19.2.17)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(tailwindcss@4.2.2)': dependencies: '@base-ui/react': 1.3.0(@types/react@19.2.17)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) class-variance-authority: 0.7.1 @@ -17090,7 +17091,7 @@ snapshots: tailwind-merge: 2.6.1 tailwindcss: 4.2.2 - '@posthog/quill@file:.local-quill/posthog-quill-local-5d2e0b61.tgz(@base-ui/react@1.3.0(@types/react@19.2.17)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(tailwindcss@4.3.1)': + '@posthog/quill@0.3.0-beta.21(@base-ui/react@1.3.0(@types/react@19.2.17)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(tailwindcss@4.3.1)': dependencies: '@base-ui/react': 1.3.0(@types/react@19.2.17)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) class-variance-authority: 0.7.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 951510ab42..d85e5dc170 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -8,7 +8,7 @@ catalog: '@hono/trpc-server': ^0.3.4 '@parcel/watcher': ^2.5.6 '@phosphor-icons/react': ^2.1.10 - '@posthog/quill': 0.3.0-beta.19 + '@posthog/quill': 0.3.0-beta.21 '@radix-ui/themes': ^3.2.1 '@tanstack/devtools-vite': ^0.7.0 '@tanstack/react-devtools': ^0.10.5 @@ -99,19 +99,6 @@ overrides: # undici 8.4.1 dropped that assertion and handles the paused case gracefully; # node-gyp only uses stable public exports (fetch/Agent), so 8.x is safe. 'node-gyp>undici': 8.4.1 - # LOCAL DEV ONLY — point @posthog/quill at a packed tarball of the local mono build to - # test the ChatX thread primitives before publishing. A tarball (not link:) is used on - # purpose: 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). - # The tarball is copied into this repo's store and deduped against React 19. - # Re-sync after a quill change: - # (cd ../posthog/packages/quill/packages/quill && pnpm build && npm pack --pack-destination /Users/adamleithp/Dev/code/.local-quill) - # then rename the tgz to a content-hashed name and point the override at it (pnpm pins the - # tarball by integrity, so a stable filename gets cached stale across re-syncs): - # cd .local-quill && cp posthog-quill-*.tgz posthog-quill-local-$(md5 -q posthog-quill-*.tgz | cut -c1-8).tgz - # update the line below to the new name, then `pnpm install`. - # Revert (back to catalog 0.3.0-beta.x) once the ChatX primitives are published to npm. - '@posthog/quill': file:./.local-quill/posthog-quill-local-5d2e0b61.tgz onlyBuiltDependencies: - '@parcel/watcher' From 8c2ecf9d3b944b21ed12e65a1920298b05ef2ebf Mon Sep 17 00:00:00 2001 From: Adam Leith Date: Tue, 30 Jun 2026 17:51:52 +0100 Subject: [PATCH 6/6] =?UTF-8?q?fix(thread):=20address=20review=20=E2=80=94?= =?UTF-8?q?=20stale=20tool-group=20label,=20PascalCase=20name,=20react-doc?= =?UTF-8?q?tor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ChatThread: stop syncing `offscreen` to the anchor prop inside the sticky-header effect (the render already guards on `active`), clearing the blocking react-doctor `no-adjust-state-on-prop-change` finding. - ToolGroup: a missing tool-map entry means the tool is still in-flight, so default its status to "in_progress" instead of casting to a status-less ToolCall — fixes the group label reading "Used …" mid-stream (greptile P1). - ToolGroup: split PascalCase tool names so `ToolSearch` reads "Tool search", not "Toolsearch" (greptile P2). - SessionView: comment why the pending-permission box uses `shrink-0` (greptile P2). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../sessions/components/SessionView.tsx | 3 +++ .../components/chat-thread/ChatThread.tsx | 8 ++++---- .../components/chat-thread/ToolGroup.tsx | 19 ++++++++++++++----- 3 files changed, 21 insertions(+), 9 deletions(-) diff --git a/packages/ui/src/features/sessions/components/SessionView.tsx b/packages/ui/src/features/sessions/components/SessionView.tsx index c9f563c9b1..7f59d8f607 100644 --- a/packages/ui/src/features/sessions/components/SessionView.tsx +++ b/packages/ui/src/features/sessions/components/SessionView.tsx @@ -566,6 +566,9 @@ export function SessionView({ ) : hideInput ? null : firstPendingPermission ? ( + // 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. { - if (activeId == null) { - setOffscreen(false); - return; - } + // No reset when there's no anchor: the overlay render already guards on `active != null`, so a + // stale `offscreen` is never shown, and a fresh anchor re-measures synchronously below. (Avoids + // the prop-sync-in-effect pattern react-doctor flags.) + if (activeId == null) return; const viewport = probeRef.current ?.closest('[data-slot="chat-message-scroller"]') ?.querySelector('[data-slot="chat-message-scroller-viewport"]'); diff --git a/packages/ui/src/features/sessions/components/chat-thread/ToolGroup.tsx b/packages/ui/src/features/sessions/components/chat-thread/ToolGroup.tsx index 0824e1cee2..2d1a5623a6 100644 --- a/packages/ui/src/features/sessions/components/chat-thread/ToolGroup.tsx +++ b/packages/ui/src/features/sessions/components/chat-thread/ToolGroup.tsx @@ -27,9 +27,16 @@ function resolveTool(item: ToolGroupItem["tools"][number]): { ConversationItem, { type: "session_update" } >["update"] & { toolCallId?: string }; - const fromMap = - (update.toolCallId && item.turnContext.toolCalls.get(update.toolCallId)) || - (update as unknown as ToolCall); + const mapped = update.toolCallId + ? item.turnContext.toolCalls.get(update.toolCallId) + : undefined; + // A missing map entry means the tool is still in-flight (the resolved ToolCall is written when it + // settles), so default its status to "in_progress" — otherwise the cast yields a status-less + // ToolCall, `isToolActive` reads false, and the group label shows "Used …" mid-stream. + const fromMap: ToolCall = mapped ?? { + ...(update as unknown as ToolCall), + status: (update as unknown as ToolCall).status ?? "in_progress", + }; const meta = fromMap._meta as | { claudeCode?: { toolName?: string } } | undefined; @@ -42,10 +49,12 @@ function toolKey(item: ToolGroupItem["tools"][number]): string { return toolName ?? toolCall.kind ?? "tool"; } -/** Human label for a uniform group, e.g. `ToolSearch` → "Toolsearch", `mcp__x__run` → "Run". */ +/** Human label for a uniform group, e.g. `ToolSearch` → "Tool search", `mcp__x__run` → "Run". */ function friendlyName(key: string): string { const last = key.includes("__") ? (key.split("__").pop() ?? key) : key; - return last.charAt(0).toUpperCase() + last.slice(1).toLowerCase(); + // Split PascalCase/camelCase into words so `ToolSearch` reads "Tool search" rather than "Toolsearch". + const spaced = last.replace(/([a-z\d])([A-Z])/g, "$1 $2"); + return spaced.charAt(0).toUpperCase() + spaced.slice(1).toLowerCase(); } function isToolActive(item: ToolGroupItem["tools"][number]): boolean {