diff --git a/packages/ui/src/features/sessions/components/QueuedMessagesDock.test.tsx b/packages/ui/src/features/sessions/components/QueuedMessagesDock.test.tsx new file mode 100644 index 000000000..f39b9c9cb --- /dev/null +++ b/packages/ui/src/features/sessions/components/QueuedMessagesDock.test.tsx @@ -0,0 +1,130 @@ +import { Theme } from "@radix-ui/themes"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const queuedState = vi.hoisted(() => ({ + messages: [] as Array<{ id: string; content: string; queuedAt: number }>, +})); + +vi.mock("@posthog/core/sessions/sessionService", () => ({ + SESSION_SERVICE: Symbol.for("test.session-service"), +})); + +vi.mock("@posthog/di/react", () => ({ + useService: () => ({ + steerQueuedMessage: vi.fn().mockResolvedValue(undefined), + }), +})); + +vi.mock("@posthog/ui/features/sessions/useSession", () => ({ + useQueuedMessagesForTask: () => queuedState.messages, +})); + +vi.mock("@posthog/ui/features/sessions/hooks/useMessagingMode", () => ({ + useSupportsNativeSteer: () => false, +})); + +vi.mock( + "@posthog/ui/features/sessions/hooks/useReturnQueuedMessageToEditor", + () => ({ + useReturnQueuedMessageToEditor: () => vi.fn(), + }), +); + +vi.mock("@posthog/ui/features/sessions/sessionStore", () => ({ + sessionStoreSetters: { removeQueuedMessage: vi.fn() }, + useSessionForTask: () => ({ isCompacting: false }), +})); + +vi.mock("@posthog/ui/primitives/toast", () => ({ + toast: { error: vi.fn() }, +})); + +// Stub the per-message card so the test exercises the dock's collapse/scroll +// shell, not the markdown/steer internals it already owns. +vi.mock( + "@posthog/ui/features/sessions/components/session-update/QueuedMessageView", + async () => { + const React = await import("react"); + return { + QueuedMessageView: ({ message }: { message: { content: string } }) => + React.createElement( + "div", + { "data-testid": "queued-card" }, + message.content, + ), + }; + }, +); + +import { QueuedMessagesDock } from "./QueuedMessagesDock"; + +const TWO_MESSAGES = [ + { id: "q1", content: "first queued message", queuedAt: 1 }, + { id: "q2", content: "second queued message", queuedAt: 2 }, +]; + +// Each test uses a distinct taskId so the (real, per-task) collapse state in +// sessionViewStore never bleeds between cases. +function renderDock(taskId: string) { + return render( + + + , + ); +} + +describe("QueuedMessagesDock", () => { + beforeEach(() => { + queuedState.messages = []; + }); + + it("renders nothing when the queue is empty", () => { + queuedState.messages = []; + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it("is expanded by default and shows every queued message with a count", () => { + queuedState.messages = TWO_MESSAGES; + renderDock("task-expanded"); + + expect(screen.getByText("first queued message")).toBeInTheDocument(); + expect(screen.getByText("second queued message")).toBeInTheDocument(); + expect(screen.getByText("2 queued")).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Collapse queued messages" }), + ).toHaveAttribute("aria-expanded", "true"); + }); + + it("caps the list height and scrolls so it can't push the composer down", () => { + queuedState.messages = TWO_MESSAGES; + const { container } = renderDock("task-scroll"); + + const scroller = container.querySelector(".overflow-y-auto"); + expect(scroller).not.toBeNull(); + expect(scroller?.classList.contains("max-h-[30vh]")).toBe(true); + }); + + it("collapses and expands the list when the header is toggled", () => { + queuedState.messages = TWO_MESSAGES; + renderDock("task-toggle"); + + expect(screen.getAllByTestId("queued-card")).toHaveLength(2); + + fireEvent.click( + screen.getByRole("button", { name: "Collapse queued messages" }), + ); + + // Collapsed: cards are hidden, but the header with the live count stays. + expect(screen.queryAllByTestId("queued-card")).toHaveLength(0); + expect(screen.getByText("2 queued")).toBeInTheDocument(); + const trigger = screen.getByRole("button", { + name: "Expand queued messages", + }); + expect(trigger).toHaveAttribute("aria-expanded", "false"); + + fireEvent.click(trigger); + expect(screen.getAllByTestId("queued-card")).toHaveLength(2); + }); +}); diff --git a/packages/ui/src/features/sessions/components/QueuedMessagesDock.tsx b/packages/ui/src/features/sessions/components/QueuedMessagesDock.tsx index fd67138a1..295d610a1 100644 --- a/packages/ui/src/features/sessions/components/QueuedMessagesDock.tsx +++ b/packages/ui/src/features/sessions/components/QueuedMessagesDock.tsx @@ -1,3 +1,4 @@ +import { CaretDown, CaretRight, Stack } from "@phosphor-icons/react"; import { SESSION_SERVICE, type SessionService, @@ -10,9 +11,14 @@ import { sessionStoreSetters, useSessionForTask, } from "@posthog/ui/features/sessions/sessionStore"; +import { + useQueueCollapsed, + useSessionViewActions, +} from "@posthog/ui/features/sessions/sessionViewStore"; import { useQueuedMessagesForTask } from "@posthog/ui/features/sessions/useSession"; import { toast } from "@posthog/ui/primitives/toast"; -import { Flex } from "@radix-ui/themes"; +import * as Collapsible from "@radix-ui/react-collapsible"; +import { Box, Flex, Text } from "@radix-ui/themes"; interface QueuedMessagesDockProps { taskId: string; @@ -22,6 +28,9 @@ interface QueuedMessagesDockProps { * Queued follow-ups pinned directly above the composer (outside the scrolling * thread) with per-message actions: steer it into the running turn now, return * it to the composer to re-read or edit, or discard it. + * + * The list is bounded and scrolls internally so a long queue never pushes the + * composer down or off-screen, and a header toggle lets the user collapse it. */ export function QueuedMessagesDock({ taskId }: QueuedMessagesDockProps) { const queued = useQueuedMessagesForTask(taskId); @@ -30,35 +39,68 @@ export function QueuedMessagesDock({ taskId }: QueuedMessagesDockProps) { const returnToEditor = useReturnQueuedMessageToEditor(taskId); // Steer can't inject mid-compaction, so it would be a silent no-op; hide it. const isCompacting = useSessionForTask(taskId)?.isCompacting ?? false; + const collapsed = useQueueCollapsed(taskId); + const { setQueueCollapsed } = useSessionViewActions(); if (queued.length === 0) return null; + const isOpen = !collapsed; + return ( - - {queued.map((message) => ( - { - void sessionService - .steerQueuedMessage(taskId, message.id) - .catch(() => { - toast.error( - "Couldn't steer this message. It's still queued.", - ); - }); - } + setQueueCollapsed(taskId, !next)} + className="mb-1" + > + + + + + + + {queued.map((message) => ( + { + void sessionService + .steerQueuedMessage(taskId, message.id) + .catch(() => { + toast.error( + "Couldn't steer this message. It's still queued.", + ); + }); + } + } + onReturnToEditor={() => returnToEditor(message)} + onRemove={() => + sessionStoreSetters.removeQueuedMessage(taskId, message.id) + } + /> + ))} + + + + ); } diff --git a/packages/ui/src/features/sessions/components/SessionView.tsx b/packages/ui/src/features/sessions/components/SessionView.tsx index 88cf60b9e..206779260 100644 --- a/packages/ui/src/features/sessions/components/SessionView.tsx +++ b/packages/ui/src/features/sessions/components/SessionView.tsx @@ -583,7 +583,7 @@ export function SessionView({ ) : ( - + ; + /** + * Ephemeral per-task collapse of the queued-messages dock, keyed by taskId. + * `true` = collapsed; absent/`false` = expanded (the default). Not persisted; + * resets to expanded on app restart. + */ + queueCollapsedByTaskId: Record; } interface SessionViewActions { @@ -18,6 +24,7 @@ interface SessionViewActions { toggleSearch: () => void; setGroupOverride: (id: string, expanded: boolean) => void; clearGroupOverrides: () => void; + setQueueCollapsed: (taskId: string, collapsed: boolean) => void; } type SessionViewStore = SessionViewState & { actions: SessionViewActions }; @@ -27,6 +34,7 @@ const useStore = create((set) => ({ searchQuery: "", showSearch: false, groupOverrides: {}, + queueCollapsedByTaskId: {}, actions: { setShowRawLogs: (show) => set({ showRawLogs: show }), setSearchQuery: (query) => set({ searchQuery: query }), @@ -45,6 +53,13 @@ const useStore = create((set) => ({ ? state : { groupOverrides: {} }, ), + setQueueCollapsed: (taskId, collapsed) => + set((state) => ({ + queueCollapsedByTaskId: { + ...state.queueCollapsedByTaskId, + [taskId]: collapsed, + }, + })), }, })); @@ -52,4 +67,6 @@ export const useShowRawLogs = () => useStore((s) => s.showRawLogs); export const useSearchQuery = () => useStore((s) => s.searchQuery); export const useShowSearch = () => useStore((s) => s.showSearch); export const useGroupOverrides = () => useStore((s) => s.groupOverrides); +export const useQueueCollapsed = (taskId: string) => + useStore((s) => s.queueCollapsedByTaskId[taskId] ?? false); export const useSessionViewActions = () => useStore((s) => s.actions);