Skip to content
Open
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
7 changes: 5 additions & 2 deletions apps/server/src/orchestration/ActivityPayloadProjection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ function collectChangedFiles(
}
}

function projectCommandData(data: Record<string, unknown>): Record<string, unknown> | undefined {
function projectItemData(data: Record<string, unknown>): Record<string, unknown> | undefined {
const item = asRecord(data.item);
if (!item) {
return undefined;
Expand All @@ -90,6 +90,9 @@ function projectCommandData(data: Record<string, unknown>): Record<string, unkno
if ("command" in item) {
projectedItem.command = item.command;
}
if ("savedPath" in item) {
projectedItem.savedPath = item.savedPath;
}

const input = asRecord(item.input);
if (input && "command" in input) {
Expand Down Expand Up @@ -165,7 +168,7 @@ export function projectActivityPayload(
}

const projectedData: Record<string, unknown> = {};
const item = projectCommandData(data);
const item = projectItemData(data);
if (item) {
projectedData.item = item;
}
Expand Down
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
85 changes: 81 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,73 @@ 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: {
item: {
savedPath: "/repo/project/generated/cat.png",
},
},
});
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("keeps generated image paths when the activity detail is truncated", () => {
const imagePath = `/repo/project/${"generated/".repeat(20)}cat.png`;
const truncatedDetail = `${imagePath.slice(0, 177)}...`;
const projected = projectActivityPayload(
makeActivity(
"long-image",
"image_view",
{
item: {
type: "imageGeneration",
savedPath: imagePath,
},
},
truncatedDetail,
),
);

expect(imagePath.length).toBeGreaterThan(180);
expect(deriveWorkLogEntries([projected])[0]?.imagePath).toBe(imagePath);
});

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 @@ -1451,6 +1497,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
47 changes: 46 additions & 1 deletion apps/web/src/components/chat/MessagesTimeline.test.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,20 @@
import { CheckpointRef, EnvironmentId, MessageId, TurnId } from "@t3tools/contracts";
import { CheckpointRef, EnvironmentId, MessageId, ThreadId, TurnId } from "@t3tools/contracts";
import { createRef, type ReactNode, type Ref } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { beforeAll, describe, expect, it, vi } from "vite-plus/test";
import type { LegendListRef } from "@legendapp/list/react";

const assetUrlMocks = vi.hoisted(() => ({
useAssetUrlState: vi.fn(() => ({
_tag: "Success" as const,
url: "https://environment.test/api/assets/signed-token/result.png",
})),
}));

vi.mock("../../assets/assetUrls", () => ({
useAssetUrlState: assetUrlMocks.useAssetUrlState,
}));

vi.mock("@legendapp/list/react", async () => {
const legendListTestId = "legend-list";

Expand Down Expand Up @@ -553,6 +564,40 @@ describe("MessagesTimeline", () => {
expect(markup).not.toContain("C:/Users/mike/dev-stuff/t3code/apps/web/src/session-logic.ts");
});

it("renders Windows work-log image paths after Markdown URL sanitization", () => {
const imagePath = "C:\\Users\\mike\\dev-stuff\\t3code\\result.png";
assetUrlMocks.useAssetUrlState.mockClear();

const markup = renderToStaticMarkup(
<MessagesTimeline
{...buildProps()}
timelineEntries={[
{
id: "entry-image-view",
kind: "work",
createdAt: MESSAGE_CREATED_AT,
entry: {
id: "work-image-view",
createdAt: MESSAGE_CREATED_AT,
label: "Viewed image",
tone: "tool",
itemType: "image_view",
imagePath,
},
},
]}
workspaceRoot="C:\\Users\\mike\\dev-stuff\\t3code"
/>,
);

expect(assetUrlMocks.useAssetUrlState).toHaveBeenCalledWith(ACTIVE_THREAD_ENVIRONMENT_ID, {
_tag: "workspace-file",
threadId: ThreadId.make("thread-1"),
path: imagePath,
});
expect(markup).toContain('src="https://environment.test/api/assets/signed-token/result.png"');
});

it("renders review comment contexts as structured cards instead of raw tags", () => {
const markup = renderToStaticMarkup(
<MessagesTimeline
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
Loading
Loading