Skip to content

Commit 9918a6a

Browse files
arthurdedeuscharlesvien
authored andcommitted
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 9f76c9b commit 9918a6a

4 files changed

Lines changed: 220 additions & 27 deletions

File tree

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

1824
interface QueuedMessagesDockProps {
1925
taskId: string;
@@ -23,6 +29,9 @@ interface QueuedMessagesDockProps {
2329
* Queued follow-ups pinned directly above the composer (outside the scrolling
2430
* thread) with per-message actions: steer it into the running turn now, return
2531
* it to the composer to re-read or edit, or discard it.
32+
*
33+
* The list is bounded and scrolls internally so a long queue never pushes the
34+
* composer down or off-screen, and a header toggle lets the user collapse it.
2635
*/
2736
export function QueuedMessagesDock({ taskId }: QueuedMessagesDockProps) {
2837
const queued = useQueuedMessagesForTask(taskId);
@@ -40,35 +49,68 @@ export function QueuedMessagesDock({ taskId }: QueuedMessagesDockProps) {
4049
// Cloud has no real mid-turn steer either (it would just interrupt the turn),
4150
// so hide it there too — the message stays queued and lands next turn.
4251
const canSteer = !isCompacting && !isCloud;
52+
const collapsed = useQueueCollapsed(taskId);
53+
const { setQueueCollapsed } = useSessionViewActions();
4354

4455
if (queued.length === 0) return null;
4556

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

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -632,7 +632,7 @@ export function SessionView({
632632
/>
633633
</ComposerSlot>
634634
) : (
635-
<Box className="relative">
635+
<Box className="relative shrink-0">
636636
<Box
637637
className={`absolute inset-0 flex min-h-[66px] items-center justify-center gap-2 transition-opacity duration-200 ${
638638
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)