From 28705fd086365baa7dcb5b9322dbe9cbcf0d6276 Mon Sep 17 00:00:00 2001 From: liangfung Date: Wed, 2 Sep 2026 22:11:11 +0800 Subject: [PATCH] feat(chat): support pasted text message parts and data-part-to-text conversion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement end-to-end support for pasted text message parts, including custom data-pasted-text rendering in the webui and proper plain text conversion before sending to the LLM. šŸ¤– Generated with [Pochi](https://getpochi.com) | [Task](https://app.getpochi.com/share/p-71ae12ea06af4482815032d592a05bf7) Co-Authored-By: Pochi --- .../src/base/__tests__/formatters.test.ts | 68 +++++++ packages/common/src/base/formatters.ts | 16 ++ packages/common/src/base/message.ts | 28 +++ .../src/vscode-webui-bridge/types/task.ts | 1 + .../memory/__tests__/adaptors.test.ts | 44 +++++ .../src/background-task/memory/auto-memory.ts | 6 + .../src/chat/llm/generate-task-title.test.ts | 127 +++++++++++++ .../src/chat/llm/generate-task-title.ts | 24 ++- packages/livekit/src/types.ts | 3 + .../components/attachment-preview-list.tsx | 4 +- .../src/components/dev-mode-button.tsx | 10 +- .../pasted-text-regression.stories.tsx | 117 ++++++++++++ .../message-list-pagination.test.tsx | 90 ++++++++++ .../src/components/message/attachments.tsx | 8 +- .../src/components/message/message-list.tsx | 27 ++- .../src/components/pasted-text-card.tsx | 136 ++++++++++++++ .../__tests__/form-editor.test.tsx | 115 ++++++++++++ .../prompt-form/__tests__/utils.test.ts | 60 ++++++- .../components/prompt-form/form-editor.tsx | 31 +++- .../src/components/prompt-form/utils.ts | 19 ++ .../chat/components/chat-input-form.test.tsx | 169 ++++++++++++++++++ .../chat/components/chat-input-form.tsx | 39 +++- .../features/chat/components/chat-toolbar.tsx | 14 +- .../chat/components/create-task-input.tsx | 23 ++- .../chat/components/queued-messages.test.tsx | 19 ++ .../chat/components/queued-messages.tsx | 4 + .../hooks/use-chat-initialization.test.tsx | 34 ++++ .../chat/hooks/use-chat-initialization.ts | 2 + .../chat/hooks/use-chat-input-state.ts | 4 +- .../features/chat/hooks/use-chat-status.ts | 5 +- .../chat/hooks/use-chat-submit.test.tsx | 35 +++- .../features/chat/hooks/use-chat-submit.ts | 25 ++- .../vscode-webui/src/features/chat/page.tsx | 5 +- .../vscode-webui/src/i18n/locales/en.json | 5 + .../vscode-webui/src/i18n/locales/jp.json | 5 + .../vscode-webui/src/i18n/locales/ko.json | 5 + .../vscode-webui/src/i18n/locales/zh.json | 5 + .../lib/hooks/use-task-input-draft.test.tsx | 65 +++++++ .../src/lib/hooks/use-task-input-draft.ts | 9 +- .../src/lib/message-utils.test.ts | 21 +++ .../vscode-webui/src/lib/message-utils.ts | 5 + 41 files changed, 1386 insertions(+), 46 deletions(-) create mode 100644 packages/livekit/src/chat/llm/generate-task-title.test.ts create mode 100644 packages/vscode-webui/src/components/message/__stories__/pasted-text-regression.stories.tsx create mode 100644 packages/vscode-webui/src/components/pasted-text-card.tsx create mode 100644 packages/vscode-webui/src/components/prompt-form/__tests__/form-editor.test.tsx create mode 100644 packages/vscode-webui/src/features/chat/components/chat-input-form.test.tsx create mode 100644 packages/vscode-webui/src/lib/hooks/use-task-input-draft.test.tsx diff --git a/packages/common/src/base/__tests__/formatters.test.ts b/packages/common/src/base/__tests__/formatters.test.ts index e988e99b13..cc6a8396d3 100644 --- a/packages/common/src/base/__tests__/formatters.test.ts +++ b/packages/common/src/base/__tests__/formatters.test.ts @@ -75,6 +75,10 @@ describe('formatters', () => { describe('formatters.ui', () => { it.each([ ['content', [{ type: 'text', text: 'Visible prompt' }]], + [ + 'content', + [{ type: 'data-pasted-text', data: { text: 'large pasted text' } }], + ], ['compact', [{ type: 'text', text: 'Summary' }]], [ 'hidden', @@ -623,6 +627,70 @@ describe('formatters', () => { }); describe('formatters.llm', () => { + it('converts pasted text data into model-visible text without mutating the source message', () => { + const messages = [ + { + id: 'user-pasted-text', + role: 'user', + parts: [ + { + type: 'data-pasted-text', + data: { text: 'const answer = 42;' }, + }, + ], + }, + ] as UIMessage[]; + + expect(formatters.llm(messages)).toEqual([ + { + id: 'user-pasted-text', + role: 'user', + parts: [{ type: 'text', text: 'const answer = 42;' }], + }, + ]); + expect(messages[0].parts).toEqual([ + { + type: 'data-pasted-text', + data: { text: 'const answer = 42;' }, + }, + ]); + }); + + it('does not treat compact tags inside pasted text as a compaction boundary', () => { + const messages = [ + { + id: 'old-assistant', + role: 'assistant', + metadata: { kind: 'assistant' }, + parts: [{ type: 'text', text: 'old response' }], + }, + { + id: 'user-pasted-text', + role: 'user', + parts: [ + { + type: 'data-pasted-text', + data: { text: 'literal user content' }, + }, + ], + }, + { + id: 'new-assistant', + role: 'assistant', + metadata: { kind: 'assistant' }, + parts: [{ type: 'text', text: 'new response' }], + }, + ] as UIMessage[]; + + const formatted = formatters.llm(messages); + + expect(formatted.map((message) => message.id)).toEqual([ + 'old-assistant', + 'user-pasted-text', + 'new-assistant', + ]); + }); + it('should keep reasoning parts by default', () => { const formatted = formatters.llm(clone(baseMessages)); const assistantMsg = formatted.find((m) => m.id === 'assistant-1'); diff --git a/packages/common/src/base/formatters.ts b/packages/common/src/base/formatters.ts index d24f8d8ded..5d97d8084d 100644 --- a/packages/common/src/base/formatters.ts +++ b/packages/common/src/base/formatters.ts @@ -117,6 +117,7 @@ export function getUIUserMessageKind(message: UIMessage): UIUserMessageKind { } if ( + part.type === "data-pasted-text" || part.type === "data-reviews" || part.type === "data-bash-outputs" || part.type === "data-background-job-notification" || @@ -745,6 +746,20 @@ function resolvePendingToolCallsForShareUI(messages: UIMessage[]) { type FormatOp = (messages: UIMessage[]) => UIMessage[]; +function convertPastedTextPartsForLLM(messages: UIMessage[]): UIMessage[] { + return messages.map((message) => { + message.parts = message.parts.map((part) => { + if (part.type !== "data-pasted-text") return part; + + return { + type: "text", + text: (part as { data: { text: string } }).data.text, + }; + }); + return message; + }); +} + function removePendingTodoAttemptCompletion( messages: UIMessage[], ): UIMessage[] { @@ -793,6 +808,7 @@ const LLMFormatOps: FormatOp[] = [ removeEmptyMessages, refineDetectedNewPromblems, extractCompactMessages, + convertPastedTextPartsForLLM, removeMessagesWithoutTextOrToolCall, replaceAttemptTodoCompletionForLLM, resolvePendingToolCalls, diff --git a/packages/common/src/base/message.ts b/packages/common/src/base/message.ts index b368ae30a4..3131ee54bf 100644 --- a/packages/common/src/base/message.ts +++ b/packages/common/src/base/message.ts @@ -33,6 +33,34 @@ export const MessageMetadata = z.discriminatedUnion("kind", [ export type MessageMetadata = z.infer; +export function getPastedTextTitle(text: string): string | undefined { + const maxLength = 80; + const title: string[] = []; + let pendingSpace = false; + + for (const character of text) { + if (character === "\n" || character === "\r") { + if (title.length > 0) break; + pendingSpace = false; + continue; + } + if (/\s/.test(character)) { + pendingSpace = title.length > 0; + continue; + } + if (pendingSpace) { + title.push(" "); + pendingSpace = false; + } + title.push(character); + if (title.length > maxLength) { + return `${title.slice(0, maxLength - 1).join("")}…`; + } + } + + return title.join("") || undefined; +} + export const BackgroundJobNotification = z.object({ notificationId: z.string(), backgroundJobId: z.string(), diff --git a/packages/common/src/vscode-webui-bridge/types/task.ts b/packages/common/src/vscode-webui-bridge/types/task.ts index 6fecf06568..c587922860 100644 --- a/packages/common/src/vscode-webui-bridge/types/task.ts +++ b/packages/common/src/vscode-webui-bridge/types/task.ts @@ -23,6 +23,7 @@ export type PochiTaskParams = { cwd: string } & ( type: "new-task"; uid?: string; prompt?: string; + pastedTexts?: string[]; todos?: Todo[]; files?: FileUIPart[]; activeSelection?: ActiveSelection; diff --git a/packages/livekit/src/background-task/memory/__tests__/adaptors.test.ts b/packages/livekit/src/background-task/memory/__tests__/adaptors.test.ts index 10b945ff28..14052609c4 100644 --- a/packages/livekit/src/background-task/memory/__tests__/adaptors.test.ts +++ b/packages/livekit/src/background-task/memory/__tests__/adaptors.test.ts @@ -423,6 +423,50 @@ describe("auto-memory adaptor", () => { expect(transcript.length).toBeLessThan(24_000); }); + it("keeps pasted text content in the bounded transcript", async () => { + const store = new FakeStore([ + makeTask({ + id: "parent", + status: "completed", + background: false, + title: "Analyze pasted logs", + }), + ]); + const manager = makeAutoMemoryManager(); + const adaptor = new AutoMemoryAdaptor({ + store: store as unknown as LiveKitStore, + backgroundTask: createTestBackgroundTask({ + store: store as unknown as LiveKitStore, + stateStore: new BackgroundTaskStateStore(), + }), + parentTaskId: "parent", + parentCwd: "/repo", + manager, + }); + + await adaptor.update({ + messages: [ + { + id: "u1", + role: "user", + parts: [ + { + type: "data-pasted-text", + data: { text: "important pasted context" }, + }, + ], + }, + ] as Message[], + status: "completed", + }); + + const transcript = vi.mocked(manager.writeTaskTranscript).mock.calls[0]?.[0] + .transcript; + expect(transcript).toContain( + JSON.stringify({ type: "text", text: "important pasted context" }), + ); + }); + it("starts a dream background task after extraction completes and finishes the dream lock", async () => { const store = new FakeStore([ makeTask({ diff --git a/packages/livekit/src/background-task/memory/auto-memory.ts b/packages/livekit/src/background-task/memory/auto-memory.ts index b6075fd231..b9a8d2647d 100644 --- a/packages/livekit/src/background-task/memory/auto-memory.ts +++ b/packages/livekit/src/background-task/memory/auto-memory.ts @@ -377,6 +377,12 @@ function isWindowsAbsolutePath(inputPath: string): boolean { function sanitizePart(part: UIMessage["parts"][number]) { if (part.type === "text") return part; + if (part.type === "data-pasted-text") { + return { + type: "text", + text: (part as { data: { text: string } }).data.text, + }; + } if (part.type.startsWith("data-")) return { type: part.type }; if (isStaticToolUIPart(part)) { return { diff --git a/packages/livekit/src/chat/llm/generate-task-title.test.ts b/packages/livekit/src/chat/llm/generate-task-title.test.ts new file mode 100644 index 0000000000..0f21abd7f4 --- /dev/null +++ b/packages/livekit/src/chat/llm/generate-task-title.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it, vi } from "vitest"; +import type { BlobStore } from "../../blob-store"; +import type { LiveKitStore, Message } from "../../types"; +import { generateTaskTitle } from "./generate-task-title"; + +const generateTextMock = vi.hoisted(() => vi.fn()); + +vi.mock("ai", async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + generateText: generateTextMock, + }; +}); + +const unusedStore = {} as LiveKitStore; +const unusedBlobStore = {} as BlobStore; + +describe("generateTaskTitle pasted text fallback", () => { + it("uses a bounded pasted-text preview when there is no typed prompt", async () => { + const text = ` ${"x".repeat(100)} `; + const messages: Message[] = [ + { + id: "user-1", + role: "user", + parts: [{ type: "data-pasted-text", data: { text } }], + }, + ]; + + const title = await generateTaskTitle({ + store: unusedStore, + blobStore: unusedBlobStore, + taskId: "task-1", + title: null, + messages, + getModel: vi.fn(), + }); + + expect(title).toBe(`${"x".repeat(79)}…`); + }); + + it("does not split a Unicode character in the pasted-text preview", async () => { + const messages: Message[] = [ + { + id: "user-1", + role: "user", + parts: [ + { type: "data-pasted-text", data: { text: "šŸ˜€".repeat(100) } }, + ], + }, + ]; + + const title = await generateTaskTitle({ + store: unusedStore, + blobStore: unusedBlobStore, + taskId: "task-1", + title: null, + messages, + getModel: vi.fn(), + }); + + expect(title).toBe(`${"šŸ˜€".repeat(79)}…`); + }); + + it("prefers the typed prompt over pasted text", async () => { + const messages: Message[] = [ + { + id: "user-1", + role: "user", + parts: [ + { type: "text", text: "Analyze these logs" }, + { + type: "data-pasted-text", + data: { text: "serialized message list" }, + }, + ], + }, + ]; + + const title = await generateTaskTitle({ + store: unusedStore, + blobStore: unusedBlobStore, + taskId: "task-1", + title: null, + messages, + getModel: vi.fn(), + }); + + expect(title).toBe("Analyze these logs"); + }); + + it("sends only a bounded pasted-text preview to the title model", async () => { + const text = "x".repeat(6_000); + const fallbackTitle = `${"x".repeat(79)}…`; + const messages: Message[] = [ + { + id: "user-1", + role: "user", + parts: [{ type: "data-pasted-text", data: { text } }], + }, + ...Array.from({ length: 4 }, (_, index) => ({ + id: `assistant-${index}`, + role: "assistant" as const, + metadata: { + kind: "assistant" as const, + totalTokens: 1, + finishReason: "stop" as const, + }, + parts: [{ type: "text" as const, text: `response ${index}` }], + })), + ]; + generateTextMock.mockResolvedValueOnce({ text: "Generated title" }); + + await generateTaskTitle({ + store: unusedStore, + blobStore: unusedBlobStore, + taskId: "task-1", + title: fallbackTitle, + messages, + getModel: vi.fn(() => ({}) as never), + }); + + const prompt = generateTextMock.mock.calls[0]?.[0]?.prompt; + expect(JSON.stringify(prompt)).not.toContain(text); + expect(JSON.stringify(prompt)).toContain(fallbackTitle); + }); +}); diff --git a/packages/livekit/src/chat/llm/generate-task-title.ts b/packages/livekit/src/chat/llm/generate-task-title.ts index 8d5a0a7cd4..640bf60e3a 100644 --- a/packages/livekit/src/chat/llm/generate-task-title.ts +++ b/packages/livekit/src/chat/llm/generate-task-title.ts @@ -3,6 +3,7 @@ import { type PochiProviderOptions, formatters, getLogger, + getPastedTextTitle, prompts, } from "@getpochi/common"; import { convertToModelMessages, generateText } from "ai"; @@ -93,6 +94,13 @@ function getTitleFromMessages(messages: Message[]) { if (lastTextPart && lastTextPart.type === "text") { return lastTextPart.text.split("\n")[0].trim(); } + + const pastedTextPart = firstMessage.parts.find( + (part) => part.type === "data-pasted-text", + ); + if (pastedTextPart?.type === "data-pasted-text") { + return getPastedTextTitle(pastedTextPart.data.text); + } } function isTitleGeneratedByLlm( @@ -111,7 +119,17 @@ async function generateTitle( abortSignal: AbortSignal | undefined, ) { const messages: Message[] = [ - ...inputMessages, + ...inputMessages.map((message) => ({ + ...message, + parts: message.parts.map((part) => + part.type === "data-pasted-text" + ? { + ...part, + data: { text: getPastedTextTitle(part.data.text) ?? "" }, + } + : part, + ), + })), { id: crypto.randomUUID(), role: "user", @@ -135,7 +153,9 @@ async function generateTitle( }, model, prompt: await convertToModelMessages( - formatters.llm(messages, { removeSystemReminder: true }), + formatters.llm(messages, { + removeSystemReminder: true, + }), ), experimental_download: makeDownloadFunction(blobStore), abortSignal, diff --git a/packages/livekit/src/types.ts b/packages/livekit/src/types.ts index 3ab9cb8ff7..1e42a31cce 100644 --- a/packages/livekit/src/types.ts +++ b/packages/livekit/src/types.ts @@ -18,6 +18,9 @@ import type { defaultCatalog } from "./livestore"; import type { tables } from "./livestore/default-schema"; export type DataParts = { + "pasted-text": { + text: string; + }; checkpoint: { commit: string; }; diff --git a/packages/vscode-webui/src/components/attachment-preview-list.tsx b/packages/vscode-webui/src/components/attachment-preview-list.tsx index 153a414896..86d1593bc1 100644 --- a/packages/vscode-webui/src/components/attachment-preview-list.tsx +++ b/packages/vscode-webui/src/components/attachment-preview-list.tsx @@ -14,12 +14,14 @@ interface AttachmentPreviewListProps { files: File[]; onRemove: (index: number) => void; isUploading: boolean; + className?: string; } export function AttachmentPreviewList({ files, onRemove, isUploading, + className, }: AttachmentPreviewListProps) { const { t } = useTranslation(); const [previews, setPreviews] = useState([]); @@ -107,7 +109,7 @@ export function AttachmentPreviewList({ if (files.length === 0) return null; return ( -
+
{files.map((file, index) => { const previewUrl = previews[index]; const isImage = file.type.startsWith("image/"); diff --git a/packages/vscode-webui/src/components/dev-mode-button.tsx b/packages/vscode-webui/src/components/dev-mode-button.tsx index 7d51478753..f1cb50b288 100644 --- a/packages/vscode-webui/src/components/dev-mode-button.tsx +++ b/packages/vscode-webui/src/components/dev-mode-button.tsx @@ -12,6 +12,7 @@ import { useCurrentWorkspace } from "@/lib/hooks/use-current-workspace"; import { usePochiCredentials } from "@/lib/hooks/use-pochi-credentials"; import { useDefaultStore } from "@/lib/use-default-store"; import { vscodeHost } from "@/lib/vscode"; +import { formatters } from "@getpochi/common"; import type { Message } from "@getpochi/livekit"; import type { Todo } from "@getpochi/tools"; import { convertToModelMessages } from "ai"; @@ -70,9 +71,12 @@ export function DevModeButton({ return JSON.stringify(x, null, 2); }; const getCoreMessagesContent = async () => { - const coreMessages = await convertToModelMessages(messages, { - ignoreIncompleteToolCalls: true, - }); + const coreMessages = await convertToModelMessages( + formatters.llm(messages), + { + ignoreIncompleteToolCalls: true, + }, + ); return JSON.stringify(coreMessages, null, 2); }; diff --git a/packages/vscode-webui/src/components/message/__stories__/pasted-text-regression.stories.tsx b/packages/vscode-webui/src/components/message/__stories__/pasted-text-regression.stories.tsx new file mode 100644 index 0000000000..228c732245 --- /dev/null +++ b/packages/vscode-webui/src/components/message/__stories__/pasted-text-regression.stories.tsx @@ -0,0 +1,117 @@ +import { formatters } from "@getpochi/common"; +import type { Message } from "@getpochi/livekit"; +import type { Meta, StoryObj } from "@storybook/react"; +import { useState } from "react"; +import { ChatInputForm } from "../../../features/chat/components/chat-input-form"; +import type { ChatInput } from "../../../features/chat/hooks/use-chat-input-state"; +import { AttachmentPreviewList } from "../../attachment-preview-list"; +import { MessageList } from "../message-list"; + +const CapturedPromptLength = 406_311; +const capturedPromptPrefix = + '[{\\"role\\":\\"system\\",\\"content\\":\\"You are Pochi, a highly skilled software engineer'; +const largeSerializedMessageList = capturedPromptPrefix.padEnd( + CapturedPromptLength, + "x", +); + +const messages: Message[] = [ + { + id: "large-pasted-user-message", + role: "user", + parts: [ + { + type: "data-pasted-text", + data: { text: largeSerializedMessageList }, + }, + { + type: "file", + filename: "design-mockup.png", + mediaType: "image/png", + url: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Wl2nxoAAAAASUVORK5CYII=", + }, + ], + }, + { + id: "streaming-assistant-message", + role: "assistant", + parts: [ + { type: "text", text: "First streamed assistant part." }, + { type: "text", text: "Second streamed assistant part." }, + ], + }, +]; + +const meta = { + title: "Message/PastedTextRegression", + component: MessageList, + parameters: { + layout: "fullscreen", + backgrounds: { disable: true }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const WhileCompactingAndStreaming: Story = { + args: { + messages, + user: { name: "jueliang fung" }, + isLoading: true, + loadingLabel: "Compacting...", + formatMessages: formatters.ui, + renderAllMessages: true, + }, +}; + +function ComposerAttachmentRowStory() { + const [input, setInput] = useState({ + json: null, + text: "", + pastedTexts: [largeSerializedMessageList], + }); + const [files, setFiles] = useState(() => [ + new File(["pdf preview"], "report.pdf", { type: "application/pdf" }), + new File(["log preview"], "output.log", { type: "text/plain" }), + ]); + + return ( +
+ {}} + onCtrlSubmit={async () => {}} + isLoading={false} + editable + onPaste={() => {}} + pendingApproval={undefined} + status="ready" + isSubTask={false} + reviews={[]} + > + + setFiles((current) => + current.filter((_, itemIndex) => itemIndex !== index), + ) + } + isUploading={false} + className="contents" + /> + +
+ ); +} + +export const ComposerAttachmentRow: Story = { + render: ComposerAttachmentRowStory, + args: { + messages: [], + isLoading: false, + loadingLabel: "", + formatMessages: formatters.ui, + }, +}; diff --git a/packages/vscode-webui/src/components/message/__tests__/message-list-pagination.test.tsx b/packages/vscode-webui/src/components/message/__tests__/message-list-pagination.test.tsx index a73ba9a302..fb4cbe9707 100644 --- a/packages/vscode-webui/src/components/message/__tests__/message-list-pagination.test.tsx +++ b/packages/vscode-webui/src/components/message/__tests__/message-list-pagination.test.tsx @@ -729,3 +729,93 @@ describe("MessageList pagination", () => { ).toBe(true); }); }); + +describe("MessageList pasted text", () => { + it("renders a compact plain-text attachment beside image attachments", () => { + const pastedText = `first log line\n${"x".repeat(6_000)}`; + const message = { + id: "user-pasted-text", + role: "user", + parts: [ + { type: "text", text: "explain" }, + { type: "data-pasted-text", data: { text: pastedText } }, + { + type: "file", + filename: "design-mockup.png", + mediaType: "image/png", + url: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB", + }, + ], + } as Message; + + render( + , + ); + + const card = screen.getByTestId("pasted-text-card"); + expect(card.className).toContain("h-8"); + expect(card.textContent).not.toContain("pastedText.label"); + expect(card.parentElement?.textContent).toContain("design-mockup.png"); + expect(screen.getByText("first log l…")).toBeTruthy(); + expect(screen.getByTestId("markdown").textContent).toBe("explain"); + expect(screen.queryByText(pastedText)).toBeNull(); + + fireEvent.click(screen.getByRole("button", { name: "first log line" })); + + expect( + screen.getByText( + (_content, element) => + element?.tagName === "PRE" && element.textContent === pastedText, + ), + ).toBeTruthy(); + }); + + it("keeps the card visible across assistant streaming updates", () => { + const pastedText = `[{\\"role\\":\\"system\\",\\"content\\":\\"You are Pochi${"x".repeat(406_200)}`; + const userMessage = { + id: "user-large-paste", + role: "user", + parts: [{ type: "data-pasted-text", data: { text: pastedText } }], + } as Message; + const assistantMessage = { + id: "assistant-streaming", + role: "assistant", + parts: [{ type: "text", text: "First streamed part" }], + } as Message; + const { rerender } = render( + , + ); + + expect(screen.getByTestId("pasted-text-card")).toBeTruthy(); + expect(screen.queryByText(pastedText)).toBeNull(); + + rerender( + , + ); + + expect(screen.getByTestId("pasted-text-card")).toBeTruthy(); + expect(screen.getByText("First streamed part")).toBeTruthy(); + expect(screen.getByText("Second streamed part")).toBeTruthy(); + }); +}); diff --git a/packages/vscode-webui/src/components/message/attachments.tsx b/packages/vscode-webui/src/components/message/attachments.tsx index 14ae372dd5..c7aaaef521 100644 --- a/packages/vscode-webui/src/components/message/attachments.tsx +++ b/packages/vscode-webui/src/components/message/attachments.tsx @@ -12,13 +12,17 @@ import { CopyableImage } from "../ui/copyable-image"; interface MessageAttachmentsProps { attachments: FileUIPart[]; + className?: string; } -export function MessageAttachments({ attachments }: MessageAttachmentsProps) { +export function MessageAttachments({ + attachments, + className, +}: MessageAttachmentsProps) { if (!attachments || attachments.length === 0) return null; return ( -
+
{attachments.map((attachment, index) => { return ( part.type === "file", ) as FileUIPart[]; + const pastedTextParts = message.parts.filter( + (part) => part.type === "data-pasted-text", + ) as { type: "data-pasted-text"; data: { text: string } }[]; - if (message.role === "user" && fileParts.length) { + if ( + message.role === "user" && + (fileParts.length > 0 || pastedTextParts.length > 0) + ) { return ( -
- +
+ {pastedTextParts.map((part, index) => ( + + ))} +
); } @@ -433,6 +443,17 @@ function Part({ ); } + if (part.type === "data-pasted-text") { + if (role === "user") return null; + return ( + + ); + } + if (part.type === "step-start" || part.type === "file") { return; } diff --git a/packages/vscode-webui/src/components/pasted-text-card.tsx b/packages/vscode-webui/src/components/pasted-text-card.tsx new file mode 100644 index 0000000000..88e94a145f --- /dev/null +++ b/packages/vscode-webui/src/components/pasted-text-card.tsx @@ -0,0 +1,136 @@ +import { + HoverCard, + HoverCardContent, + HoverCardTrigger, +} from "@/components/ui/hover-card"; +import { cn } from "@/lib/utils"; +import { getPastedTextTitle } from "@getpochi/common"; +import { FileText, X } from "lucide-react"; +import { memo, useCallback, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; + +const DisplayTitleMaxLength = 12; + +function getDisplayTitle(title: string): string { + const characters = Array.from(title); + if (characters.length <= DisplayTitleMaxLength) return title; + return `${characters.slice(0, DisplayTitleMaxLength - 1).join("")}…`; +} + +export const PastedTextCard = memo(function PastedTextCard({ + text, + onRemove, + className, + variant = "default", +}: { + text: string; + onRemove?: () => void; + className?: string; + variant?: "default" | "compact"; +}) { + const { t } = useTranslation(); + const title = useMemo(() => getPastedTextTitle(text), [text]); + const accessibleTitle = title || t("pastedText.label"); + const displayTitle = useMemo( + () => getDisplayTitle(accessibleTitle), + [accessibleTitle], + ); + const [isOpen, setIsOpen] = useState(false); + const [isPinned, setIsPinned] = useState(false); + const isCompact = variant === "compact"; + + const handleOpenChange = useCallback( + (open: boolean) => { + if (open || !isPinned) setIsOpen(open); + }, + [isPinned], + ); + + const togglePinned = useCallback(() => { + setIsPinned((pinned) => { + const nextPinned = !pinned; + setIsOpen(nextPinned); + return nextPinned; + }); + }, []); + + const closePreview = useCallback(() => { + setIsPinned(false); + setIsOpen(false); + }, []); + + return ( + +
+ + + + {onRemove ? ( + + ) : null} +
+ +
+          {text}
+        
+
+
+ ); +}); diff --git a/packages/vscode-webui/src/components/prompt-form/__tests__/form-editor.test.tsx b/packages/vscode-webui/src/components/prompt-form/__tests__/form-editor.test.tsx new file mode 100644 index 0000000000..42dbc2ed2c --- /dev/null +++ b/packages/vscode-webui/src/components/prompt-form/__tests__/form-editor.test.tsx @@ -0,0 +1,115 @@ +// @vitest-environment jsdom + +import { act, fireEvent, render, waitFor } from "@testing-library/react"; +import type { Editor } from "@tiptap/react"; +import { createRef } from "react"; +import { describe, expect, it, vi } from "vitest"; +import { FormEditor } from "../form-editor"; + +vi.hoisted(() => { + Object.defineProperty(HTMLCanvasElement.prototype, "getContext", { + configurable: true, + value: () => null, + }); +}); + +vi.mock("@/features/settings", () => ({ + useSelectedModels: () => ({ + updateSelectedModelId: vi.fn(), + models: [], + }), +})); + +vi.mock("@/lib/hooks/use-active-tabs", () => ({ + useActiveTabs: () => [], +})); + +vi.mock("@/lib/vscode", () => ({ + vscodeHost: { + getSessionState: vi.fn().mockResolvedValue({}), + setSessionState: vi.fn().mockResolvedValue(undefined), + getGlobalState: vi.fn().mockResolvedValue(undefined), + setGlobalState: vi.fn().mockResolvedValue(undefined), + }, +})); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +describe("FormEditor pasted text", () => { + it("intercepts an eligible paste without inserting it into TipTap", async () => { + const editorRef = createRef(); + const onPastedText = vi.fn(); + const setInput = vi.fn(); + const largeText = "x".repeat(5_001); + const { container } = render( + , + ); + + await waitFor(() => expect(editorRef.current).toBeTruthy()); + const editorElement = container.querySelector("[contenteditable=true]"); + expect(editorElement).toBeTruthy(); + const pasteEvent = new Event("paste", { + bubbles: true, + cancelable: true, + }); + Object.defineProperty(pasteEvent, "clipboardData", { + value: { + getData: (type: string) => (type === "text/plain" ? largeText : ""), + files: [], + }, + }); + + fireEvent(editorElement as Element, pasteEvent); + + expect(pasteEvent.defaultPrevented).toBe(true); + expect(onPastedText).toHaveBeenCalledWith(largeText); + expect(editorRef.current?.getText()).toBe(""); + }); + + it("preserves pasted text drafts when the editor text changes", async () => { + const editorRef = createRef(); + const setInput = vi.fn(); + render( + , + ); + + await waitFor(() => expect(editorRef.current).toBeTruthy()); + act(() => { + editorRef.current?.commands.insertContent("Analyze this"); + }); + + expect(setInput).toHaveBeenLastCalledWith( + expect.objectContaining({ + text: "Analyze this", + pastedTexts: ["large pasted text"], + }), + ); + }); +}); diff --git a/packages/vscode-webui/src/components/prompt-form/__tests__/utils.test.ts b/packages/vscode-webui/src/components/prompt-form/__tests__/utils.test.ts index 4201005ab9..c6a3f5a47f 100644 --- a/packages/vscode-webui/src/components/prompt-form/__tests__/utils.test.ts +++ b/packages/vscode-webui/src/components/prompt-form/__tests__/utils.test.ts @@ -1,6 +1,11 @@ import { Schema } from "@tiptap/pm/model"; import { describe, expect, it } from "vitest"; -import { createPlainTextSlice, shouldPasteAsPlainText } from "../utils"; +import { + PastedTextMinLength, + createPlainTextSlice, + shouldAttachPastedText, + shouldPasteAsPlainText, +} from "../utils"; const schema = new Schema({ nodes: { @@ -101,3 +106,56 @@ describe("shouldPasteAsPlainText", () => { ).toBe(false); }); }); + +describe("shouldAttachPastedText", () => { + it("attaches external text only when it exceeds the threshold", () => { + expect( + shouldAttachPastedText({ + text: "x".repeat(PastedTextMinLength), + html: "", + hasFiles: false, + }), + ).toBe(false); + expect( + shouldAttachPastedText({ + text: "x".repeat(PastedTextMinLength + 1), + html: "", + hasFiles: false, + }), + ).toBe(true); + }); + + it("does not attach file or structured ProseMirror pastes", () => { + const text = "x".repeat(PastedTextMinLength + 1); + expect( + shouldAttachPastedText({ text, html: "", hasFiles: true }), + ).toBe(false); + expect( + shouldAttachPastedText({ + text, + html: '

large

', + hasFiles: false, + }), + ).toBe(false); + }); + + it("uses plain text from eligible external rich-text pastes", () => { + expect( + shouldAttachPastedText({ + text: "x".repeat(PastedTextMinLength + 1), + html: "

large external paste

", + hasFiles: false, + }), + ).toBe(true); + }); + + it("does not attach whitespace-only text", () => { + expect( + shouldAttachPastedText({ + text: " ".repeat(PastedTextMinLength + 1), + html: "", + hasFiles: false, + }), + ).toBe(false); + }); +}); diff --git a/packages/vscode-webui/src/components/prompt-form/form-editor.tsx b/packages/vscode-webui/src/components/prompt-form/form-editor.tsx index 26abdaed02..4563e7cacf 100644 --- a/packages/vscode-webui/src/components/prompt-form/form-editor.tsx +++ b/packages/vscode-webui/src/components/prompt-form/form-editor.tsx @@ -27,7 +27,6 @@ import { } from "./issue-mention/extension"; import "./prompt-form.css"; -import type { ChatInput } from "@/features/chat"; import { useSelectedModels } from "@/features/settings"; import { useLatest } from "@/lib/hooks/use-latest"; import { cn } from "@/lib/utils"; @@ -41,6 +40,7 @@ import { } from "@tiptap/suggestion"; import { ArrowRightToLine } from "lucide-react"; import { useTranslation } from "react-i18next"; +import type { ChatInput } from "../../features/chat/hooks/use-chat-input-state"; import { ScrollArea } from "../ui/scroll-area"; import { AutoCompleteExtension } from "./auto-completion/extension"; import { @@ -63,7 +63,11 @@ import { TextUpdateTrackerExtension, createMentionSuggestionAllow, } from "./suggestion-activation"; -import { createPlainTextSlice, shouldPasteAsPlainText } from "./utils"; +import { + createPlainTextSlice, + shouldAttachPastedText, + shouldPasteAsPlainText, +} from "./utils"; const newLineCharacter = "\n"; @@ -120,6 +124,7 @@ interface FormEditorProps { children?: React.ReactNode; onError?: (e: Error) => void; onPaste?: (e: ClipboardEvent) => void; + onPastedText?: (text: string) => void; enableSubmitHistory?: boolean; onFileDrop?: (files: File[]) => boolean; onFocus?: (event: FocusEvent) => void; @@ -141,6 +146,7 @@ export function FormEditor({ editorRef, autoFocus = true, onPaste, + onPastedText, onFocus, enableSubmitHistory = true, onFileDrop, @@ -166,6 +172,8 @@ export function FormEditor({ // State for drag overlay UI const [isDragOver, setIsDragOver] = useState(false); + const inputRef = useLatest(input); + const onPastedTextRef = useLatest(onPastedText); const onSelectSlashCandidate = useLatest((data: SlashCandidate) => { let model: string | undefined; @@ -438,6 +446,18 @@ export function FormEditor({ const text = clipboardData.getData("text/plain"); const html = clipboardData.getData("text/html"); + if ( + onPastedTextRef.current && + shouldAttachPastedText({ + text, + html, + hasFiles: clipboardData.files.length > 0, + }) + ) { + event.preventDefault(); + onPastedTextRef.current(text); + return true; + } if ( !shouldPasteAsPlainText({ text, @@ -506,7 +526,11 @@ export function FormEditor({ const text = props.editor.getText({ blockSeparator: newLineCharacter, }); - setInput({ json, text }); + setInput({ + json, + text, + pastedTexts: inputRef.current.pastedTexts, + }); // Update current draft if we have submit history enabled if ( @@ -662,6 +686,7 @@ export function FormEditor({ ? { json: editor.getJSON(), text: editor.getText({ blockSeparator: newLineCharacter }), + pastedTexts: input.pastedTexts, } : input; if (enableSubmitHistory && editor && !editor.isDestroyed) { diff --git a/packages/vscode-webui/src/components/prompt-form/utils.ts b/packages/vscode-webui/src/components/prompt-form/utils.ts index 922d193d39..9a8e2ce17e 100644 --- a/packages/vscode-webui/src/components/prompt-form/utils.ts +++ b/packages/vscode-webui/src/components/prompt-form/utils.ts @@ -1,5 +1,24 @@ import { Fragment, type Schema, Slice } from "@tiptap/pm/model"; +export const PastedTextMinLength = 5_000; + +export function shouldAttachPastedText({ + text, + html, + hasFiles, +}: { + text: string; + html: string; + hasFiles: boolean; +}): boolean { + return ( + !hasFiles && + text.length > PastedTextMinLength && + text.trim().length > 0 && + !html.includes("data-pm-slice") + ); +} + /** * Builds a ProseMirror slice from plain text, splitting on newlines into * separate paragraphs. diff --git a/packages/vscode-webui/src/features/chat/components/chat-input-form.test.tsx b/packages/vscode-webui/src/features/chat/components/chat-input-form.test.tsx new file mode 100644 index 0000000000..f3847835eb --- /dev/null +++ b/packages/vscode-webui/src/features/chat/components/chat-input-form.test.tsx @@ -0,0 +1,169 @@ +// @vitest-environment jsdom + +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { ChatInputForm } from "./chat-input-form"; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => + key === "pastedText.label" ? "Localized pasted text" : key, + }), +})); + +vi.mock("@/components/dev-retry-countdown", () => ({ + DevRetryCountdown: () => null, +})); +vi.mock("@/components/prompt-form/active-selection-badge", () => ({ + ActiveSelectionBadge: () => null, +})); +vi.mock("@/components/prompt-form/add-context-menu", () => ({ + AddContextMenu: () => null, +})); +vi.mock("@/components/prompt-form/form-editor", () => ({ + FormEditor: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})); +vi.mock("@/components/prompt-form/review-badges", () => ({ + ReviewBadges: () => null, +})); +vi.mock("@/components/prompt-form/terminal-context-badges", () => ({ + TerminalContextBadges: () => null, +})); +vi.mock("@/components/prompt-form/user-edits", () => ({ + UserEdits: () => null, +})); +vi.mock("@/lib/hooks/use-active-selection", () => ({ + useActiveSelection: () => undefined, +})); +vi.mock("./queued-messages", () => ({ + QueuedMessages: () => null, +})); + +describe("ChatInputForm pasted text", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it("renders pasted text in the same attachment row as files", () => { + render( + +
+ , + ); + + expect(screen.getByTestId("pasted-text-card").parentElement).toBe( + screen.getByTestId("file-attachments").parentElement, + ); + }); + + it("uses a short title and supports hover preview with click pinning", () => { + vi.useFakeTimers(); + const pastedText = `0123456789ABCDEFGHIJ\n${"x".repeat(6_000)}`; + + render( + , + ); + + const trigger = screen.getByRole("button", { + name: "0123456789ABCDEFGHIJ", + }); + expect(screen.getByText("0123456789A…")).toBeTruthy(); + expect(screen.queryByText(pastedText)).toBeNull(); + + fireEvent.pointerEnter(trigger, { pointerType: "mouse" }); + act(() => { + vi.advanceTimersByTime(200); + }); + + const preview = screen.getByText( + (_content, element) => + element?.tagName === "PRE" && element.textContent === pastedText, + ); + expect(preview).toBeTruthy(); + expect(preview.parentElement?.className).toContain("max-h-80"); + expect(preview.parentElement?.className).toContain("w-[min(48rem,80vw)]"); + expect(preview.parentElement?.className).toContain("overflow-auto"); + + fireEvent.click(trigger); + fireEvent.pointerLeave(trigger, { pointerType: "mouse" }); + act(() => { + vi.advanceTimersByTime(300); + }); + + expect( + screen.getByText( + (_content, element) => + element?.tagName === "PRE" && element.textContent === pastedText, + ), + ).toBeTruthy(); + }); + + it("localizes and removes pasted-text cards", () => { + const pastedText = `Analyze this\n${"x".repeat(6_000)}`; + const whitespaceText = " ".repeat(5_001); + const setInput = vi.fn(); + + render( + , + ); + + expect(screen.getByRole("button", { name: "Analyze this" })).toBeTruthy(); + expect( + screen.getByRole("button", { name: "Localized pasted text" }), + ).toBeTruthy(); + const removeButton = screen.getAllByRole("button", { + name: "pastedText.remove", + })[0]; + expect(removeButton).toBeTruthy(); + if (!removeButton) throw new Error("Expected a pasted-text remove button"); + fireEvent.click(removeButton); + expect(setInput).toHaveBeenCalledWith({ + json: null, + text: "", + pastedTexts: [whitespaceText], + }); + }); +}); diff --git a/packages/vscode-webui/src/features/chat/components/chat-input-form.tsx b/packages/vscode-webui/src/features/chat/components/chat-input-form.tsx index e956234e51..6640aa5556 100644 --- a/packages/vscode-webui/src/features/chat/components/chat-input-form.tsx +++ b/packages/vscode-webui/src/features/chat/components/chat-input-form.tsx @@ -1,5 +1,5 @@ import type { Editor } from "@tiptap/react"; -import { forwardRef, useImperativeHandle, useRef } from "react"; +import { forwardRef, useCallback, useImperativeHandle, useRef } from "react"; import { DevRetryCountdown } from "@/components/dev-retry-countdown"; import { ActiveSelectionBadge } from "@/components/prompt-form/active-selection-badge"; @@ -9,6 +9,7 @@ import type { useApprovalAndRetry } from "@/features/approval"; import type { UseChatHelpers } from "@ai-sdk/react"; import type { Message } from "@getpochi/livekit"; +import { PastedTextCard } from "@/components/pasted-text-card"; import { ReviewBadges } from "@/components/prompt-form/review-badges"; import { TerminalContextBadges } from "@/components/prompt-form/terminal-context-badges"; import { UserEdits } from "@/components/prompt-form/user-edits"; @@ -109,6 +110,27 @@ export const ChatInputForm = forwardRef< const editorRef = useRef(null); const activeSelection = useActiveSelection(); const showAddContextLabel = !activeSelection; + const pastedTexts = input.pastedTexts ?? []; + + const appendPastedText = useCallback( + (text: string) => { + setInput({ + ...input, + pastedTexts: [...pastedTexts, text], + }); + }, + [input, pastedTexts, setInput], + ); + + const removePastedText = useCallback( + (index: number) => { + setInput({ + ...input, + pastedTexts: pastedTexts.filter((_, itemIndex) => itemIndex !== index), + }); + }, + [input, pastedTexts, setInput], + ); useImperativeHandle(ref, () => ({ addToSubmitHistory: () => { @@ -125,6 +147,7 @@ export const ChatInputForm = forwardRef< return { json: editor.getJSON(), text: editor.getText({ blockSeparator: "\n" }), + pastedTexts: input.pastedTexts, }; }, })); @@ -139,6 +162,7 @@ export const ChatInputForm = forwardRef< editable={editable} editorRef={editorRef} onPaste={onPaste} + onPastedText={appendPastedText} enableSubmitHistory={true} onFileDrop={onFileDrop} messageContent={messageContent} @@ -197,7 +221,18 @@ export const ChatInputForm = forwardRef< allowSteer={allowSteer} /> )} - {children} + {pastedTexts.length > 0 || children ? ( +
+ {pastedTexts.map((text, index) => ( + removePastedText(index)} + /> + ))} + {children} +
+ ) : null} ); }); diff --git a/packages/vscode-webui/src/features/chat/components/chat-toolbar.tsx b/packages/vscode-webui/src/features/chat/components/chat-toolbar.tsx index dcf167e740..cffc7323ba 100644 --- a/packages/vscode-webui/src/features/chat/components/chat-toolbar.tsx +++ b/packages/vscode-webui/src/features/chat/components/chat-toolbar.tsx @@ -291,6 +291,7 @@ export const ChatToolbar: React.FC = ({ isFilesEmpty: files.length === 0, isReviewsEmpty: reviews.length === 0, isTerminalContextEmpty: terminalContextSelections.length === 0, + isPastedTextsEmpty: (input.pastedTexts?.length ?? 0) === 0, isUploadingAttachments, blockingState, taskStatus: task?.status, @@ -526,13 +527,12 @@ export const ChatToolbar: React.FC = ({ })} > {files.length > 0 && ( -
- -
+ )}
diff --git a/packages/vscode-webui/src/features/chat/components/create-task-input.tsx b/packages/vscode-webui/src/features/chat/components/create-task-input.tsx index 3fb4a0c3ab..0409dd829f 100644 --- a/packages/vscode-webui/src/features/chat/components/create-task-input.tsx +++ b/packages/vscode-webui/src/features/chat/components/create-task-input.tsx @@ -178,6 +178,7 @@ export const CreateTaskInput: React.FC = ({ todos?: Todo[]; invokedSkills?: ValidSkillFile[]; invokedCustomAgents?: string[]; + pastedTexts?: string[]; }): Promise => { const { content, @@ -186,6 +187,7 @@ export const CreateTaskInput: React.FC = ({ todos, invokedSkills, invokedCustomAgents, + pastedTexts, } = params; let worktree: typeof selectedWorktree | null = selectedWorktree; @@ -193,7 +195,7 @@ export const CreateTaskInput: React.FC = ({ worktree = await vscodeHost.createWorktree({ baseBranch: baseBranch || undefined, generateBranchName: { - prompt: content, + prompt: content || pastedTexts?.[0]?.slice(0, 2_000) || "", files: uploadedFiles, }, }); @@ -209,6 +211,7 @@ export const CreateTaskInput: React.FC = ({ type: "new-task", cwd: worktree && typeof worktree === "object" ? worktree.path : cwd, prompt: content, + pastedTexts, todos, files: uploadedFiles, activeSelection: activeSelection ?? undefined, @@ -293,6 +296,7 @@ export const CreateTaskInput: React.FC = ({ // Disallow empty submissions if ( content.length === 0 && + (currentInput.pastedTexts?.length ?? 0) === 0 && files.length === 0 && terminalContextSelections.length === 0 ) @@ -335,9 +339,11 @@ export const CreateTaskInput: React.FC = ({ shouldCreateWorktree: shouldCreateWorktree === true || selectedWorktree === "new-worktree", uploadedFiles: uploadedFiles.length > 0 ? uploadedFiles : undefined, - todos: shouldCreateTodo ? initTodoModeTodos(content) : undefined, + todos: + shouldCreateTodo && content ? initTodoModeTodos(content) : undefined, invokedSkills: validationResult.invokedSkills, invokedCustomAgents, + pastedTexts: currentInput.pastedTexts, }); // Set isCreatingTask state false @@ -425,13 +431,12 @@ export const CreateTaskInput: React.FC = ({ contextMenuSide="bottom" > {files.length > 0 && ( -
- -
+ )}
diff --git a/packages/vscode-webui/src/features/chat/components/queued-messages.test.tsx b/packages/vscode-webui/src/features/chat/components/queued-messages.test.tsx index afb38ff1df..7168064747 100644 --- a/packages/vscode-webui/src/features/chat/components/queued-messages.test.tsx +++ b/packages/vscode-webui/src/features/chat/components/queued-messages.test.tsx @@ -102,6 +102,22 @@ describe("QueuedMessages", () => { expect(getByText("chat.terminalContextCount:2")).toBeTruthy(); }); + it("shows the pasted text count alongside other counts", () => { + const { getByText } = render( + , + ); + + expect(getByText("chat.pastedTextCount:2")).toBeTruthy(); + }); + it("renders a minimal icon-only preview for the active editor selection captured at queue time", () => { const activeSelection: ActiveSelection = { filepath: "/workspace/foo.ts", @@ -225,6 +241,7 @@ function queuedMessage({ reviewsCount = 0, userEditsCount = 0, terminalContextCount = 0, + pastedTextCount = 0, activeSelection, nonRemovable, }: { @@ -234,6 +251,7 @@ function queuedMessage({ reviewsCount?: number; userEditsCount?: number; terminalContextCount?: number; + pastedTextCount?: number; activeSelection?: ActiveSelection; nonRemovable?: boolean; }): DraftMessage { @@ -245,6 +263,7 @@ function queuedMessage({ reviewsCount, userEditsCount, terminalContextCount, + pastedTextCount, isTodoMode, activeSelection, nonRemovable, diff --git a/packages/vscode-webui/src/features/chat/components/queued-messages.tsx b/packages/vscode-webui/src/features/chat/components/queued-messages.tsx index ec5212c0cc..2955b76782 100644 --- a/packages/vscode-webui/src/features/chat/components/queued-messages.tsx +++ b/packages/vscode-webui/src/features/chat/components/queued-messages.tsx @@ -60,6 +60,7 @@ export const QueuedMessages: React.FC = ({ reviewsCount = 0, userEditsCount = 0, terminalContextCount = 0, + pastedTextCount = 0, isTodoMode, activeSelection, } = raw; @@ -85,6 +86,9 @@ export const QueuedMessages: React.FC = ({ terminalContextCount > 0 ? t("chat.terminalContextCount", { count: terminalContextCount }) : "", + pastedTextCount > 0 + ? t("chat.pastedTextCount", { count: pastedTextCount }) + : "", ].filter(Boolean); return { diff --git a/packages/vscode-webui/src/features/chat/hooks/use-chat-initialization.test.tsx b/packages/vscode-webui/src/features/chat/hooks/use-chat-initialization.test.tsx index bdc88f61ad..12408166b9 100644 --- a/packages/vscode-webui/src/features/chat/hooks/use-chat-initialization.test.tsx +++ b/packages/vscode-webui/src/features/chat/hooks/use-chat-initialization.test.tsx @@ -109,4 +109,38 @@ describe("useChatInitialization", () => { ], }); }); + + it("preserves pasted text as a data part for a new task", () => { + const info = { + type: "new-task", + uid: "task-1", + cwd: "/workspace", + prompt: "Analyze this", + pastedTexts: ["large pasted text"], + } as PochiTaskInfo; + const init = vi.fn(); + + renderHook(() => + useChatInitialization({ + chatKit: { inited: false, init } as never, + info, + storeRegistry: {} as never, + jwt: null, + t: ((key: string) => key) as TFunction, + setMcpConfigOverride: vi.fn() as never, + isMcpConfigLoading: false, + }), + ); + + expect(init).toHaveBeenCalledWith("/workspace", { + prompt: "Analyze this", + parts: [ + { type: "text", text: "Analyze this" }, + { + type: "data-pasted-text", + data: { text: "large pasted text" }, + }, + ], + }); + }); }); diff --git a/packages/vscode-webui/src/features/chat/hooks/use-chat-initialization.ts b/packages/vscode-webui/src/features/chat/hooks/use-chat-initialization.ts index ceb4ab7f08..471a741bf8 100644 --- a/packages/vscode-webui/src/features/chat/hooks/use-chat-initialization.ts +++ b/packages/vscode-webui/src/features/chat/hooks/use-chat-initialization.ts @@ -59,6 +59,7 @@ export function useChatInitialization({ })); const shouldUseParts = (files?.length ?? 0) > 0 || + (info.pastedTexts?.length ?? 0) > 0 || !!activeSelection || (terminalContextSelections?.length ?? 0) > 0 || (info.invokedSkills?.length ?? 0) > 0 || @@ -77,6 +78,7 @@ export function useChatInitialization({ terminalContextSelections, info.invokedSkills, info.invokedCustomAgents, + info.pastedTexts, ), }); } else { diff --git a/packages/vscode-webui/src/features/chat/hooks/use-chat-input-state.ts b/packages/vscode-webui/src/features/chat/hooks/use-chat-input-state.ts index 5957b67ed9..1ebef32972 100644 --- a/packages/vscode-webui/src/features/chat/hooks/use-chat-input-state.ts +++ b/packages/vscode-webui/src/features/chat/hooks/use-chat-input-state.ts @@ -4,6 +4,7 @@ import { create } from "zustand"; export interface ChatInput { json: JSONContent | null; text: string; + pastedTexts?: string[]; } export interface ChatInputState { @@ -16,6 +17,7 @@ export const useChatInputState = create()((set) => ({ input: { json: null, text: "", + pastedTexts: [], }, setInput: (content: Partial) => set((state) => ({ @@ -23,6 +25,6 @@ export const useChatInputState = create()((set) => ({ })), clearInput: () => set(() => ({ - input: { json: null, text: "" }, + input: { json: null, text: "", pastedTexts: [] }, })), })); diff --git a/packages/vscode-webui/src/features/chat/hooks/use-chat-status.ts b/packages/vscode-webui/src/features/chat/hooks/use-chat-status.ts index 5babc5a5e6..5a8ae3246f 100644 --- a/packages/vscode-webui/src/features/chat/hooks/use-chat-status.ts +++ b/packages/vscode-webui/src/features/chat/hooks/use-chat-status.ts @@ -12,6 +12,7 @@ interface UseChatStatusProps { isFilesEmpty: boolean; isReviewsEmpty: boolean; isTerminalContextEmpty: boolean; + isPastedTextsEmpty: boolean; isUploadingAttachments: boolean; blockingState: BlockingState; taskStatus: Task["status"] | undefined; @@ -24,6 +25,7 @@ export function useChatStatus({ isFilesEmpty, isReviewsEmpty, isTerminalContextEmpty, + isPastedTextsEmpty, isUploadingAttachments, blockingState, taskStatus, @@ -49,7 +51,8 @@ export function useChatStatus({ (!isInputEmpty || !isFilesEmpty || !isReviewsEmpty || - !isTerminalContextEmpty); + !isTerminalContextEmpty || + !isPastedTextsEmpty); // `stop`: stop chat streaming or tool execution const isStopEnabled = diff --git a/packages/vscode-webui/src/features/chat/hooks/use-chat-submit.test.tsx b/packages/vscode-webui/src/features/chat/hooks/use-chat-submit.test.tsx index a8d0038a9a..2ef924107d 100644 --- a/packages/vscode-webui/src/features/chat/hooks/use-chat-submit.test.tsx +++ b/packages/vscode-webui/src/features/chat/hooks/use-chat-submit.test.tsx @@ -30,10 +30,12 @@ const messageUtilsMocks = vi.hoisted(() => ({ _terminalContextSelections, invokedSkills: ValidSkillFile[] = [], invokedCustomAgents: string[] = [], + pastedTexts: string[] = [], ) => [ ...invokedSkills.map((skill) => `skill:${skill.instructions}`), ...invokedCustomAgents.map((agentName) => `agent:${agentName}`), `text:${text}`, + ...pastedTexts.map((pastedText) => `pasted:${pastedText}`), ], ), })); @@ -133,6 +135,23 @@ describe("useChatSubmit", () => { expect(context.sendMessage).not.toHaveBeenCalled(); }); + it("sends pasted text when the editor is empty", async () => { + const context = setup({ + isLoading: false, + inputText: "", + pastedTexts: ["large pasted text"], + }); + + await act(async () => { + await context.result.current.handleSubmit(); + }); + + expect(context.sendMessage).toHaveBeenCalledWith({ + parts: ["text:", "pasted:large pasted text"], + }); + expect(context.clearInput).toHaveBeenCalledOnce(); + }); + it("sends a non-user-invocable skill typed as plain text", async () => { const context = setup({ isLoading: false, @@ -236,6 +255,7 @@ describe("useChatSubmit", () => { [], [], [], + [], ); }); @@ -287,6 +307,7 @@ describe("useChatSubmit", () => { [], [currentSkill], [], + [], ); expect(context.sendMessage).toHaveBeenCalledWith({ parts: ["skill:current instructions", "text:/changing"], @@ -338,6 +359,7 @@ describe("useChatSubmit", () => { [], [], ["tester"], + [], ); expect(context.sendMessage).toHaveBeenCalledWith({ parts: ["agent:tester", `text:${prompt}`], @@ -690,6 +712,7 @@ describe("useChatSubmit", () => { [], [], [], + [], ); }); @@ -721,6 +744,7 @@ describe("useChatSubmit", () => { [], [], [], + [], ); }); @@ -752,6 +776,7 @@ describe("useChatSubmit", () => { [], [], [], + [], ); expect(context.queuedMessages).toEqual([ @@ -795,6 +820,7 @@ describe("useChatSubmit", () => { [], [], [], + [], ); }); @@ -841,6 +867,7 @@ describe("useChatSubmit", () => { terminalContextSelections, [], [], + [], ); expect(context.clearTerminalContextSelections).toHaveBeenCalledOnce(); }); @@ -860,6 +887,7 @@ function setup({ isLoading: initialIsLoading, inputText: initialInputText = " follow up ", inputJson = null, + pastedTexts = [], queuedMessages: initialQueuedMessages = [], files = [], reviews = [], @@ -875,6 +903,7 @@ function setup({ isLoading: boolean; inputText?: string; inputJson?: JSONContent | null; + pastedTexts?: string[]; queuedMessages?: DraftMessage[]; files?: File[]; reviews?: Review[]; @@ -921,11 +950,13 @@ function setup({ const isFilesEmpty = files.length === 0; const isReviewsEmpty = reviews.length === 0; const isTerminalContextEmpty = terminalContextSelections.length === 0; + const isPastedTextsEmpty = pastedTexts.length === 0; const isSubmitEnabled = !isInputEmpty || !isFilesEmpty || !isReviewsEmpty || - !isTerminalContextEmpty; + !isTerminalContextEmpty || + !isPastedTextsEmpty; const isStopEnabled = isRunning; const allowSendMessage = !isRunning; const allowSteer = true; @@ -935,7 +966,7 @@ function setup({ sendMessage, stop: stopChat, }, - input: { json: inputJson, text: initialInputText }, + input: { json: inputJson, text: initialInputText, pastedTexts }, clearInput, attachmentUpload: { files, diff --git a/packages/vscode-webui/src/features/chat/hooks/use-chat-submit.ts b/packages/vscode-webui/src/features/chat/hooks/use-chat-submit.ts index 0a82c06560..f34ae42877 100644 --- a/packages/vscode-webui/src/features/chat/hooks/use-chat-submit.ts +++ b/packages/vscode-webui/src/features/chat/hooks/use-chat-submit.ts @@ -31,6 +31,10 @@ const logger = getLogger("UseChatSubmit"); type UseChatReturn = Pick, "sendMessage" | "stop">; type UseAttachmentUploadReturn = ReturnType; +type ResolvedChatInput = Extract< + ReturnType, + { status: "valid" } +> & { pastedTexts: string[] }; export interface DraftMessage { parts: Message["parts"]; @@ -40,6 +44,7 @@ export interface DraftMessage { reviewsCount?: number; userEditsCount?: number; terminalContextCount?: number; + pastedTextCount?: number; isTodoMode?: boolean; activeSelection?: ActiveSelection; backgroundJobNotificationIds?: string[]; @@ -150,7 +155,10 @@ export function useChatSubmit({ async (submittedInput: ChatInput = input) => { const result = resolveSlashMentions(submittedInput, skills, customAgents); if (result.status === "valid") { - return result; + return { + ...result, + pastedTexts: submittedInput.pastedTexts ?? [], + }; } await vscodeHost.showWarningMessage(result.message, { modal: false }); @@ -190,26 +198,26 @@ export function useChatSubmit({ const createMessage = useCallback( async ( - resolvedInput: Extract< - ReturnType, - { status: "valid" } - > = { + resolvedInput: ResolvedChatInput = { status: "valid", text: input.text, invokedSkills: [], invokedCustomAgents: [], + pastedTexts: input.pastedTexts ?? [], }, ): Promise => { const text = resolvedInput.text.trim(); const currentFiles = [...files]; const currentReviews = [...reviews]; const currentTerminalContextSelections = [...terminalContextSelections]; + const currentPastedTexts = [...resolvedInput.pastedTexts]; if ( text.length === 0 && currentFiles.length === 0 && currentReviews.length === 0 && - currentTerminalContextSelections.length === 0 + currentTerminalContextSelections.length === 0 && + currentPastedTexts.length === 0 ) { return undefined; } @@ -246,6 +254,9 @@ export function useChatSubmit({ reviewsCount: currentReviews.length, userEditsCount: currentUserEdits.length, terminalContextCount: currentTerminalContextSelections.length, + ...(currentPastedTexts.length > 0 + ? { pastedTextCount: currentPastedTexts.length } + : {}), isTodoMode, activeSelection: currentSelection, }; @@ -259,6 +270,7 @@ export function useChatSubmit({ currentTerminalContextSelections, resolvedInput.invokedSkills, resolvedInput.invokedCustomAgents, + currentPastedTexts, ); return { parts, raw }; @@ -266,6 +278,7 @@ export function useChatSubmit({ [ t, input.text, + input.pastedTexts, files, reviews, userEdits, diff --git a/packages/vscode-webui/src/features/chat/page.tsx b/packages/vscode-webui/src/features/chat/page.tsx index b3694f9323..0124cbf2fc 100644 --- a/packages/vscode-webui/src/features/chat/page.tsx +++ b/packages/vscode-webui/src/features/chat/page.tsx @@ -307,7 +307,10 @@ function Chat({ user, uid, info }: ChatProps) { [hidePendingTodoAttemptCompletion], ); const shouldHideEmptyPlaceholder = - info.type === "new-task" && (!!info.prompt || !!info.files?.length); + info.type === "new-task" && + (!!info.prompt || + !!info.files?.length || + (info.pastedTexts?.length ?? 0) > 0); const approvalAndRetry = useApprovalAndRetry({ ...chat, diff --git a/packages/vscode-webui/src/i18n/locales/en.json b/packages/vscode-webui/src/i18n/locales/en.json index 8372b62d66..a7231fdf5a 100644 --- a/packages/vscode-webui/src/i18n/locales/en.json +++ b/packages/vscode-webui/src/i18n/locales/en.json @@ -45,6 +45,7 @@ "reviewCount": "{{count}} reviews", "userEditCount": "{{count}} edits", "terminalContextCount": "{{count}} terminal selections", + "pastedTextCount": "{{count}} pasted texts", "steer": "Steer", "copyImage": "Copy Image", "openImage": "Open Image", @@ -388,6 +389,10 @@ "removeAttachment": "Remove Attachment", "uploading": "Uploading..." }, + "pastedText": { + "label": "Pasted text", + "remove": "Remove pasted text" + }, "checkpointUI": { "restoring": "Restoring...", "success": "Success", diff --git a/packages/vscode-webui/src/i18n/locales/jp.json b/packages/vscode-webui/src/i18n/locales/jp.json index 9d80a08f52..b68b7db9a6 100644 --- a/packages/vscode-webui/src/i18n/locales/jp.json +++ b/packages/vscode-webui/src/i18n/locales/jp.json @@ -44,6 +44,7 @@ "reviewCount": "{{count}} ä»¶ć®ćƒ¬ćƒ“ćƒ„ćƒ¼", "userEditCount": "{{count}} 件の編集", "terminalContextCount": "{{count}} ä»¶ć®ć‚æćƒ¼ćƒŸćƒŠćƒ«éøęŠž", + "pastedTextCount": "{{count}} ä»¶ć®č²¼ć‚Šä»˜ć‘ćƒ†ć‚­ć‚¹ćƒˆ", "steer": "介兄", "copyImage": "ē”»åƒć‚’ć‚³ćƒ”ćƒ¼", "openImage": "ē”»åƒć‚’é–‹ć", @@ -382,6 +383,10 @@ "removeAttachment": "ę·»ä»˜ćƒ•ć‚”ć‚¤ćƒ«ć‚’å‰Šé™¤", "uploading": "ć‚¢ćƒƒćƒ—ćƒ­ćƒ¼ćƒ‰äø­..." }, + "pastedText": { + "label": "č²¼ć‚Šä»˜ć‘ćŸćƒ†ć‚­ć‚¹ćƒˆ", + "remove": "č²¼ć‚Šä»˜ć‘ćŸćƒ†ć‚­ć‚¹ćƒˆć‚’å‰Šé™¤" + }, "checkpointUI": { "restoring": "å¾©å…ƒäø­...", "success": "成功", diff --git a/packages/vscode-webui/src/i18n/locales/ko.json b/packages/vscode-webui/src/i18n/locales/ko.json index 2234b732c1..600cd4ffee 100644 --- a/packages/vscode-webui/src/i18n/locales/ko.json +++ b/packages/vscode-webui/src/i18n/locales/ko.json @@ -43,6 +43,7 @@ "reviewCount": "리뷰 {{count}}개", "userEditCount": "ķŽøģ§‘ {{count}}개", "terminalContextCount": "터미널 ģ„ ķƒ {{count}}개", + "pastedTextCount": "ė¶™ģ—¬ė„£ģ€ ķ…ģŠ¤ķŠø {{count}}개", "steer": "ź°œģž…", "copyImage": "ģ“ėÆøģ§€ 복사", "openImage": "ģ“ėÆøģ§€ ģ—“źø°", @@ -380,6 +381,10 @@ "removeAttachment": "첨부 ķŒŒģ¼ 제거", "uploading": "ģ—…ė”œė“œ 중..." }, + "pastedText": { + "label": "ė¶™ģ—¬ė„£ģ€ ķ…ģŠ¤ķŠø", + "remove": "ė¶™ģ—¬ė„£ģ€ ķ…ģŠ¤ķŠø 제거" + }, "checkpointUI": { "restoring": "복원 중...", "success": "성공", diff --git a/packages/vscode-webui/src/i18n/locales/zh.json b/packages/vscode-webui/src/i18n/locales/zh.json index afbb559c07..1914dea7d5 100644 --- a/packages/vscode-webui/src/i18n/locales/zh.json +++ b/packages/vscode-webui/src/i18n/locales/zh.json @@ -43,6 +43,7 @@ "reviewCount": "{{count}} ę”čÆ„å®”", "userEditCount": "{{count}} 处编辑", "terminalContextCount": "{{count}} äøŖē»ˆē«Æé€‰åŒŗ", + "pastedTextCount": "{{count}} ę®µē²˜č““ę–‡ęœ¬", "steer": "介兄", "copyImage": "å¤åˆ¶å›¾ē‰‡", "openImage": "打开图片", @@ -380,6 +381,10 @@ "removeAttachment": "移除附件", "uploading": "上传中..." }, + "pastedText": { + "label": "ē²˜č““ēš„ę–‡ęœ¬", + "remove": "ē§»é™¤ē²˜č““ēš„ę–‡ęœ¬" + }, "checkpointUI": { "restoring": "ę¢å¤äø­...", "success": "成功", diff --git a/packages/vscode-webui/src/lib/hooks/use-task-input-draft.test.tsx b/packages/vscode-webui/src/lib/hooks/use-task-input-draft.test.tsx new file mode 100644 index 0000000000..e2fa9a0018 --- /dev/null +++ b/packages/vscode-webui/src/lib/hooks/use-task-input-draft.test.tsx @@ -0,0 +1,65 @@ +// @vitest-environment jsdom +import { act, renderHook, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { useTaskInputDraft } from "./use-task-input-draft"; + +const vscodeMocks = vi.hoisted(() => ({ + getState: vi.fn(), + setState: vi.fn(), +})); + +vi.mock("../vscode", () => ({ + getVSCodeApi: () => vscodeMocks, +})); + +describe("useTaskInputDraft", () => { + beforeEach(() => { + vscodeMocks.getState.mockReset(); + vscodeMocks.setState.mockReset(); + vscodeMocks.getState.mockReturnValue({}); + }); + + it("does not restore pasted text data from a persisted draft", () => { + vscodeMocks.getState.mockReturnValue({ + taskInputDraft: { + content: { + json: null, + text: "instruction", + pastedTexts: ["large pasted text"], + }, + timestamp: 1, + }, + }); + + const { result } = renderHook(() => useTaskInputDraft()); + + expect(result.current.draft).toEqual({ + json: null, + text: "instruction", + }); + }); + + it("omits pasted text data when persisting the editor draft", async () => { + const { result } = renderHook(() => useTaskInputDraft()); + + act(() => { + result.current.setDraft({ + json: null, + text: "instruction", + pastedTexts: ["large pasted text"], + }); + }); + + await waitFor(() => { + expect(vscodeMocks.setState).toHaveBeenLastCalledWith({ + taskInputDraft: { + content: { + json: null, + text: "instruction", + }, + timestamp: expect.any(Number), + }, + }); + }); + }); +}); diff --git a/packages/vscode-webui/src/lib/hooks/use-task-input-draft.ts b/packages/vscode-webui/src/lib/hooks/use-task-input-draft.ts index 3706938840..0b4c534018 100644 --- a/packages/vscode-webui/src/lib/hooks/use-task-input-draft.ts +++ b/packages/vscode-webui/src/lib/hooks/use-task-input-draft.ts @@ -15,6 +15,11 @@ interface VscodeState { taskInputDraft?: TaskInputDraft; } +function withoutPastedTexts(input: ChatInput): ChatInput { + const { pastedTexts: _pastedTexts, ...draftInput } = input; + return draftInput; +} + /** * Hook to persist task input draft content across page navigation * Uses VSCode's built-in state management API @@ -34,7 +39,7 @@ export function useTaskInputDraft() { const state = vscodeApi.getState() as VscodeState | undefined; const stored = state?.taskInputDraft; if (stored?.content?.text) { - return stored.content; + return withoutPastedTexts(stored.content); } } } catch (error) { @@ -55,7 +60,7 @@ export function useTaskInputDraft() { const draftText = draft.text; if (draftText.trim()) { const data: TaskInputDraft = { - content: draft, + content: withoutPastedTexts(draft), timestamp: Date.now(), }; diff --git a/packages/vscode-webui/src/lib/message-utils.test.ts b/packages/vscode-webui/src/lib/message-utils.test.ts index efbf7bb40a..73436ad2c8 100644 --- a/packages/vscode-webui/src/lib/message-utils.test.ts +++ b/packages/vscode-webui/src/lib/message-utils.test.ts @@ -67,4 +67,25 @@ describe("prepareMessageParts", () => { { type: "text", text: "/deploy" }, ]); }); + + it("appends pasted text after the visible prompt in paste order", () => { + const parts = prepareMessageParts( + ((key: string) => key) as TFunction, + "Analyze these logs", + [], + [], + undefined, + undefined, + undefined, + undefined, + undefined, + ["first paste", "second paste"], + ); + + expect(parts).toEqual([ + { type: "text", text: "Analyze these logs" }, + { type: "data-pasted-text", data: { text: "first paste" } }, + { type: "data-pasted-text", data: { text: "second paste" } }, + ]); + }); }); diff --git a/packages/vscode-webui/src/lib/message-utils.ts b/packages/vscode-webui/src/lib/message-utils.ts index 10b24ca512..ce0776540d 100644 --- a/packages/vscode-webui/src/lib/message-utils.ts +++ b/packages/vscode-webui/src/lib/message-utils.ts @@ -21,6 +21,7 @@ export function prepareMessageParts( terminalContextSelections?: TerminalTextSelection[], invokedSkills?: ValidSkillFile[], invokedCustomAgents?: string[], + pastedTexts?: string[], ) { const parts: Message["parts"] = []; const attachedContextLabels: string[] = []; @@ -69,6 +70,10 @@ export function prepareMessageParts( parts.push({ type: "text", text: finalPrompt }); } + for (const text of pastedTexts ?? []) { + parts.push({ type: "data-pasted-text", data: { text } }); + } + for (const x of files) { parts.push({ type: "text",