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
Binary file added .github/pr-assets/7724-after.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added .github/pr-assets/7724-before.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added .github/pr-assets/7724-mobile-after.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added .github/pr-assets/7724-mobile-before.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
16 changes: 16 additions & 0 deletions apps/mobile/src/features/threads/ThreadFeed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -982,6 +982,7 @@ function renderFeedEntry(
readonly onPressImage: (uri: string, headers?: Record<string, string>) => void;
readonly onMarkdownLinkPress: (href: string) => void;
readonly renderMarkdownImage: MarkdownImageRenderer;
readonly renderViewedWorkImage: (path: string) => ReactNode;
readonly iconSubtleColor: string | import("react-native").ColorValue;
readonly userBubbleColor: string | import("react-native").ColorValue;
readonly markdownStyles: MarkdownStyleSets;
Expand Down Expand Up @@ -1182,6 +1183,7 @@ function renderFeedEntry(
iconSubtleColor={iconSubtleColor}
onCopyRow={props.onCopyWorkRow}
onToggleRow={props.onToggleWorkRow}
renderViewedImage={props.renderViewedWorkImage}
/>
);
}
Expand Down Expand Up @@ -1624,6 +1626,18 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
},
[props.environmentId, props.threadId, props.workspaceRoot],
);
const renderViewedWorkImage = useCallback(
(path: string) => (
<ThreadMarkdownImage
environmentId={props.environmentId}
threadId={props.threadId}
path={path}
alt={null}
onPressImage={(uri) => setExpandedImage({ uri })}
/>
),
[props.environmentId, props.threadId],
);
const markdownStyles = useMarkdownStyles(onMarkdownLinkPress, renderMarkdownImage);
const reviewCommentColors = useReviewCommentColors();
// LegendList does not invalidate visible rows when only the renderItem closure changes.
Expand Down Expand Up @@ -2016,6 +2030,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
onPressImage,
onMarkdownLinkPress,
renderMarkdownImage,
renderViewedWorkImage,
iconSubtleColor,
userBubbleColor,
markdownStyles,
Expand Down Expand Up @@ -2044,6 +2059,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
props.environmentId,
props.skills,
renderMarkdownImage,
renderViewedWorkImage,
],
);

Expand Down
6 changes: 6 additions & 0 deletions apps/mobile/src/features/threads/thread-work-log.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import * as Haptics from "expo-haptics";
import type { ReactNode } from "react";
import { type AppSymbolName, SymbolView } from "../../components/AppSymbol";
import { LayoutAnimation, Pressable, ScrollView, View } from "react-native";

