Skip to content
This repository was archived by the owner on Aug 6, 2026. It is now read-only.

Commit a4a7436

Browse files
committed
scope queued card drag to its grip handle
1 parent 767b08a commit a4a7436

4 files changed

Lines changed: 295 additions & 57 deletions

File tree

packages/ui/src/features/sessions/components/QueuedMessagesDock.test.tsx

Lines changed: 131 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,30 @@ const queuedState = vi.hoisted(() => ({
66
messages: [] as Array<{ id: string; content: string; queuedAt: number }>,
77
}));
88

9+
const sessionState = vi.hoisted(() => ({
10+
editingQueuedId: undefined as string | undefined,
11+
}));
12+
13+
const sessionService = vi.hoisted(() => ({
14+
steerQueuedMessage: vi.fn().mockResolvedValue(undefined),
15+
clearEditingQueuedMessage: vi.fn(),
16+
}));
17+
18+
const storeSetters = vi.hoisted(() => ({
19+
removeQueuedMessage: vi.fn(),
20+
moveQueuedMessage: vi.fn(),
21+
}));
22+
23+
const dndCapture = vi.hoisted(() => ({
24+
onDragOver: undefined as ((event: unknown) => void) | undefined,
25+
}));
26+
927
vi.mock("@posthog/core/sessions/sessionService", () => ({
1028
SESSION_SERVICE: Symbol.for("test.session-service"),
1129
}));
1230

1331
vi.mock("@posthog/di/react", () => ({
14-
useService: () => ({
15-
steerQueuedMessage: vi.fn().mockResolvedValue(undefined),
16-
}),
32+
useService: () => sessionService,
1733
}));
1834

