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"
+ >
+
+
+ className="flex w-full items-center gap-2 rounded-sm px-1 py-0.5 text-left hover:bg-gray-3"
+ >
+ {isOpen ? (
+
+ ) : (
+
+ )}
+
+
+ {queued.length} queued
+
+
+
+
+
+
+ {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);