Skip to content

Commit d5e98e0

Browse files
committed
feat(sessions): Make the queued-messages dock scrollable and collapsible
A long queue of follow-ups no longer pushes the composer off-screen or crops it: the dock is height-capped and scrolls internally, and a header toggle collapses it. Adds per-task collapse view-state in sessionViewStore. Generated-By: PostHog Code Task-Id: 36c7f9ac-946a-403b-8b7f-c2dd5e3f66fd
1 parent 7e3e9f6 commit d5e98e0

4 files changed

Lines changed: 216 additions & 27 deletions

File tree

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import { Theme } from "@radix-ui/themes";
2+
import { fireEvent, render, screen } from "@testing-library/react";
3+
import { beforeEach, describe, expect, it, vi } from "vitest";
4+
5+
const queuedState = vi.hoisted(() => ({
6+
messages: [] as Array<{ id: string; content: string; queuedAt: number }>,
7+
}));
8+
9+
vi.mock("@posthog/core/sessions/sessionService", () => ({
10+
SESSION_SERVICE: Symbol.for("test.session-service"),
11+
}));
12+
13+
vi.mock("@posthog/di/react", () => ({
14+
useService: () => ({
15+
steerQueuedMessage: vi.fn().mockResolvedValue(undefined),
16+
}),
17+
}));
18+
19+
vi.mock("@posthog/ui/features/sessions/useSession", () => ({
20+
useQueuedMessagesForTask: () => queuedState.messages,
21+
}));
22+
23+
vi.mock("@posthog/ui/features/sessions/hooks/useMessagingMode", () => ({
24+
useSupportsNativeSteer: () => false,
25+
}));
26+
27+
vi.mock(
28+
"@posthog/ui/features/sessions/hooks/useReturnQueuedMessageToEditor",
29+
() => ({
30+
useReturnQueuedMessageToEditor: () => vi.fn(),
31+
}),
32+
);
33+
34+
vi.mock("@posthog/ui/features/sessions/sessionStore", () => ({
35+
sessionStoreSetters: { removeQueuedMessage: vi.fn() },
36+
useSessionForTask: () => ({ isCompacting: false }),
37+
}));
38+
39+
vi.mock("@posthog/ui/primitives/toast", () => ({
40+
toast: { error: vi.fn() },
41+
}));
42+
43+
// Stub the per-message card so the test exercises the dock's collapse/scroll
44+
// shell, not the markdown/steer internals it already owns.
45+
vi.mock(
46+
"@posthog/ui/features/sessions/components/session-update/QueuedMessageView",
47+
async () => {
48+
const React = await import("react");
49+
return {
50+
QueuedMessageView: ({ message }: { message: { content: string } }) =>
51+
React.createElement(
52+
"div",
53+
{ "data-testid": "queued-card" },
54+
message.content,
55+
),
56+
};
57+
},
58+
);
59+
60+
import { QueuedMessagesDock } from "./QueuedMessagesDock";
61+
62+
const TWO_MESSAGES = [
63+
{ id: "q1", content: "first queued message", queuedAt: 1 },
64+
{ id: "q2", content: "second queued message", queuedAt: 2 },
65+
];
66+
67+
// Each test uses a distinct taskId so the (real, per-task) collapse state in
68+
// sessionViewStore never bleeds between cases.
69+
function renderDock(taskId: string) {
70+
return render(
71+
<Theme>
72+
<QueuedMessagesDock taskId={taskId} />
73+
</Theme>,
74+
);
75+
}
76+
77+
describe("QueuedMessagesDock", () => {
78+
beforeEach(() => {
79+
queuedState.messages = [];
80+
});
81+
82+
it("renders nothing when the queue is empty", () => {
83+
queuedState.messages = [];
84+
const { container } = render(<QueuedMessagesDock taskId="task-empty" />);
85+
expect(container).toBeEmptyDOMElement();
86+
});
87+
88+
it("is expanded by default and shows every queued message with a count", () => {
89+
queuedState.messages = TWO_MESSAGES;
90+
renderDock("task-expanded");
91+
92+
expect(screen.getByText("first queued message")).toBeInTheDocument();
93+
expect(screen.getByText("second queued message")).toBeInTheDocument();
94+
expect(screen.getByText("2 queued")).toBeInTheDocument();
95+
expect(
96+
screen.getByRole("button", { name: "Collapse queued messages" }),
97+
).toHaveAttribute("aria-expanded", "true");
98+
});
99+
100+
it("caps the list height and scrolls so it can't push the composer down", () => {
101+
queuedState.messages = TWO_MESSAGES;
102+
const { container } = renderDock("task-scroll");
103+
104+
const scroller = container.querySelector(".overflow-y-auto");
105+
expect(scroller).not.toBeNull();
106+
expect(scroller?.classList.contains("max-h-[30vh]")).toBe(true);
107+
});
108+
109+
it("collapses and expands the list when the header is toggled", () => {
110+
queuedState.messages = TWO_MESSAGES;
111+
renderDock("task-toggle");
112+
113+
expect(screen.getAllByTestId("queued-card")).toHaveLength(2);
114+
115+
fireEvent.click(
116+
screen.getByRole("button", { name: "Collapse queued messages" }),
117+
);
118+
119+
// Collapsed: cards are hidden, but the header with the live count stays.
120+
expect(screen.queryAllByTestId("queued-card")).toHaveLength(0);
121+
expect(screen.getByText("2 queued")).toBeInTheDocument();
122+
const trigger = screen.getByRole("button", {
123+
name: "Expand queued messages",
124+
});
125+
expect(trigger).toHaveAttribute("aria-expanded", "false");
126+
127+
fireEvent.click(trigger);
128+
expect(screen.getAllByTestId("queued-card")).toHaveLength(2);
129+
});
130+
});
Lines changed: 68 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { CaretDown, CaretRight, Stack } from "@phosphor-icons/react";
12
import {
23
SESSION_SERVICE,
34
type SessionService,
@@ -10,9 +11,14 @@ import {
1011
sessionStoreSetters,
1112
useSessionForTask,
1213
} from "@posthog/ui/features/sessions/sessionStore";
14+
import {
15+
useQueueCollapsed,
16+
useSessionViewActions,
17+
} from "@posthog/ui/features/sessions/sessionViewStore";
1318
import { useQueuedMessagesForTask } from "@posthog/ui/features/sessions/useSession";
1419
import { toast } from "@posthog/ui/primitives/toast";
15-
import { Flex } from "@radix-ui/themes";
20+
import * as Collapsible from "@radix-ui/react-collapsible";
21+
import { Box, Flex, Text } from "@radix-ui/themes";
1622

1723
interface QueuedMessagesDockProps {
1824
taskId: string;
@@ -22,6 +28,9 @@ interface QueuedMessagesDockProps {
2228
* Queued follow-ups pinned directly above the composer (outside the scrolling
2329
* thread) with per-message actions: steer it into the running turn now, return
2430
* it to the composer to re-read or edit, or discard it.
31+
*
32+
* The list is bounded and scrolls internally so a long queue never pushes the
33+
* composer down or off-screen, and a header toggle lets the user collapse it.
2534
*/
2635
export function QueuedMessagesDock({ taskId }: QueuedMessagesDockProps) {
2736
const queued = useQueuedMessagesForTask(taskId);
@@ -30,35 +39,68 @@ export function QueuedMessagesDock({ taskId }: QueuedMessagesDockProps) {
3039
const returnToEditor = useReturnQueuedMessageToEditor(taskId);
3140
// Steer can't inject mid-compaction, so it would be a silent no-op; hide it.
3241
const isCompacting = useSessionForTask(taskId)?.isCompacting ?? false;
42+
const collapsed = useQueueCollapsed(taskId);
43+
const { setQueueCollapsed } = useSessionViewActions();
3344

3445
if (queued.length === 0) return null;
3546

47+
const isOpen = !collapsed;
48+
3649
return (
37-
<Flex direction="column" gap="1" className="mb-1">
38-
{queued.map((message) => (
39-
<QueuedMessageView
40-
key={message.id}
41-
message={message}
42-
supportsNativeSteer={supportsNativeSteer}
43-
onSteer={
44-
isCompacting
45-
? undefined
46-
: () => {
47-
void sessionService
48-
.steerQueuedMessage(taskId, message.id)
49-
.catch(() => {
50-
toast.error(
51-
"Couldn't steer this message. It's still queued.",
52-
);
53-
});
54-
}
50+
<Collapsible.Root
51+
open={isOpen}
52+
onOpenChange={(next) => setQueueCollapsed(taskId, !next)}
53+
className="mb-1"
54+
>
55+
<Collapsible.Trigger asChild>
56+
<button
57+
type="button"
58+
aria-label={
59+
isOpen ? "Collapse queued messages" : "Expand queued messages"
5560
}
56-
onReturnToEditor={() => returnToEditor(message)}
57-
onRemove={() =>
58-
sessionStoreSetters.removeQueuedMessage(taskId, message.id)
59-
}
60-
/>
61-
))}
62-
</Flex>
61+
className="flex w-full items-center gap-2 rounded-sm px-1 py-0.5 text-left hover:bg-gray-3"
62+
>
63+
{isOpen ? (
64+
<CaretDown size={12} className="text-gray-10" />
65+
) : (
66+
<CaretRight size={12} className="text-gray-10" />
67+
)}
68+
<Stack size={14} className="shrink-0 text-gray-9" />
69+
<Text className="font-medium text-[13px] text-gray-11">
70+
{queued.length} queued
71+
</Text>
72+
</button>
73+
</Collapsible.Trigger>
74+
<Collapsible.Content>
75+
<Box className="max-h-[30vh] overflow-y-auto">
76+
<Flex direction="column" gap="1">
77+
{queued.map((message) => (
78+
<QueuedMessageView
79+
key={message.id}
80+
message={message}
81+
supportsNativeSteer={supportsNativeSteer}
82+
onSteer={
83+
isCompacting
84+
? undefined
85+
: () => {
86+
void sessionService
87+
.steerQueuedMessage(taskId, message.id)
88+
.catch(() => {
89+
toast.error(
90+
"Couldn't steer this message. It's still queued.",
91+
);
92+
});
93+
}
94+
}
95+
onReturnToEditor={() => returnToEditor(message)}
96+
onRemove={() =>
97+
sessionStoreSetters.removeQueuedMessage(taskId, message.id)
98+
}
99+
/>
100+
))}
101+
</Flex>
102+
</Box>
103+
</Collapsible.Content>
104+
</Collapsible.Root>
63105
);
64106
}

packages/ui/src/features/sessions/components/SessionView.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -583,7 +583,7 @@ export function SessionView({
583583
</Box>
584584
</Box>
585585
) : (
586-
<Box className="relative">
586+
<Box className="relative shrink-0">
587587
<Box
588588
className={`absolute inset-0 flex min-h-[66px] items-center justify-center gap-2 transition-opacity duration-200 ${
589589
isRunning

packages/ui/src/features/sessions/sessionViewStore.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,12 @@ interface SessionViewState {
1010
* global collapse mode. Not persisted; wiped when the global mode changes.
1111
*/
1212
groupOverrides: Record<string, boolean>;
13+
/**
14+
* Ephemeral per-task collapse of the queued-messages dock, keyed by taskId.
15+
* `true` = collapsed; absent/`false` = expanded (the default). Not persisted;
16+
* resets to expanded on app restart.
17+
*/
18+
queueCollapsedByTaskId: Record<string, boolean>;
1319
}
1420

1521
interface SessionViewActions {
@@ -18,6 +24,7 @@ interface SessionViewActions {
1824
toggleSearch: () => void;
1925
setGroupOverride: (id: string, expanded: boolean) => void;
2026
clearGroupOverrides: () => void;
27+
setQueueCollapsed: (taskId: string, collapsed: boolean) => void;
2128
}
2229

2330
type SessionViewStore = SessionViewState & { actions: SessionViewActions };
@@ -27,6 +34,7 @@ const useStore = create<SessionViewStore>((set) => ({
2734
searchQuery: "",
2835
showSearch: false,
2936
groupOverrides: {},
37+
queueCollapsedByTaskId: {},
3038
actions: {
3139
setShowRawLogs: (show) => set({ showRawLogs: show }),
3240
setSearchQuery: (query) => set({ searchQuery: query }),
@@ -45,11 +53,20 @@ const useStore = create<SessionViewStore>((set) => ({
4553
? state
4654
: { groupOverrides: {} },
4755
),
56+
setQueueCollapsed: (taskId, collapsed) =>
57+
set((state) => ({
58+
queueCollapsedByTaskId: {
59+
...state.queueCollapsedByTaskId,
60+
[taskId]: collapsed,
61+
},
62+
})),
4863
},
4964
}));
5065

5166
export const useShowRawLogs = () => useStore((s) => s.showRawLogs);
5267
export const useSearchQuery = () => useStore((s) => s.searchQuery);
5368
export const useShowSearch = () => useStore((s) => s.showSearch);
5469
export const useGroupOverrides = () => useStore((s) => s.groupOverrides);
70+
export const useQueueCollapsed = (taskId: string) =>
71+
useStore((s) => s.queueCollapsedByTaskId[taskId] ?? false);
5572
export const useSessionViewActions = () => useStore((s) => s.actions);

0 commit comments

Comments
 (0)