Skip to content
Closed
Show file tree
Hide file tree
Changes from 5 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
39 changes: 39 additions & 0 deletions apps/server/src/provider/Layers/CodexAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -613,6 +613,45 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => {
}),
);

it.effect("preserves generated image paths in canonical lifecycle entries", () =>
Effect.gen(function* () {
const { adapter, runtime } = yield* startLifecycleRuntime();
const firstEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe(Effect.forkChild);

yield* runtime.emit({
id: asEventId("evt-image-complete"),
kind: "notification",
provider: ProviderDriverKind.make("codex"),
createdAt: "2026-01-01T00:00:00.000Z",
method: "item/completed",
threadId: asThreadId("thread-1"),
turnId: asTurnId("turn-1"),
itemId: asItemId("image_1"),
payload: {
completedAtMs: 1_778_000_000_000,
threadId: "thread-1",
turnId: "turn-1",
item: {
type: "imageGeneration",
id: "image_1",
result: "generated",
revisedPrompt: null,
savedPath: "/repo/project/generated/cat.png",
status: "completed",
},
},
});
const firstEvent = yield* Fiber.join(firstEventFiber);

NodeAssert.equal(firstEvent._tag, "Some");
if (firstEvent._tag !== "Some" || firstEvent.value.type !== "item.completed") {
return;
}
NodeAssert.equal(firstEvent.value.payload.itemType, "image_view");
NodeAssert.equal(firstEvent.value.payload.detail, "/repo/project/generated/cat.png");
}),
);

