diff --git a/.github/workflows/react-doctor.yml b/.github/workflows/react-doctor.yml index 03583d4b7..63a46706f 100644 --- a/.github/workflows/react-doctor.yml +++ b/.github/workflows/react-doctor.yml @@ -59,7 +59,7 @@ jobs: fi REPORT="${RUNNER_TEMP}/react-doctor-report.json" status=0 - npx --yes react-doctor@0.4.2 . --blocking error --changed-files-from "$CHANGED" --json --json-compact --no-telemetry > "$REPORT" || status=$? + npx --yes react-doctor@0.5.4 . --blocking error --changed-files-from "$CHANGED" --json --json-compact --no-telemetry > "$REPORT" || status=$? echo "exit-code=$status" >> "$GITHUB_OUTPUT" echo "report=$REPORT" >> "$GITHUB_OUTPUT" if [ "$status" -ne 0 ]; then diff --git a/packages/agent/src/adapters/claude/UPSTREAM.md b/packages/agent/src/adapters/claude/UPSTREAM.md index 3c3584c51..4e2c9e76d 100644 --- a/packages/agent/src/adapters/claude/UPSTREAM.md +++ b/packages/agent/src/adapters/claude/UPSTREAM.md @@ -34,6 +34,7 @@ Fork of `@anthropic-ai/claude-agent-acp`. Upstream repo: https://github.com/anth - Branch naming in system prompt - `broadcastUserMessage` in prompt() - `interruptReason` on cancel +- Steer mode: `_meta.steer` maps to `priority:"next"` in `promptToClaude` (`acp-to-sdk.ts`). A mid-turn branch in `prompt()` pushes the message into the running turn's input and returns immediately with a benign `end_turn` instead of queueing a new turn. Advertised via `_meta.posthog.steering:"native"` in `initialize()` - `SYSTEM_REMINDER` stripping from Read tool results - WebFetch `resourceLink` content enrichment - `customTitle` in listSessions (PostHog Code is ahead of upstream here) diff --git a/packages/agent/src/adapters/claude/claude-agent.ts b/packages/agent/src/adapters/claude/claude-agent.ts index 8df465ae2..5c8f7105d 100644 --- a/packages/agent/src/adapters/claude/claude-agent.ts +++ b/packages/agent/src/adapters/claude/claude-agent.ts @@ -79,7 +79,7 @@ import { estimateSkillsTokens, estimateSystemPrompt, } from "./context-breakdown"; -import { promptToClaude } from "./conversion/acp-to-sdk"; +import { isSteerMeta, promptToClaude } from "./conversion/acp-to-sdk"; import { handleResultMessage, handleStreamEvent, @@ -274,6 +274,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent { _meta: { posthog: { resumeSession: true, + steering: "native", }, claudeCode: { promptQueueing: true, @@ -433,6 +434,18 @@ export class ClaudeAcpAgent extends BaseAcpAgent { } if (this.session.promptRunning) { + const isSteer = isSteerMeta(params._meta); + if (isSteer) { + // Fold this message into the turn already running instead of queueing a + // new turn. promptToClaude tagged it priority:"next" so the SDK delivers + // it at the next tool-call boundary. Return immediately with a benign + // end_turn: the in-flight turn (not this call) owns the loop and the + // real stop reason. The client tells steers apart by the request's + // _meta.steer, not by this value. + this.session.input.push(userMessage); + await this.broadcastUserMessage(params); + return { stopReason: "end_turn" }; + } this.session.input.push(userMessage); const order = this.session.nextPendingOrder++; const cancelled = await new Promise((resolve) => { diff --git a/packages/agent/src/adapters/claude/conversion/acp-to-sdk.test.ts b/packages/agent/src/adapters/claude/conversion/acp-to-sdk.test.ts index 9843b339a..a8b8ffe3e 100644 --- a/packages/agent/src/adapters/claude/conversion/acp-to-sdk.test.ts +++ b/packages/agent/src/adapters/claude/conversion/acp-to-sdk.test.ts @@ -1,6 +1,7 @@ import type { PromptRequest } from "@agentclientprotocol/sdk"; import { describe, expect, it } from "vitest"; import { + isSteerMeta, promptToClaude, readToolGuidanceForPath, workspacePromptFromFileUri, @@ -27,6 +28,19 @@ describe("workspacePromptFromFileUri", () => { }); }); +describe("isSteerMeta", () => { + it.each([ + [{ steer: true }, true], + [{ steer: false }, false], + [{}, false], + [undefined, false], + [null, false], + [{ steer: "true" }, false], + ])("detects steer in %o as %s", (meta, expected) => { + expect(isSteerMeta(meta)).toBe(expected); + }); +}); + describe("promptToClaude", () => { it("maps file resource_link to workspace path + Read guidance", () => { const result = promptToClaude({ @@ -56,6 +70,24 @@ describe("promptToClaude", () => { expect(text).toContain("pages"); }); + it("tags a steer message with priority 'next' for tool-boundary delivery", () => { + const result = promptToClaude({ + sessionId: "session-1", + prompt: [{ type: "text", text: "use a different approach" }], + _meta: { steer: true }, + }); + expect(result.priority).toBe("next"); + }); + + it("leaves priority and shouldQuery unset for a normal message", () => { + const result = promptToClaude({ + sessionId: "session-1", + prompt: [{ type: "text", text: "hello" }], + }); + expect(result.priority).toBeUndefined(); + expect(result.shouldQuery).toBeUndefined(); + }); + it("drops embedded body for file:// resource but keeps attachment:// payload", () => { const hugeInline = `${"y".repeat(30_000)}KEEP_ATTACH${"y".repeat(30_000)}`; const fileRes = promptToClaude({ diff --git a/packages/agent/src/adapters/claude/conversion/acp-to-sdk.ts b/packages/agent/src/adapters/claude/conversion/acp-to-sdk.ts index 7780e70a6..37d9a87a2 100644 --- a/packages/agent/src/adapters/claude/conversion/acp-to-sdk.ts +++ b/packages/agent/src/adapters/claude/conversion/acp-to-sdk.ts @@ -150,12 +150,21 @@ function processPromptChunk( } } +/** True when an ACP request's `_meta` marks it as a mid-turn steer. */ +export function isSteerMeta(meta: unknown): boolean { + return ( + typeof meta === "object" && + meta !== null && + (meta as Record).steer === true + ); +} + export function promptToClaude(prompt: PromptRequest): SDKUserMessage { const content: ContentBlockParam[] = []; const context: ContentBlockParam[] = []; - const prContext = (prompt._meta as Record | undefined) - ?.prContext; + const meta = prompt._meta as Record | undefined; + const prContext = meta?.prContext; if (typeof prContext === "string") { content.push(sdkText(prContext)); } @@ -166,10 +175,18 @@ export function promptToClaude(prompt: PromptRequest): SDKUserMessage { content.push(...context); - return { + const message: SDKUserMessage = { type: "user", message: { role: "user", content }, session_id: prompt.sessionId, parent_tool_use_id: null, }; + + // A steer is folded into the turn already running: priority "next" tells the + // SDK to deliver it at the next tool-call boundary rather than as a new turn. + if (isSteerMeta(meta)) { + message.priority = "next"; + } + + return message; } diff --git a/packages/agent/src/adapters/codex/codex-agent.ts b/packages/agent/src/adapters/codex/codex-agent.ts index 3976edd7a..374296b30 100644 --- a/packages/agent/src/adapters/codex/codex-agent.ts +++ b/packages/agent/src/adapters/codex/codex-agent.ts @@ -393,6 +393,7 @@ export class CodexAcpAgent extends BaseAcpAgent { _meta: { posthog: { resumeSession: true, + steering: "interrupt-resend", }, }, }, diff --git a/packages/core/src/sessions/sessionService.ts b/packages/core/src/sessions/sessionService.ts index 0eb24e96d..d57e27d3a 100644 --- a/packages/core/src/sessions/sessionService.ts +++ b/packages/core/src/sessions/sessionService.ts @@ -1255,6 +1255,21 @@ export class SessionService { this.idleKilledSubscription = null; } + /** + * A steer message rides on `session/prompt` with `_meta.steer`. It is folded + * into the running turn, so its request must not participate in turn-state + * bookkeeping (currentPromptId / isPromptPending) or the live turn would be + * cut short. Its response carries a foreign request id, so the currentPromptId + * guard ignores it without needing a marker here. + */ + private isSteerMessage(msg: AcpMessage["message"]): boolean { + if (isJsonRpcRequest(msg) && msg.method === "session/prompt") { + const params = msg.params as { _meta?: { steer?: boolean } } | undefined; + return params?._meta?.steer === true; + } + return false; + } + private updatePromptStateFromEvents( taskRunId: string, events: AcpMessage[], @@ -1262,6 +1277,12 @@ export class SessionService { ): void { for (const acpMsg of events) { const msg = acpMsg.message; + // A steer is injected into the running turn, not a turn of its own. Skip + // its request so it never claims currentPromptId. Otherwise the steer's + // instant response would clear the live turn's pending state. + if (this.isSteerMessage(msg)) { + continue; + } if (isJsonRpcRequest(msg) && msg.method === "session/prompt") { this.d.store.updateSession(taskRunId, { isPromptPending: true, @@ -1648,6 +1669,7 @@ export class SessionService { async sendPrompt( taskId: string, prompt: string | ContentBlock[], + options?: { steer?: boolean }, ): Promise<{ stopReason: string }> { if (!this.d.getIsOnline()) { throw new Error( @@ -1667,6 +1689,23 @@ export class SessionService { ); } + // Steer: the user sent a message mid-turn and asked to fold it into the + // running turn rather than queue it. Native (Claude) injects at the next + // tool boundary; everything else interrupts the turn and resends below as a + // fresh prompt. Compaction always falls through to the queue. + if (options?.steer && session.isPromptPending && !session.isCompacting) { + const supportsNativeSteer = + !session.isCloud && session.adapter === "claude"; + if (supportsNativeSteer) { + return this.sendSteerPrompt(session, prompt); + } + await this.cancelPrompt(taskId); + const refreshed = this.d.store.getSessionByTaskId(taskId); + if (refreshed) { + session = refreshed; + } + } + if (session.isCloud) { return this.sendCloudPrompt(session, prompt); } @@ -1751,6 +1790,34 @@ export class SessionService { }); } + /** + * Send a steer message: folded into the turn already running rather than + * queued. It renders when its `session/prompt` echo arrives and is injected + * by the agent at the next tool boundary. The running turn keeps ownership of + * the prompt lifecycle, so this never touches isPromptPending. + */ + private async sendSteerPrompt( + session: AgentSession, + prompt: string | ContentBlock[], + ): Promise<{ stopReason: string }> { + const blocks = normalizePromptToBlocks(prompt); + const promptText = extractPromptText(prompt); + + this.d.track(ANALYTICS_EVENTS.PROMPT_SENT, { + task_id: session.taskId, + is_initial: false, + execution_type: "local", + prompt_length_chars: promptText.length, + is_steer: true, + }); + + return this.d.trpc.agent.prompt.mutate({ + sessionId: session.taskRunId, + prompt: blocks, + steer: true, + }); + } + /** * Send all queued messages as a single prompt. * Called internally when a turn completes and there are queued messages. diff --git a/packages/host-router/src/routers/agent.router.ts b/packages/host-router/src/routers/agent.router.ts index 38b0ef98e..e62ea18cf 100644 --- a/packages/host-router/src/routers/agent.router.ts +++ b/packages/host-router/src/routers/agent.router.ts @@ -45,7 +45,9 @@ export const agentRouter = router({ .mutation(({ ctx, input }) => ctx.container .get(AGENT_SERVICE) - .prompt(input.sessionId, input.prompt as ContentBlock[]), + .prompt(input.sessionId, input.prompt as ContentBlock[], { + steer: input.steer, + }), ), cancel: publicProcedure diff --git a/packages/ui/src/features/command/keyboard-shortcuts.ts b/packages/ui/src/features/command/keyboard-shortcuts.ts index 6bfdc0f79..adc2bc2c1 100644 --- a/packages/ui/src/features/command/keyboard-shortcuts.ts +++ b/packages/ui/src/features/command/keyboard-shortcuts.ts @@ -24,6 +24,7 @@ export const SHORTCUTS = { FIND_IN_CONVERSATION: "mod+f", BLUR: "escape", SUBMIT_BLUR: "mod+enter", + SWITCH_MESSAGING_MODE: "mod+s", } as const; export type ShortcutCategory = "general" | "navigation" | "panels" | "editor"; @@ -63,6 +64,13 @@ export const KEYBOARD_SHORTCUTS: KeyboardShortcut[] = [ description: "Show keyboard shortcuts", category: "general", }, + { + id: "switch-messaging-mode", + keys: SHORTCUTS.SWITCH_MESSAGING_MODE, + description: "Switch Steer / Queue mode", + category: "editor", + context: "Session composer", + }, { id: "inbox", keys: SHORTCUTS.INBOX, diff --git a/packages/ui/src/features/message-editor/components/PromptInput.tsx b/packages/ui/src/features/message-editor/components/PromptInput.tsx index 7a06c1d73..d1743253a 100644 --- a/packages/ui/src/features/message-editor/components/PromptInput.tsx +++ b/packages/ui/src/features/message-editor/components/PromptInput.tsx @@ -2,6 +2,7 @@ import "./message-editor.css"; import type { SessionConfigOption } from "@agentclientprotocol/sdk"; import { ArrowUp, Stop } from "@phosphor-icons/react"; import { InputGroup, InputGroupAddon, InputGroupButton } from "@posthog/quill"; +import { SHORTCUTS } from "@posthog/ui/features/command/keyboard-shortcuts"; import { cycleModeOption } from "@posthog/ui/features/sessions/sessionStore"; import { hasOpenOverlay } from "@posthog/ui/utils/overlay"; import { Flex, Text, Tooltip } from "@radix-ui/themes"; @@ -42,6 +43,7 @@ export interface PromptInputProps { // toolbar slots modelSelector?: React.ReactElement | null | false; reasoningSelector?: React.ReactElement | null | false; + messagingModeToggle?: React.ReactNode; historyButton?: React.ReactNode; // prompt history provider getPromptHistory?: () => string[]; @@ -51,6 +53,7 @@ export interface PromptInputProps { onBashCommand?: (command: string) => void; onBashModeChange?: (isBashMode: boolean) => void; onCancel?: () => void; + onToggleMessagingMode?: () => void; onAttachFiles?: (files: File[]) => void; onEmptyChange?: (isEmpty: boolean) => void; onFocus?: () => void; @@ -82,6 +85,7 @@ export const PromptInput = forwardRef( enableCommands = true, modelSelector, reasoningSelector, + messagingModeToggle, historyButton, getPromptHistory, onBeforeSubmit, @@ -89,6 +93,7 @@ export const PromptInput = forwardRef( onBashCommand, onBashModeChange, onCancel, + onToggleMessagingMode, onAttachFiles, onEmptyChange, onFocus, @@ -238,6 +243,23 @@ export const PromptInput = forwardRef( [editor, modeOption, onModeChange, allowBypassPermissions, disabled], ); + useHotkeys( + SHORTCUTS.SWITCH_MESSAGING_MODE, + (e) => { + if (!editor?.isFocused) return; + if (hasOpenOverlay()) return; + if (!onToggleMessagingMode) return; + e.preventDefault(); + onToggleMessagingMode(); + }, + { + enableOnFormTags: true, + enableOnContentEditable: true, + enabled: !disabled && !!onToggleMessagingMode, + }, + [editor, onToggleMessagingMode, disabled], + ); + const handleContainerClick = useCallback( (e: React.MouseEvent) => { const target = e.target as HTMLElement; @@ -344,6 +366,7 @@ export const PromptInput = forwardRef( )} {modelSelector && {modelSelector}} {reasoningSelector && {reasoningSelector}} + {messagingModeToggle && {messagingModeToggle}} {isBashMode && ( ! bash diff --git a/packages/ui/src/features/sessions/components/SessionView.tsx b/packages/ui/src/features/sessions/components/SessionView.tsx index fccb3f179..ec9b477ba 100644 --- a/packages/ui/src/features/sessions/components/SessionView.tsx +++ b/packages/ui/src/features/sessions/components/SessionView.tsx @@ -22,7 +22,9 @@ import { PlanStatusBar } from "@posthog/ui/features/sessions/components/PlanStat import { ReasoningLevelSelector } from "@posthog/ui/features/sessions/components/ReasoningLevelSelector"; import { RawLogsView } from "@posthog/ui/features/sessions/components/raw-logs/RawLogsView"; import { SessionResourcesBar } from "@posthog/ui/features/sessions/components/SessionResourcesBar"; +import { SteerQueueToggle } from "@posthog/ui/features/sessions/components/SteerQueueToggle"; import { CHAT_CONTENT_MAX_WIDTH } from "@posthog/ui/features/sessions/constants"; +import { useToggleMessagingMode } from "@posthog/ui/features/sessions/hooks/useToggleMessagingMode"; import { useAdapterForTask, useModeConfigOptionForTask, @@ -161,6 +163,7 @@ export function SessionView({ const modeOption = useModeConfigOptionForTask(taskId); const thoughtOption = useThoughtLevelConfigOptionForTask(taskId); const adapter = useAdapterForTask(taskId); + const toggleMessagingMode = useToggleMessagingMode(taskId); const { allowBypassPermissions } = useSettingsStore(); const { isOnline } = useConnectivity(); const currentModeId = modeOption?.currentValue; @@ -625,6 +628,12 @@ export function SessionView({ /> ) : null } + messagingModeToggle={ + taskId ? ( + + ) : undefined + } + onToggleMessagingMode={toggleMessagingMode} onBeforeSubmit={handleBeforeSubmit} onSubmit={handleSubmit} onBashCommand={onBashCommand} diff --git a/packages/ui/src/features/sessions/components/SteerQueueToggle.tsx b/packages/ui/src/features/sessions/components/SteerQueueToggle.tsx new file mode 100644 index 000000000..113a56ad7 --- /dev/null +++ b/packages/ui/src/features/sessions/components/SteerQueueToggle.tsx @@ -0,0 +1,61 @@ +import { Lightning, Stack } from "@phosphor-icons/react"; +import { Button } from "@posthog/quill"; +import { + formatHotkey, + SHORTCUTS, +} from "@posthog/ui/features/command/keyboard-shortcuts"; +import { + useMessagingMode, + useSupportsNativeSteer, +} from "@posthog/ui/features/sessions/hooks/useMessagingMode"; +import { useToggleMessagingMode } from "@posthog/ui/features/sessions/hooks/useToggleMessagingMode"; +import { useQueuedMessagesForTask } from "@posthog/ui/features/sessions/useSession"; +import { Tooltip } from "@radix-ui/themes"; + +interface SteerQueueToggleProps { + taskId: string; +} + +export function SteerQueueToggle({ taskId }: SteerQueueToggleProps) { + const mode = useMessagingMode(taskId); + const supportsNativeSteer = useSupportsNativeSteer(taskId); + const queuedCount = useQueuedMessagesForTask(taskId).length; + const toggle = useToggleMessagingMode(taskId); + + const isSteer = mode === "steer"; + const shortcut = formatHotkey(SHORTCUTS.SWITCH_MESSAGING_MODE); + const label = isSteer + ? "Steer" + : queuedCount > 0 + ? `Queue (${queuedCount})` + : "Queue"; + + const tooltip = isSteer + ? supportsNativeSteer + ? `Steer: injects your message mid-turn at the next tool boundary. ${shortcut} to switch to Queue.` + : `Steer: interrupts the current turn and resends with your message. ${shortcut} to switch to Queue.` + : `Queue: holds messages until the current turn ends. ${shortcut} to switch to Steer.`; + + const colorClass = isSteer ? "text-purple-11" : "text-gray-11"; + + return ( + + + + ); +} diff --git a/packages/ui/src/features/sessions/hooks/useMessagingMode.ts b/packages/ui/src/features/sessions/hooks/useMessagingMode.ts new file mode 100644 index 000000000..e63d40e79 --- /dev/null +++ b/packages/ui/src/features/sessions/hooks/useMessagingMode.ts @@ -0,0 +1,30 @@ +import { + type MessagingMode, + useMessagingModeStore, +} from "@posthog/ui/features/sessions/messagingModeStore"; +import { useSessionStore } from "@posthog/ui/features/sessions/sessionStore"; +import { useSettingsStore } from "@posthog/ui/features/settings/settingsStore"; + +/** Effective messaging mode for a task: per-task override, else global default. */ +export function useMessagingMode(taskId: string | undefined): MessagingMode { + const override = useMessagingModeStore((s) => + taskId ? s.modesByTaskId[taskId] : undefined, + ); + const globalDefault = useSettingsStore((s) => s.defaultMessagingMode); + return override ?? globalDefault; +} + +/** + * Whether the task's session steers natively (Claude, local) versus falling + * back to interrupt-and-resend (Codex, cloud). Drives the steer label/tooltip, + * not whether steer is allowed: every adapter supports steer in some form. + */ +export function useSupportsNativeSteer(taskId: string | undefined): boolean { + return useSessionStore((s) => { + if (!taskId) return false; + const taskRunId = s.taskIdIndex[taskId]; + if (!taskRunId) return false; + const session = s.sessions[taskRunId]; + return !!session && !session.isCloud && session.adapter === "claude"; + }); +} diff --git a/packages/ui/src/features/sessions/hooks/useSessionCallbacks.ts b/packages/ui/src/features/sessions/hooks/useSessionCallbacks.ts index 5678edca4..70796b198 100644 --- a/packages/ui/src/features/sessions/hooks/useSessionCallbacks.ts +++ b/packages/ui/src/features/sessions/hooks/useSessionCallbacks.ts @@ -10,6 +10,7 @@ import { useService } from "@posthog/di/react"; import type { Task } from "@posthog/shared/domain-types"; import { tryExecuteCodeCommand } from "@posthog/ui/features/message-editor/commands"; import { useDraftStore } from "@posthog/ui/features/message-editor/draftStore"; +import { useMessagingMode } from "@posthog/ui/features/sessions/hooks/useMessagingMode"; import { type AgentSession, sessionStoreSetters, @@ -47,6 +48,8 @@ export function useSessionCallbacks({ const sessionRef = useRef(session); sessionRef.current = session; + const messagingMode = useMessagingMode(taskId); + const handleSendPrompt = useCallback( async (text: string) => { const currentSession = sessionRef.current; @@ -68,7 +71,9 @@ export function useSessionCallbacks({ try { markAsViewed(taskId); markActivity(taskId); - await sessionService.sendPrompt(taskId, text); + await sessionService.sendPrompt(taskId, text, { + steer: messagingMode === "steer", + }); const view = getAppViewSnapshot(); const isViewingTask = @@ -90,6 +95,7 @@ export function useSessionCallbacks({ markAsViewed, task.latest_run, sessionService, + messagingMode, ], ); diff --git a/packages/ui/src/features/sessions/hooks/useToggleMessagingMode.ts b/packages/ui/src/features/sessions/hooks/useToggleMessagingMode.ts new file mode 100644 index 000000000..ef7de8354 --- /dev/null +++ b/packages/ui/src/features/sessions/hooks/useToggleMessagingMode.ts @@ -0,0 +1,54 @@ +import { + SESSION_SERVICE, + type SessionService, +} from "@posthog/core/sessions/sessionService"; +import { useService } from "@posthog/di/react"; +import { useMessagingMode } from "@posthog/ui/features/sessions/hooks/useMessagingMode"; +import { useMessagingModeStore } from "@posthog/ui/features/sessions/messagingModeStore"; +import { sessionStoreSetters } from "@posthog/ui/features/sessions/sessionStore"; +import { useCallback } from "react"; + +/** + * Toggle a task between Steer and Queue, transferring pending messages so they + * follow the new mode: + * - Queue -> Steer: locally buffered messages are flushed into the running turn + * as steers (sent to the backend now). + * - Steer -> Queue: future messages buffer locally. Steers already handed to the + * backend keep injecting; the Claude SDK exposes no way to recall them. + */ +export function useToggleMessagingMode(taskId: string | undefined): () => void { + const sessionService = useService(SESSION_SERVICE); + const mode = useMessagingMode(taskId); + const setMode = useMessagingModeStore((s) => s.setMode); + + return useCallback(() => { + if (!taskId) return; + const next = mode === "steer" ? "queue" : "steer"; + setMode(taskId, next); + + if (next === "steer") { + // Flush buffered messages into the running turn in queued order so a + // message typed first lands first. rawPrompt preserves rich content. + // They are already dequeued, so roll any failed send back onto the queue + // rather than dropping it silently. + const queued = sessionStoreSetters.dequeueMessages(taskId); + void (async () => { + const failed: typeof queued = []; + for (const message of queued) { + try { + await sessionService.sendPrompt( + taskId, + message.rawPrompt ?? message.content, + { steer: true }, + ); + } catch { + failed.push(message); + } + } + if (failed.length > 0) { + sessionStoreSetters.prependQueuedMessages(taskId, failed); + } + })(); + } + }, [taskId, mode, setMode, sessionService]); +} diff --git a/packages/ui/src/features/sessions/messagingModeStore.test.ts b/packages/ui/src/features/sessions/messagingModeStore.test.ts new file mode 100644 index 000000000..917544c09 --- /dev/null +++ b/packages/ui/src/features/sessions/messagingModeStore.test.ts @@ -0,0 +1,24 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { useMessagingModeStore } from "./messagingModeStore"; + +describe("messagingModeStore", () => { + beforeEach(() => { + useMessagingModeStore.setState({ modesByTaskId: {} }); + }); + + it("stores a per-task mode override", () => { + useMessagingModeStore.getState().setMode("task-1", "steer"); + expect(useMessagingModeStore.getState().modesByTaskId["task-1"]).toBe( + "steer", + ); + }); + + it("keeps overrides independent per task", () => { + useMessagingModeStore.getState().setMode("task-1", "steer"); + useMessagingModeStore.getState().setMode("task-2", "queue"); + expect(useMessagingModeStore.getState().modesByTaskId).toEqual({ + "task-1": "steer", + "task-2": "queue", + }); + }); +}); diff --git a/packages/ui/src/features/sessions/messagingModeStore.ts b/packages/ui/src/features/sessions/messagingModeStore.ts new file mode 100644 index 000000000..535e73703 --- /dev/null +++ b/packages/ui/src/features/sessions/messagingModeStore.ts @@ -0,0 +1,27 @@ +import { electronStorage } from "@posthog/ui/shell/rendererStorage"; +import { create } from "zustand"; +import { persist } from "zustand/middleware"; + +export type MessagingMode = "queue" | "steer"; + +interface MessagingModeState { + modesByTaskId: Record; + setMode: (taskId: string, mode: MessagingMode) => void; +} + +export const useMessagingModeStore = create()( + persist( + (set) => ({ + modesByTaskId: {}, + setMode: (taskId, mode) => + set((state) => ({ + modesByTaskId: { ...state.modesByTaskId, [taskId]: mode }, + })), + }), + { + name: "messaging-mode-storage", + storage: electronStorage, + partialize: (state) => ({ modesByTaskId: state.modesByTaskId }), + }, + ), +); diff --git a/packages/ui/src/features/settings/sections/GeneralSettings.tsx b/packages/ui/src/features/settings/sections/GeneralSettings.tsx index a1abbf40b..2ddd484f1 100644 --- a/packages/ui/src/features/settings/sections/GeneralSettings.tsx +++ b/packages/ui/src/features/settings/sections/GeneralSettings.tsx @@ -8,6 +8,7 @@ import { type AutoConvertLongText, type CompletionSound, type DefaultInitialTaskMode, + type DefaultMessagingMode, type DefaultReasoningEffort, type DiffOpenMode, type SendMessagesWith, @@ -78,6 +79,7 @@ export function GeneralSettings() { completionVolume, autoConvertLongText, defaultInitialTaskMode, + defaultMessagingMode, defaultReasoningEffort, diffOpenMode, sendMessagesWith, @@ -89,6 +91,7 @@ export function GeneralSettings() { setCompletionVolume, setAutoConvertLongText, setDefaultInitialTaskMode, + setDefaultMessagingMode, setDefaultReasoningEffort, setDiffOpenMode, setSendMessagesWith, @@ -193,6 +196,18 @@ export function GeneralSettings() { [defaultInitialTaskMode, setDefaultInitialTaskMode], ); + const handleDefaultMessagingModeChange = useCallback( + (value: DefaultMessagingMode) => { + track(ANALYTICS_EVENTS.SETTING_CHANGED, { + setting_name: "default_messaging_mode", + new_value: value, + old_value: defaultMessagingMode, + }); + setDefaultMessagingMode(value); + }, + [defaultMessagingMode, setDefaultMessagingMode], + ); + const handleDefaultReasoningEffortChange = useCallback( (value: DefaultReasoningEffort) => { track(ANALYTICS_EVENTS.SETTING_CHANGED, { @@ -402,6 +417,25 @@ export function GeneralSettings() { + + + handleDefaultMessagingModeChange(value as DefaultMessagingMode) + } + size="1" + > + + + Queue + Steer + + + + void; setDefaultRunMode: (mode: DefaultRunMode) => void; setLastUsedRunMode: (mode: "local" | "cloud") => void; setLastUsedLocalWorkspaceMode: (mode: LocalWorkspaceMode) => void; @@ -153,6 +156,7 @@ export const useSettingsStore = create()( defaultInitialTaskMode: "plan", lastUsedInitialTaskMode: "plan", defaultReasoningEffort: "last_used", + defaultMessagingMode: "queue", setDefaultRunMode: (mode) => set({ defaultRunMode: mode }), setLastUsedRunMode: (mode) => set({ lastUsedRunMode: mode }), setLastUsedLocalWorkspaceMode: (mode) => @@ -182,6 +186,7 @@ export const useSettingsStore = create()( set({ lastUsedInitialTaskMode: mode }), setDefaultReasoningEffort: (effort) => set({ defaultReasoningEffort: effort }), + setDefaultMessagingMode: (mode) => set({ defaultMessagingMode: mode }), // Notifications desktopNotifications: true, @@ -283,6 +288,7 @@ export const useSettingsStore = create()( defaultInitialTaskMode: state.defaultInitialTaskMode, lastUsedInitialTaskMode: state.lastUsedInitialTaskMode, defaultReasoningEffort: state.defaultReasoningEffort, + defaultMessagingMode: state.defaultMessagingMode, // Notifications desktopNotifications: state.desktopNotifications, diff --git a/packages/workspace-server/src/services/agent/agent.ts b/packages/workspace-server/src/services/agent/agent.ts index dc8f8d66c..ddfa374fa 100644 --- a/packages/workspace-server/src/services/agent/agent.ts +++ b/packages/workspace-server/src/services/agent/agent.ts @@ -954,12 +954,28 @@ When creating pull requests, add the following footer at the end of the PR descr async prompt( sessionId: string, prompt: ContentBlock[], + options?: { steer?: boolean }, ): Promise { const session = this.sessions.get(sessionId); if (!session) { throw new Error(`Session not found: ${sessionId}`); } + // A steer is injected into the turn that is already running, which owns the + // promptPending/sleep/idle lifecycle. Forward it fire-and-forget so this + // call does not flip that shared state out from under the live turn. + if (options?.steer) { + const result = await session.clientSideConnection.prompt({ + sessionId: getAgentSessionId(session), + prompt, + _meta: { steer: true }, + }); + return { + stopReason: result.stopReason, + _meta: result._meta as PromptOutput["_meta"], + }; + } + // Prepend pending context if present let finalPrompt = prompt; if (session.pendingContext) { diff --git a/packages/workspace-server/src/services/agent/schemas.ts b/packages/workspace-server/src/services/agent/schemas.ts index af23b1a0b..5cfb8cd01 100644 --- a/packages/workspace-server/src/services/agent/schemas.ts +++ b/packages/workspace-server/src/services/agent/schemas.ts @@ -138,6 +138,7 @@ export const contentBlockSchema = z.looseObject({ export const promptInput = z.object({ sessionId: z.string(), prompt: z.array(contentBlockSchema), + steer: z.boolean().optional(), }); export type PromptInput = z.infer;