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/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/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/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/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..7f59d8f607 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 { @@ -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 = @@ -421,7 +422,7 @@ export function SessionView({ > {isSuspended ? ( <> - )} - - + {!useNewChatThread && } @@ -565,7 +566,10 @@ 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. + s.useNewChatThread); + return useNewChatThread ? ( + + ) : ( + + ); +} 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/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..45fd378ee9 --- /dev/null +++ b/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx @@ -0,0 +1,681 @@ +import { CaretDown, ChatCircle, FileText, Scroll } from "@phosphor-icons/react"; +import { WorkerPoolContextProvider } from "@pierre/diffs/react"; +import { useService } from "@posthog/di/react"; +import { + Button, + ChatBubble, + ChatBubbleContent, + ChatMarker, + ChatMarkerContent, + ChatMessage, + ChatMessageContent, + ChatMessageFooter, + ChatMessageHeader, + ChatMessageScroller, + ChatMessageScrollerButton, + ChatMessageScrollerContent, + ChatMessageScrollerItem, + ChatMessageScrollerProvider, + ChatMessageScrollerViewport, + cn, + 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 { ChatThreadFooter } from "@posthog/ui/features/sessions/components/chat-thread/ChatThreadFooter"; +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"; +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"; +import { useConversationItems } from "@posthog/ui/features/sessions/hooks/useConversationItems"; +import { + useOptimisticItemsForTask, + useSessionForTask, +} from "@posthog/ui/features/sessions/sessionStore"; +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 { + DIFF_WORKER_FACTORY, + type DiffWorkerFactory, +} from "@posthog/ui/shell/diffWorkerHost"; +import { AnimatePresence, motion, useReducedMotion } from "framer-motion"; +import { + memo, + type ReactNode, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react"; + +import type { ConversationViewProps } from "../ConversationView"; + +const DIFFS_HIGHLIGHTER_OPTIONS = { + theme: { dark: "github-dark" as const, light: "github-light" as const }, +}; + +/** 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; +} + +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, + 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); + + // 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(); + }, [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 && ( + + )} + + + {timestamp != null && ( + + {formatTimestamp(timestamp)} + + )} + + + ); +} + +/** + * "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 } = 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 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(() => { + // 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"]'); + 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(() => { + 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 ( + <> +