it.effect("maps completed plan items to canonical proposed-plan completion events", () =>
Effect.gen(function* () {
const { adapter, runtime } = yield* startLifecycleRuntime();
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/provider/Layers/CodexAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,7 @@ function itemDetail(itemType: CanonicalItemType, item: CodexLifecycleItem): stri
"summary" in item ? item.summary : undefined,
"text" in item ? item.text : undefined,
"path" in item ? item.path : undefined,
"savedPath" in item ? item.savedPath : undefined,
"prompt" in item ? item.prompt : undefined,
];

Expand Down
60 changes: 56 additions & 4 deletions apps/server/test/ActivityPayloadProjection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ function makeActivity(
id: string,
itemType: string,
data: Record<string, unknown>,
detail = `${itemType} detail`,
): OrchestrationThreadActivity {
return {
id: EventId.make(id),
Expand All @@ -32,7 +33,7 @@ function makeActivity(
payload: {
itemType,
title: itemType,
detail: `${itemType} detail`,
detail,
status: "completed",
requestKind: "command",
data,
Expand Down Expand Up @@ -129,9 +130,18 @@ const fixtures = [
},
ignored: "top-level bulk",
}),
makeActivity("image", "image_view", {
ignored: "top-level bulk",
}),
makeActivity(
"image",
"image_view",
{
item: {
type: "imageGeneration",
savedPath: "/repo/project/generated/cat.png",
},
ignored: "top-level bulk",
},
"/repo/project/generated/cat.png",
),
] satisfies ReadonlyArray<OrchestrationThreadActivity>;

describe("projectActivityPayload", () => {
Expand Down Expand Up @@ -196,6 +206,48 @@ describe("projectActivityPayload", () => {
}
});

it("keeps image paths available after projecting nested provider data", () => {
const projectedCodex = projectActivityPayload(fixtures[6]!);

expect(projectedCodex.payload).toEqual({
itemType: "image_view",
title: "image_view",
detail: "/repo/project/generated/cat.png",
status: "completed",
requestKind: "command",
data: {},
});
expect(deriveWorkLogEntries([projectedCodex])[0]?.imagePath).toBe(
"/repo/project/generated/cat.png",
);

const projectedClaude = projectActivityPayload(
makeActivity(
"claude-image",
"image_view",
{
toolName: "ReadImage",
input: { path: "/repo/project/screenshots/claude.png" },
},
'ReadImage: {"path":"/repo/project/screenshots/claude.png"}',
),
);

expect(projectedClaude.payload).toEqual({
itemType: "image_view",
title: "image_view",
detail: 'ReadImage: {"path":"/repo/project/screenshots/claude.png"}',
status: "completed",
requestKind: "command",
data: {
files: [{ path: "/repo/project/screenshots/claude.png" }],
},
});
expect(deriveWorkLogEntries([projectedClaude])[0]?.imagePath).toBe(
"/repo/project/screenshots/claude.png",
);
});

it("projects snapshot and event transports without mutating their sources", () => {
const activity = fixtures[0]!;
const thread = makeThread([activity]);
Expand Down
61 changes: 61 additions & 0 deletions apps/web/src/components/ChatMarkdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ import { getSyntaxHighlighterPromise } from "../lib/syntaxHighlighting";
import { RenderErrorBoundary } from "./RenderErrorBoundary";
import { useTheme } from "../hooks/useTheme";
import { getClientSettings } from "../hooks/useSettings";
import { useAssetUrlState } from "../assets/assetUrls";
import {
chatMarkdownClipboardPayload,
serializeTableElementToCsv,
Expand All @@ -70,6 +71,7 @@ import {
normalizeMarkdownLinkDestination,
resolveInlineCodeFileLinkMeta,
resolveMarkdownFileLinkMeta,
resolveMarkdownImageFileLinkMeta,
rewriteMarkdownFileUriHref,
type MarkdownFileLinkMeta,
} from "../markdown-links";
Expand Down Expand Up @@ -148,6 +150,7 @@ const CHAT_MARKDOWN_SANITIZE_SCHEMA = {
protocols: {
...defaultSchema.protocols,
href: [...(defaultSchema.protocols?.href ?? []), "file"],
src: [...(defaultSchema.protocols?.src ?? []), "file"],
},
} satisfies Parameters<typeof rehypeSanitize>[0];

Expand Down Expand Up @@ -846,6 +849,49 @@ const MARKDOWN_LINK_FAVICON_CLASS_NAME = "block size-full shrink-0 select-none";
/** Hosts whose favicon request already failed this session — skip straight to the globe. */
const failedFaviconHosts = new Set<string>();

function MarkdownWorkspaceImage({
filePath,
markdownSrc,
threadRef,
alt,
className,
onError,
...props
}: Omit<React.ComponentProps<"img">, "src"> & {
readonly filePath: string;
readonly markdownSrc: string;
readonly threadRef: ScopedThreadRef;
}) {
const assetUrl = useAssetUrlState(threadRef.environmentId, {
_tag: "workspace-file",
threadId: threadRef.threadId,
path: filePath,
});
const [failedUrl, setFailedUrl] = useState<string | null>(null);
const label = alt?.trim() || filePath.split(/[\\/]/).at(-1) || "image";

if (assetUrl._tag === "Failure" || (assetUrl._tag === "Success" && failedUrl === assetUrl.url)) {
return <span className="text-destructive">Unable to load {label}.</span>;
}
if (assetUrl._tag !== "Success") {
return <span className="text-muted-foreground">Loading {label}…</span>;
}

return (
<img
{...props}
className={cn("max-h-[32rem] max-w-full rounded-md object-contain", className)}
data-markdown-src={markdownSrc}
src={assetUrl.url}
alt={alt}
onError={(event) => {
setFailedUrl(assetUrl.url);
onError?.(event);
}}
/>
);
}

const MarkdownLinkFavicon = memo(function MarkdownLinkFavicon({ host }: { host: string }) {
const [failedHost, setFailedHost] = useState<string | null>(null);
return (
Expand Down Expand Up @@ -1448,6 +1494,21 @@ function ChatMarkdown({
/>
);
},
img({ node: _node, src, alt, ...props }) {
const fileLinkMeta = resolveMarkdownImageFileLinkMeta(src, cwd);
if (!fileLinkMeta || !threadRef) {
return <img {...props} src={src} alt={alt} />;
}
return (
<MarkdownWorkspaceImage
{...props}
filePath={fileLinkMeta.filePath}
markdownSrc={src ?? fileLinkMeta.filePath}
threadRef={threadRef}
alt={alt}
/>
);
},
a({ node, href, children, ...props }) {
const normalizedHref = href ? normalizeMarkdownLinkHrefKey(href) : "";
const fileLinkMeta = normalizedHref ? markdownFileLinkMetaByHref.get(normalizedHref) : null;
Expand Down
14 changes: 14 additions & 0 deletions apps/web/src/components/chat/MessagesTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1927,6 +1927,7 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: {
}) {
const { workEntry, workspaceRoot } = props;
const activity = use(TimelineRowActivityCtx);
const timeline = use(TimelineRowCtx);
const [expanded, setExpanded] = useState(false);
const iconConfig = workToneIcon(workEntry.tone);
const showWarningIndicator = workEntry.sourceActivityKind === "runtime.warning";
Expand Down Expand Up @@ -2065,6 +2066,19 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: {
</div>
</div>
</div>
{workEntry.imagePath && timeline.threadRef ? (
<div
className="mt-1.5 ms-7 cursor-default"
onClick={stopRowToggle}
onPointerDown={stopRowToggle}
>
<ChatMarkdown
text={`![Generated image](${encodeURIComponent(workEntry.imagePath)})`}
cwd={workspaceRoot}
threadRef={timeline.threadRef}
/>
Comment thread
cursor[bot] marked this conversation as resolved.
Comment thread
cursor[bot] marked this conversation as resolved.
</div>
) : null}
{expanded && canExpand && expandedBody ? (
<div
className="mt-1 ms-7 cursor-default border-s border-border/45 ps-3 pt-0.5"
Expand Down
62 changes: 62 additions & 0 deletions apps/web/src/markdown-clipboard.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { describe, expect, it } from "vite-plus/test";

import {
restoreMarkdownImageSourcesForClipboard,
serializeMarkdownImageElement,
} from "./markdown-clipboard";

function makeImageAttributes(initial: Record<string, string>) {
const attributes = new Map(Object.entries(initial));
return {
attributes,
element: {
getAttribute(name: string) {
return attributes.get(name) ?? null;
},
removeAttribute(name: string) {
attributes.delete(name);
},
setAttribute(name: string, value: string) {
attributes.set(name, value);
},
},
};
}

describe("workspace image clipboard serialization", () => {
it("uses the original Markdown source instead of the signed rendering URL", () => {
const { element } = makeImageAttributes({
alt: "Result",
"data-markdown-src": "screenshots/result.png",
src: "https://environment.example/api/assets/signed-token/result.png",
});

expect(serializeMarkdownImageElement(element)).toBe("![Result](screenshots/result.png)");
});

it("restores original sources and removes signed-URL metadata before rich copy", () => {
const { attributes, element } = makeImageAttributes({
alt: "Result",
"data-markdown-src": "screenshots/result.png",
src: "https://environment.example/api/assets/signed-token/result.png",
});

restoreMarkdownImageSourcesForClipboard([element]);

expect(Object.fromEntries(attributes)).toEqual({
alt: "Result",
src: "screenshots/result.png",
});
});

it("keeps ordinary remote image sources unchanged", () => {
const { element } = makeImageAttributes({
alt: "Remote",
src: "https://example.com/result.png",
});

expect(serializeMarkdownImageElement(element)).toBe(
"![Remote](https://example.com/result.png)",
);
});
});
37 changes: 32 additions & 5 deletions apps/web/src/markdown-clipboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

const SKIPPED_TAGS = new Set(["BUTTON", "INPUT", "SCRIPT", "STYLE", "TEMPLATE"]);
const SKIPPED_CLASS_NAMES = ["select-none", "sr-only"];
const MARKDOWN_IMAGE_SOURCE_ATTRIBUTE = "data-markdown-src";
const SANITIZED_HTML_SELECTOR = [
"button",
"input",
Expand All @@ -29,6 +30,32 @@ function isSkippedElement(element: Element): boolean {
return SKIPPED_CLASS_NAMES.some((className) => element.classList.contains(className));
}

type ClipboardImageElement = Pick<Element, "getAttribute" | "removeAttribute" | "setAttribute">;

function copiedMarkdownImageSource(element: Pick<Element, "getAttribute">): string {
return element.getAttribute(MARKDOWN_IMAGE_SOURCE_ATTRIBUTE) ?? element.getAttribute("src") ?? "";
}

export function serializeMarkdownImageElement(element: Pick<Element, "getAttribute">): string {
const alt = element.getAttribute("alt") ?? "";
const src = copiedMarkdownImageSource(element);
return alt && src ? `![${alt}](${src})` : "";
}

export function restoreMarkdownImageSourcesForClipboard(
images: Iterable<ClipboardImageElement>,
): void {
for (const image of images) {
const markdownSrc = image.getAttribute(MARKDOWN_IMAGE_SOURCE_ATTRIBUTE);
if (markdownSrc) {
image.setAttribute("src", markdownSrc);
} else {
image.removeAttribute("src");
}
image.removeAttribute(MARKDOWN_IMAGE_SOURCE_ATTRIBUTE);
}
}

/** Hoists surrounding whitespace outside the markers: "` bold `" → " **bold** ". */
function wrapInlineMarker(content: string, marker: string): string {
const match = /^(\s*)([\s\S]*?)(\s*)$/.exec(content);
Expand Down Expand Up @@ -214,11 +241,8 @@ function serializeNode(node: Node): string {
return wrapInlineMarker(serializeChildren(element), "~~");
case "A":
return serializeAnchor(element);
case "IMG": {
const alt = element.getAttribute("alt") ?? "";
const src = element.getAttribute("src") ?? "";
return alt && src ? `![${alt}](${src})` : "";
}
case "IMG":
return serializeMarkdownImageElement(element);
case "UL":
return serializeList(element, false);
case "OL":
Expand Down Expand Up @@ -288,6 +312,9 @@ function sanitizedHtmlFrom(container: Element): string {
}
node.remove();
}
restoreMarkdownImageSourcesForClipboard(
container.querySelectorAll(`img[${MARKDOWN_IMAGE_SOURCE_ATTRIBUTE}]`),
);
return `<meta charset="utf-8">${container.innerHTML}`;
}

Expand Down
Loading
Loading