diff --git a/packages/ui/src/features/canvas/components/ActivityPanel.tsx b/packages/ui/src/features/canvas/components/ActivityPanel.tsx index 67216759df..a0a36a23dd 100644 --- a/packages/ui/src/features/canvas/components/ActivityPanel.tsx +++ b/packages/ui/src/features/canvas/components/ActivityPanel.tsx @@ -3,7 +3,7 @@ import { CaretRightIcon, XIcon, } from "@phosphor-icons/react"; -import { Button, cn, Tabs, TabsList, TabsTrigger } from "@posthog/quill"; +import { Button, Tabs, TabsList, TabsTrigger } from "@posthog/quill"; import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; import type { Task } from "@posthog/shared/domain-types"; import { ActivityTimeline } from "@posthog/ui/features/canvas/components/ActivityTimeline"; @@ -35,9 +35,6 @@ const TABS_WITH_COMPOSER: ReadonlySet = new Set([ "comments", ]); -const TIMESTAMP_END_CLASS = - "[&_[data-slot=thread-item-timestamp]]:ml-auto [&_[data-slot=thread-item-timestamp]]:shrink-0 [&_[data-slot=thread-item-timestamp]]:pl-2"; - /** The 32px row this panel leads with: the tabs are the header, so the strip * lines up with the tab bar of the pane on its left (TabbedPanel) and the * review toolbar, which are the same fixed height and border. */ @@ -119,6 +116,7 @@ function ActivityConversation({ onToggleCollapsed, onOpenFull, showTaskSummary, + canOpenInPlace, }: { task: Task; channelId: string; @@ -126,6 +124,7 @@ function ActivityConversation({ onToggleCollapsed?: () => void; onOpenFull?: () => void; showTaskSummary: boolean; + canOpenInPlace?: boolean; }) { const taskId = task.id; const { @@ -183,7 +182,13 @@ function ActivityConversation({ const body = () => { if (tab === "artifacts") { - return ; + return ( + + ); } if (tab === "comments") { return ( @@ -209,6 +214,7 @@ function ActivityConversation({ currentUserEmail={currentUser?.email} isTaskAuthor={isTaskAuthor} canForward={canForward} + canOpenInPlace={canOpenInPlace} onSendToAgent={sendMessageToAgent} onDelete={deleteMessage} /> @@ -216,12 +222,7 @@ function ActivityConversation({ }; return ( -
+
void; onOpenFull?: () => void; showTaskSummary?: boolean; + canOpenInPlace?: boolean; }) { const { data: fetchedTask } = useQuery({ ...taskDetailQuery(taskId), @@ -315,6 +318,7 @@ export function ActivityPanel({ onToggleCollapsed={onToggleCollapsed} onOpenFull={onOpenFull} showTaskSummary={showTaskSummary} + canOpenInPlace={canOpenInPlace} /> ); } diff --git a/packages/ui/src/features/canvas/components/ActivityTimeline.test.tsx b/packages/ui/src/features/canvas/components/ActivityTimeline.test.tsx new file mode 100644 index 0000000000..c1f625b0fe --- /dev/null +++ b/packages/ui/src/features/canvas/components/ActivityTimeline.test.tsx @@ -0,0 +1,93 @@ +import type { Task } from "@posthog/shared/domain-types"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@posthog/ui/features/git-interaction/usePrDetails", () => ({ + usePrDetails: () => ({ + meta: { state: "open", merged: false, draft: false }, + }), +})); + +import { useThreadNavigationStore } from "@posthog/ui/features/sessions/threadNavigationStore"; +import { ActivityTimeline } from "./ActivityTimeline"; + +const task = { + id: "task-1", + created_at: "2026-07-17T09:00:00Z", + updated_at: "2026-07-17T09:00:00Z", + created_by: { uuid: "u1", first_name: "Shy", last_name: "Alter" }, + latest_run: null, +} as unknown as Task; + +// Every conversation row is attributed to the task's creator, which is exactly why +// an author-derived accessible name would be identical on all of them. +const conversationItems = [ + { + type: "user_message" as const, + id: "turn-1-1-user", + content: "first thing\nand more detail", + timestamp: Date.parse("2026-07-17T09:05:00Z"), + }, + { + type: "user_message" as const, + id: "turn-2-2-user", + content: "second thing", + timestamp: Date.parse("2026-07-17T09:10:00Z"), + }, +]; + +function renderTimeline(canOpenInPlace?: boolean) { + return render( + {}} + onDelete={() => {}} + />, + ); +} + +beforeEach(() => { + useThreadNavigationStore.setState({ scrollRequests: {} }); +}); + +describe("ActivityTimeline", () => { + it("names each message row by its own content, not a shared template", () => { + renderTimeline(true); + + expect(screen.getAllByRole("button")).toHaveLength(2); + // `name` here is the computed accessible name, so this is what a screen reader + // announces: each row carries the content a sighted user sees, which is what + // makes the two distinguishable — the author is the same on every row. + expect( + screen.getByRole("button", { name: /Shy Alter.*first thing/ }), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /Shy Alter.*second thing/ }), + ).toBeInTheDocument(); + // The avatar is decorative, so its initials stay out of the name. + expect(screen.queryByRole("button", { name: /SA/ })).toBeNull(); + }); + + it("asks the transcript to scroll to the clicked message", () => { + renderTimeline(true); + + fireEvent.click(screen.getAllByRole("button")[1]); + + expect(useThreadNavigationStore.getState().scrollRequests["task-1"]).toBe( + "turn-2-2-user", + ); + }); + + it("leaves rows inert with no transcript alongside to drive", () => { + renderTimeline(); + + expect(screen.queryAllByRole("button")).toHaveLength(0); + expect(screen.getByText(/first thing/)).toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/features/canvas/components/ActivityTimeline.tsx b/packages/ui/src/features/canvas/components/ActivityTimeline.tsx index c8846bd6ed..aaf59e4acc 100644 --- a/packages/ui/src/features/canvas/components/ActivityTimeline.tsx +++ b/packages/ui/src/features/canvas/components/ActivityTimeline.tsx @@ -1,6 +1,11 @@ -import { CheckCircleIcon, XCircleIcon } from "@phosphor-icons/react"; +import { + CheckCircleIcon, + PlusCircleIcon, + XCircleIcon, +} from "@phosphor-icons/react"; import type { ThreadTimelineRow } from "@posthog/core/canvas/threadTimeline"; import { + cn, ThreadItem, ThreadItemAuthor, ThreadItemBody, @@ -23,70 +28,90 @@ import { import { ThreadTimestamp } from "@posthog/ui/features/canvas/components/ThreadTimestamp"; import { userDisplayName } from "@posthog/ui/features/canvas/utils/userDisplay"; import type { buildConversationItems } from "@posthog/ui/features/sessions/components/buildConversationItems"; -import { Fragment, type ReactNode, useMemo } from "react"; +import { useThreadNavigationStore } from "@posthog/ui/features/sessions/threadNavigationStore"; +import { Fragment, type KeyboardEvent, type ReactNode, useMemo } from "react"; type ConversationItem = ReturnType< typeof buildConversationItems >["items"][number]; +/** A lifecycle marker (task created, run finished): an icon bubble and a single + * line, in the same size and colour as every other row's copy. Only the icon + * distinguishes it, so the pane reads as one typographic system. */ function ActivityEventRow({ - node, - title, - action, + icon, + label, timestamp, }: { - node: ReactNode; - title: string; - action?: string; + icon: ReactNode; + label: string; timestamp: string; }) { return (
-
-
{node}
+
+ + {icon} +
- - {title} - {action && {action}} - + {label}
); } -function EventNode({ icon }: { icon: ReactNode }) { - return ( - - {icon} - - ); -} - function UserMessageRow({ author, content, timestamp, + onSelect, }: { author?: UserBasic | null; content: string; timestamp: string; + /** Jumps the transcript to this message. Absent once the run is unavailable. */ + onSelect?: () => void; }) { + const name = author ? userDisplayName(author) : "You"; + // The row itself is the hit target. `ThreadItem` renders an
, which a + // + {onOpenExternal && ( + )} - {external && ( - - )} - +
); } -function PrRow({ url, taskId }: { url: string; taskId: string }) { +function PrRow({ + url, + openInPlaceTaskId, +}: { + url: string; + openInPlaceTaskId?: string; +}) { const { safeUrl, title, stateLabel, Icon, iconColor } = usePrArtifact(url); - const setReviewMode = useReviewNavigationStore((s) => s.setReviewMode); - const setSelectedPrUrl = useReviewNavigationStore((s) => s.setSelectedPrUrl); const [countsWanted, setCountsWanted] = useState(false); const comments = usePrComments(countsWanted ? safeUrl : null); @@ -197,12 +218,13 @@ function PrRow({ url, taskId }: { url: string; taskId: string }) { onHoverStart={() => setCountsWanted(true)} onOpen={ safeUrl - ? () => { - setSelectedPrUrl(taskId, safeUrl); - setReviewMode(taskId, "split"); - } + ? () => + openInPlaceTaskId + ? openPrInReview(openInPlaceTaskId, safeUrl) + : openExternalUrl(safeUrl) : undefined } + onOpenExternal={safeUrl ? () => openExternalUrl(safeUrl) : undefined} /> ); } @@ -271,9 +293,13 @@ function FileRow({ export function TaskArtifactsList({ task, timeline, + canOpenInPlace, }: { task: Task; timeline: ThreadTimelineRow[]; + /** See `ActivityTimeline` — without the task's own view alongside, a PR has to + * open externally rather than into a review pane nobody is showing. */ + canOpenInPlace?: boolean; }) { const { runs } = useTaskRuns(task.id); const rows = useMemo( @@ -302,7 +328,11 @@ export function TaskArtifactsList({
{rows.map((row) => row.kind === "pr" ? ( - + ) : row.kind === "canvas" ? ( ) : row.kind === "file" ? ( diff --git a/packages/ui/src/features/canvas/components/ThreadPanel.test.tsx b/packages/ui/src/features/canvas/components/ThreadPanel.test.tsx index eb3d49c5e1..35b16ac5cd 100644 --- a/packages/ui/src/features/canvas/components/ThreadPanel.test.tsx +++ b/packages/ui/src/features/canvas/components/ThreadPanel.test.tsx @@ -1,3 +1,4 @@ +import { useReviewNavigationStore } from "@posthog/ui/features/code-review/reviewNavigationStore"; import { fireEvent, render, screen } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { @@ -68,6 +69,33 @@ describe("ThreadMessageRow", () => { screen.getByRole("button", { name: "Message actions" }), ).toBeInTheDocument(); }); + + const multiline = "First line\n\nSecond line with more detail"; + + // `preview` only adds a CSS clamp, so the full message is in the DOM either + // way. What's worth pinning is that no code path slices the text away. + it("renders the whole message, so a comment is never cut short", () => { + render( + {}} + onDelete={() => {}} + />, + ); + + expect( + screen.getByText(/Second line with more detail/), + ).toBeInTheDocument(); + }); }); describe("ThreadArtifactRow", () => { @@ -80,6 +108,7 @@ describe("ThreadArtifactRow", () => { url: "https://us.posthog.com/code/canvas/channel-1/dash-1", }} createdAt="2026-07-17T00:00:00Z" + openInPlaceTaskId="task-1" />, ); @@ -101,6 +130,7 @@ describe("ThreadArtifactRow", () => { , ); @@ -117,6 +147,7 @@ describe("ThreadArtifactRow", () => { , ); @@ -126,21 +157,32 @@ describe("ThreadArtifactRow", () => { expect(navigateToShareTarget).not.toHaveBeenCalled(); }); - it("renders a pull request artifact and opens it externally", () => { + it("opens a pull request in the review pane, and on GitHub from its own button", () => { + const url = "https://github.com/org/repo/pull/123"; + render( , ); expect(screen.getByText("Pull request #123")).toBeInTheDocument(); - fireEvent.click(screen.getByRole("button", { name: /Pull request #123/ })); + // The card's own name leads with the title; the GitHub button's trails it. + fireEvent.click(screen.getByRole("button", { name: /^Pull request #123/ })); + + const review = useReviewNavigationStore.getState(); + expect(review.selectedPrUrls["task-1"]).toBe(url); + expect(review.reviewModes["task-1"]).toBe("split"); + expect(openExternalUrl).not.toHaveBeenCalled(); - expect(openExternalUrl).toHaveBeenCalledWith( - "https://github.com/org/repo/pull/123", + fireEvent.click( + screen.getByRole("button", { name: "Open Pull request #123 externally" }), ); + + expect(openExternalUrl).toHaveBeenCalledWith(url); expect(navigateToShareTarget).not.toHaveBeenCalled(); }); @@ -162,6 +204,7 @@ describe("ThreadArtifactRow", () => { , ); @@ -197,6 +240,7 @@ describe("ThreadArtifactRow", () => { , ); diff --git a/packages/ui/src/features/canvas/components/ThreadPanel.tsx b/packages/ui/src/features/canvas/components/ThreadPanel.tsx index 1070a6ae0b..807f3fbd01 100644 --- a/packages/ui/src/features/canvas/components/ThreadPanel.tsx +++ b/packages/ui/src/features/canvas/components/ThreadPanel.tsx @@ -17,6 +17,7 @@ import { AvatarFallback, Badge, Button, + cn, DropdownMenu, DropdownMenuContent, DropdownMenuItem, @@ -52,6 +53,7 @@ import { MentionText } from "@posthog/ui/features/canvas/components/MentionText" import { ThreadTimestamp } from "@posthog/ui/features/canvas/components/ThreadTimestamp"; import { useThreadConversation } from "@posthog/ui/features/canvas/hooks/useThreadConversation"; import { userDisplayName } from "@posthog/ui/features/canvas/utils/userDisplay"; +import { openPrInReview } from "@posthog/ui/features/code-review/openPrInReview"; import { usePrArtifact } from "@posthog/ui/features/git-interaction/usePrArtifact"; import { taskDetailQuery } from "@posthog/ui/features/tasks/queries"; import { openExternalUrl } from "@posthog/ui/shell/openExternal"; @@ -67,6 +69,7 @@ export function ThreadMessageRow({ isOwnMessage, currentUserEmail, canForward, + preview, onSendToAgent, onDelete, }: { @@ -75,6 +78,8 @@ export function ThreadMessageRow({ isOwnMessage: boolean; currentUserEmail?: string | null; canForward: boolean; + /** Timeline rows show one truncated line; the Comments tab shows it all. */ + preview?: boolean; onSendToAgent: () => void; onDelete: () => void; }) { @@ -83,15 +88,24 @@ export function ThreadMessageRow({ return ( - - + + - {userDisplayName(message.author)} + + {userDisplayName(message.author)} + - + void; + /** Renders a trailing button that leaves the app instead of opening the + * artifact in place. Absent when there is nowhere safe to send the user. */ + onOpenExternal?: () => void; }) { const body = ( <> @@ -174,19 +192,35 @@ function ArtifactCardButton({ )} ); - const cardClass = - "flex w-fit max-w-full items-center gap-2 rounded-md border border-border bg-muted px-2 py-1.5 text-[13px]"; - if (!onOpen) { - return {body}; - } + const innerClass = "flex min-w-0 items-center gap-2 px-2 py-1.5"; return ( - + // overflow-hidden so each half's hover fill is clipped to the card's radius. +
+ {onOpen ? ( + + ) : ( + {body} + )} + {onOpenExternal && ( + + )} +
); } @@ -222,7 +256,13 @@ function CanvasArtifactCard({ ); } -function PrArtifactCard({ url }: { url: string }) { +function PrArtifactCard({ + url, + openInPlaceTaskId, +}: { + url: string; + openInPlaceTaskId?: string; +}) { const { safeUrl, title, stateLabel, Icon, iconColor } = usePrArtifact(url); return ( openExternalUrl(safeUrl) : undefined} + onOpen={ + safeUrl + ? () => + openInPlaceTaskId + ? openPrInReview(openInPlaceTaskId, safeUrl) + : openExternalUrl(safeUrl) + : undefined + } + onOpenExternal={safeUrl ? () => openExternalUrl(safeUrl) : undefined} /> ); } @@ -244,31 +292,37 @@ function PrArtifactCard({ url }: { url: string }) { export function ThreadArtifactRow({ artifact, createdAt, + openInPlaceTaskId, }: { artifact: ThreadArtifact; createdAt: string; + /** Task whose review pane is mounted alongside; absent means open externally. */ + openInPlaceTaskId?: string; }) { return ( - - + + - + - + {artifact.kind === "canvas" ? "Canvas" : "Pull request"} - + {artifact.kind === "canvas" ? ( ) : ( - + )} diff --git a/packages/ui/src/features/canvas/components/ThreadSidebar.tsx b/packages/ui/src/features/canvas/components/ThreadSidebar.tsx index 1dfa13cb29..03255bffc0 100644 --- a/packages/ui/src/features/canvas/components/ThreadSidebar.tsx +++ b/packages/ui/src/features/canvas/components/ThreadSidebar.tsx @@ -17,6 +17,7 @@ export function ThreadSidebar({ onClose, onOpenFull, showTaskSummary, + canOpenInPlace, }: { taskId: string; channelId: string; @@ -25,6 +26,9 @@ export function ThreadSidebar({ onClose?: () => void; onOpenFull?: () => void; showTaskSummary?: boolean; + /** Set where the task's own view (transcript, review pane) is mounted beside + * this dock, so activity rows can drive it instead of going nowhere. */ + canOpenInPlace?: boolean; }) { const collapsed = useThreadPanelStore((s) => s.collapsed); const width = useThreadPanelStore((s) => s.width); @@ -42,6 +46,15 @@ export function ThreadSidebar({ task_id: taskId, }); }; + const panelProps = { + taskId, + channelId, + task, + onClose, + onToggleCollapsed: () => toggleCollapsed(true), + onOpenFull, + showTaskSummary, + }; if (collapsed) { return ( @@ -64,15 +77,11 @@ export function ThreadSidebar({ setIsResizing={setIsResizing} side="right" > - toggleCollapsed(true)} - onOpenFull={onOpenFull} - showTaskSummary={showTaskSummary} - /> + {channelsLayout ? ( + + ) : ( + + )} ); } diff --git a/packages/ui/src/features/canvas/components/ThreadTimestamp.tsx b/packages/ui/src/features/canvas/components/ThreadTimestamp.tsx index ef754f19e8..6d4a9483ed 100644 --- a/packages/ui/src/features/canvas/components/ThreadTimestamp.tsx +++ b/packages/ui/src/features/canvas/components/ThreadTimestamp.tsx @@ -24,6 +24,10 @@ function formatTooltip(date: Date): string { return `${month} ${ordinal(date.getDate())} at ${formatClock(date)}`; } +// Sits next to the actor rather than out at the row's right edge, a step below the +// 13px row copy. Sized here rather than by an ancestor +// `[data-slot=thread-item-timestamp]` rule: `TooltipTrigger` replaces the wrapped +// element's `data-slot` with its own, so such a rule never matches. export function ThreadTimestamp({ dateTime }: { dateTime: string }) { const date = new Date(dateTime); if (Number.isNaN(date.getTime())) return null; @@ -33,7 +37,10 @@ export function ThreadTimestamp({ dateTime }: { dateTime: string }) { + {formatClock(date)} } diff --git a/packages/ui/src/features/code-review/openPrInReview.ts b/packages/ui/src/features/code-review/openPrInReview.ts new file mode 100644 index 0000000000..88c425e698 --- /dev/null +++ b/packages/ui/src/features/code-review/openPrInReview.ts @@ -0,0 +1,10 @@ +import { useReviewNavigationStore } from "@posthog/ui/features/code-review/reviewNavigationStore"; + +/** Open a PR in the task's in-app review pane. Callers that also offer an + * "open on GitHub" affordance pair this with `openExternalUrl`. */ +export function openPrInReview(taskId: string, safeUrl: string): void { + const { setSelectedPrUrl, setReviewMode } = + useReviewNavigationStore.getState(); + setSelectedPrUrl(taskId, safeUrl); + setReviewMode(taskId, "split"); +} diff --git a/packages/ui/src/features/sessions/components/ConversationView.tsx b/packages/ui/src/features/sessions/components/ConversationView.tsx index b19331cfa0..6adcfcf522 100644 --- a/packages/ui/src/features/sessions/components/ConversationView.tsx +++ b/packages/ui/src/features/sessions/components/ConversationView.tsx @@ -64,6 +64,7 @@ import { useGroupOverrides, useSessionViewActions, } from "@posthog/ui/features/sessions/sessionViewStore"; +import { useThreadScrollRequest } from "@posthog/ui/features/sessions/threadNavigationStore"; 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"; @@ -353,6 +354,10 @@ export function ConversationView({ [userMessages, scrollToUserMessage], ); + // The Activity timeline lives in a sibling pane, so it asks for the jump + // through the store rather than reaching in here. + useThreadScrollRequest(taskId, handleJumpToMessage); + const handleScrollStateChange = useCallback((isAtBottom: boolean) => { isAtBottomRef.current = isAtBottom; setShowScrollButton(!isAtBottom); 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 15c7019086..6a80be72ab 100644 --- a/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx +++ b/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx @@ -90,6 +90,7 @@ import { useOptimisticItemsForTask, useSessionIsCloud, } from "@posthog/ui/features/sessions/sessionStore"; +import { useThreadScrollRequest } from "@posthog/ui/features/sessions/threadNavigationStore"; import type { UserMessageAttachment } from "@posthog/ui/features/sessions/userMessageTypes"; import { SessionTaskIdProvider, @@ -965,6 +966,21 @@ export interface ChatThreadProps extends SharedChatThreadProps { events: AgentConversationEvent[]; } +/** Serves scroll-to-message requests from panes outside this tree (the Activity + * timeline). Sits inside `ChatMessageScrollerProvider` so it can fall back to the + * engine's `scrollToMessage`, which the windowed body's own jump replaces. */ +function ThreadScrollRequestBridge({ + taskId, + jumpToMessage, +}: { + taskId?: string; + jumpToMessage?: (id: string) => void; +}) { + const { scrollToMessage } = useChatMessageScroller(); + useThreadScrollRequest(taskId, jumpToMessage ?? scrollToMessage); + return null; +} + export interface AcpChatThreadProps extends SharedChatThreadProps { events: AcpMessage[]; } @@ -1154,15 +1170,21 @@ function ChatThreadRenderer({ // The nav layer sits beside the scroll body so it can be handed the windowed body's jump // implementation — the engine's `scrollToMessage` only reaches mounted rows. const renderNav = (jumpToMessage?: (id: string) => void) => ( - + <> + + + ); return ( diff --git a/packages/ui/src/features/sessions/threadNavigationStore.test.ts b/packages/ui/src/features/sessions/threadNavigationStore.test.ts new file mode 100644 index 0000000000..22ccbc91b6 --- /dev/null +++ b/packages/ui/src/features/sessions/threadNavigationStore.test.ts @@ -0,0 +1,46 @@ +import { act, renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + useThreadNavigationStore, + useThreadScrollRequest, +} from "./threadNavigationStore"; + +beforeEach(() => { + useThreadNavigationStore.setState({ scrollRequests: {} }); +}); + +describe("useThreadScrollRequest", () => { + it("serves a request from another pane and consumes it", () => { + const jump = vi.fn(); + renderHook(() => useThreadScrollRequest("task-1", jump)); + + act(() => { + useThreadNavigationStore + .getState() + .requestScrollToMessage("task-1", "turn-10-1-user"); + }); + + expect(jump).toHaveBeenCalledWith("turn-10-1-user"); + // Cleared, so re-rendering for any other reason can't re-fire the jump and + // yank the transcript back. + expect(useThreadNavigationStore.getState().scrollRequests["task-1"]).toBe( + null, + ); + }); + + it("re-fires when the same message is requested again", () => { + const jump = vi.fn(); + renderHook(() => useThreadScrollRequest("task-1", jump)); + const request = () => + act(() => { + useThreadNavigationStore + .getState() + .requestScrollToMessage("task-1", "turn-10-1-user"); + }); + + request(); + request(); + + expect(jump).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/ui/src/features/sessions/threadNavigationStore.ts b/packages/ui/src/features/sessions/threadNavigationStore.ts new file mode 100644 index 0000000000..3e8fb3681f --- /dev/null +++ b/packages/ui/src/features/sessions/threadNavigationStore.ts @@ -0,0 +1,59 @@ +import { useEffect } from "react"; +import { create } from "zustand"; + +interface ThreadNavigationStoreState { + /** taskId → conversation item id the transcript should scroll to, if any. */ + scrollRequests: Record; +} + +interface ThreadNavigationStoreActions { + requestScrollToMessage: (taskId: string, messageId: string) => void; + clearScrollRequest: (taskId: string) => void; +} + +type ThreadNavigationStore = ThreadNavigationStoreState & + ThreadNavigationStoreActions; + +/** + * Lets a pane outside the transcript ask it to scroll to a message — the + * Activity timeline sits in a sibling tree, so it can't reach the scroller's + * context or the windowed body's jump callback directly. Mirrors + * `reviewNavigationStore`'s scroll-request shape: the writer sets a request, the + * transcript consumes it and clears it. + */ +export const useThreadNavigationStore = create()( + (set) => ({ + scrollRequests: {}, + + requestScrollToMessage: (taskId, messageId) => + set((state) => ({ + scrollRequests: { ...state.scrollRequests, [taskId]: messageId }, + })), + + clearScrollRequest: (taskId) => + set((state) => ({ + scrollRequests: { ...state.scrollRequests, [taskId]: null }, + })), + }), +); + +/** + * Consumes a pending request for this task, handing it to the transcript's own + * jump and clearing it. Each transcript jumps differently (DOM registry, + * virtualizer index, grouped-row index), so the store carries only the target. + */ +export function useThreadScrollRequest( + taskId: string | undefined, + jumpToMessage: (messageId: string) => void, +): void { + const requestedMessageId = useThreadNavigationStore((state) => + taskId ? state.scrollRequests[taskId] : null, + ); + + useEffect(() => { + if (!taskId || !requestedMessageId) return; + jumpToMessage(requestedMessageId); + // Clear via getState so the action isn't an effect dependency. + useThreadNavigationStore.getState().clearScrollRequest(taskId); + }, [taskId, requestedMessageId, jumpToMessage]); +} 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 6491430682..b3c8bfb174 100644 --- a/packages/ui/src/router/routes/website/$channelId/tasks/$taskId.tsx +++ b/packages/ui/src/router/routes/website/$channelId/tasks/$taskId.tsx @@ -119,6 +119,7 @@ function ChannelTaskDetailRoute() { channelId={channelId} task={task} showTaskSummary={false} + canOpenInPlace />
);