From 2e5355e9471ac6b6d61e5e53fc2ee2256233e183 Mon Sep 17 00:00:00 2001 From: Georges-Antoine Assi Date: Tue, 7 Jul 2026 13:50:45 -0400 Subject: [PATCH] feat(sessions): reorder and edit queued messages in place Let users drag queued follow-ups to reorder them (the queue drains in array order, so the order shown is the order they send) and edit a queued message in the composer without pulling it out of the queue. - Add moveQueuedMessage and updateQueuedMessage store setters - Add SessionService.updateQueuedMessage mirroring enqueue normalization (cloud recomputes transport display + raw payload; local stores text) - Track the active edit target in sessionViewStore; the composer's next submit updates the message in place, falling back to a fresh send if it already drained or was discarded - Make each queued card a dnd-kit drag handle; highlight the card being edited with a cancel affordance Generated-By: PostHog Code Task-Id: 96d2978d-909b-4bbf-a45b-44ede597a1b8 --- .../src/sessions/sessionQueueEditing.test.ts | 93 ++++++++++ packages/core/src/sessions/sessionService.ts | 37 ++++ packages/core/src/sessions/sessionStore.ts | 48 ++++++ .../components/QueuedMessagesDock.test.tsx | 18 +- .../components/QueuedMessagesDock.tsx | 161 ++++++++++++++---- .../session-update/QueuedMessageView.tsx | 106 ++++++++---- .../sessions/hooks/useEditQueuedMessage.ts | 78 +++++++++ .../hooks/useReturnQueuedMessageToEditor.ts | 53 ------ .../sessions/hooks/useSessionCallbacks.ts | 27 +++ .../src/features/sessions/sessionViewStore.ts | 31 ++++ 10 files changed, 528 insertions(+), 124 deletions(-) create mode 100644 packages/core/src/sessions/sessionQueueEditing.test.ts create mode 100644 packages/ui/src/features/sessions/hooks/useEditQueuedMessage.ts delete mode 100644 packages/ui/src/features/sessions/hooks/useReturnQueuedMessageToEditor.ts diff --git a/packages/core/src/sessions/sessionQueueEditing.test.ts b/packages/core/src/sessions/sessionQueueEditing.test.ts new file mode 100644 index 0000000000..6afc3c18f1 --- /dev/null +++ b/packages/core/src/sessions/sessionQueueEditing.test.ts @@ -0,0 +1,93 @@ +import type { AgentSession, QueuedMessage } from "@posthog/shared"; +import { afterEach, describe, expect, it } from "vitest"; +import { sessionStore, sessionStoreSetters } from "./sessionStore"; + +const RUN = "run-queue"; +const TASK = "task-queue"; + +function seedQueue(messages: QueuedMessage[]) { + sessionStoreSetters.setSession({ + taskRunId: RUN, + taskId: TASK, + events: [], + messageQueue: messages, + pendingPermissions: new Map(), + status: "connected", + } as unknown as AgentSession); +} + +function queue(): QueuedMessage[] { + return sessionStore.getState().sessions[RUN].messageQueue; +} + +function msg(id: string, content: string): QueuedMessage { + return { id, content, queuedAt: 1 }; +} + +afterEach(() => sessionStoreSetters.removeSession(RUN)); + +describe("moveQueuedMessage", () => { + it("moves a message to a later position, preserving the others' order", () => { + seedQueue([msg("a", "A"), msg("b", "B"), msg("c", "C")]); + + sessionStoreSetters.moveQueuedMessage(TASK, 0, 2); + + expect(queue().map((m) => m.id)).toEqual(["b", "c", "a"]); + }); + + it("moves a message to an earlier position", () => { + seedQueue([msg("a", "A"), msg("b", "B"), msg("c", "C")]); + + sessionStoreSetters.moveQueuedMessage(TASK, 2, 0); + + expect(queue().map((m) => m.id)).toEqual(["c", "a", "b"]); + }); + + it.each([ + ["same index", 1, 1], + ["from out of range", 5, 0], + ["to out of range", 0, 9], + ["negative index", -1, 0], + ])("is a no-op for %s", (_label, from, to) => { + seedQueue([msg("a", "A"), msg("b", "B"), msg("c", "C")]); + + sessionStoreSetters.moveQueuedMessage(TASK, from, to); + + expect(queue().map((m) => m.id)).toEqual(["a", "b", "c"]); + }); +}); + +describe("updateQueuedMessage", () => { + it("replaces content and rawPrompt in place, keeping id and position", () => { + seedQueue([msg("a", "A"), msg("b", "B")]); + + sessionStoreSetters.updateQueuedMessage(TASK, "a", { + content: "edited A", + rawPrompt: "edited A raw", + }); + + expect(queue().map((m) => m.id)).toEqual(["a", "b"]); + expect(queue()[0].content).toBe("edited A"); + expect(queue()[0].rawPrompt).toBe("edited A raw"); + expect(queue()[1].content).toBe("B"); + }); + + it("clears a stale rawPrompt when the patch omits it (local edit)", () => { + seedQueue([{ id: "a", content: "A", rawPrompt: "old raw", queuedAt: 1 }]); + + sessionStoreSetters.updateQueuedMessage(TASK, "a", { content: "edited" }); + + expect(queue()[0].content).toBe("edited"); + expect(queue()[0].rawPrompt).toBeUndefined(); + }); + + it("is a no-op when the target id is not in the queue", () => { + seedQueue([msg("a", "A")]); + + sessionStoreSetters.updateQueuedMessage(TASK, "missing", { + content: "edited", + }); + + expect(queue()[0].content).toBe("A"); + }); +}); diff --git a/packages/core/src/sessions/sessionService.ts b/packages/core/src/sessions/sessionService.ts index 3c4f1b9559..7a9f832eb5 100644 --- a/packages/core/src/sessions/sessionService.ts +++ b/packages/core/src/sessions/sessionService.ts @@ -222,6 +222,11 @@ export interface ISessionStore { rawPrompt?: string | ContentBlock[], ): void; removeQueuedMessage(taskId: string, messageId: string): void; + updateQueuedMessage( + taskId: string, + messageId: string, + patch: { content: string; rawPrompt?: string | ContentBlock[] }, + ): void; clearMessageQueue(taskId: string): void; dequeueMessagesAsText(taskId: string): string | null; dequeueMessages(taskId: string): QueuedMessage[]; @@ -2546,6 +2551,38 @@ export class SessionService { } } + /** + * Update a queued message in place from an edited composer prompt, keeping it + * in the queue at its current position. Mirrors the enqueue normalization so + * the stored `content`/`rawPrompt` match what a freshly-queued prompt would + * hold (cloud recomputes the transport display + raw payload; local stores + * the serialized text). Returns false when the target is no longer queued so + * the caller can fall back to sending it as a new message. + */ + async updateQueuedMessage( + taskId: string, + messageId: string, + prompt: string | ContentBlock[], + ): Promise { + const session = this.d.store.getSessionByTaskId(taskId); + if (!session) return false; + if (!session.messageQueue.some((m) => m.id === messageId)) return false; + + if (session.isCloud) { + const normalizedPrompt = await this.resolveCloudPrompt(prompt); + const transport = this.d.h.getCloudPromptTransport(normalizedPrompt); + this.d.store.updateQueuedMessage(taskId, messageId, { + content: transport.promptText, + rawPrompt: normalizedPrompt, + }); + } else { + this.d.store.updateQueuedMessage(taskId, messageId, { + content: extractPromptText(prompt), + }); + } + return true; + } + /** * Cancel the current prompt. */ diff --git a/packages/core/src/sessions/sessionStore.ts b/packages/core/src/sessions/sessionStore.ts index 0e86a78c6a..80921cbdae 100644 --- a/packages/core/src/sessions/sessionStore.ts +++ b/packages/core/src/sessions/sessionStore.ts @@ -197,6 +197,54 @@ export const sessionStoreSetters = { }); }, + /** + * Move a queued message to a new position, preserving its identity. Used by + * the drag-to-reorder affordance: the queue drains in array order, so the + * order the user sees is the order the messages send. + */ + moveQueuedMessage: (taskId: string, fromIndex: number, toIndex: number) => { + sessionStore.setState((state) => { + const taskRunId = state.taskIdIndex[taskId]; + if (!taskRunId) return; + const session = state.sessions[taskRunId]; + if (!session) return; + const queue = session.messageQueue; + if ( + fromIndex < 0 || + toIndex < 0 || + fromIndex >= queue.length || + toIndex >= queue.length || + fromIndex === toIndex + ) { + return; + } + const [moved] = queue.splice(fromIndex, 1); + if (moved) queue.splice(toIndex, 0, moved); + }); + }, + + /** + * Replace a queued message's content in place, keeping its id, position, and + * queue timestamp. Used by edit-in-place: the message is edited in the + * composer and re-serialized here without leaving the queue. + */ + updateQueuedMessage: ( + taskId: string, + messageId: string, + patch: { content: string; rawPrompt?: string | ContentBlock[] }, + ) => { + sessionStore.setState((state) => { + const taskRunId = state.taskIdIndex[taskId]; + if (!taskRunId) return; + const session = state.sessions[taskRunId]; + if (!session) return; + const message = session.messageQueue.find((m) => m.id === messageId); + if (!message) return; + message.content = patch.content; + message.rawPrompt = patch.rawPrompt; + }); + }, + dequeueMessagesAsText: (taskId: string): string | null => { // Read the queue from the frozen committed state BEFORE entering the // immer draft — same rationale as `dequeueMessages`: anything captured diff --git a/packages/ui/src/features/sessions/components/QueuedMessagesDock.test.tsx b/packages/ui/src/features/sessions/components/QueuedMessagesDock.test.tsx index 0813f010dd..1307c4f529 100644 --- a/packages/ui/src/features/sessions/components/QueuedMessagesDock.test.tsx +++ b/packages/ui/src/features/sessions/components/QueuedMessagesDock.test.tsx @@ -24,20 +24,24 @@ 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/hooks/useEditQueuedMessage", () => ({ + useEditQueuedMessage: () => vi.fn(), + useCancelQueuedMessageEdit: () => vi.fn(), +})); vi.mock("@posthog/ui/features/sessions/sessionStore", () => ({ - sessionStoreSetters: { removeQueuedMessage: vi.fn() }, + sessionStoreSetters: { + removeQueuedMessage: vi.fn(), + moveQueuedMessage: vi.fn(), + }, useSessionIsCloud: () => false, useSessionSelector: ( _taskId: string, select: (session: undefined) => T, ) => select(undefined), + useSessionStore: { + getState: () => ({ taskIdIndex: {}, sessions: {} }), + }, })); vi.mock("@posthog/ui/primitives/toast", () => ({ diff --git a/packages/ui/src/features/sessions/components/QueuedMessagesDock.tsx b/packages/ui/src/features/sessions/components/QueuedMessagesDock.tsx index ac463bf8cf..2d30364036 100644 --- a/packages/ui/src/features/sessions/components/QueuedMessagesDock.tsx +++ b/packages/ui/src/features/sessions/components/QueuedMessagesDock.tsx @@ -1,3 +1,6 @@ +import { PointerSensor } from "@dnd-kit/dom"; +import { type DragDropEvents, DragDropProvider } from "@dnd-kit/react"; +import { useSortable } from "@dnd-kit/react/sortable"; import { CaretDown, CaretRight, Stack } from "@phosphor-icons/react"; import { SESSION_SERVICE, @@ -5,14 +8,19 @@ import { } from "@posthog/core/sessions/sessionService"; import { useService } from "@posthog/di/react"; import { QueuedMessageView } from "@posthog/ui/features/sessions/components/session-update/QueuedMessageView"; +import { + useCancelQueuedMessageEdit, + useEditQueuedMessage, +} from "@posthog/ui/features/sessions/hooks/useEditQueuedMessage"; import { useSupportsNativeSteer } from "@posthog/ui/features/sessions/hooks/useMessagingMode"; -import { useReturnQueuedMessageToEditor } from "@posthog/ui/features/sessions/hooks/useReturnQueuedMessageToEditor"; import { sessionStoreSetters, useSessionIsCloud, useSessionSelector, + useSessionStore, } from "@posthog/ui/features/sessions/sessionStore"; import { + useEditingQueuedId, useQueueCollapsed, useSessionViewActions, } from "@posthog/ui/features/sessions/sessionViewStore"; @@ -20,15 +28,53 @@ import { useQueuedMessagesForTask } from "@posthog/ui/features/sessions/useSessi import { toast } from "@posthog/ui/primitives/toast"; import * as Collapsible from "@radix-ui/react-collapsible"; import { Box, Flex, Text } from "@radix-ui/themes"; +import { type ReactNode, useCallback, useEffect } from "react"; interface QueuedMessagesDockProps { taskId: string; } +/** + * A single queued card, wrapped so the whole card is a drag handle for + * reordering the queue. Drag activation waits for a small pointer move (see the + * provider's sensor) so the card's buttons still take clicks. + */ +function SortableQueuedMessage({ + id, + index, + taskId, + children, +}: { + id: string; + index: number; + taskId: string; + children: ReactNode; +}) { + const { ref, isDragging } = useSortable({ + id, + index, + group: `queue:${taskId}`, + transition: { duration: 200, easing: "ease" }, + }); + + return ( +
+ {children} +
+ ); +} + /** * 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. + * thread) with per-message actions: steer it into the running turn now, edit it + * in the composer while it stays queued, or discard it. Cards can be dragged to + * reorder the queue — the order shown is the order they send. * * 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. @@ -37,7 +83,9 @@ export function QueuedMessagesDock({ taskId }: QueuedMessagesDockProps) { const queued = useQueuedMessagesForTask(taskId); const sessionService = useService(SESSION_SERVICE); const supportsNativeSteer = useSupportsNativeSteer(taskId); - const returnToEditor = useReturnQueuedMessageToEditor(taskId); + const editMessage = useEditQueuedMessage(taskId); + const cancelEdit = useCancelQueuedMessageEdit(taskId); + const editingId = useEditingQueuedId(taskId); // Narrow reads (not the whole session) so the dock doesn't re-render on every // streamed token while a turn is running. const isCompacting = useSessionSelector( @@ -50,7 +98,35 @@ export function QueuedMessagesDock({ taskId }: QueuedMessagesDockProps) { // so hide it there too — the message stays queued and lands next turn. const canSteer = !isCompacting && !isCloud; const collapsed = useQueueCollapsed(taskId); - const { setQueueCollapsed } = useSessionViewActions(); + const { setQueueCollapsed, clearEditingQueuedId } = useSessionViewActions(); + + // If the message being edited leaves the queue (drained on turn end, or + // discarded), drop the stale edit target so the composer sends normally. + useEffect(() => { + if (editingId && !queued.some((m) => m.id === editingId)) { + clearEditingQueuedId(taskId); + } + }, [editingId, queued, taskId, clearEditingQueuedId]); + + const handleDragOver: DragDropEvents["dragover"] = useCallback( + (event) => { + const sourceId = event.operation.source?.id; + const targetId = event.operation.target?.id; + if (!sourceId || !targetId || sourceId === targetId) return; + + const state = useSessionStore.getState(); + const taskRunId = state.taskIdIndex[taskId]; + const queue = taskRunId + ? (state.sessions[taskRunId]?.messageQueue ?? []) + : []; + const fromIndex = queue.findIndex((m) => m.id === sourceId); + const toIndex = queue.findIndex((m) => m.id === targetId); + if (fromIndex === -1 || toIndex === -1 || fromIndex === toIndex) return; + + sessionStoreSetters.moveQueuedMessage(taskId, fromIndex, toIndex); + }, + [taskId], + ); if (queued.length === 0) return null; @@ -83,32 +159,55 @@ export function QueuedMessagesDock({ taskId }: QueuedMessagesDockProps) { - - {queued.map((message) => ( - { - void sessionService - .steerQueuedMessage(taskId, message.id) - .catch(() => { - toast.error( - "Couldn't steer this message. It's still queued.", - ); - }); - } - : undefined - } - onReturnToEditor={() => returnToEditor(message)} - onRemove={() => - sessionStoreSetters.removeQueuedMessage(taskId, message.id) - } - /> - ))} - + + + {queued.map((message, index) => ( + + { + void sessionService + .steerQueuedMessage(taskId, message.id) + .catch(() => { + toast.error( + "Couldn't steer this message. It's still queued.", + ); + }); + } + : undefined + } + onEdit={() => editMessage(message)} + onCancelEdit={cancelEdit} + onRemove={() => + sessionStoreSetters.removeQueuedMessage( + taskId, + message.id, + ) + } + /> + + ))} + + diff --git a/packages/ui/src/features/sessions/components/session-update/QueuedMessageView.tsx b/packages/ui/src/features/sessions/components/session-update/QueuedMessageView.tsx index 119245faf2..427598357f 100644 --- a/packages/ui/src/features/sessions/components/session-update/QueuedMessageView.tsx +++ b/packages/ui/src/features/sessions/components/session-update/QueuedMessageView.tsx @@ -1,11 +1,13 @@ import { ArrowBendDownLeft, + DotsSixVertical, PencilSimple, - Stack, Trash, + X, } from "@phosphor-icons/react"; import { Button } from "@posthog/quill"; -import { Box, Flex, IconButton, Tooltip } from "@radix-ui/themes"; +import { Box, Flex, IconButton, Text, Tooltip } from "@radix-ui/themes"; +import clsx from "clsx"; import { MarkdownRenderer } from "../../../editor/components/MarkdownRenderer"; import type { QueuedMessage } from "../../sessionStore"; import { CollapsibleMessageContent } from "./CollapsibleMessageContent"; @@ -14,16 +16,20 @@ import { hasFileMentions, parseFileMentions } from "./parseFileMentions"; interface QueuedMessageViewProps { message: QueuedMessage; onSteer?: () => void; - onReturnToEditor?: () => void; + onEdit?: () => void; + onCancelEdit?: () => void; onRemove?: () => void; + isEditing?: boolean; supportsNativeSteer?: boolean; } export function QueuedMessageView({ message, onSteer, - onReturnToEditor, + onEdit, + onCancelEdit, onRemove, + isEditing = false, supportsNativeSteer = false, }: QueuedMessageViewProps) { const steerTooltip = supportsNativeSteer @@ -31,13 +37,24 @@ export function QueuedMessageView({ : "Interrupt the current turn and resend with this message."; return ( - + - + {hasFileMentions(message.content) ? ( parseFileMentions(message.content) @@ -46,32 +63,55 @@ export function QueuedMessageView({ )} - {onSteer && ( - - - - )} - {onReturnToEditor && ( - - - - - + {isEditing ? ( + <> + + Editing in composer + + {onCancelEdit && ( + + + + + + )} + + ) : ( + <> + {onSteer && ( + + + + )} + {onEdit && ( + + + + + + )} + )} {onRemove && ( diff --git a/packages/ui/src/features/sessions/hooks/useEditQueuedMessage.ts b/packages/ui/src/features/sessions/hooks/useEditQueuedMessage.ts new file mode 100644 index 0000000000..4dd5f51df5 --- /dev/null +++ b/packages/ui/src/features/sessions/hooks/useEditQueuedMessage.ts @@ -0,0 +1,78 @@ +import { + type EditorContent, + xmlToContent, +} from "@posthog/core/message-editor/content"; +import { + combineQueuedCloudPrompts, + promptToQueuedEditorContent, +} from "@posthog/core/sessions/cloudPrompt"; +import { useDraftStore } from "@posthog/ui/features/message-editor/draftStore"; +import { + type QueuedMessage, + useSessionIsCloud, +} from "@posthog/ui/features/sessions/sessionStore"; +import { useSessionViewActions } from "@posthog/ui/features/sessions/sessionViewStore"; +import { useCallback } from "react"; + +/** + * Empty editor content, used to clear the composer when an edit is cancelled. + */ +const EMPTY_CONTENT: EditorContent = { segments: [] }; + +/** + * Load a queued message back into the composer for editing while keeping it in + * the queue at its current position. Marks the message as the active edit + * target so the composer's next submit updates it in place (see + * `useSessionCallbacks.handleSendPrompt`) rather than sending a new prompt. + * + * Content restore mirrors the cancel-to-composer path: cloud keeps its rich + * payload (mentions, attachments) via the queued-cloud conversion; local + * restores the serialized text (chips reparse from the `` tags). + */ +export function useEditQueuedMessage( + taskId: string | undefined, +): (message: QueuedMessage) => void { + const { requestFocus, setPendingContent } = useDraftStore((s) => s.actions); + const { setEditingQueuedId } = useSessionViewActions(); + const isCloud = useSessionIsCloud(taskId); + + return useCallback( + (message: QueuedMessage) => { + if (!taskId) return; + + let pendingContent: EditorContent | null; + if (isCloud) { + const combined = combineQueuedCloudPrompts([message]); + pendingContent = combined + ? promptToQueuedEditorContent(combined) + : null; + } else { + pendingContent = xmlToContent(message.content); + } + if (!pendingContent) return; + + setEditingQueuedId(taskId, message.id); + setPendingContent(taskId, pendingContent); + requestFocus(taskId); + }, + [taskId, isCloud, requestFocus, setPendingContent, setEditingQueuedId], + ); +} + +/** + * Abandon an in-progress queued-message edit: drop the edit target so the next + * submit sends normally again, and clear the composer so the abandoned draft + * doesn't linger. + */ +export function useCancelQueuedMessageEdit( + taskId: string | undefined, +): () => void { + const { setPendingContent } = useDraftStore((s) => s.actions); + const { clearEditingQueuedId } = useSessionViewActions(); + + return useCallback(() => { + if (!taskId) return; + clearEditingQueuedId(taskId); + setPendingContent(taskId, EMPTY_CONTENT); + }, [taskId, clearEditingQueuedId, setPendingContent]); +} diff --git a/packages/ui/src/features/sessions/hooks/useReturnQueuedMessageToEditor.ts b/packages/ui/src/features/sessions/hooks/useReturnQueuedMessageToEditor.ts deleted file mode 100644 index fccef985c1..0000000000 --- a/packages/ui/src/features/sessions/hooks/useReturnQueuedMessageToEditor.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { - type EditorContent, - xmlToContent, -} from "@posthog/core/message-editor/content"; -import { - combineQueuedCloudPrompts, - promptToQueuedEditorContent, -} from "@posthog/core/sessions/cloudPrompt"; -import { useDraftStore } from "@posthog/ui/features/message-editor/draftStore"; -import { - type QueuedMessage, - sessionStoreSetters, - useSessionIsCloud, -} from "@posthog/ui/features/sessions/sessionStore"; -import { useCallback } from "react"; - -/** - * Pull a queued message out of the queue and back into the composer so it can be - * re-read or edited before re-sending. Mirrors the cancel-to-composer restore: - * cloud keeps its rich payload (mentions, attachments) via the queued-cloud - * conversion; local restores the plain text it was queued with. - */ -export function useReturnQueuedMessageToEditor( - taskId: string | undefined, -): (message: QueuedMessage) => void { - const { requestFocus, setPendingContent } = useDraftStore((s) => s.actions); - const isCloud = useSessionIsCloud(taskId); - - return useCallback( - (message: QueuedMessage) => { - if (!taskId) return; - - let pendingContent: EditorContent | null; - if (isCloud) { - const combined = combineQueuedCloudPrompts([message]); - pendingContent = combined - ? promptToQueuedEditorContent(combined) - : null; - } else { - // Local queued content is the serialized form (text + `` - // tags); parse it back into chip segments so attachments restore as - // chips, not raw XML. - pendingContent = xmlToContent(message.content); - } - if (!pendingContent) return; - - sessionStoreSetters.removeQueuedMessage(taskId, message.id); - setPendingContent(taskId, pendingContent); - requestFocus(taskId); - }, - [taskId, isCloud, requestFocus, setPendingContent], - ); -} diff --git a/packages/ui/src/features/sessions/hooks/useSessionCallbacks.ts b/packages/ui/src/features/sessions/hooks/useSessionCallbacks.ts index 53b0c06f93..cb6dbe2c38 100644 --- a/packages/ui/src/features/sessions/hooks/useSessionCallbacks.ts +++ b/packages/ui/src/features/sessions/hooks/useSessionCallbacks.ts @@ -20,6 +20,7 @@ import { type AgentSession, sessionStoreSetters, } from "@posthog/ui/features/sessions/sessionStore"; +import { sessionViewStore } from "@posthog/ui/features/sessions/sessionViewStore"; import { useTaskViewed } from "@posthog/ui/features/sidebar/useTaskViewed"; import { SHELL_CLIENT, @@ -90,6 +91,32 @@ export function useSessionCallbacks({ } } + // Editing a queued message in place: update it where it sits in the + // queue rather than sending a new prompt. If the target already drained + // or was discarded, fall through and send it as a fresh message. + const editingId = + sessionViewStore.getState().editingQueuedIdByTaskId[taskId]; + if (editingId) { + sessionViewStore.getState().actions.clearEditingQueuedId(taskId); + try { + const updated = await sessionService.updateQueuedMessage( + taskId, + editingId, + promptText ?? text, + ); + if (updated) { + markAsViewed(taskId); + return; + } + } catch (error) { + const message = + error instanceof Error ? error.message : "Failed to update message"; + toast.error(message); + log.error("Failed to update queued message", error); + return; + } + } + try { markAsViewed(taskId); markActivity(taskId); diff --git a/packages/ui/src/features/sessions/sessionViewStore.ts b/packages/ui/src/features/sessions/sessionViewStore.ts index ac87639745..6991a3b4ab 100644 --- a/packages/ui/src/features/sessions/sessionViewStore.ts +++ b/packages/ui/src/features/sessions/sessionViewStore.ts @@ -16,6 +16,12 @@ interface SessionViewState { * resets to expanded on app restart. */ queueCollapsedByTaskId: Record; + /** + * Ephemeral per-task id of the queued message currently being edited in the + * composer, keyed by taskId. When set, the composer's next submit updates + * that message in place instead of sending a new prompt. Not persisted. + */ + editingQueuedIdByTaskId: Record; } interface SessionViewActions { @@ -25,6 +31,8 @@ interface SessionViewActions { setGroupOverride: (id: string, expanded: boolean) => void; clearGroupOverrides: () => void; setQueueCollapsed: (taskId: string, collapsed: boolean) => void; + setEditingQueuedId: (taskId: string, messageId: string) => void; + clearEditingQueuedId: (taskId: string) => void; } type SessionViewStore = SessionViewState & { actions: SessionViewActions }; @@ -35,6 +43,7 @@ const useStore = create((set) => ({ showSearch: false, groupOverrides: {}, queueCollapsedByTaskId: {}, + editingQueuedIdByTaskId: {}, actions: { setShowRawLogs: (show) => set({ showRawLogs: show }), setSearchQuery: (query) => set({ searchQuery: query }), @@ -60,6 +69,20 @@ const useStore = create((set) => ({ [taskId]: collapsed, }, })), + setEditingQueuedId: (taskId, messageId) => + set((state) => ({ + editingQueuedIdByTaskId: { + ...state.editingQueuedIdByTaskId, + [taskId]: messageId, + }, + })), + clearEditingQueuedId: (taskId) => + set((state) => { + if (state.editingQueuedIdByTaskId[taskId] === undefined) return state; + const next = { ...state.editingQueuedIdByTaskId }; + delete next[taskId]; + return { editingQueuedIdByTaskId: next }; + }), }, })); @@ -69,4 +92,12 @@ 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 useEditingQueuedId = (taskId: string) => + useStore((s) => s.editingQueuedIdByTaskId[taskId]); export const useSessionViewActions = () => useStore((s) => s.actions); + +/** + * Imperative handle to the view store for non-React call sites (the composer + * submit path reads/clears the editing target here). + */ +export const sessionViewStore = useStore;