Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .agents/upstream-review.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
14 changes: 14 additions & 0 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,7 @@ import {
} from "./chat/timelineScrollAnchoring";
import {
buildPendingUserInputAnswers,
carryDisplacedCustomAnswerIntoPrompt,
derivePendingUserInputProgress,
setPendingUserInputCustomAnswer,
togglePendingUserInputOptionSelection,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -9101,7 +9112,10 @@ export default function ChatView(props: ChatViewProps) {
activePendingProgress?.activeQuestion,
activePendingUserInput,
activePendingRequestKey,
composerDraftTarget,
composerRef,
pendingUserInputAnswersByRequestId,
setComposerDraftPrompt,
],
);

Expand Down
22 changes: 14 additions & 8 deletions apps/web/src/components/chat/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ import {
type ComposerSubmissionIntent,
type ComposerTrigger,
collapseExpandedComposerCursor,
composerStateAtPromptEnd,
composerSubmissionIntentForEnter,
detectComposerTrigger,
expandCollapsedComposerCursor,
Expand Down Expand Up @@ -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;
}
Expand All @@ -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,
]);

Expand Down
12 changes: 2 additions & 10 deletions apps/web/src/components/preview/PreviewView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@ const mocks = vi.hoisted(() => ({
openPictureInPicture: vi.fn(async (_tabId: string): Promise<void> => undefined),
closePictureInPicture: vi.fn(async (_tabId: string): Promise<void> => undefined),
pickElement: vi.fn(),
capturePreviewAnnotationScreenshot: vi.fn(),
addPreviewAnnotation: vi.fn(),
addImage: vi.fn(),
toggleAnnotation: null as (() => void) | null,
Expand Down Expand Up @@ -90,10 +89,6 @@ vi.mock("~/composerDraftStore", () => ({
}),
}));

vi.mock("~/lib/previewAnnotation", () => ({
capturePreviewAnnotationScreenshot: mocks.capturePreviewAnnotationScreenshot,
}));

vi.mock("~/localApi", () => ({
ensureLocalApi: vi.fn(),
}));
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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",
Expand All @@ -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 },
Expand All @@ -616,7 +609,6 @@ describe("PreviewView navigation", () => {
};
const onSendAnnotation = vi.fn();
mocks.pickElement.mockResolvedValue({ annotation, submission: "send" });
mocks.capturePreviewAnnotationScreenshot.mockResolvedValue({ status: "failed" });

renderToStaticMarkup(
<PreviewView
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/preview/PreviewView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -603,7 +603,7 @@ export function PreviewView({
// instead of holding the composer for an attachment that never lands.
// The stored copy drops the screenshot on failure, otherwise the prompt
// would tell the agent a crop is attached when none was sent.
const capture = await capturePreviewAnnotationScreenshot(picked);
const capture = capturePreviewAnnotationScreenshot(picked);
// Main reports a crop that failed or timed out on its side; the local
// conversion can fail too. Either way the user should hear about it.
const cropDropped = screenshotFailed || capture.status === "failed";
Expand Down
10 changes: 5 additions & 5 deletions apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1549,7 +1549,7 @@ export function PullRequestDetailPanel({
!detail && !onClose && "hidden",
)}
>
<div className="ml-4 grid h-7 min-w-0 items-center overflow-hidden">
<div className="pl-4 grid h-7 min-w-0 items-center overflow-hidden">
<div
aria-hidden={condensed}
inert={condensed}
Expand Down Expand Up @@ -1771,7 +1771,7 @@ export function PullRequestDetailPanel({
</Tooltip>
<MenuPopup align="end" side="bottom" className="min-w-72">
<MenuItem onClick={() => startCheckout("worktree")}>
<GitBranchIcon className="mt-0.5 size-3.5 shrink-0 self-start" />
<GitBranchIcon className="mt-1 size-3.5 shrink-0 self-start" />
<span className="flex min-w-0 flex-col">
<span>In a separate worktree</span>
<span className="text-xs text-muted-foreground">
Expand All @@ -1780,7 +1780,7 @@ export function PullRequestDetailPanel({
</span>
</MenuItem>
<MenuItem onClick={() => startCheckout("local")}>
<FolderGit2Icon className="mt-0.5 size-3.5 shrink-0 self-start" />
<FolderGit2Icon className="mt-1 size-3.5 shrink-0 self-start" />
<span className="flex min-w-0 flex-col">
<span>In this repository</span>
<span className="text-xs text-muted-foreground">
Expand Down Expand Up @@ -1996,7 +1996,7 @@ export function PullRequestDetailPanel({
Refresh
</MenuItem>
<MenuItem disabled={handoff !== null} onClick={askAboutPullRequest}>
<MessageCircleQuestionIcon className="mt-0.5 size-3.5 shrink-0 self-start" />
<MessageCircleQuestionIcon className="mt-1 size-3.5 shrink-0 self-start" />
<span className="flex min-w-0 flex-col">
<span>{handoff === "ask" ? "Opening..." : "Ask a question"}</span>
<span className="text-xs text-muted-foreground">
Expand All @@ -2007,7 +2007,7 @@ export function PullRequestDetailPanel({
</span>
</MenuItem>
<MenuItem disabled={handoff !== null} onClick={explainPullRequest}>
<BookOpenIcon className="mt-0.5 size-3.5 shrink-0 self-start" />
<BookOpenIcon className="mt-1 size-3.5 shrink-0 self-start" />
<span className="flex min-w-0 flex-col">
<span>{handoff === "explain" ? "Opening..." : "Explain this PR"}</span>
<span className="text-xs text-muted-foreground">
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/pullRequest/PullRequestRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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. */}
<span className="relative inline-flex shrink-0">
<span className="relative inline-flex self-start mt-0.75 shrink-0">
<PullRequestStateGlyph state={entry.state} isDraft={entry.isDraft} />
{/* The wrapper takes the offset, not the icon, so the tooltip trigger inside keeps the
badge's size and anchors the popup to it. */}
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/components/settings/ProviderSettingsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,7 @@ function ProviderSettingsPlaceholder({
readonly children?: ReactNode;
}) {
return (
<SettingsSection {...searchableSetting("providers")} hideTitle variant="plain">
<SettingsSection {...searchableSetting("providers")} variant="plain">
{deviceTabs ? (
<div className="flex min-h-11 min-w-0 items-center px-3 sm:px-4">{deviceTabs}</div>
) : null}
Expand Down Expand Up @@ -1069,7 +1069,7 @@ export function EnvironmentProviderSettings({

return (
<>
<SettingsSection {...searchableSetting("providers")} hideTitle variant="plain">
<SettingsSection {...searchableSetting("providers")} variant="plain">
<div className="flex min-h-11 min-w-0 items-center gap-2 px-3 sm:px-4">
{deviceTabs}
<div className="ml-auto flex min-w-0 shrink-0 items-center gap-2">
Expand Down
30 changes: 30 additions & 0 deletions apps/web/src/composer-logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { describe, expect, it } from "vite-plus/test";
import {
clampCollapsedComposerCursor,
collapseExpandedComposerCursor,
composerStateAtPromptEnd,
composerSubmissionIntentForEnter,
detectComposerTrigger,
expandCollapsedComposerCursor,
Expand All @@ -18,6 +19,7 @@ import {
parseStandaloneComposerSlashCommand,
replaceTextRange,
} from "./composer-logic";
import { carryDisplacedCustomAnswerIntoPrompt } from "./pendingUserInput";
import { formatTerminalContextReference } from "./lib/terminalContext";

const terminalReference = formatTerminalContextReference({
Expand Down Expand Up @@ -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);
Expand Down
12 changes: 12 additions & 0 deletions apps/web/src/composer-logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ComposerSlashCommand, "model"> | null {
Expand Down
74 changes: 28 additions & 46 deletions apps/web/src/lib/previewAnnotation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Response>(() => {}));
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" });
},
);
});
Loading
Loading