Expand Down Expand Up @@ -127,6 +128,8 @@ export function ThreadWorkLog(props: {
readonly iconSubtleColor: import("react-native").ColorValue;
readonly onCopyRow: (rowId: string, value: string) => void;
readonly onToggleRow: (rowId: string) => void;
/** Renders the image a read/view entry looked at inside its expanded detail. */
readonly renderViewedImage: (path: string) => ReactNode;
}) {
const pressedBackground = useThemeColor("--color-subtle");
const rows = visibleWorkLogActivities(props.activities).map((activity) => ({
Expand Down Expand Up @@ -250,6 +253,9 @@ export function ThreadWorkLog(props: {

{fullDetail ? (
<View className="ml-7 border-l border-neutral-300/60 pb-1 pl-3 pt-0.5 dark:border-white/[0.12]">
{row.viewedImagePath ? (
<View className="pb-1.5">{props.renderViewedImage(row.viewedImagePath)}</View>
) : null}
<ScrollView
nestedScrollEnabled
directionalLockEnabled
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/src/lib/threadActivity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -764,6 +764,7 @@ describe("buildThreadFeed", () => {
summary: `Tool ${id}`,
detail: null,
canExpand: false,
viewedImagePath: null,
getFullDetail: () => null,
getCopyText: () => id,
icon: "command",
Expand Down
19 changes: 19 additions & 0 deletions apps/mobile/src/lib/threadActivity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import type {
TurnId,
UserInputQuestion,
} from "@t3tools/contracts";
import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview";
import { formatDuration } from "@t3tools/shared/orchestrationTiming";

import * as Arr from "effect/Array";
Expand Down Expand Up @@ -48,6 +49,8 @@ export interface ThreadFeedActivity {
readonly summary: string;
readonly detail: string | null;
readonly canExpand: boolean;
/** Workspace path of the image a read/view entry looked at — the expanded row renders it. */
readonly viewedImagePath: string | null;
readonly getFullDetail: () => string | null;
readonly getCopyText: () => string;
readonly icon:
Expand Down Expand Up @@ -680,6 +683,21 @@ function buildWorkEntryExpandedBody(entry: WorkLogEntry): string | null {
return blocks.length > 0 ? blocks.join("\n\n") : null;
}

/**
* Workspace path of the image a read/view tool entry looked at. Non-null only
* when the entry's detail is a single image path the asset route can serve.
*/
function workEntryViewedImagePath(entry: WorkLogEntry): string | null {
const isReadEntry =
entry.requestKind === "file-read" ||
entry.itemType === "image_view" ||
(entry.itemType === "dynamic_tool_call" && entry.toolTitle === "Read File");
if (!isReadEntry) return null;
const detail = entry.detail?.trim();
if (!detail || detail.includes("\n") || !isWorkspaceImagePreviewPath(detail)) return null;
return detail;
}

function workEntryHasExpandedBody(entry: WorkLogEntry): boolean {
return (
(entry.itemType === "mcp_tool_call" && entry.toolData !== undefined) ||
Expand Down Expand Up @@ -1592,6 +1610,7 @@ export function buildThreadFeed(
summary,
detail,
canExpand: workEntryHasExpandedBody(entry),
viewedImagePath: workEntryViewedImagePath(entry),
getFullDetail,
getCopyText,
icon: workEntryIcon(entry),
Expand Down
47 changes: 47 additions & 0 deletions apps/web/src/components/chat/MessagesTimeline.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,54 @@ import {
normalizeCompactToolLabel,
resolveAssistantMessageCopyState,
shouldPreserveAssistantLineBreaks,
workEntryViewedImagePath,
} from "./MessagesTimeline.logic";
import { type WorkLogEntry } from "../../session-logic";

describe("workEntryViewedImagePath", () => {
const readEntry = (overrides: Partial<WorkLogEntry>): WorkLogEntry => ({
id: "e1",
createdAt: "2026-01-01T00:00:00Z",
label: "Image view",
tone: "tool",
itemType: "image_view",
...overrides,
});

it("returns the detail path for image_view entries", () => {
const entry = readEntry({ detail: "/workspace/screenshots/after.png" });
expect(workEntryViewedImagePath(entry)).toBe("/workspace/screenshots/after.png");
});

it("returns the path for file-read entries that read an image", () => {
const entry: WorkLogEntry = {
id: "e2",
createdAt: "2026-01-01T00:00:00Z",
label: "Read file",
tone: "tool",
requestKind: "file-read",
detail: "assets/logo.webp",
};
expect(workEntryViewedImagePath(entry)).toBe("assets/logo.webp");
});

it("ignores non-image details", () => {
expect(workEntryViewedImagePath(readEntry({ detail: "src/index.ts" }))).toBeNull();
});

it("ignores multi-line details", () => {
expect(workEntryViewedImagePath(readEntry({ detail: "a.png\nb.png" }))).toBeNull();
});

it("ignores entries that are not reads", () => {
const entry = readEntry({ itemType: "command_execution", detail: "shot.png" });
expect(workEntryViewedImagePath(entry)).toBeNull();
});

it("ignores entries without detail", () => {
expect(workEntryViewedImagePath(readEntry({}))).toBeNull();
});
});

describe("shouldPreserveAssistantLineBreaks", () => {
it("preserves Claude insight formatting without changing regular markdown", () => {
Expand Down
13 changes: 13 additions & 0 deletions apps/web/src/components/chat/MessagesTimeline.logic.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import * as Equal from "effect/Equal";
import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview";
import {
formatDuration,
workEntryDisplayIndicatesToolFailure,
Expand Down Expand Up @@ -307,6 +308,18 @@ export function toolGroupAction(entry: WorkLogEntry): ToolGroupAction {
return "other";
}

/**
* Workspace path of the image a read/view tool entry looked at. Non-null only
* when the entry's detail is a single image path the asset route can serve —
* the expanded row then renders the image itself above the text detail.
*/
export function workEntryViewedImagePath(entry: WorkLogEntry): string | null {
if (toolGroupAction(entry) !== "read") return null;
const detail = entry.detail?.trim();
if (!detail || detail.includes("\n") || !isWorkspaceImagePreviewPath(detail)) return null;
return detail;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor read title case mismatch

High Severity

workEntryViewedImagePath treats Cursor reads as image candidates only when toolTitle is exactly Read File, but ACP persists the derived presentation title as Read file. Those rows stay dynamic_tool_call with no file-read/image_view markers, so expanded Cursor image reads never get a viewedImagePath and the preview never mounts.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit bd2928a. Configure here.


function toolGroupActionCount(
action: ToolGroupAction,
entries: ReadonlyArray<WorkLogEntry>,
Expand Down
58 changes: 58 additions & 0 deletions apps/web/src/components/chat/MessagesTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ import {
shouldPreserveAssistantLineBreaks,
toolGroupAction,
workEntryIsVisibleInGroup,
workEntryViewedImagePath,
type StableMessagesTimelineRowsState,
type MessagesTimelineRow,
TIMELINE_MINIMAP_MIN_ITEMS,
Expand Down Expand Up @@ -117,6 +118,8 @@ import {
} from "./userMessageTerminalContexts";
import { SkillInlineText } from "./SkillInlineText";
import { formatWorkspaceRelativePath } from "../../filePathDisplay";
import { useAssetUrlState } from "../../assets/assetUrls";
import { Skeleton } from "../ui/skeleton";
import {
buildReviewCommentRenderablePatch,
formatReviewCommentFence,
Expand Down Expand Up @@ -2457,6 +2460,52 @@ function buildToolCallExpandedBody(
const toolCallExpandedBodyClassName =
"max-h-64 cursor-text overflow-auto whitespace-pre-wrap break-words font-mono text-secondary-label text-[length:var(--font-size-code,0.6875rem)] leading-relaxed select-text";

/**
* The image a read/view tool entry looked at, loaded through a signed
* workspace-file asset URL. Mounted only while the row is expanded, so
* collapsed rows never fetch. Falls back to nothing on failure — the path
* stays visible in the text body underneath.
*/
const ToolCallExpandedImage = memo(function ToolCallExpandedImage(props: {
readonly threadRef: ScopedThreadRef;
readonly path: string;
}) {
const { onImageExpand } = use(TimelineRowCtx);
const assetUrl = useAssetUrlState(props.threadRef.environmentId, {
_tag: "workspace-file",
threadId: props.threadRef.threadId,
path: props.path,
});
const [failedUrl, setFailedUrl] = useState<string | null>(null);

if (assetUrl._tag === "Failure" || (assetUrl._tag === "Success" && failedUrl === assetUrl.url)) {
return null;
}
if (assetUrl._tag !== "Success") {
return (
<Skeleton role="status" aria-label="Loading image" className="mb-1.5 h-24 w-40 rounded-md" />
);
}
const name = props.path.split(/[\\/]/).pop() ?? props.path;
return (
<button
type="button"
className="mb-1.5 block cursor-zoom-in rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/70"
aria-label={`Preview ${name}`}
onClick={() => onImageExpand({ images: [{ src: assetUrl.url, name }], index: 0 })}
>
<img
src={assetUrl.url}
alt={name}
loading="lazy"
draggable={false}
className="block max-h-64 max-w-full rounded-md border border-border/40 object-contain"
onError={() => setFailedUrl(assetUrl.url)}
/>
</button>
);
});

function workEntryIconName(workEntry: TimelineWorkEntry): WorkEntryIconName {
if (
workEntry.sourceActivityKind === "user-input.requested" ||
Expand Down Expand Up @@ -2620,6 +2669,7 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {
isExpandedToolGroupEntry: boolean;
}) {
const { workEntry, workspaceRoot, isExpandedToolGroupEntry } = props;
const { threadRef } = use(TimelineRowCtx);
const [expanded, setExpanded] = useState(false);
const iconConfig = workToneIcon(workEntry.tone);
const showWarningIndicator = workEntry.sourceActivityKind === "runtime.warning";
Expand All @@ -2628,6 +2678,7 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {
showWarningIndicator || showFailedIndicator ? "x" : workEntryIconName(workEntry);
const displayText = workEntryPreview(workEntry, workspaceRoot) ?? toolWorkEntryHeading(workEntry);
const expandedBody = buildToolCallExpandedBody(workEntry, workspaceRoot);
const viewedImagePath = workEntryViewedImagePath(workEntry);
const canExpand = expandedBody !== null;
const showDestructiveRowStyle =
showFailedIndicator &&
Expand Down Expand Up @@ -2718,7 +2769,14 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {
className="mt-1 ms-7 cursor-default border-s border-border/45 ps-3 pt-0.5"
onClick={stopRowToggle}
onPointerDown={stopRowToggle}
// Keys pressed on the expanded body (e.g. Enter on the image
// preview button) must not reach the row's toggle handler — its
// preventDefault would also cancel the button's click activation.
onKeyDown={stopRowToggle}
Comment on lines +2772 to +2775

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

stopRowToggle stops every keydown, not just the row's toggle keys. React dispatches from the root container and SyntheticEvent.stopPropagation() also calls nativeEvent.stopPropagation(), so while the new preview button has focus no keydown reaches the bubble-phase window listeners — e.g. the command-palette keybinding (CommandPalette.tsx), the thread-jump shortcuts (Sidebar.tsx), and the _chat.tsx window handler all stop working until focus leaves the row.

Suggest narrowing the stop to the keys the row actually toggles on (or, equivalently, having the row handler ignore events whose target is not currentTarget) so unrelated shortcuts keep bubbling:

Suggested change
// Keys pressed on the expanded body (e.g. Enter on the image
// preview button) must not reach the row's toggle handler — its
// preventDefault would also cancel the button's click activation.
onKeyDown={stopRowToggle}
// Enter/Space pressed inside the expanded body (e.g. the image
// preview button) must not reach the row's toggle handler — its
// preventDefault would also cancel the button's click activation.
// Other keys keep bubbling so window-level shortcuts still fire.
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") e.stopPropagation();
}}

Posted via Macroscope — UI Consistency

>
{viewedImagePath && threadRef ? (
<ToolCallExpandedImage threadRef={threadRef} path={viewedImagePath} />
) : null}
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
<pre className={toolCallExpandedBodyClassName}>{expandedBody}</pre>
</div>
) : null}
Expand Down
Loading