diff --git a/.claude/skills/quill-code/SKILL.md b/.claude/skills/quill-code/SKILL.md new file mode 100644 index 0000000000..a61c9429b1 --- /dev/null +++ b/.claude/skills/quill-code/SKILL.md @@ -0,0 +1,93 @@ +--- +name: quill-code +description: Edit the @posthog/quill design system locally and consume the change in this repo (posthog-code) before it is published to npm. Use when changing quill components/primitives/tokens, when a quill change must be tested inside the Code app, or when the user mentions quill, the design system, the .local-quill tarball, or the @posthog/quill pnpm override. +--- + +# quill-code + +`@posthog/quill` is **not** in this repo. It is a published catalog dependency whose +source lives in the main PostHog monorepo at `../posthog/packages/quill`. To test an +unpublished quill change inside this repo (posthog-code), you build quill, pack it to a +tarball, and point a pnpm `overrides` entry at that tarball. This is a **temporary +local-dev** state — revert before merging (see below). + +## Quill layout (where to edit) + +`../posthog/packages/quill` is the **workspace** (`@posthog/quill-workspace`). It +contains sub-packages, each a layer of the design system: + +- `packages/primitives/src` — base components (Card, Badge, Button, Progress, …) +- `packages/components/src` — composed components (DataTable, DateTimePicker, Metric) +- `packages/blocks/src` — **product-level blocks** (e.g. `ExperimentCard`). Add the + file here and export it from `packages/blocks/src/index.ts`. +- `packages/quill/src` — the **aggregate** that re-exports all layers as `@posthog/quill`. + +A new export in any sub-package flows to `@posthog/quill` automatically on build. + +## The loop (every quill change) + +1. Edit/add the component in the right sub-package (above) and export it from that + package's `src/index.ts`. +2. Re-sync into this repo manually — build → pack → point the override → reinstall: + + ```bash + QUILL_DIR="${QUILL_DIR:-../posthog/packages/quill/packages/quill}" + + # a. Build the WHOLE quill workspace (two levels up from the aggregate), so the + # sub-packages rebuild BEFORE the aggregate bundles them. + ( cd "$QUILL_DIR/../.." && pnpm build ) + + # b. Pack into .local-quill/ under a UNIQUE filename. pnpm pins a tarball by + # integrity, so a stable name caches stale across re-syncs — drop old local + # tarballs first, then rename the packed file to a unique local name. + rm -f .local-quill/posthog-quill-local-*.tgz + ( cd "$QUILL_DIR" && npm pack --pack-destination "$(git rev-parse --show-toplevel)/.local-quill" ) + mv .local-quill/posthog-quill-[0-9]*.tgz ".local-quill/posthog-quill-local-$(git rev-parse --short HEAD)-$$.tgz" + + # c. Point the override at the new tarball, then reinstall. + # Edit pnpm-workspace.yaml so overrides['@posthog/quill'] = file:./.local-quill/ + pnpm install + ``` + + > Building only the aggregate (`packages/quill/packages/quill`) re-bundles the + > sub-packages' **stale** `dist/`, so edits to primitives/components/blocks are + > silently dropped. Always build at the workspace root. +3. Verify in the Code app (`pnpm dev`, or the `test-electron-app` skill). Repeat from 1. + +After every quill edit you **must** re-run the sync — the app consumes the tarball, not +the quill source, so unsynced edits are invisible here. + +If quill lives elsewhere, set `QUILL_DIR=/abs/path/to/posthog/packages/quill/packages/quill`. + +## Why a tarball, not `link:` + +`link:` symlinks into the mono's `node_modules` and drags in its **React 18** types, +colliding with this repo's **React 19** (dual-React → broken typecheck + +invalid-hook-call at runtime). The tarball is copied into this repo's store and deduped +against React 19. The filename is **content-hashed** because pnpm pins a tarball by +integrity, so a stable filename gets cached stale across re-syncs. + +## The override (what the script rewrites) + +In `pnpm-workspace.yaml`, under `overrides:`: + +```yaml +'@posthog/quill': file:./.local-quill/posthog-quill-local-.tgz +``` + +There is also a permanent pin you should leave alone: + +```yaml +'@posthog/quill>@base-ui/react': ^1.3.0 # quill ships a broken catalog: dep; do not remove +``` + +## Reverting (before merge) + +The override is local-dev only. Once the quill change is published to npm: + +1. Bump the catalog version in `pnpm-workspace.yaml` (`'@posthog/quill': 0.3.0-beta.x`) + to the published version. +2. Restore the override line to point back at the catalog, or remove the `file:` override. +3. `pnpm install`. + +Do not commit a `file:./.local-quill/...` override or the `.local-quill/` tarballs. diff --git a/packages/core/src/canvas/threadTimeline.test.ts b/packages/core/src/canvas/threadTimeline.test.ts new file mode 100644 index 0000000000..145386ebe3 --- /dev/null +++ b/packages/core/src/canvas/threadTimeline.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from "vitest"; +import { + buildThreadTimeline, + deriveThreadAgentStatus, + shouldSuspendThreadSession, +} from "./threadTimeline"; + +describe("buildThreadTimeline", () => { + it("interleaves prompts, human replies, and agent turns chronologically", () => { + const timeline = buildThreadTimeline({ + prompts: [{ id: "prompt", text: "Start", timestamp: 100 }], + humanMessages: [ + { + id: "human", + content: "Reply", + createdAt: "1970-01-01T00:00:00.150Z", + }, + ], + agentMessages: [{ id: "agent", text: "Done", timestamp: 200 }], + }); + + expect(timeline.map((row) => row.kind)).toEqual([ + "prompt", + "human", + "agent", + ]); + }); + + it("keeps malformed timestamps at the end", () => { + const timeline = buildThreadTimeline({ + prompts: [{ id: "prompt", text: "Start", timestamp: 100 }], + humanMessages: [{ id: "human", content: "Reply", createdAt: "invalid" }], + agentMessages: [{ id: "agent", text: "Done", timestamp: 200 }], + }); + + expect(timeline.map((row) => row.kind)).toEqual([ + "prompt", + "agent", + "human", + ]); + }); +}); + +describe("deriveThreadAgentStatus", () => { + it.each([ + { + name: "returns no status before activity", + input: {}, + expected: null, + }, + { + name: "prioritizes failures", + input: { hasActivity: true, hasError: true, errorTitle: "Run failed" }, + expected: { phase: "error", label: "Run failed" }, + }, + { + name: "prioritizes pending permissions over active work", + input: { + hasActivity: true, + pendingPermissionCount: 1, + isPromptPending: true, + }, + expected: { phase: "needs_input", label: "Needs input" }, + }, + { + name: "reports active work", + input: { hasActivity: true, isPromptPending: true }, + expected: { phase: "active", label: "Working…" }, + }, + { + name: "reports shipped work", + input: { hasActivity: true, hasPullRequest: true }, + expected: { phase: "complete", label: "Shipped" }, + }, + ])("$name", ({ input, expected }) => { + expect(deriveThreadAgentStatus(input)).toEqual(expected); + }); +}); + +describe("shouldSuspendThreadSession", () => { + it("suspends a local runless task so reading cannot start work", () => { + expect( + shouldSuspendThreadSession({ + isCloud: false, + hasRun: false, + hasSession: false, + }), + ).toBe(true); + }); + + it.each([ + { isCloud: true, hasRun: false, hasSession: false }, + { isCloud: false, hasRun: true, hasSession: false }, + { isCloud: false, hasRun: false, hasSession: true }, + ])("keeps an existing or cloud session attached", (input) => { + expect(shouldSuspendThreadSession(input)).toBe(false); + }); +}); diff --git a/packages/core/src/canvas/threadTimeline.ts b/packages/core/src/canvas/threadTimeline.ts new file mode 100644 index 0000000000..aa28dc49b4 --- /dev/null +++ b/packages/core/src/canvas/threadTimeline.ts @@ -0,0 +1,116 @@ +export interface ThreadAgentMessage { + id: string; + text: string; + timestamp?: number; +} + +export interface ThreadHumanMessage { + id: string; + content: string; + createdAt: string; + value?: T; +} + +export type ThreadTimelineRow = + | { kind: "prompt"; timestamp: number; message: ThreadAgentMessage } + | { kind: "agent"; timestamp: number; message: ThreadAgentMessage } + | { kind: "human"; timestamp: number; message: ThreadHumanMessage }; + +function validTimestamp(timestamp: number | undefined): number { + return timestamp !== undefined && Number.isFinite(timestamp) + ? timestamp + : Number.MAX_SAFE_INTEGER; +} + +function parsedTimestamp(timestamp: string): number { + const parsed = Date.parse(timestamp); + return Number.isFinite(parsed) ? parsed : Number.MAX_SAFE_INTEGER; +} + +export function buildThreadTimeline({ + prompts, + agentMessages, + humanMessages, +}: { + prompts: ThreadAgentMessage[]; + agentMessages: ThreadAgentMessage[]; + humanMessages: ThreadHumanMessage[]; +}): ThreadTimelineRow[] { + return [ + ...prompts.map( + (message): ThreadTimelineRow => ({ + kind: "prompt", + timestamp: validTimestamp(message.timestamp), + message, + }), + ), + ...humanMessages.map( + (message): ThreadTimelineRow => ({ + kind: "human", + timestamp: parsedTimestamp(message.createdAt), + message, + }), + ), + ...agentMessages.map( + (message): ThreadTimelineRow => ({ + kind: "agent", + timestamp: validTimestamp(message.timestamp), + message, + }), + ), + ].sort((left, right) => left.timestamp - right.timestamp); +} + +export type ThreadAgentPhase = "active" | "needs_input" | "complete" | "error"; + +export interface ThreadAgentStatus { + phase: ThreadAgentPhase; + label: string; +} + +export function deriveThreadAgentStatus({ + hasActivity = false, + hasError = false, + cloudStatus, + errorTitle, + pendingPermissionCount = 0, + isPromptPending = false, + isInitializing = false, + hasPullRequest = false, +}: { + hasActivity?: boolean; + hasError?: boolean; + cloudStatus?: string | null; + errorTitle?: string | null; + pendingPermissionCount?: number; + isPromptPending?: boolean; + isInitializing?: boolean; + hasPullRequest?: boolean; +}): ThreadAgentStatus | null { + if (!hasActivity) return null; + if (hasError || cloudStatus === "failed") { + return { phase: "error", label: errorTitle ?? "Failed" }; + } + if (pendingPermissionCount > 0) { + return { phase: "needs_input", label: "Needs input" }; + } + if (isPromptPending || isInitializing) { + return { phase: "active", label: "Working…" }; + } + return { + phase: "complete", + label: hasPullRequest ? "Shipped" : "Ready to ship", + }; +} + +export function shouldSuspendThreadSession({ + isCloud, + hasRun, + hasSession, +}: { + isCloud: boolean; + hasRun: boolean; + hasSession: boolean; +}): boolean { + return !isCloud && !hasRun && !hasSession; +} diff --git a/packages/ui/src/features/canvas/components/ChannelFeedView.tsx b/packages/ui/src/features/canvas/components/ChannelFeedView.tsx index 90fff8e7df..782581ecc0 100644 --- a/packages/ui/src/features/canvas/components/ChannelFeedView.tsx +++ b/packages/ui/src/features/canvas/components/ChannelFeedView.tsx @@ -44,15 +44,19 @@ import { mentionChipClass } from "@posthog/ui/features/canvas/components/Mention import type { ChannelFeedSystemMessage } from "@posthog/ui/features/canvas/hooks/useChannelFeedMessages"; import { useChannelTaskData } from "@posthog/ui/features/canvas/hooks/useChannelTaskData"; import { useTaskThread } from "@posthog/ui/features/canvas/hooks/useTaskThread"; +import { shouldOpenTaskCardInline } from "@posthog/ui/features/canvas/taskCardNavigation"; import { userDisplayName } from "@posthog/ui/features/canvas/utils/userDisplay"; +import { useSessionSelector } from "@posthog/ui/features/sessions/useSession"; import { type SidebarPrState, useTaskPrStatus, } from "@posthog/ui/features/sidebar/useTaskPrStatus"; import { useInView } from "@posthog/ui/primitives/hooks/useInView"; import { Text } from "@radix-ui/themes"; +import { Link } from "@tanstack/react-router"; import { Fragment, + type MouseEvent, memo, type ReactNode, useEffect, @@ -159,6 +163,14 @@ interface TaskStatusDisplay { // deliberate end state we should not soften with a PR. function useTaskStatusDisplay(task: Task): TaskStatusDisplay { const data = useChannelTaskData(task); + const agentSettledAfterRun = useSessionSelector( + task.id, + (session) => + !!session && + (session.events?.length ?? 0) > 0 && + !session.isPromptPending && + (session.pendingPermissions?.size ?? 0) === 0, + ); const { prState } = useTaskPrStatus({ id: task.id, cloudPrUrl: data?.cloudPrUrl ?? null, @@ -198,7 +210,9 @@ function useTaskStatusDisplay(task: Task): TaskStatusDisplay { } else if (!status) { base = Draft; } else if (environment === "cloud" || isTerminalStatus(status)) { - base = statusBadge(status); + const effectiveStatus = + agentSettledAfterRun && !isTerminalStatus(status) ? "completed" : status; + base = statusBadge(effectiveStatus); } else { // Local, non-terminal: the run status is unreliable (the backend row stays // "queued" while the agent runs on the creator's machine), so we render no @@ -258,61 +272,84 @@ const NO_PENDING: PendingKickoff[] = []; // The task the message kicked off, as a card everyone in the channel sees: // bold title + status up top, then run metadata. -function TaskCard({ task, onOpen }: { task: Task; onOpen: () => void }) { +export function TaskCard({ + task, + channelId, + onOpen, + inThread = false, +}: { + task: Task; + channelId: string; + onOpen?: () => void; + inThread?: boolean; +}) { const statusDisplay = useTaskStatusDisplay(task); const prUrl = typeof task.latest_run?.output?.pr_url === "string" ? task.latest_run.output.pr_url : undefined; const stage = task.latest_run?.stage; + const handleClick = (event: MouseEvent) => { + if (!onOpen || !shouldOpenTaskCardInline(event)) return; + event.preventDefault(); + onOpen(); + }; return ( - - -
-
- {/* Same live status icon as the code side nav, so the card and the - nav never disagree (generating spinner, needs-permission, cloud - status colors, PR state). */} - - - {task.title || "Untitled task"} - -
- -
- {(stage || task.repository || prUrl) && ( -
- {task.repository && ( - - - {task.repository} - - )} - {stage && ( - - {stage} - - )} - {prUrl && ( - - - PR + + +
+
+ + + {task.title || "Untitled task"} - )} +
+
- )} -
-
+ {(stage || task.repository || prUrl) && ( +
+ {task.repository && ( + + + {task.repository} + + )} + {stage && ( + + {stage} + + )} + {prUrl && ( + + + PR + + )} +
+ )} + + + ); } @@ -388,11 +425,13 @@ function ReplyFooter({ const FeedItem = memo(function FeedItem({ task, + channelId, inView, onOpenTask, onOpenThread, }: { task: Task; + channelId: string; inView: boolean; onOpenTask: (task: Task) => void; onOpenThread: (task: Task) => void; @@ -435,7 +474,11 @@ const FeedItem = memo(function FeedItem({ )} - onOpenTask(task)} /> + onOpenThread(task)} + /> void; onOpenThread: (task: Task) => void; }) { @@ -483,6 +528,7 @@ function FeedRow({ > diff --git a/packages/ui/src/features/canvas/components/MentionComposer.tsx b/packages/ui/src/features/canvas/components/MentionComposer.tsx index aaee6d89aa..e2c07e5c15 100644 --- a/packages/ui/src/features/canvas/components/MentionComposer.tsx +++ b/packages/ui/src/features/canvas/components/MentionComposer.tsx @@ -13,6 +13,7 @@ import Placeholder from "@tiptap/extension-placeholder"; import { EditorContent, useEditor } from "@tiptap/react"; import StarterKit from "@tiptap/starter-kit"; import { type ReactNode, useEffect, useRef, useState } from "react"; +import "./mention-chip.css"; import "./mention-composer.css"; interface MentionComposerProps { @@ -31,7 +32,7 @@ interface MentionComposerProps { } /** Styled like the chips MentionText renders on sent messages. */ -const MENTION_CHIP_CLASS = "rounded px-0.5 font-medium text-[var(--accent-11)]"; +const MENTION_CHIP_CLASS = "mention-chip"; // Padding lives on the editable element (not the input-group control) so a // click anywhere in the box focuses the editor. diff --git a/packages/ui/src/features/canvas/components/MentionText.test.tsx b/packages/ui/src/features/canvas/components/MentionText.test.tsx new file mode 100644 index 0000000000..75bfe97a37 --- /dev/null +++ b/packages/ui/src/features/canvas/components/MentionText.test.tsx @@ -0,0 +1,24 @@ +import { Theme } from "@radix-ui/themes"; +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { MentionText } from "./MentionText"; + +describe("MentionText", () => { + it("uses the shared mention styles and emphasizes the current user", () => { + render( + + + , + ); + + expect(screen.getByText("@Alice")).toHaveClass("mention-chip"); + expect(screen.getByText("@Alice")).not.toHaveClass("mention-chip--self"); + expect(screen.getByText("@Bob")).toHaveClass( + "mention-chip", + "mention-chip--self", + ); + }); +}); diff --git a/packages/ui/src/features/canvas/components/MentionText.tsx b/packages/ui/src/features/canvas/components/MentionText.tsx index ce6c4bd26a..5f640b440f 100644 --- a/packages/ui/src/features/canvas/components/MentionText.tsx +++ b/packages/ui/src/features/canvas/components/MentionText.tsx @@ -2,6 +2,7 @@ import { splitMentionSegments } from "@posthog/shared"; import { splitLinkSegments } from "@posthog/ui/features/canvas/utils/linkify"; import { Text } from "@radix-ui/themes"; import { Fragment, useMemo } from "react"; +import "./mention-chip.css"; type RenderSegment = | { type: "text"; text: string } @@ -11,8 +12,7 @@ type RenderSegment = // The plain (not-the-viewer) mention chip look, also used by surfaces that // render a mention-styled name without real mention semantics (e.g. the // channel feed's "started a new task" row). -export const mentionChipClass = - "rounded px-0.5 font-medium text-[var(--accent-11)]"; +export const mentionChipClass = "mention-chip"; /** * Thread message content with inline mention tokens rendered as highlighted @@ -60,7 +60,7 @@ export function MentionText({ key={key} className={ selfEmail && segment.email.toLowerCase() === selfEmail - ? "rounded bg-[var(--accent-a4)] px-0.5 font-medium text-[var(--accent-12)]" + ? `${mentionChipClass} mention-chip--self` : mentionChipClass } title={segment.email} diff --git a/packages/ui/src/features/canvas/components/ThreadPanel.tsx b/packages/ui/src/features/canvas/components/ThreadPanel.tsx index 47e5ef82ea..e872045efc 100644 --- a/packages/ui/src/features/canvas/components/ThreadPanel.tsx +++ b/packages/ui/src/features/canvas/components/ThreadPanel.tsx @@ -1,4 +1,5 @@ import { + ArrowSquareOutIcon, CaretRightIcon, DotsThreeIcon, PaperPlaneRightIcon, @@ -6,6 +7,13 @@ import { TrashIcon, XIcon, } from "@phosphor-icons/react"; +import { + buildThreadTimeline, + deriveThreadAgentStatus, + shouldSuspendThreadSession, + type ThreadAgentMessage, + type ThreadAgentStatus, +} from "@posthog/core/canvas/threadTimeline"; import { Avatar, AvatarFallback, @@ -17,9 +25,19 @@ import { DropdownMenuTrigger, InputGroupAddon, InputGroupButton, + Skeleton, + SkeletonText, Spinner, + ThreadItem, + ThreadItemAction, + ThreadItemActions, + ThreadItemAuthor, + ThreadItemBody, + ThreadItemContent, + ThreadItemGroup, + ThreadItemGutter, + ThreadItemHeader, } from "@posthog/quill"; -import { formatRelativeTimeShort } from "@posthog/shared"; import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; import type { Task, @@ -30,20 +48,31 @@ import { isTerminalStatus } from "@posthog/shared/domain-types"; import { useOptionalAuthenticatedClient } from "@posthog/ui/features/auth/authClient"; import { useCurrentUser } from "@posthog/ui/features/auth/useCurrentUser"; import { getUserInitials } from "@posthog/ui/features/auth/userInitials"; +import { TaskCard } from "@posthog/ui/features/canvas/components/ChannelFeedView"; import { MentionComposer } from "@posthog/ui/features/canvas/components/MentionComposer"; import { MentionText } from "@posthog/ui/features/canvas/components/MentionText"; +import { ThreadTimestamp } from "@posthog/ui/features/canvas/components/ThreadTimestamp"; import { useOrgMembers } from "@posthog/ui/features/canvas/hooks/useOrgMembers"; import { useTaskThread, useTaskThreadMutations, } from "@posthog/ui/features/canvas/hooks/useTaskThread"; import { userDisplayName } from "@posthog/ui/features/canvas/utils/userDisplay"; +import type { ConversationItem } from "@posthog/ui/features/sessions/components/buildConversationItems"; +import { + ChatMarkdown, + ChatStreamingMarkdown, +} from "@posthog/ui/features/sessions/components/chat-thread/ChatMarkdown"; +import { extractChannelContext } from "@posthog/ui/features/sessions/components/session-update/channelContext"; +import { useConversationItems } from "@posthog/ui/features/sessions/hooks/useConversationItems"; +import { useSessionConnection } from "@posthog/ui/features/sessions/hooks/useSessionConnection"; +import { useSessionViewState } from "@posthog/ui/features/sessions/hooks/useSessionViewState"; +import { usePendingPermissionsForTask } from "@posthog/ui/features/sessions/sessionStore"; import { taskDetailQuery } from "@posthog/ui/features/tasks/queries"; import { toast } from "@posthog/ui/primitives/toast"; import { track } from "@posthog/ui/shell/analytics"; -import { Text } from "@radix-ui/themes"; import { useQuery } from "@tanstack/react-query"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; function ThreadMessageRow({ message, @@ -55,7 +84,6 @@ function ThreadMessageRow({ onDelete, }: { message: TaskThreadMessage; - /** Whether the current user authored the task (may forward to the agent). */ isTaskAuthor: boolean; isOwnMessage: boolean; currentUserEmail?: string | null; @@ -67,97 +95,237 @@ function ThreadMessageRow({ const showMenu = (isTaskAuthor && !forwarded) || isOwnMessage; return ( -
- - {getUserInitials(message.author)} - -
-
- - {userDisplayName(message.author)} - - - {formatRelativeTimeShort(message.created_at)} - -
- + + + + {getUserInitials(message.author)} + + + + + {userDisplayName(message.author)} + + + + + {forwarded && ( - + Sent to agent )} -
+ {showMenu && ( - - - - - } - /> - - {isTaskAuthor && !forwarded && ( - - - Send to agent - - )} - {isOwnMessage && ( - - - Delete message - - )} - - + + + + + + } + /> + + {isTaskAuthor && !forwarded && ( + + + Send to agent + + )} + {isOwnMessage && ( + + + Delete message + + )} + + + )} -
+ ); } -// The right-hand thread dock: the human-only conversation around a task. -// Nothing here reaches the agent unless the task author explicitly forwards a -// message ("Send to agent" in the row's hover menu). -export function ThreadPanel({ - taskId, - task: taskProp, +function agentTurns(items: ConversationItem[]): ThreadAgentMessage[] { + const turns: ThreadAgentMessage[] = []; + let current: ThreadAgentMessage | null = null; + for (const item of items) { + if (item.type === "user_message") { + if (current) turns.push(current); + current = null; + continue; + } + if ( + item.type === "session_update" && + item.update.sessionUpdate === "agent_message_chunk" && + "content" in item.update && + item.update.content.type === "text" && + item.update.content.text.trim() + ) { + current = { + id: item.id, + text: item.update.content.text, + timestamp: item.timestamp, + }; + } + } + if (current) turns.push(current); + return turns; +} + +function agentPrompts(items: ConversationItem[]): ThreadAgentMessage[] { + const prompts: ThreadAgentMessage[] = []; + for (const item of items) { + if (item.type !== "user_message") continue; + const text = ( + extractChannelContext(item.content)?.stripped ?? item.content + ).trim(); + if (!text) continue; + prompts.push({ id: item.id, text, timestamp: item.timestamp }); + } + return prompts; +} + +function AgentStatusChip({ status }: { status: ThreadAgentStatus }) { + switch (status.phase) { + case "active": + return ( + + + {status.label} + + ); + case "needs_input": + return {status.label}; + case "error": + return {status.label}; + default: + return {status.label}; + } +} + +function AgentTurnRow({ + message, + status, + streaming, +}: { + message?: ThreadAgentMessage; + status?: ThreadAgentStatus; + streaming: boolean; +}) { + return ( + + + + + + + + + + + Agent + {status && } + {message?.timestamp !== undefined && ( + + )} + + {message?.text && ( + +
+ {streaming ? ( + + ) : ( + + )} +
+
+ )} +
+
+ ); +} + +function UserPromptRow({ + message, + author, +}: { + message: ThreadAgentMessage; + author: TaskThreadMessage["author"]; +}) { + return ( + + + + {getUserInitials(author)} + + + + + {userDisplayName(author)} + {message.timestamp !== undefined && ( + + )} + + + {message.text} + + + + ); +} + +function ThreadTimelineSkeleton() { + return ( + + {[0, 1, 2].map((i) => ( + + + + + + + + + + + + ))} + + ); +} + +function ThreadConversation({ + task, + channelId, onClose, - collapsed, onToggleCollapsed, - showTaskTitle = true, + onOpenFull, + showTaskSummary, }: { - taskId: string; - /** The thread's task when the caller already has it; fetched otherwise. */ - task?: Task; + task: Task; + channelId: string; onClose?: () => void; - collapsed?: boolean; onToggleCollapsed?: () => void; - /** - * Show the task title under the "Thread" heading. Hidden in the task detail - * view, where the TaskDetail header already names the task. - */ - showTaskTitle?: boolean; + onOpenFull?: () => void; + showTaskSummary: boolean; }) { + const taskId = task.id; const client = useOptionalAuthenticatedClient(); const { data: currentUser } = useCurrentUser({ client }); - const { data: fetchedTask } = useQuery({ - ...taskDetailQuery(taskId), - enabled: !taskProp, - }); - const task = taskProp ?? fetchedTask; - const { messages, isLoading } = useTaskThread(collapsed ? undefined : taskId); + const { messages, isLoading } = useTaskThread(taskId); const { postMessage, deleteMessage, @@ -165,7 +333,82 @@ export function ThreadPanel({ isPosting, isSendingToAgent, } = useTaskThreadMutations(taskId); - const { members } = useOrgMembers({ enabled: !collapsed }); + const { members } = useOrgMembers(); + + const { + session, + repoPath, + isCloud, + events, + cloudStatus, + isPromptPending, + isInitializing, + hasError, + errorTitle, + } = useSessionViewState(taskId, task); + useSessionConnection({ + taskId, + task, + session, + repoPath, + isCloud, + isSuspended: shouldSuspendThreadSession({ + isCloud, + hasRun: Boolean(task.latest_run?.id), + hasSession: Boolean(session), + }), + }); + const { items } = useConversationItems(events, isPromptPending); + const pendingPermissions = usePendingPermissionsForTask(taskId); + const prUrl = + typeof task.latest_run?.output?.pr_url === "string" + ? task.latest_run.output.pr_url + : undefined; + + const agentMsgs = useMemo(() => agentTurns(items), [items]); + const promptMsgs = useMemo(() => agentPrompts(items), [items]); + + const agentStatus = useMemo( + () => + deriveThreadAgentStatus({ + hasActivity: events.length > 0 || !!task.latest_run, + hasError, + cloudStatus, + errorTitle, + pendingPermissionCount: pendingPermissions.size, + isPromptPending, + isInitializing, + hasPullRequest: !!prUrl, + }), + [ + events.length, + task.latest_run, + hasError, + cloudStatus, + errorTitle, + pendingPermissions.size, + isPromptPending, + isInitializing, + prUrl, + ], + ); + + const timeline = useMemo( + () => + buildThreadTimeline({ + prompts: promptMsgs, + agentMessages: agentMsgs, + humanMessages: messages.map((message) => ({ + id: message.id, + content: message.content, + createdAt: message.created_at, + value: message, + })), + }), + [promptMsgs, messages, agentMsgs], + ); + + const lastAgentId = agentMsgs[agentMsgs.length - 1]?.id; const [draft, setDraft] = useState(""); const scrollRef = useRef(null); @@ -182,17 +425,15 @@ export function ThreadPanel({ [taskId], ); - // Keep the newest message in view, Slack-style. - // biome-ignore lint/correctness/useExhaustiveDependencies: scroll on new messages + // biome-ignore lint/correctness/useExhaustiveDependencies: scroll when rendered thread content changes useEffect(() => { scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight }); - }, [messages.length]); + }, [timeline, agentStatus?.phase]); const isTaskAuthor = - !!currentUser?.uuid && currentUser.uuid === task?.created_by?.uuid; - // Forwarding needs a run the workflow can still signal, one send at a time. + !!currentUser?.uuid && currentUser.uuid === task.created_by?.uuid; const canForward = - !!task?.latest_run && + !!task.latest_run && !isTerminalStatus(task.latest_run.status) && !isSendingToAgent; @@ -224,34 +465,25 @@ export function ThreadPanel({ }); }; - if (collapsed) { - return ( -
- -
- ); - } + const isEmpty = timeline.length === 0 && !agentStatus; + const isReady = !isInitializing && !isLoading; return (
- - Thread - - {showTaskTitle && task && ( - - {task.title || "Untitled task"} - - )} + Thread
+ {onOpenFull && ( + + )} {onToggleCollapsed && ( +
+ ); + } + + if (!task) { + return ( +
+ +
+ ); + } + + return ( + + ); +} diff --git a/packages/ui/src/features/canvas/components/ThreadSidebar.tsx b/packages/ui/src/features/canvas/components/ThreadSidebar.tsx index fb4d7703dd..4a81cf8700 100644 --- a/packages/ui/src/features/canvas/components/ThreadSidebar.tsx +++ b/packages/ui/src/features/canvas/components/ThreadSidebar.tsx @@ -12,16 +12,19 @@ import { useState } from "react"; // re-render on every resize tick. export function ThreadSidebar({ taskId, + channelId, task, onClose, - showTaskTitle, + onOpenFull, + showTaskSummary, }: { taskId: string; + channelId: string; /** The thread's task when the caller already has it; fetched otherwise. */ task?: Task; onClose?: () => void; - /** Forwarded to ThreadPanel; hidden in the task detail view. */ - showTaskTitle?: boolean; + onOpenFull?: () => void; + showTaskSummary?: boolean; }) { const collapsed = useThreadPanelStore((s) => s.collapsed); const width = useThreadPanelStore((s) => s.width); @@ -42,6 +45,7 @@ export function ThreadSidebar({ return ( toggleCollapsed(false)} @@ -60,10 +64,12 @@ export function ThreadSidebar({ > toggleCollapsed(true)} - showTaskTitle={showTaskTitle} + onOpenFull={onOpenFull} + showTaskSummary={showTaskSummary} /> ); diff --git a/packages/ui/src/features/canvas/components/ThreadTimestamp.tsx b/packages/ui/src/features/canvas/components/ThreadTimestamp.tsx new file mode 100644 index 0000000000..ef754f19e8 --- /dev/null +++ b/packages/ui/src/features/canvas/components/ThreadTimestamp.tsx @@ -0,0 +1,45 @@ +import { + ThreadItemTimestamp, + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@posthog/quill"; + +function ordinal(value: number): string { + const remainder = value % 100; + const suffixes = ["th", "st", "nd", "rd"]; + return `${value}${suffixes[(remainder - 20) % 10] ?? suffixes[remainder] ?? suffixes[0]}`; +} + +function formatClock(date: Date): string { + const minutes = String(date.getMinutes()).padStart(2, "0"); + const meridiem = date.getHours() >= 12 ? "pm" : "am"; + const hour = date.getHours() % 12 || 12; + return `${hour}:${minutes}${meridiem}`; +} + +function formatTooltip(date: Date): string { + const month = date.toLocaleString("en-US", { month: "long" }); + return `${month} ${ordinal(date.getDate())} at ${formatClock(date)}`; +} + +export function ThreadTimestamp({ dateTime }: { dateTime: string }) { + const date = new Date(dateTime); + if (Number.isNaN(date.getTime())) return null; + + return ( + + + + {formatClock(date)} + + } + /> + {formatTooltip(date)} + + + ); +} diff --git a/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx b/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx index bc471627d7..005afd42cd 100644 --- a/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx +++ b/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx @@ -43,7 +43,7 @@ import { track } from "@posthog/ui/shell/analytics"; import { Heading, Text } from "@radix-ui/themes"; import { useQueryClient } from "@tanstack/react-query"; import { useNavigate } from "@tanstack/react-router"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useMemo, useRef, useState } from "react"; // A channel: a Slack-style multiplayer feed. Each member message kicks off a // task rendered as a card everyone in the channel sees; the composer stays @@ -107,16 +107,12 @@ export function WebsiteChannelHome({ channelId }: { channelId: string }) { // onboarding checklist. Describe-mode: seeds a plan session for this context. const [contextMdDialogOpen, setContextMdDialogOpen] = useState(false); - const threadTaskId = useThreadPanelStore((s) => s.taskId); + const threadTaskId = useThreadPanelStore( + (s) => s.openByChannel[channelId] ?? null, + ); const openThread = useThreadPanelStore((s) => s.openThread); const closeThread = useThreadPanelStore((s) => s.closeThread); - // A thread from another channel shouldn't linger when switching feeds. - // biome-ignore lint/correctness/useExhaustiveDependencies: re-close per channel - useEffect(() => { - closeThread(); - }, [closeThread, channelId]); - const handleSuggestionSelect = useCallback( (prompt: string, mode?: string) => { composerRef.current?.applySuggestion(prompt, mode); @@ -171,21 +167,23 @@ export function WebsiteChannelHome({ channelId }: { channelId: string }) { [backendChannel?.id, channelId, fileTask, invalidateFeed, queryClient], ); - // The task route's mount effect points the panel at the task, so navigating - // is enough here. - const handleOpenTask = useCallback( - (task: Task) => { + const handleOpenFull = useCallback( + (taskId: string) => { void navigate({ to: "/website/$channelId/tasks/$taskId", - params: { channelId, taskId: task.id }, + params: { channelId, taskId }, }); }, [channelId, navigate], ); + const handleOpenTask = useCallback( + (task: Task) => handleOpenFull(task.id), + [handleOpenFull], + ); const handleOpenThread = useCallback( - (task: Task) => openThread(task.id), - [openThread], + (task: Task) => openThread(channelId, task.id), + [channelId, openThread], ); const threadTask = threadTaskId @@ -262,6 +260,7 @@ export function WebsiteChannelHome({ channelId }: { channelId: string }) {
closeThread(channelId)} + onOpenFull={() => handleOpenFull(threadTaskId)} /> )} diff --git a/packages/ui/src/features/canvas/components/mention-chip.css b/packages/ui/src/features/canvas/components/mention-chip.css new file mode 100644 index 0000000000..55a96cab4b --- /dev/null +++ b/packages/ui/src/features/canvas/components/mention-chip.css @@ -0,0 +1,12 @@ +.mention-chip { + border-radius: var(--radius-1); + background: color-mix(in oklab, var(--primary) 10%, transparent); + color: color-mix(in oklab, var(--primary) 80%, transparent); + font-weight: 500; + padding-inline: 0.125rem; +} + +.mention-chip--self { + background: color-mix(in oklab, var(--primary) 50%, transparent); + color: var(--primary); +} diff --git a/packages/ui/src/features/canvas/hooks/useChannelFeedMessages.test.ts b/packages/ui/src/features/canvas/hooks/useChannelFeedMessages.test.ts new file mode 100644 index 0000000000..b222a17140 --- /dev/null +++ b/packages/ui/src/features/canvas/hooks/useChannelFeedMessages.test.ts @@ -0,0 +1,23 @@ +import { useAuthenticatedQuery } from "@posthog/ui/hooks/useAuthenticatedQuery"; +import { renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { useChannelFeedMessages } from "./useChannelFeedMessages"; + +vi.mock("@posthog/ui/hooks/useAuthenticatedQuery", () => ({ + useAuthenticatedQuery: vi.fn(() => ({ data: [], isLoading: false })), +})); + +describe("useChannelFeedMessages", () => { + beforeEach(() => { + vi.mocked(useAuthenticatedQuery).mockClear(); + }); + + it("keeps polling after a transient query error", () => { + renderHook(() => useChannelFeedMessages("channel-id")); + + expect(vi.mocked(useAuthenticatedQuery).mock.calls[0]?.[2]).toMatchObject({ + retry: false, + refetchInterval: 5_000, + }); + }); +}); diff --git a/packages/ui/src/features/canvas/hooks/useChannelFeedMessages.ts b/packages/ui/src/features/canvas/hooks/useChannelFeedMessages.ts index 4ffb818045..a7442a620e 100644 --- a/packages/ui/src/features/canvas/hooks/useChannelFeedMessages.ts +++ b/packages/ui/src/features/canvas/hooks/useChannelFeedMessages.ts @@ -80,14 +80,8 @@ export function useChannelFeedMessages(channelId: string | undefined): { (client) => client.getChannelFeed(channelId as string), { enabled: !!channelId, - // The endpoint may not be deployed yet (posthog#70320): don't retry, and - // stop polling once the query errors — otherwise every open channel view - // streams 404s (poll tick × default retries) forever. retry: false, - refetchInterval: (query) => - query.state.status === "error" - ? false - : CHANNEL_FEED_MESSAGES_POLL_INTERVAL_MS, + refetchInterval: CHANNEL_FEED_MESSAGES_POLL_INTERVAL_MS, }, ); const messages = useMemo( diff --git a/packages/ui/src/features/canvas/stores/threadPanelStore.test.ts b/packages/ui/src/features/canvas/stores/threadPanelStore.test.ts new file mode 100644 index 0000000000..722bd29263 --- /dev/null +++ b/packages/ui/src/features/canvas/stores/threadPanelStore.test.ts @@ -0,0 +1,37 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { useThreadPanelStore } from "./threadPanelStore"; + +describe("threadPanelStore", () => { + beforeEach(() => { + useThreadPanelStore.setState({ + openByChannel: {}, + collapsed: false, + width: 360, + }); + }); + + it("keeps each channel tab's open thread independent", () => { + const { openThread } = useThreadPanelStore.getState(); + + openThread("channel-a", "task-a"); + openThread("channel-b", "task-b"); + + expect(useThreadPanelStore.getState().openByChannel).toEqual({ + "channel-a": "task-a", + "channel-b": "task-b", + }); + }); + + it("closes only the active channel's thread", () => { + useThreadPanelStore.setState({ + openByChannel: { "channel-a": "task-a", "channel-b": "task-b" }, + }); + + useThreadPanelStore.getState().closeThread("channel-a"); + + expect(useThreadPanelStore.getState().openByChannel).toEqual({ + "channel-a": null, + "channel-b": "task-b", + }); + }); +}); diff --git a/packages/ui/src/features/canvas/stores/threadPanelStore.ts b/packages/ui/src/features/canvas/stores/threadPanelStore.ts index e003ad89cb..ee2c66427e 100644 --- a/packages/ui/src/features/canvas/stores/threadPanelStore.ts +++ b/packages/ui/src/features/canvas/stores/threadPanelStore.ts @@ -2,20 +2,18 @@ import { electronStorage } from "@posthog/ui/shell/rendererStorage"; import { create } from "zustand"; import { persist } from "zustand/middleware"; -// View state for the thread side panel — the right-hand human conversation -// dock next to a channel feed or task detail. Which thread is open is -// per-navigation state; collapse and width are persisted user preferences so -// the panel keeps its shape across tasks and channels. const DEFAULT_PANEL_WIDTH = 360; interface ThreadPanelState { - /** Task whose thread is open, or null when the panel is closed. */ - taskId: string | null; + openByChannel: Record; collapsed: boolean; width: number; - /** Points the panel at a task; expands it unless `expand: false`. */ - openThread: (taskId: string, opts?: { expand?: boolean }) => void; - closeThread: () => void; + openThread: ( + channelId: string, + taskId: string, + opts?: { expand?: boolean }, + ) => void; + closeThread: (channelId: string) => void; setCollapsed: (collapsed: boolean) => void; setWidth: (width: number) => void; } @@ -23,12 +21,18 @@ interface ThreadPanelState { export const useThreadPanelStore = create()( persist( (set) => ({ - taskId: null, + openByChannel: {}, collapsed: false, width: DEFAULT_PANEL_WIDTH, - openThread: (taskId, opts) => - set(opts?.expand === false ? { taskId } : { taskId, collapsed: false }), - closeThread: () => set({ taskId: null }), + openThread: (channelId, taskId, opts) => + set((state) => ({ + openByChannel: { ...state.openByChannel, [channelId]: taskId }, + ...(opts?.expand === false ? {} : { collapsed: false }), + })), + closeThread: (channelId) => + set((state) => ({ + openByChannel: { ...state.openByChannel, [channelId]: null }, + })), setCollapsed: (collapsed) => set({ collapsed }), setWidth: (width) => set({ width }), }), diff --git a/packages/ui/src/features/canvas/taskCardNavigation.test.ts b/packages/ui/src/features/canvas/taskCardNavigation.test.ts new file mode 100644 index 0000000000..667ff66870 --- /dev/null +++ b/packages/ui/src/features/canvas/taskCardNavigation.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { shouldOpenTaskCardInline } from "./taskCardNavigation"; + +const PRIMARY_CLICK = { + defaultPrevented: false, + button: 0, + metaKey: false, + ctrlKey: false, + shiftKey: false, + altKey: false, +}; + +describe("shouldOpenTaskCardInline", () => { + it("opens an unmodified primary click in the thread dock", () => { + expect(shouldOpenTaskCardInline(PRIMARY_CLICK)).toBe(true); + }); + + it.each([ + { defaultPrevented: true }, + { button: 1 }, + { metaKey: true }, + { ctrlKey: true }, + { shiftKey: true }, + { altKey: true }, + ])("leaves browser navigation intact for %o", (override) => { + expect(shouldOpenTaskCardInline({ ...PRIMARY_CLICK, ...override })).toBe( + false, + ); + }); +}); diff --git a/packages/ui/src/features/canvas/taskCardNavigation.ts b/packages/ui/src/features/canvas/taskCardNavigation.ts new file mode 100644 index 0000000000..27ad8f0abb --- /dev/null +++ b/packages/ui/src/features/canvas/taskCardNavigation.ts @@ -0,0 +1,21 @@ +export interface TaskCardPointerIntent { + defaultPrevented: boolean; + button: number; + metaKey: boolean; + ctrlKey: boolean; + shiftKey: boolean; + altKey: boolean; +} + +export function shouldOpenTaskCardInline( + event: TaskCardPointerIntent, +): boolean { + return ( + !event.defaultPrevented && + event.button === 0 && + !event.metaKey && + !event.ctrlKey && + !event.shiftKey && + !event.altKey + ); +} diff --git a/packages/ui/src/router/routes/website/$channelId/tasks/$taskId.tsx b/packages/ui/src/router/routes/website/$channelId/tasks/$taskId.tsx index 85fbec93f8..b50fe78fe3 100644 --- a/packages/ui/src/router/routes/website/$channelId/tasks/$taskId.tsx +++ b/packages/ui/src/router/routes/website/$channelId/tasks/$taskId.tsx @@ -1,7 +1,6 @@ import type { Task } from "@posthog/shared/domain-types"; import { ThreadSidebar } from "@posthog/ui/features/canvas/components/ThreadSidebar"; import { useChannels } from "@posthog/ui/features/canvas/hooks/useChannels"; -import { useThreadPanelStore } from "@posthog/ui/features/canvas/stores/threadPanelStore"; import { useTaskViewed } from "@posthog/ui/features/sidebar/useTaskViewed"; import { TaskDetail } from "@posthog/ui/features/task-detail/components/TaskDetail"; import { @@ -49,13 +48,6 @@ function ChannelTaskDetailRoute() { markAsViewed(taskId); }, [taskId, markAsViewed]); - // Opening a task shows its thread docked on the right, keeping the user's - // collapse preference. The panel follows the task being viewed. - const openThread = useThreadPanelStore((s) => s.openThread); - useEffect(() => { - openThread(taskId, { expand: false }); - }, [openThread, taskId]); - const { data: fetched } = useQuery({ ...taskDetailQuery(taskId), enabled: !fromList && !loaderTask, @@ -77,7 +69,12 @@ function ChannelTaskDetailRoute() { channelId={channelId} />
- +
); }