diff --git a/components/ChatWindow.tsx b/components/ChatWindow.tsx
index 794a34cf3..826b6375c 100644
--- a/components/ChatWindow.tsx
+++ b/components/ChatWindow.tsx
@@ -5,6 +5,7 @@ import type { AgentMessage, AssistantContentBlock, AssistantMessage, BashExecuti
import { normalizeCustomPanelLines, parseAnsiLine } from "@/lib/ansi";
import { asBracketedPaste, toTerminalKeyData } from "@/lib/terminal-input";
import { countToolCallBlocks, getAssistantErrorMessage, getDisplayableAssistantBlocks, splitFinalAssistantBlocks } from "@/lib/message-display";
+import { extractTurnWrittenFiles, type WrittenFile } from "@/lib/turn-written-files";
import { MessageView } from "./MessageView";
import { ChatInput, type ChatInputHandle } from "./ChatInput";
import { ChatMinimap, useMessageRefs } from "./ChatMinimap";
@@ -547,7 +548,7 @@ export function ChatWindow({ session, newSessionCwd, onAgentEnd, onSessionCreate
if (idx === lastUserIdx) { (lastUserMsgRef as { current: HTMLDivElement | null }).current = el; }
};
- const renderMessage = (idx: number, options: { attachRef?: boolean; keyPrefix?: string; messageOverride?: AgentMessage; showTimestamp?: boolean } = {}): ReactNode => {
+ const renderMessage = (idx: number, options: { attachRef?: boolean; keyPrefix?: string; messageOverride?: AgentMessage; showTimestamp?: boolean; writtenFiles?: WrittenFile[] } = {}): ReactNode => {
const msg = options.messageOverride ?? messages[idx];
const prevAssistantEntryId =
msg.role === "user" && idx > 0 && messages[idx - 1].role === "assistant"
@@ -587,6 +588,7 @@ export function ChatWindow({ session, newSessionCwd, onAgentEnd, onSessionCreate
showTimestamp={showTimestamp}
prevTimestamp={idx > 0 ? (messages[idx - 1] as AgentMessage & { timestamp?: number }).timestamp : undefined}
sessionId={session?.id ?? sessionIdRef.current ?? undefined}
+ writtenFiles={options.writtenFiles}
/>
);
if (!isVisible || options.attachRef === false || currentRefIdx === undefined) return view;
@@ -672,7 +674,19 @@ export function ChatWindow({ session, newSessionCwd, onAgentEnd, onSessionCreate
}
if (finalAnswerMessage) {
- rendered.push(renderMessage(finalAssistantIdx, { messageOverride: finalAnswerMessage }));
+ // Each tool call is stored as its own assistant entry, so the
+ // final answer alone carries no record of what the turn wrote.
+ // Gather the turn's assistant blocks and derive the file list
+ // from the write/edit calls among them.
+ const turnContent: AssistantContentBlock[] = [];
+ for (let i = userIdx + 1; i <= finalAssistantIdx; i++) {
+ const m = messages[i];
+ if (m?.role === "assistant") {
+ for (const b of (m as AssistantMessage).content ?? []) turnContent.push(b);
+ }
+ }
+ const writtenFiles = extractTurnWrittenFiles(turnContent, toolResultsMap, messageCwd);
+ rendered.push(renderMessage(finalAssistantIdx, { messageOverride: finalAnswerMessage, writtenFiles }));
}
for (let renderIdx = finalAssistantIdx + 1; renderIdx < endIdx; renderIdx++) {
rendered.push(renderMessage(renderIdx));
diff --git a/components/FileViewer.tsx b/components/FileViewer.tsx
index 9635b4c6d..2b2630e52 100644
--- a/components/FileViewer.tsx
+++ b/components/FileViewer.tsx
@@ -901,7 +901,10 @@ function TextFileViewer({ filePath, cwd, sourceSessionId, onOpenFile, onMentionL
}, [fetchGitDiff, filePath, gitRefreshKey]);
useEffect(() => {
- if (data?.language === "markdown" && initialDisplayMode !== "diff") {
+ // HTML gets the same rendered-first treatment as markdown: a generated page
+ // is usually more useful viewed than read as source. Both have a preview
+ // mode already; the source tab stays one click away.
+ if ((data?.language === "markdown" || data?.language === "html") && initialDisplayMode !== "diff") {
setDisplayMode("preview");
}
}, [data?.language, initialDisplayMode]);
diff --git a/components/MessageView.tsx b/components/MessageView.tsx
index d73bee37f..1d8d16fce 100644
--- a/components/MessageView.tsx
+++ b/components/MessageView.tsx
@@ -7,6 +7,9 @@ import { useI18n } from "@/hooks/useI18n";
import { parseCompactionSummary } from "@/lib/compaction-summary";
import { getAssistantErrorMessage, isEmptyThinkingBlock } from "@/lib/message-display";
import { parseUnifiedPatch, type SplitDiffCell } from "@/lib/patch";
+import { isEditToolName } from "@/lib/tool-names";
+import { TurnWrittenFiles } from "./TurnWrittenFiles";
+import type { WrittenFile } from "@/lib/turn-written-files";
import type {
AgentMessage,
UserMessage,
@@ -69,6 +72,13 @@ interface Props {
showTimestamp?: boolean;
prevTimestamp?: number;
sessionId?: string;
+ /**
+ * Files this turn wrote, derived by the caller from the whole turn's
+ * successful write/edit tool calls. ChatWindow computes this because the
+ * saved-message path splits tool calls into their own entries, leaving the
+ * final answer text-only.
+ */
+ writtenFiles?: WrittenFile[];
}
function formatTime(ts?: number): string | null {
@@ -98,12 +108,12 @@ function haveSameRelevantToolResults(
return true;
}
-export const MessageView = memo(function MessageView({ message, isStreaming, toolResults, modelNames, cwd, onOpenFile, entryId, onFork, forking, onNavigate, prevAssistantEntryId, onEditContent, showTimestamp, prevTimestamp, sessionId }: Props) {
+export const MessageView = memo(function MessageView({ message, isStreaming, toolResults, modelNames, cwd, onOpenFile, entryId, onFork, forking, onNavigate, prevAssistantEntryId, onEditContent, showTimestamp, prevTimestamp, sessionId, writtenFiles }: Props) {
if (message.role === "user") {
return ;
}
if (message.role === "assistant") {
- return ;
+ return ;
}
if (message.role === "toolResult") {
// Rendered inline under its toolCall — skip standalone rendering if paired
@@ -349,6 +359,7 @@ function AssistantMessageView({
prevTimestamp,
sessionId,
entryId,
+ writtenFiles,
}: {
message: AssistantMessage;
isStreaming?: boolean;
@@ -360,6 +371,7 @@ function AssistantMessageView({
prevTimestamp?: number;
sessionId?: string;
entryId?: string;
+ writtenFiles?: WrittenFile[];
}) {
const { t } = useI18n();
const time = showTimestamp ? formatTime(message.timestamp) : null;
@@ -554,6 +566,10 @@ function AssistantMessageView({
)}
+ {writtenFiles && writtenFiles.length > 0 && (
+
+ )}
+
@@ -1031,16 +1047,6 @@ function getResultDiff(result: ToolResultMessage): ResultDiff | null {
return null;
}
-function isEditToolName(toolName: string): boolean {
- const name = toolName.toLowerCase();
- return name === "edit" ||
- name.startsWith("edit_") ||
- name.endsWith(".edit") ||
- name.endsWith("_edit") ||
- name.includes("str_replace") ||
- name.includes("replace_editor");
-}
-
function isRecord(value: unknown): value is Record
{
return typeof value === "object" && value !== null && !Array.isArray(value);
}
diff --git a/components/TurnWrittenFiles.test.mjs b/components/TurnWrittenFiles.test.mjs
new file mode 100644
index 000000000..68c701d77
--- /dev/null
+++ b/components/TurnWrittenFiles.test.mjs
@@ -0,0 +1,34 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import React from "react";
+import { renderToStaticMarkup } from "react-dom/server";
+import { createJiti } from "jiti";
+
+const jiti = createJiti(import.meta.url, {
+ jsx: { runtime: "automatic" },
+ tsconfigPaths: true,
+});
+const { TurnWrittenFiles } = await jiti.import("./TurnWrittenFiles.tsx");
+const { I18nProvider } = await jiti.import("../hooks/useI18n.tsx");
+
+function render(props) {
+ return renderToStaticMarkup(
+ React.createElement(I18nProvider, null, React.createElement(TurnWrittenFiles, props)),
+ );
+}
+
+test("renders a button per file showing the basename and full path", () => {
+ const html = render({
+ files: [{ filePath: "/abs/out/report.html" }, { filePath: "/abs/out/data.json" }],
+ onOpenFile() {},
+ });
+ assert.match(html, /