diff --git a/apps/code/src/renderer/api/posthogClient.ts b/apps/code/src/renderer/api/posthogClient.ts index 7307ffac4..ad27cc96f 100644 --- a/apps/code/src/renderer/api/posthogClient.ts +++ b/apps/code/src/renderer/api/posthogClient.ts @@ -728,20 +728,25 @@ export class PostHogAPIClient { async runTaskInCloud( taskId: string, branch?: string | null, - resumeOptions?: { resumeFromRunId: string; pendingUserMessage: string }, - sandboxEnvironmentId?: string, + options?: { + resumeFromRunId?: string; + pendingUserMessage?: string; + sandboxEnvironmentId?: string; + }, ): Promise { const teamId = await this.getTeamId(); const body: Record = { mode: "interactive" }; if (branch) { body.branch = branch; } - if (resumeOptions) { - body.resume_from_run_id = resumeOptions.resumeFromRunId; - body.pending_user_message = resumeOptions.pendingUserMessage; + if (options?.resumeFromRunId) { + body.resume_from_run_id = options.resumeFromRunId; + } + if (options?.pendingUserMessage) { + body.pending_user_message = options.pendingUserMessage; } - if (sandboxEnvironmentId) { - body.sandbox_environment_id = sandboxEnvironmentId; + if (options?.sandboxEnvironmentId) { + body.sandbox_environment_id = options.sandboxEnvironmentId; } const data = await this.api.post( diff --git a/apps/code/src/renderer/features/message-editor/components/AttachmentMenu.test.tsx b/apps/code/src/renderer/features/message-editor/components/AttachmentMenu.test.tsx new file mode 100644 index 000000000..739c4b9d7 --- /dev/null +++ b/apps/code/src/renderer/features/message-editor/components/AttachmentMenu.test.tsx @@ -0,0 +1,66 @@ +import { Theme } from "@radix-ui/themes"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockSelectFiles = vi.hoisted(() => vi.fn()); + +vi.mock("@renderer/trpc/client", () => ({ + trpcClient: { + os: { + selectFiles: { + query: mockSelectFiles, + }, + }, + }, + useTRPC: () => ({ + git: { + getGhStatus: { + queryOptions: () => ({}), + }, + }, + }), +})); + +vi.mock("@tanstack/react-query", () => ({ + useQuery: () => ({ data: undefined }), +})); + +vi.mock("@renderer/utils/toast", () => ({ + toast: { + error: vi.fn(), + }, +})); + +import { AttachmentMenu } from "./AttachmentMenu"; + +describe("AttachmentMenu", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("adds attachments using absolute file paths from the OS picker", async () => { + const user = userEvent.setup(); + const onAddAttachment = vi.fn(); + + mockSelectFiles.mockResolvedValue(["/tmp/demo/test.txt"]); + + render( + + + , + ); + + await user.click(screen.getByRole("button")); + await user.click(await screen.findByText("Add file")); + + expect(mockSelectFiles).toHaveBeenCalledOnce(); + expect(onAddAttachment).toHaveBeenCalledWith({ + id: "/tmp/demo/test.txt", + label: "test.txt", + }); + }); +}); diff --git a/apps/code/src/renderer/features/message-editor/components/AttachmentMenu.tsx b/apps/code/src/renderer/features/message-editor/components/AttachmentMenu.tsx index b36539ff6..73704b692 100644 --- a/apps/code/src/renderer/features/message-editor/components/AttachmentMenu.tsx +++ b/apps/code/src/renderer/features/message-editor/components/AttachmentMenu.tsx @@ -1,11 +1,13 @@ import "./AttachmentMenu.css"; -import { Tooltip } from "@components/ui/Tooltip"; import { File, GithubLogo, Paperclip } from "@phosphor-icons/react"; import { IconButton, Popover } from "@radix-ui/themes"; -import { useTRPC } from "@renderer/trpc/client"; +import { trpcClient, useTRPC } from "@renderer/trpc/client"; +import { toast } from "@renderer/utils/toast"; import { useQuery } from "@tanstack/react-query"; +import { getFileName } from "@utils/path"; import { useRef, useState } from "react"; import type { FileAttachment, MentionChip } from "../utils/content"; +import { persistBrowserFile } from "../utils/persistFile"; import { IssuePicker } from "./IssuePicker"; type View = "menu" | "issues"; @@ -54,20 +56,42 @@ export function AttachmentMenu({ const issueDisabledReason = getIssueDisabledReason(ghStatus, repoPath); - const handleFileSelect = (e: React.ChangeEvent) => { - const files = e.target.files; - if (files && files.length > 0) { - const fileArray = Array.from(files); - for (const file of fileArray) { - const filePath = - (file as globalThis.File & { path?: string }).path || file.name; - onAddAttachment({ id: filePath, label: file.name }); - } - onAttachFiles?.(fileArray); - } + const handleFileSelect = async (e: React.ChangeEvent) => { + const files = e.target.files ? Array.from(e.target.files) : []; if (fileInputRef.current) { fileInputRef.current.value = ""; } + + if (files.length === 0) { + return; + } + + try { + const attachments = await Promise.all( + files.map(async (file) => { + const filePath = (file as globalThis.File & { path?: string }).path; + if (filePath) { + return { id: filePath, label: file.name } satisfies FileAttachment; + } + + return await persistBrowserFile(file); + }), + ); + + for (const attachment of attachments) { + if (attachment) { + onAddAttachment(attachment); + } + } + + onAttachFiles?.(files); + } catch (error) { + toast.error( + error instanceof Error + ? error.message + : "Unable to attach selected files from this picker", + ); + } }; const handleOpenChange = (isOpen: boolean) => { @@ -77,8 +101,21 @@ export function AttachmentMenu({ } }; - const handleAddFile = () => { + const handleAddFile = async () => { setOpen(false); + + try { + const filePaths = await trpcClient.os.selectFiles.query(); + if (filePaths.length > 0) { + for (const filePath of filePaths) { + onAddAttachment({ id: filePath, label: getFileName(filePath) }); + } + } + return; + } catch { + // Fall back to the input element for non-Electron environments. + } + fileInputRef.current?.click(); }; @@ -112,18 +149,17 @@ export function AttachmentMenu({ style={{ display: "none" }} /> - - - - - - - + + + + + {view === "menu" ? (
@@ -138,9 +174,7 @@ export function AttachmentMenu({ Add file {issueDisabledReason ? ( - - {issueButton} - + {issueButton} ) : ( issueButton )} diff --git a/apps/code/src/renderer/features/message-editor/tiptap/useDraftSync.test.tsx b/apps/code/src/renderer/features/message-editor/tiptap/useDraftSync.test.tsx new file mode 100644 index 000000000..133365e52 --- /dev/null +++ b/apps/code/src/renderer/features/message-editor/tiptap/useDraftSync.test.tsx @@ -0,0 +1,63 @@ +import { act, render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@utils/electronStorage", () => ({ + electronStorage: { + getItem: () => null, + setItem: () => {}, + removeItem: () => {}, + }, +})); + +import { useDraftStore } from "../stores/draftStore"; +import { useDraftSync } from "./useDraftSync"; + +function DraftAttachmentsProbe({ sessionId }: { sessionId: string }) { + const { restoredAttachments } = useDraftSync(null, sessionId); + return ( +
+ {restoredAttachments.map((att) => att.label).join(",") || "empty"} +
+ ); +} + +describe("useDraftSync", () => { + beforeEach(() => { + vi.clearAllMocks(); + useDraftStore.setState((state) => ({ + ...state, + drafts: {}, + contexts: {}, + commands: {}, + focusRequested: {}, + pendingContent: {}, + _hasHydrated: true, + })); + }); + + it("clears restored attachments when a draft no longer has attachments", () => { + const { rerender } = render( + , + ); + + act(() => { + useDraftStore.getState().actions.setDraft("session-1", { + segments: [{ type: "text", text: "hello" }], + attachments: [{ id: "/tmp/file.txt", label: "file.txt" }], + }); + }); + + expect(screen.getByText("file.txt")).toBeInTheDocument(); + + act(() => { + useDraftStore.getState().actions.setDraft("session-1", { + segments: [{ type: "text", text: "hello" }], + }); + }); + + expect(screen.getByText("empty")).toBeInTheDocument(); + + rerender(); + expect(screen.getByText("empty")).toBeInTheDocument(); + }); +}); diff --git a/apps/code/src/renderer/features/message-editor/tiptap/useDraftSync.ts b/apps/code/src/renderer/features/message-editor/tiptap/useDraftSync.ts index 340fb19d3..eefe4e26a 100644 --- a/apps/code/src/renderer/features/message-editor/tiptap/useDraftSync.ts +++ b/apps/code/src/renderer/features/message-editor/tiptap/useDraftSync.ts @@ -171,9 +171,12 @@ export function useDraftSync( >([]); useLayoutEffect(() => { if (!draft || typeof draft === "string") return; - if (draft.attachments && draft.attachments.length > 0) { - setRestoredAttachments(draft.attachments); - } + const incoming = draft.attachments ?? []; + // Short-circuit the common empty→empty case to avoid creating a new array + // reference that would trigger unnecessary re-renders. + setRestoredAttachments((prev) => + prev.length === 0 && incoming.length === 0 ? prev : incoming, + ); }, [draft]); const attachmentsRef = useRef([]); diff --git a/apps/code/src/renderer/features/message-editor/tiptap/useTiptapEditor.ts b/apps/code/src/renderer/features/message-editor/tiptap/useTiptapEditor.ts index ae101b0d2..a5752157a 100644 --- a/apps/code/src/renderer/features/message-editor/tiptap/useTiptapEditor.ts +++ b/apps/code/src/renderer/features/message-editor/tiptap/useTiptapEditor.ts @@ -1,6 +1,5 @@ import { sessionStoreSetters } from "@features/sessions/stores/sessionStore"; import { useSettingsStore as useFeatureSettingsStore } from "@features/settings/stores/settingsStore"; -import { trpcClient } from "@renderer/trpc/client"; import { toast } from "@renderer/utils/toast"; import { useSettingsStore } from "@stores/settingsStore"; import type { EditorView } from "@tiptap/pm/view"; @@ -10,6 +9,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { usePromptHistoryStore } from "../stores/promptHistoryStore"; import type { FileAttachment, MentionChip } from "../utils/content"; import { contentToXml, isContentEmpty } from "../utils/content"; +import { persistImageFile, persistTextContent } from "../utils/persistFile"; import { getEditorExtensions } from "./extensions"; import { type DraftContext, useDraftSync } from "./useDraftSync"; @@ -45,7 +45,7 @@ async function pasteTextAsFile( text: string, pasteCountRef: React.MutableRefObject, ): Promise { - const result = await trpcClient.os.saveClipboardText.mutate({ text }); + const result = await persistTextContent(text); pasteCountRef.current += 1; const lineCount = text.split("\n").length; const label = `Pasted text #${pasteCountRef.current} (${lineCount} lines)`; @@ -331,19 +331,7 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) { if (!file) continue; try { - const arrayBuffer = await file.arrayBuffer(); - const base64 = btoa( - new Uint8Array(arrayBuffer).reduce( - (data, byte) => data + String.fromCharCode(byte), - "", - ), - ); - - const result = await trpcClient.os.saveClipboardImage.mutate({ - base64Data: base64, - mimeType: file.type, - originalName: file.name, - }); + const result = await persistImageFile(file); setAttachments((prev) => { if (prev.some((a) => a.id === result.path)) return prev; @@ -448,9 +436,7 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) { // Restore attachments from draft on mount useEffect(() => { - if (draft.restoredAttachments.length > 0) { - setAttachments(draft.restoredAttachments); - } + setAttachments(draft.restoredAttachments); // Only run on mount / session change // eslint-disable-next-line react-hooks/exhaustive-deps }, [draft.restoredAttachments]); diff --git a/apps/code/src/renderer/features/sessions/components/ConversationView.tsx b/apps/code/src/renderer/features/sessions/components/ConversationView.tsx index 91e6af776..b5bacb4dc 100644 --- a/apps/code/src/renderer/features/sessions/components/ConversationView.tsx +++ b/apps/code/src/renderer/features/sessions/components/ConversationView.tsx @@ -146,6 +146,7 @@ export function ConversationView({ return ( { + it("extracts cloud prompt attachments into user messages", () => { + const uri = makeAttachmentUri("/tmp/hello world.txt"); + + const events: AcpMessage[] = [ + { + type: "acp_message", + ts: 1, + message: { + jsonrpc: "2.0", + id: 1, + method: "session/prompt", + params: { + prompt: [ + { type: "text", text: "read this file" }, + { + type: "resource", + resource: { + uri, + text: "watup", + mimeType: "text/plain", + }, + }, + ], + }, + }, + }, + ]; + + const result = buildConversationItems(events, null); + + expect(result.items).toEqual([ + { + type: "user_message", + id: "turn-1-1-user", + content: "read this file", + timestamp: 1, + attachments: [ + { + id: uri, + label: "hello world.txt", + }, + ], + }, + ]); + }); + + it("keeps attachment-only prompts visible", () => { + const uri = makeAttachmentUri("/tmp/test.txt"); + + const events: AcpMessage[] = [ + { + type: "acp_message", + ts: 1, + message: { + jsonrpc: "2.0", + id: 1, + method: "session/prompt", + params: { + prompt: [ + { + type: "resource", + resource: { + uri, + text: "watup", + mimeType: "text/plain", + }, + }, + ], + }, + }, + }, + ]; + + const result = buildConversationItems(events, null); + + expect(result.items).toEqual([ + { + type: "user_message", + id: "turn-1-1-user", + content: "", + timestamp: 1, + attachments: [ + { + id: uri, + label: "test.txt", + }, + ], + }, + ]); + }); +}); diff --git a/apps/code/src/renderer/features/sessions/components/buildConversationItems.ts b/apps/code/src/renderer/features/sessions/components/buildConversationItems.ts index 38f442e42..7e5d972e5 100644 --- a/apps/code/src/renderer/features/sessions/components/buildConversationItems.ts +++ b/apps/code/src/renderer/features/sessions/components/buildConversationItems.ts @@ -12,8 +12,10 @@ import { isJsonRpcResponse, type UserShellExecuteParams, } from "@shared/types/session-events"; +import { extractPromptDisplayContent } from "@utils/promptContent"; import { type GitActionType, parseGitActionMessage } from "./GitActionMessage"; import type { RenderItem } from "./session-update/SessionUpdateView"; +import type { UserMessageAttachment } from "./session-update/UserMessage"; import type { UserShellExecute } from "./session-update/UserShellExecuteView"; export interface TurnContext { @@ -24,7 +26,13 @@ export interface TurnContext { } export type ConversationItem = - | { type: "user_message"; id: string; content: string; timestamp: number } + | { + type: "user_message"; + id: string; + content: string; + timestamp: number; + attachments?: UserMessageAttachment[]; + } | { type: "git_action"; id: string; actionType: GitActionType } | { type: "session_update"; @@ -197,9 +205,12 @@ function handlePromptRequest( b.currentTurn.context.turnComplete = true; } - const userContent = extractUserContent(msg.params); + const userPrompt = extractUserPrompt(msg.params); + const userContent = userPrompt.content; - if (userContent.trim().length === 0) return; + if (userContent.trim().length === 0 && userPrompt.attachments.length === 0) { + return; + } const turnId = `turn-${ts}-${msg.id}`; const toolCalls = new Map(); @@ -238,6 +249,7 @@ function handlePromptRequest( id: `${turnId}-user`, content: userContent, timestamp: ts, + attachments: userPrompt.attachments, }); } } @@ -406,23 +418,19 @@ function ensureImplicitTurn(b: ItemBuilder, ts: number) { }; } -interface TextBlockWithMeta { - type: "text"; - text: string; - _meta?: { ui?: { hidden?: boolean } }; -} - -function extractUserContent(params: unknown): string { +function extractUserPrompt(params: unknown): { + content: string; + attachments: UserMessageAttachment[]; +} { const p = params as { prompt?: ContentBlock[] }; - if (!p?.prompt?.length) return ""; + if (!p?.prompt?.length) { + return { content: "", attachments: [] }; + } - const visibleTextBlocks = p.prompt.filter((b): b is TextBlockWithMeta => { - if (b.type !== "text") return false; - const meta = (b as TextBlockWithMeta)._meta; - return !meta?.ui?.hidden; + const { text, attachments } = extractPromptDisplayContent(p.prompt, { + filterHidden: true, }); - - return visibleTextBlocks.map((b) => b.text).join(""); + return { content: text, attachments }; } function getParentToolCallId(update: SessionUpdate): string | undefined { diff --git a/apps/code/src/renderer/features/sessions/components/session-update/UserMessage.test.tsx b/apps/code/src/renderer/features/sessions/components/session-update/UserMessage.test.tsx new file mode 100644 index 000000000..a1ac9cd60 --- /dev/null +++ b/apps/code/src/renderer/features/sessions/components/session-update/UserMessage.test.tsx @@ -0,0 +1,24 @@ +import { Theme } from "@radix-ui/themes"; +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { UserMessage } from "./UserMessage"; + +describe("UserMessage", () => { + it("renders attachment chips for cloud prompts", () => { + render( + + + , + ); + + expect(screen.getByText("read this file")).toBeInTheDocument(); + expect(screen.getByText("test.txt")).toBeInTheDocument(); + expect(screen.getByText("notes.md")).toBeInTheDocument(); + }); +}); diff --git a/apps/code/src/renderer/features/sessions/components/session-update/UserMessage.tsx b/apps/code/src/renderer/features/sessions/components/session-update/UserMessage.tsx index 41df0951c..403cdbc8e 100644 --- a/apps/code/src/renderer/features/sessions/components/session-update/UserMessage.tsx +++ b/apps/code/src/renderer/features/sessions/components/session-update/UserMessage.tsx @@ -5,18 +5,29 @@ import { CaretUp, Check, Copy, + File, SlackLogo, } from "@phosphor-icons/react"; -import { Box, IconButton } from "@radix-ui/themes"; +import { Box, Flex, IconButton } from "@radix-ui/themes"; import { useCallback, useEffect, useRef, useState } from "react"; -import { hasFileMentions, parseFileMentions } from "./parseFileMentions"; +import { + hasFileMentions, + MentionChip, + parseFileMentions, +} from "./parseFileMentions"; const COLLAPSED_MAX_HEIGHT = 160; +export interface UserMessageAttachment { + id: string; + label: string; +} + interface UserMessageProps { content: string; timestamp?: number; sourceUrl?: string; + attachments?: UserMessageAttachment[]; } function formatTimestamp(ts: number): string { @@ -34,8 +45,10 @@ export function UserMessage({ content, timestamp, sourceUrl, + attachments = [], }: UserMessageProps) { const containsFileMentions = hasFileMentions(content); + const showAttachmentChips = attachments.length > 0 && !containsFileMentions; const [copied, setCopied] = useState(false); const [isExpanded, setIsExpanded] = useState(false); const [isOverflowing, setIsOverflowing] = useState(false); @@ -73,6 +86,17 @@ export function UserMessage({ ) : ( )} + {showAttachmentChips && ( + + {attachments.map((attachment) => ( + } + label={attachment.label} + /> + ))} + + )} {!isExpanded && isOverflowing && ( ({ writeLocalLogs: { mutate: vi.fn() }, })); +const mockTrpcCloudTask = vi.hoisted(() => ({ + sendCommand: { mutate: vi.fn() }, +})); + vi.mock("@renderer/trpc/client", () => ({ trpcClient: { agent: mockTrpcAgent, workspace: mockTrpcWorkspace, logs: mockTrpcLogs, + cloudTask: mockTrpcCloudTask, }, })); @@ -583,6 +589,50 @@ describe("SessionService", () => { }); }); + it("serializes structured prompts before sending cloud follow-ups", async () => { + const service = getSessionService(); + mockSessionStoreSetters.getSessionByTaskId.mockReturnValue( + createMockSession({ + isCloud: true, + cloudStatus: "in_progress", + }), + ); + mockTrpcCloudTask.sendCommand.mutate.mockResolvedValue({ + success: true, + result: { stopReason: "end_turn" }, + }); + + const prompt: ContentBlock[] = [ + { type: "text", text: "read this" }, + { + type: "resource", + resource: { + uri: "attachment://test.txt", + text: "hello from file", + mimeType: "text/plain", + }, + }, + ]; + + const result = await service.sendPrompt("task-123", prompt); + + expect(result.stopReason).toBe("end_turn"); + expect(mockTrpcCloudTask.sendCommand.mutate).toHaveBeenCalledTimes(1); + + const [args] = mockTrpcCloudTask.sendCommand.mutate.mock.calls[0] as [ + { + params?: { content?: unknown }; + }, + ]; + + expect(args.params?.content).toEqual( + expect.stringContaining("__twig_cloud_prompt_v1__:"), + ); + expect(args.params?.content).toEqual( + expect.stringContaining('"type":"resource"'), + ); + }); + it("sets session to error state on fatal error", async () => { const service = getSessionService(); const mockSession = createMockSession(); diff --git a/apps/code/src/renderer/features/sessions/service/service.ts b/apps/code/src/renderer/features/sessions/service/service.ts index faca70e45..2940b4195 100644 --- a/apps/code/src/renderer/features/sessions/service/service.ts +++ b/apps/code/src/renderer/features/sessions/service/service.ts @@ -8,6 +8,11 @@ import { getAuthenticatedClient, } from "@features/auth/hooks/authClient"; import { fetchAuthState } from "@features/auth/hooks/authQueries"; +import { + buildCloudPromptBlocks, + buildCloudTaskDescription, + serializeCloudPrompt, +} from "@features/editor/utils/cloud-prompt"; import { useSessionAdapterStore } from "@features/sessions/stores/sessionAdapterStore"; import { getPersistedConfigOptions, @@ -61,6 +66,7 @@ import { } from "@utils/session"; const log = logger.scope("session-service"); +const TERMINAL_CLOUD_STATUSES = new Set(["completed", "failed", "cancelled"]); interface AuthCredentials { apiHost: string; @@ -1149,23 +1155,42 @@ export class SessionService { // --- Cloud Commands --- + private async prepareCloudPrompt( + prompt: string | ContentBlock[], + ): Promise<{ blocks: ContentBlock[]; promptText: string }> { + const blocks = + typeof prompt === "string" + ? await buildCloudPromptBlocks(prompt) + : prompt; + + if (blocks.length === 0) { + throw new Error("Cloud prompt cannot be empty"); + } + + const promptText = + extractPromptText(blocks).trim() || + (typeof prompt === "string" ? buildCloudTaskDescription(prompt) : ""); + + return { blocks, promptText }; + } + private async sendCloudPrompt( session: AgentSession, prompt: string | ContentBlock[], options?: { skipQueueGuard?: boolean }, ): Promise<{ stopReason: string }> { - const promptText = extractPromptText(prompt); - if (!promptText.trim()) { - return { stopReason: "empty" }; - } - - const terminalStatuses = new Set(["completed", "failed", "cancelled"]); - if (session.cloudStatus && terminalStatuses.has(session.cloudStatus)) { - return this.resumeCloudRun(session, promptText); + if ( + session.cloudStatus && + TERMINAL_CLOUD_STATUSES.has(session.cloudStatus) + ) { + return this.resumeCloudRun(session, prompt); } if (!options?.skipQueueGuard && session.isPromptPending) { - sessionStoreSetters.enqueueMessage(session.taskId, promptText); + sessionStoreSetters.enqueueMessage( + session.taskId, + typeof prompt === "string" ? prompt : extractPromptText(prompt), + ); log.info("Cloud message queued", { taskId: session.taskId, queueLength: session.messageQueue.length + 1, @@ -1178,6 +1203,8 @@ export class SessionService { throw new Error("Authentication required for cloud commands"); } + const { blocks, promptText } = await this.prepareCloudPrompt(prompt); + sessionStoreSetters.updateSession(session.taskRunId, { isPromptPending: true, }); @@ -1196,7 +1223,11 @@ export class SessionService { apiHost: auth.apiHost, teamId: auth.teamId, method: "user_message", - params: { content: promptText }, + params: { + // The live /command API still validates user_message content as a + // string, so structured prompts must go through the serialized form. + content: serializeCloudPrompt(blocks), + }, }); sessionStoreSetters.updateSession(session.taskRunId, { @@ -1302,13 +1333,15 @@ export class SessionService { private async resumeCloudRun( session: AgentSession, - promptText: string, + prompt: string | ContentBlock[], ): Promise<{ stopReason: string }> { const client = await getAuthenticatedClient(); if (!client) { throw new Error("Authentication required for cloud commands"); } + const { blocks, promptText } = await this.prepareCloudPrompt(prompt); + log.info("Creating resume run for terminal cloud task", { taskId: session.taskId, previousRunId: session.taskRunId, @@ -1323,7 +1356,7 @@ export class SessionService { session.cloudBranch, { resumeFromRunId: session.taskRunId, - pendingUserMessage: promptText, + pendingUserMessage: serializeCloudPrompt(blocks), }, ); const newRun = updatedTask.latest_run; @@ -1372,8 +1405,10 @@ export class SessionService { } private async cancelCloudPrompt(session: AgentSession): Promise { - const terminalStatuses = new Set(["completed", "failed", "cancelled"]); - if (session.cloudStatus && terminalStatuses.has(session.cloudStatus)) { + if ( + session.cloudStatus && + TERMINAL_CLOUD_STATUSES.has(session.cloudStatus) + ) { log.info("Skipping cancel for terminal cloud run", { taskId: session.taskId, status: session.cloudStatus, @@ -2029,8 +2064,7 @@ export class SessionService { } } - const terminalStatuses = new Set(["completed", "failed", "cancelled"]); - if (update.status && terminalStatuses.has(update.status)) { + if (update.status && TERMINAL_CLOUD_STATUSES.has(update.status)) { // Clean up any pending resume messages that couldn't be sent const session = sessionStoreSetters.getSessions()[taskRunId]; if ( diff --git a/apps/code/src/renderer/features/task-detail/components/TaskInputEditor.tsx b/apps/code/src/renderer/features/task-detail/components/TaskInputEditor.tsx index a38049949..2b3dcc7e6 100644 --- a/apps/code/src/renderer/features/task-detail/components/TaskInputEditor.tsx +++ b/apps/code/src/renderer/features/task-detail/components/TaskInputEditor.tsx @@ -12,7 +12,7 @@ import { UnifiedModelSelector } from "@features/sessions/components/UnifiedModel import type { AgentAdapter } from "@features/settings/stores/settingsStore"; import { useConnectivity } from "@hooks/useConnectivity"; import { ArrowUp } from "@phosphor-icons/react"; -import { Box, Flex, IconButton, Text, Tooltip } from "@radix-ui/themes"; +import { Box, Flex, IconButton, Text } from "@radix-ui/themes"; import { trpcClient } from "@renderer/trpc/client"; import { EditorContent } from "@tiptap/react"; import { forwardRef, useCallback, useEffect, useImperativeHandle } from "react"; @@ -270,30 +270,29 @@ export const TaskInputEditor = forwardRef< - - { - e.stopPropagation(); - onSubmit(); - }} - disabled={!canSubmit || isSubmitDisabled} - loading={isCreatingTask} - style={{ - backgroundColor: - !canSubmit || isSubmitDisabled - ? "var(--accent-a4)" - : undefined, - color: - !canSubmit || isSubmitDisabled - ? "var(--accent-8)" - : undefined, - }} - > - - - + { + e.stopPropagation(); + onSubmit(); + }} + disabled={!canSubmit || isSubmitDisabled} + loading={isCreatingTask} + style={{ + backgroundColor: + !canSubmit || isSubmitDisabled + ? "var(--accent-a4)" + : undefined, + color: + !canSubmit || isSubmitDisabled + ? "var(--accent-8)" + : undefined, + }} + > + + diff --git a/apps/code/src/renderer/features/task-detail/hooks/useTaskCreation.ts b/apps/code/src/renderer/features/task-detail/hooks/useTaskCreation.ts index cf8fba1aa..f508afef6 100644 --- a/apps/code/src/renderer/features/task-detail/hooks/useTaskCreation.ts +++ b/apps/code/src/renderer/features/task-detail/hooks/useTaskCreation.ts @@ -1,4 +1,5 @@ import { useAuthStateValue } from "@features/auth/hooks/authQueries"; +import { buildCloudTaskDescription } from "@features/editor/utils/cloud-prompt"; import type { MessageEditorHandle } from "@features/message-editor/components/MessageEditor"; import { useTaskInputHistoryStore } from "@features/message-editor/stores/taskInputHistoryStore"; import { @@ -59,9 +60,16 @@ function prepareTaskInput( sandboxEnvironmentId?: string; }, ): TaskCreationInput { + const serializedContent = contentToXml(content).trim(); + const filePaths = extractFilePaths(content); + return { - content: contentToXml(content).trim(), - filePaths: extractFilePaths(content), + content: serializedContent, + taskDescription: + options.workspaceMode === "cloud" + ? buildCloudTaskDescription(serializedContent, filePaths) + : undefined, + filePaths, repoPath: options.selectedDirectory, repository: options.selectedRepository, githubIntegrationId: options.githubIntegrationId, @@ -81,6 +89,7 @@ function getErrorTitle(failedStep: string): string { repo_detection: "Failed to detect repository", task_creation: "Failed to create task", workspace_creation: "Failed to create workspace", + cloud_prompt_preparation: "Failed to prepare cloud attachments", cloud_run: "Failed to start cloud execution", agent_session: "Failed to start agent session", }; diff --git a/apps/code/src/renderer/sagas/task/task-creation.test.ts b/apps/code/src/renderer/sagas/task/task-creation.test.ts index 878677d08..8479dbdee 100644 --- a/apps/code/src/renderer/sagas/task/task-creation.test.ts +++ b/apps/code/src/renderer/sagas/task/task-creation.test.ts @@ -4,6 +4,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const mockWorkspaceCreate = vi.hoisted(() => vi.fn()); const mockWorkspaceDelete = vi.hoisted(() => vi.fn()); const mockGetTaskDirectory = vi.hoisted(() => vi.fn()); +const mockReadAbsoluteFile = vi.hoisted(() => vi.fn()); +const mockReadFileAsBase64 = vi.hoisted(() => vi.fn()); vi.mock("@renderer/trpc", () => ({ trpcClient: { @@ -14,6 +16,15 @@ vi.mock("@renderer/trpc", () => ({ }, })); +vi.mock("@renderer/trpc/client", () => ({ + trpcClient: { + fs: { + readAbsoluteFile: { query: mockReadAbsoluteFile }, + readFileAsBase64: { query: mockReadFileAsBase64 }, + }, + }, +})); + vi.mock("@hooks/useRepositoryDirectory", () => ({ getTaskDirectory: mockGetTaskDirectory, })); @@ -100,6 +111,8 @@ describe("TaskCreationSaga", () => { mockWorkspaceCreate.mockResolvedValue(undefined); mockWorkspaceDelete.mockResolvedValue(undefined); mockGetTaskDirectory.mockResolvedValue(null); + mockReadAbsoluteFile.mockResolvedValue(null); + mockReadFileAsBase64.mockResolvedValue(null); }); it("waits for the cloud run response before surfacing the task", async () => { @@ -107,6 +120,7 @@ describe("TaskCreationSaga", () => { const startedTask = createTask({ latest_run: createRun() }); const createTaskMock = vi.fn().mockResolvedValue(createdTask); const runTaskInCloudMock = vi.fn().mockResolvedValue(startedTask); + const sendRunCommandMock = vi.fn(); const onTaskReady = vi.fn(); const saga = new TaskCreationSaga({ @@ -115,6 +129,7 @@ describe("TaskCreationSaga", () => { deleteTask: vi.fn(), getTask: vi.fn(), runTaskInCloud: runTaskInCloudMock, + sendRunCommand: sendRunCommandMock, updateTask: vi.fn(), } as never, onTaskReady, @@ -135,9 +150,12 @@ describe("TaskCreationSaga", () => { expect(runTaskInCloudMock).toHaveBeenCalledWith( "task-123", "release/remembered-branch", - undefined, - undefined, + { + pendingUserMessage: "Ship the fix", + sandboxEnvironmentId: undefined, + }, ); + expect(sendRunCommandMock).not.toHaveBeenCalled(); expect(onTaskReady).toHaveBeenCalledTimes(1); expect(onTaskReady.mock.calls[0][0].task.latest_run?.branch).toBe( "release/remembered-branch", @@ -149,4 +167,61 @@ describe("TaskCreationSaga", () => { onTaskReady.mock.invocationCallOrder[0], ); }); + + it("sends initial cloud prompts with attachments as pending user messages", async () => { + const createdTask = createTask(); + const startedTask = createTask({ latest_run: createRun() }); + const createTaskMock = vi.fn().mockResolvedValue(createdTask); + const runTaskInCloudMock = vi.fn().mockResolvedValue(startedTask); + const sendRunCommandMock = vi.fn(); + const onTaskReady = vi.fn(); + + mockReadAbsoluteFile.mockResolvedValue("hello from attachment"); + + const saga = new TaskCreationSaga({ + posthogClient: { + createTask: createTaskMock, + deleteTask: vi.fn(), + getTask: vi.fn(), + runTaskInCloud: runTaskInCloudMock, + sendRunCommand: sendRunCommandMock, + updateTask: vi.fn(), + } as never, + onTaskReady, + }); + + const result = await saga.run({ + content: 'read this file ', + taskDescription: "read this file\n\nAttached files: test.txt", + filePaths: ["/tmp/test.txt"], + repository: "posthog/posthog", + workspaceMode: "cloud", + branch: "release/remembered-branch", + }); + + expect(result.success).toBe(true); + if (!result.success) { + throw new Error("Expected task creation to succeed"); + } + + expect(createTaskMock).toHaveBeenCalledWith( + expect.objectContaining({ + description: "read this file\n\nAttached files: test.txt", + }), + ); + expect(runTaskInCloudMock).toHaveBeenCalledWith( + "task-123", + "release/remembered-branch", + { + pendingUserMessage: expect.stringContaining( + "__twig_cloud_prompt_v1__:", + ), + sandboxEnvironmentId: undefined, + }, + ); + expect(sendRunCommandMock).not.toHaveBeenCalled(); + expect(runTaskInCloudMock.mock.invocationCallOrder[0]).toBeLessThan( + onTaskReady.mock.invocationCallOrder[0], + ); + }); }); diff --git a/apps/code/src/renderer/sagas/task/task-creation.ts b/apps/code/src/renderer/sagas/task/task-creation.ts index 35b5c0427..d4835abdf 100644 --- a/apps/code/src/renderer/sagas/task/task-creation.ts +++ b/apps/code/src/renderer/sagas/task/task-creation.ts @@ -1,3 +1,7 @@ +import { + buildCloudPromptBlocks, + serializeCloudPrompt, +} from "@features/editor/utils/cloud-prompt"; import { buildPromptBlocks } from "@features/editor/utils/prompt-builder"; import { DEFAULT_PANEL_IDS } from "@features/panels/constants/panelConstants"; import { usePanelLayoutStore } from "@features/panels/store/panelLayoutStore"; @@ -61,6 +65,7 @@ export interface TaskCreationInput { taskId?: string; // For creating new task (required if no taskId) content?: string; + taskDescription?: string; filePaths?: string[]; repoPath?: string; repository?: string | null; @@ -99,6 +104,13 @@ export class TaskCreationSaga extends Saga< protected async execute( input: TaskCreationInput, ): Promise { + const initialCloudPrompt = + input.workspaceMode === "cloud" && !input.taskId && input.content + ? await this.readOnlyStep("cloud_prompt_preparation", () => + buildCloudPromptBlocks(input.content ?? "", input.filePaths), + ) + : null; + // Step 1: Get or create task // For new tasks, start folder registration in parallel with task creation // since folder_registration only needs repoPath (from input), not task.id @@ -116,7 +128,11 @@ export class TaskCreationSaga extends Saga< // Fire-and-forget: generate a proper LLM title for new tasks if (!taskId) { - generateTaskTitle(task.id, input.content ?? "", this.deps.posthogClient); + generateTaskTitle( + task.id, + input.taskDescription ?? input.content ?? "", + this.deps.posthogClient, + ); } const repoKey = getTaskRepository(task); @@ -260,12 +276,12 @@ export class TaskCreationSaga extends Saga< task = await this.step({ name: "cloud_run", execute: () => - this.deps.posthogClient.runTaskInCloud( - task.id, - branch, - undefined, - input.sandboxEnvironmentId, - ), + this.deps.posthogClient.runTaskInCloud(task.id, branch, { + pendingUserMessage: initialCloudPrompt + ? serializeCloudPrompt(initialCloudPrompt) + : undefined, + sandboxEnvironmentId: input.sandboxEnvironmentId, + }), rollback: async () => { log.info("Rolling back: cloud run (no-op)", { taskId: task.id }); }, @@ -390,7 +406,7 @@ export class TaskCreationSaga extends Saga< name: "task_creation", execute: async () => { const result = await this.deps.posthogClient.createTask({ - description: input.content ?? "", + description: input.taskDescription ?? input.content ?? "", repository: repository ?? undefined, github_integration: input.workspaceMode === "cloud"