diff --git a/.agents/upstream-review.md b/.agents/upstream-review.md index cf9839967a..2bd2824af6 100644 --- a/.agents/upstream-review.md +++ b/.agents/upstream-review.md @@ -15,7 +15,7 @@ The maintainer authorized compatible catch-up and routine adaptations that prese ## Active cycle -None currently active. Cycle #669 and cycle #667 are fully completed and landed. +[Upstream integration cycle #682](https://github.com/pylon-code/pylon/issues/682) covers web interaction fixes, ACP diagnostics, and Claude continuation through bounded upstream head `7445aa733ada33e45289e5aa5055f79142556513`. Pylon head at open `ee01ed60b09333939e48f362afa4601ce8285a13`. Review cursor stays at `bbedad0278bbf753503184c00e0c09a0eab6679c`. ## Latest cycle diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 564d82f788..75987e5d82 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -159,6 +159,7 @@ import { } from "./chat/timelineScrollAnchoring"; import { buildPendingUserInputAnswers, + carryDisplacedCustomAnswerIntoPrompt, derivePendingUserInputProgress, setPendingUserInputCustomAnswer, togglePendingUserInputOptionSelection, @@ -9072,6 +9073,16 @@ export default function ChatView(props: ChatViewProps) { if (!activePendingUserInput) { return; } + // The option replaces the custom answer. Anything typed there is the + // user's text, so it goes back to the thread draft instead of vanishing. + const displacedAnswer = + pendingUserInputAnswersByRequestId[activePendingRequestKey]?.[questionId]?.customAnswer; + const currentPrompt = + useComposerDraftStore.getState().getComposerDraft(composerDraftTarget)?.prompt ?? ""; + const nextPrompt = carryDisplacedCustomAnswerIntoPrompt(currentPrompt, displacedAnswer); + if (nextPrompt !== currentPrompt) { + setComposerDraftPrompt(composerDraftTarget, nextPrompt); + } setPendingUserInputAnswersByRequestId((existing) => { const question = (activePendingProgress?.activeQuestion?.id === questionId @@ -9101,7 +9112,10 @@ export default function ChatView(props: ChatViewProps) { activePendingProgress?.activeQuestion, activePendingUserInput, activePendingRequestKey, + composerDraftTarget, composerRef, + pendingUserInputAnswersByRequestId, + setComposerDraftPrompt, ], ); diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index a5b1853808..bfc6b7225f 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -110,6 +110,7 @@ import { type ComposerSubmissionIntent, type ComposerTrigger, collapseExpandedComposerCursor, + composerStateAtPromptEnd, composerSubmissionIntentForEnter, detectComposerTrigger, expandCollapsedComposerCursor, @@ -3949,6 +3950,15 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) useEffect(() => { const nextCustomAnswer = activePendingProgress?.customAnswer; if (typeof nextCustomAnswer !== "string") { + // The question is gone and the editor shows the thread draft again. The + // ref still holds the last answer text, and Send reads the ref. Place + // the caret at the end so the next keystroke appends. + if (lastSyncedPendingInputRef.current !== null) { + promptRef.current = prompt; + const { cursor, trigger } = composerStateAtPromptEnd(prompt); + setComposerCursor(cursor); + setComposerTrigger(trigger); + } lastSyncedPendingInputRef.current = null; return; } @@ -3970,19 +3980,15 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) } promptRef.current = nextCustomAnswer; - const nextCursor = collapseExpandedComposerCursor(nextCustomAnswer, nextCustomAnswer.length); - setComposerCursor(nextCursor); - setComposerTrigger( - detectComposerTrigger( - nextCustomAnswer, - expandCollapsedComposerCursor(nextCustomAnswer, nextCursor), - ), - ); + const { cursor, trigger } = composerStateAtPromptEnd(nextCustomAnswer); + setComposerCursor(cursor); + setComposerTrigger(trigger); setComposerHighlightedItemId(null); }, [ activePendingProgress?.customAnswer, activePendingProgress?.activeQuestion?.id, activePendingUserInput?.requestId, + prompt, promptRef, ]); diff --git a/apps/web/src/components/preview/PreviewView.test.tsx b/apps/web/src/components/preview/PreviewView.test.tsx index 7b1ef2109f..145b3d9f62 100644 --- a/apps/web/src/components/preview/PreviewView.test.tsx +++ b/apps/web/src/components/preview/PreviewView.test.tsx @@ -27,7 +27,6 @@ const mocks = vi.hoisted(() => ({ openPictureInPicture: vi.fn(async (_tabId: string): Promise => undefined), closePictureInPicture: vi.fn(async (_tabId: string): Promise => undefined), pickElement: vi.fn(), - capturePreviewAnnotationScreenshot: vi.fn(), addPreviewAnnotation: vi.fn(), addImage: vi.fn(), toggleAnnotation: null as (() => void) | null, @@ -90,10 +89,6 @@ vi.mock("~/composerDraftStore", () => ({ }), })); -vi.mock("~/lib/previewAnnotation", () => ({ - capturePreviewAnnotationScreenshot: mocks.capturePreviewAnnotationScreenshot, -})); - vi.mock("~/localApi", () => ({ ensureLocalApi: vi.fn(), })); @@ -342,8 +337,6 @@ describe("PreviewView navigation", () => { mocks.openPictureInPicture.mockClear(); mocks.closePictureInPicture.mockClear(); mocks.pickElement.mockReset(); - mocks.capturePreviewAnnotationScreenshot.mockReset(); - mocks.capturePreviewAnnotationScreenshot.mockResolvedValue({ status: "none" }); mocks.addPreviewAnnotation.mockClear(); vi.mocked(toastManager.add).mockClear(); mocks.addImage.mockClear(); @@ -596,7 +589,7 @@ describe("PreviewView navigation", () => { expect(toastManager.add).toHaveBeenCalledTimes(1); }); - it("still sends when the picked element's crop cannot be captured", async () => { + it("still sends annotation text when the picked element's crop is malformed", async () => { const annotation = { id: "annotation-2", pageUrl: "https://example.com/dashboard", @@ -607,7 +600,7 @@ describe("PreviewView navigation", () => { strokes: [], styleChanges: [], screenshot: { - dataUrl: "data:image/png;base64,c2NyZWVuc2hvdA==", + dataUrl: "data:image/png;base64,%%%", width: 10, height: 10, cropRect: { x: 0, y: 0, width: 10, height: 10 }, @@ -616,7 +609,6 @@ describe("PreviewView navigation", () => { }; const onSendAnnotation = vi.fn(); mocks.pickElement.mockResolvedValue({ annotation, submission: "send" }); - mocks.capturePreviewAnnotationScreenshot.mockResolvedValue({ status: "failed" }); renderToStaticMarkup( -
+
startCheckout("worktree")}> - + In a separate worktree @@ -1780,7 +1780,7 @@ export function PullRequestDetailPanel({ startCheckout("local")}> - + In this repository @@ -1996,7 +1996,7 @@ export function PullRequestDetailPanel({ Refresh - + {handoff === "ask" ? "Opening..." : "Ask a question"} @@ -2007,7 +2007,7 @@ export function PullRequestDetailPanel({ - + {handoff === "explain" ? "Opening..." : "Explain this PR"} diff --git a/apps/web/src/components/pullRequest/PullRequestRow.tsx b/apps/web/src/components/pullRequest/PullRequestRow.tsx index 14b0313147..e209ed0183 100644 --- a/apps/web/src/components/pullRequest/PullRequestRow.tsx +++ b/apps/web/src/components/pullRequest/PullRequestRow.tsx @@ -116,7 +116,7 @@ function PullRequestRowImpl({ {/* The conflict warning rides the corner of the lifecycle glyph, over the arrow's merge circle, so the leading slot stays one icon wide and titles line up whether or not a row is blocked. The background fill cuts it out of the glyph beneath. */} - + {/* The wrapper takes the offset, not the icon, so the tooltip trigger inside keeps the badge's size and anchors the popup to it. */} diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.tsx b/apps/web/src/components/settings/ProviderSettingsPanel.tsx index 6e0f4fd494..5469c87a41 100644 --- a/apps/web/src/components/settings/ProviderSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProviderSettingsPanel.tsx @@ -199,7 +199,7 @@ function ProviderSettingsPlaceholder({ readonly children?: ReactNode; }) { return ( - + {deviceTabs ? (
{deviceTabs}
) : null} @@ -1069,7 +1069,7 @@ export function EnvironmentProviderSettings({ return ( <> - +
{deviceTabs}
diff --git a/apps/web/src/composer-logic.test.ts b/apps/web/src/composer-logic.test.ts index 3f319331bb..4a6113713b 100644 --- a/apps/web/src/composer-logic.test.ts +++ b/apps/web/src/composer-logic.test.ts @@ -10,6 +10,7 @@ import { describe, expect, it } from "vite-plus/test"; import { clampCollapsedComposerCursor, collapseExpandedComposerCursor, + composerStateAtPromptEnd, composerSubmissionIntentForEnter, detectComposerTrigger, expandCollapsedComposerCursor, @@ -18,6 +19,7 @@ import { parseStandaloneComposerSlashCommand, replaceTextRange, } from "./composer-logic"; +import { carryDisplacedCustomAnswerIntoPrompt } from "./pendingUserInput"; import { formatTerminalContextReference } from "./lib/terminalContext"; const terminalReference = formatTerminalContextReference({ @@ -434,6 +436,34 @@ describe("expandCollapsedComposerCursor", () => { }); }); +describe("composerStateAtPromptEnd", () => { + it("puts the caret at the end of a restored parked draft", () => { + const prompt = carryDisplacedCustomAnswerIntoPrompt("first half\n", "second half"); + + expect(composerStateAtPromptEnd(prompt)).toEqual({ + cursor: prompt.length, + trigger: null, + }); + }); + + it("collapses mention chips so the next keystroke lands after the draft", () => { + const prompt = carryDisplacedCustomAnswerIntoPrompt("", "see @AGENTS.md please"); + + expect(composerStateAtPromptEnd(prompt).cursor).toBe("see ".length + 1 + " please".length); + expect(composerStateAtPromptEnd(prompt).cursor).not.toBe(0); + expect(composerStateAtPromptEnd(prompt).cursor).not.toBe(prompt.length); + }); + + it("keeps a trailing mention trigger when the restored draft ends with @", () => { + const prompt = "look at @"; + + expect(composerStateAtPromptEnd(prompt)).toEqual({ + cursor: prompt.length, + trigger: { kind: "path", query: "", rangeStart: "look at ".length, rangeEnd: prompt.length }, + }); + }); +}); + describe("collapseExpandedComposerCursor", () => { it("keeps cursor unchanged when no mention segment is present", () => { expect(collapseExpandedComposerCursor("plain text", 5)).toBe(5); diff --git a/apps/web/src/composer-logic.ts b/apps/web/src/composer-logic.ts index 9a9354a84d..57643ba0ea 100644 --- a/apps/web/src/composer-logic.ts +++ b/apps/web/src/composer-logic.ts @@ -255,6 +255,18 @@ export function detectComposerTrigger(text: string, cursorInput: number): Compos }; } +/** Caret and trigger after replacing composer text and continuing at the end. */ +export function composerStateAtPromptEnd(text: string): { + cursor: number; + trigger: ComposerTrigger | null; +} { + const cursor = collapseExpandedComposerCursor(text, text.length); + return { + cursor, + trigger: detectComposerTrigger(text, expandCollapsedComposerCursor(text, cursor)), + }; +} + export function parseStandaloneComposerSlashCommand( text: string, ): Exclude | null { diff --git a/apps/web/src/lib/previewAnnotation.test.ts b/apps/web/src/lib/previewAnnotation.test.ts index 2752dac9e5..196654977b 100644 --- a/apps/web/src/lib/previewAnnotation.test.ts +++ b/apps/web/src/lib/previewAnnotation.test.ts @@ -9,66 +9,48 @@ const annotation: PreviewAnnotationPayload = { pageTitle: "Example", comment: "Make these cards feel related.", elements: [], - regions: [{ id: "region_1", rect: { x: 10, y: 20, width: 100, height: 80 } }], - strokes: [ - { - id: "stroke_1", - color: "#7c3aed", - width: 4, - points: [ - { x: 10, y: 10 }, - { x: 20, y: 20 }, - ], - bounds: { x: 6, y: 6, width: 18, height: 18 }, - }, - ], - styleChanges: [ - { - targetId: "element_1", - selector: ".card", - property: "border-radius", - previousValue: "4px", - value: "16px", - }, - ], + regions: [], + strokes: [], + styleChanges: [], screenshot: { - dataUrl: "data:image/png;base64,AA==", - width: 100, - height: 80, - cropRect: { x: 10, y: 20, width: 100, height: 80 }, + dataUrl: + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+ip1sAAAAASUVORK5CYII=", + width: 1, + height: 1, + cropRect: { x: 0, y: 0, width: 1, height: 1 }, }, createdAt: "2026-06-11T00:00:00.000Z", }; describe("preview annotation capture", () => { afterEach(() => { - vi.useRealTimers(); vi.unstubAllGlobals(); }); - it("returns the crop when the fetch resolves", async () => { - vi.stubGlobal("fetch", async () => new Response(new Blob(["png"], { type: "image/png" }))); - const capture = await capturePreviewAnnotationScreenshot(annotation); + it("preserves the PNG bytes, MIME type, and filename without fetching", async () => { + const fetch = vi.fn().mockRejectedValue(new TypeError("Blocked by connect-src")); + vi.stubGlobal("fetch", fetch); + const capture = capturePreviewAnnotationScreenshot(annotation); expect(capture.status).toBe("captured"); + if (capture.status !== "captured") throw new Error("Expected a screenshot file"); + expect(capture.file.name).toBe("preview-annotation-annotation_1.png"); + expect(capture.file.type).toBe("image/png"); + expect(Buffer.from(await capture.file.arrayBuffer()).toString("hex")).toBe( + "89504e470d0a1a0a0000000d4948445200000001000000010804000000b51c0c020000000b4944415478da63fcff1f0003030200efa2a75b0000000049454e44ae426082", + ); + expect(fetch).not.toHaveBeenCalled(); }); - it("reports none when the annotation carries no crop", async () => { - const capture = await capturePreviewAnnotationScreenshot({ ...annotation, screenshot: null }); + it("reports none when the annotation carries no crop", () => { + const capture = capturePreviewAnnotationScreenshot({ ...annotation, screenshot: null }); expect(capture).toEqual({ status: "none" }); }); - it("fails instead of hanging when the crop never arrives", async () => { - vi.useFakeTimers(); - vi.stubGlobal("fetch", () => new Promise(() => {})); - const capturePromise = capturePreviewAnnotationScreenshot(annotation, 1_000); - await vi.advanceTimersByTimeAsync(1_000); - expect(await capturePromise).toEqual({ status: "failed" }); - }); - - it("fails when the crop fetch throws", async () => { - vi.stubGlobal("fetch", async () => { - throw new Error("data url unreadable"); - }); - expect(await capturePreviewAnnotationScreenshot(annotation)).toEqual({ status: "failed" }); - }); + it.each(["data:image/jpeg;base64,AA==", "data:image/png;base64,", "data:image/png;base64,%%%"])( + "reports a malformed screenshot as failed: %s", + (dataUrl) => { + const picked = { ...annotation, screenshot: { ...annotation.screenshot!, dataUrl } }; + expect(capturePreviewAnnotationScreenshot(picked)).toEqual({ status: "failed" }); + }, + ); }); diff --git a/apps/web/src/lib/previewAnnotation.ts b/apps/web/src/lib/previewAnnotation.ts index 1b4a449e50..e3dda16f12 100644 --- a/apps/web/src/lib/previewAnnotation.ts +++ b/apps/web/src/lib/previewAnnotation.ts @@ -1,49 +1,30 @@ import type { PreviewAnnotationPayload } from "@t3tools/contracts"; -async function previewAnnotationScreenshotFile( - annotation: PreviewAnnotationPayload, -): Promise { - if (!annotation.screenshot) return null; - const response = await fetch(annotation.screenshot.dataUrl); - const blob = await response.blob(); - return new File([blob], `preview-annotation-${annotation.id}.png`, { - type: blob.type || "image/png", - }); -} - -/** Upper bound on turning a picked element's crop into a composer attachment. */ -const PREVIEW_ANNOTATION_CAPTURE_TIMEOUT_MS = 5_000; +import { dataUrlToFile } from "./imageCompression"; export type PreviewAnnotationCapture = /** The crop is ready to attach. */ | { readonly status: "captured"; readonly file: File } /** The pick carried no crop, which is normal for comment-only annotations. */ | { readonly status: "none" } - /** The crop stalled or threw. Send the annotation without it. */ + /** The crop could not be decoded. Send the annotation without it. */ | { readonly status: "failed" }; -/** - * Bounded wrapper around `previewAnnotationScreenshotFile`. The picker holds the - * composer while this runs, so it must always settle: a stalled crop resolves as - * `failed` instead of leaving the caller waiting. - */ -export async function capturePreviewAnnotationScreenshot( +const PNG_DATA_URL_PREFIX = "data:image/png;base64,"; + +/** Decode Electron's PNG crop locally; fetching a data URL violates desktop connect-src. */ +export function capturePreviewAnnotationScreenshot( annotation: PreviewAnnotationPayload, - timeoutMs: number = PREVIEW_ANNOTATION_CAPTURE_TIMEOUT_MS, -): Promise { +): PreviewAnnotationCapture { if (!annotation.screenshot) return { status: "none" }; - let timer: ReturnType | undefined; try { - const file = await Promise.race([ - previewAnnotationScreenshotFile(annotation), - new Promise((resolve) => { - timer = setTimeout(() => resolve(null), timeoutMs); - }), - ]); - return file ? { status: "captured", file } : { status: "failed" }; + const { dataUrl } = annotation.screenshot; + if (!dataUrl.startsWith(PNG_DATA_URL_PREFIX)) { + return { status: "failed" }; + } + const file = dataUrlToFile(dataUrl, `preview-annotation-${annotation.id}.png`, "image/png"); + return file.size > 0 ? { status: "captured", file } : { status: "failed" }; } catch { return { status: "failed" }; - } finally { - clearTimeout(timer); } } diff --git a/apps/web/src/pendingUserInput.test.ts b/apps/web/src/pendingUserInput.test.ts index f2f093a5c8..350087c3aa 100644 --- a/apps/web/src/pendingUserInput.test.ts +++ b/apps/web/src/pendingUserInput.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vite-plus/test"; import { buildPendingUserInputAnswers, + carryDisplacedCustomAnswerIntoPrompt, countAnsweredPendingUserInputQuestions, derivePendingUserInputProgress, resolvePendingUserInputAnswer, @@ -321,3 +322,22 @@ it("accepts attachment-only answers after every upload finishes", () => { }), ).toBeNull(); }); + +describe("carryDisplacedCustomAnswerIntoPrompt", () => { + it("keeps the thread draft when nothing was typed into the answer", () => { + expect(carryDisplacedCustomAnswerIntoPrompt("draft", undefined)).toBe("draft"); + expect(carryDisplacedCustomAnswerIntoPrompt("draft", " ")).toBe("draft"); + }); + + it("moves the typed answer into an empty thread draft", () => { + expect(carryDisplacedCustomAnswerIntoPrompt("", "also rename the flag ")).toBe( + "also rename the flag", + ); + }); + + it("appends the typed answer after an existing thread draft", () => { + expect(carryDisplacedCustomAnswerIntoPrompt("first half\n", "second half")).toBe( + "first half\n\nsecond half", + ); + }); +}); diff --git a/apps/web/src/pendingUserInput.ts b/apps/web/src/pendingUserInput.ts index 5ac004649a..5d22e56105 100644 --- a/apps/web/src/pendingUserInput.ts +++ b/apps/web/src/pendingUserInput.ts @@ -82,6 +82,28 @@ export function setPendingUserInputCustomAnswer( }; } +const DISPLACED_ANSWER_SEPARATOR = "\n\n"; + +/** + * Selecting an option replaces the custom answer, because a non-empty custom + * answer outranks selected options in `resolvePendingUserInputAnswer`. Text the + * user typed into the answer field must not vanish on that click: it moves back + * into the thread draft, after whatever was already waiting there. + */ +export function carryDisplacedCustomAnswerIntoPrompt( + prompt: string, + customAnswer: string | undefined, +): string { + const displaced = customAnswer?.trim() ?? ""; + if (displaced.length === 0) { + return prompt; + } + if (prompt.trim().length === 0) { + return displaced; + } + return `${prompt.trimEnd()}${DISPLACED_ANSWER_SEPARATOR}${displaced}`; +} + export function togglePendingUserInputOptionSelection( question: UserInputQuestion, draft: PendingUserInputDraftAnswer | undefined,