1935
vi.mock("@posthog/ui/features/sessions/useSession", () => ({
@@ -30,35 +46,73 @@ vi.mock("@posthog/ui/features/sessions/hooks/useEditQueuedMessage", () => ({
3046
}));
3147

3248
vi.mock("@posthog/ui/features/sessions/sessionStore", () => ({
33-
sessionStoreSetters: {
34-
removeQueuedMessage: vi.fn(),
35-
moveQueuedMessage: vi.fn(),
36-
},
49+
sessionStoreSetters: storeSetters,
3750
useSessionIsCloud: () => false,
3851
useSessionSelector: <T,>(
3952
_taskId: string,
40-
select: (session: undefined) => T,
41-
) => select(undefined),
53+
select: (session: { editingQueuedId?: string }) => T,
54+
) => select({ editingQueuedId: sessionState.editingQueuedId }),
4255
useSessionStore: {
43-
getState: () => ({ taskIdIndex: {}, sessions: {} }),
56+
getState: () => ({
57+
taskIdIndex: new Proxy({}, { get: () => "run-1" }) as Record<
58+
string,
59+
string
60+
>,
61+
sessions: { "run-1": { messageQueue: queuedState.messages } },
62+
}),
4463
},
4564
}));
4665

4766
vi.mock("@posthog/ui/primitives/toast", () => ({
4867
toast: { error: vi.fn() },
4968
}));
5069

70+
// The dock's reorder handler is driven directly through the captured
71+
// onDragOver prop; the sortable plumbing itself is @dnd-kit's to test.
72+
vi.mock("@dnd-kit/react", async () => {
73+
const React = await import("react");
74+
return {
75+
DragDropProvider: ({
76+
onDragOver,
77+
children,
78+
}: {
79+
onDragOver: (event: unknown) => void;
80+
children: React.ReactNode;
81+
}) => {
82+
dndCapture.onDragOver = onDragOver;
83+
return React.createElement(React.Fragment, null, children);
84+
},
85+
};
86+
});
87+
vi.mock("@dnd-kit/react/sortable", () => ({
88+
useSortable: () => ({
89+
ref: () => {},
90+
handleRef: () => {},
91+
isDragging: false,
92+
}),
93+
}));
94+
vi.mock("@dnd-kit/dom", () => ({ PointerSensor: class {} }));
95+
5196
// Stub the per-message card so the test exercises the dock's collapse/scroll
5297
// shell, not the markdown/steer internals it already owns.
5398
vi.mock(
5499
"@posthog/ui/features/sessions/components/session-update/QueuedMessageView",
55100
async () => {
56101
const React = await import("react");
57102
return {
58-
QueuedMessageView: ({ message }: { message: { content: string } }) =>
103+
QueuedMessageView: ({
104+
message,
105+
isEditing,
106+
}: {
107+
message: { content: string };
108+
isEditing?: boolean;
109+
}) =>
59110
React.createElement(
60111
"div",
61-
{ "data-testid": "queued-card" },
112+
{
113+
"data-testid": "queued-card",
114+
"data-editing": String(!!isEditing),
115+
},
62116
message.content,
63117
),
64118
};
@@ -84,7 +138,10 @@ function renderDock(taskId: string) {
84138

85139
describe("QueuedMessagesDock", () => {
86140
beforeEach(() => {
141+
vi.clearAllMocks();
87142
queuedState.messages = [];
143+
sessionState.editingQueuedId = undefined;
144+
dndCapture.onDragOver = undefined;
88145
});
89146

90147
it("renders nothing when the queue is empty", () => {
@@ -135,4 +192,66 @@ describe("QueuedMessagesDock", () => {
135192
fireEvent.click(trigger);
136193
expect(screen.getAllByTestId("queued-card")).toHaveLength(2);
137194
});
195+
196+
it("reorders the queue when a card is dragged over another", () => {
197+
queuedState.messages = TWO_MESSAGES;
198+
renderDock("task-drag");
199+
200+
dndCapture.onDragOver?.({
201+
operation: { source: { id: "q1" }, target: { id: "q2" } },
202+
});
203+
204+
expect(storeSetters.moveQueuedMessage).toHaveBeenCalledWith(
205+
"task-drag",
206+
0,
207+
1,
208+
);
209+
});
210+
211+
it.each([
212+
{
213+
name: "source and target are the same card",
214+
operation: { source: { id: "q1" }, target: { id: "q1" } },
215+
},
216+
{
217+
name: "source is not in the queue",
218+
operation: { source: { id: "missing" }, target: { id: "q2" } },
219+
},
220+
{
221+
name: "target is not in the queue",
222+
operation: { source: { id: "q1" }, target: { id: "missing" } },
223+
},
224+
{
225+
name: "there is no drop target",
226+
operation: { source: { id: "q1" }, target: undefined },
227+
},
228+
])("does not reorder when $name", ({ operation }) => {
229+
queuedState.messages = TWO_MESSAGES;
230+
renderDock("task-drag-noop");
231+
232+
dndCapture.onDragOver?.({ operation });
233+
234+
expect(storeSetters.moveQueuedMessage).not.toHaveBeenCalled();
235+
});
236+
237+
it("marks only the edited message's card as editing", () => {
238+
queuedState.messages = TWO_MESSAGES;
239+
sessionState.editingQueuedId = "q1";
240+
renderDock("task-editing");
241+
242+
const [first, second] = screen.getAllByTestId("queued-card");
243+
expect(first).toHaveAttribute("data-editing", "true");
244+
expect(second).toHaveAttribute("data-editing", "false");
245+
expect(sessionService.clearEditingQueuedMessage).not.toHaveBeenCalled();
246+
});
247+
248+
it("clears a stale edit hold when the edited message leaves the queue", () => {
249+
queuedState.messages = TWO_MESSAGES;
250+
sessionState.editingQueuedId = "q-gone";
251+
renderDock("task-stale-hold");
252+
253+
expect(sessionService.clearEditingQueuedMessage).toHaveBeenCalledWith(
254+
"task-stale-hold",
255+
);
256+
});
138257
});

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

Lines changed: 42 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -27,16 +27,21 @@ import { useQueuedMessagesForTask } from "@posthog/ui/features/sessions/useSessi
2727
import { toast } from "@posthog/ui/primitives/toast";
2828
import * as Collapsible from "@radix-ui/react-collapsible";
2929
import { Box, Flex, Text } from "@radix-ui/themes";
30-
import { type ReactNode, useCallback, useEffect } from "react";
30+
import {
31+
type ReactNode,
32+
type RefCallback,
33+
useCallback,
34+
useEffect,
35+
} from "react";
3136

3237
interface QueuedMessagesDockProps {
3338
taskId: string;
3439
}
3540

3641
/**
37-
* A single queued card, wrapped so the whole card is a drag handle for
38-
* reordering the queue. Drag activation waits for a small pointer move (see the
39-
* provider's sensor) so the card's buttons still take clicks.
42+
* A single queued card wrapped as a sortable item. Dragging is scoped to the
43+
* card's grip button (the handle ref passed to `children`), so the card's own
44+
* buttons never compete with a drag.
4045
*/
4146
function SortableQueuedMessage({
4247
id,
@@ -47,24 +52,18 @@ function SortableQueuedMessage({
4752
id: string;
4853
index: number;
4954
taskId: string;
50-
children: ReactNode;
55+
children: (dragHandleRef: RefCallback<HTMLButtonElement>) => ReactNode;
5156
}) {
52-
const { ref, isDragging } = useSortable({
57+
const { ref, handleRef, isDragging } = useSortable({
5358
id,
5459
index,
5560
group: `queue:${taskId}`,
5661
transition: { duration: 200, easing: "ease" },
5762
});
5863

5964
return (
60-
<div
61-
ref={ref}
62-
style={{
63-
opacity: isDragging ? 0.5 : 1,
64-
cursor: isDragging ? "grabbing" : undefined,
65-
}}
66-
>
67-
{children}
65+
<div ref={ref} style={{ opacity: isDragging ? 0.5 : 1 }}>
66+
{children(handleRef as RefCallback<HTMLButtonElement>)}
6867
</div>
6968
);
7069
}
@@ -178,32 +177,35 @@ export function QueuedMessagesDock({ taskId }: QueuedMessagesDockProps) {
178177
index={index}
179178
taskId={taskId}
180179
>
181-
<QueuedMessageView
182-
message={message}
183-
supportsNativeSteer={supportsNativeSteer}
184-
isEditing={editingId === message.id}
185-
onSteer={
186-
canSteer
187-
? () => {
188-
void sessionService
189-
.steerQueuedMessage(taskId, message.id)
190-
.catch(() => {
191-
toast.error(
192-
"Couldn't steer this message. It's still queued.",
193-
);
194-
});
195-
}
196-
: undefined
197-
}
198-
onEdit={() => editMessage(message)}
199-
onCancelEdit={cancelEdit}
200-
onRemove={() =>
201-
sessionStoreSetters.removeQueuedMessage(
202-
taskId,
203-
message.id,
204-
)
205-
}
206-
/>
180+
{(dragHandleRef) => (
181+
<QueuedMessageView
182+
message={message}
183+
dragHandleRef={dragHandleRef}
184+
supportsNativeSteer={supportsNativeSteer}
185+
isEditing={editingId === message.id}
186+
onSteer={
187+
canSteer
188+
? () => {
189+
void sessionService
190+
.steerQueuedMessage(taskId, message.id)
191+
.catch(() => {
192+
toast.error(
193+
"Couldn't steer this message. It's still queued.",
194+
);
195+
});
196+
}
197+
: undefined
198+
}
199+
onEdit={() => editMessage(message)}
200+
onCancelEdit={cancelEdit}
201+
onRemove={() =>
202+
sessionStoreSetters.removeQueuedMessage(
203+
taskId,
204+
message.id,
205+
)
206+
}
207+
/>
208+
)}
207209
</SortableQueuedMessage>
208210
))}
209211
</Flex>

0 commit comments

Comments
 (0)