Markdown in user messages, with a preview in the composer

User messages are rendered with the same Markdown component as the model's
answers; single line breaks are kept (hard breaks outside code blocks), so
plain multi-line messages look as before. The eye button next to the text
field shows the formatted message before sending. The text sent to the model
is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard ZinkandClaude Opus 5.5 committed 2026-11-10 14:01:43 +01:00
1 parent b3a298fb7c
commit 97e66d2118
8 files changed
+84 -3

No files matched your search

+2 -1
View File
@@ -608,9 +608,10 @@ function UserText({ text }: { text: string }) {
const last = rest.pop()!;
if (last.trim().endsWith(":")) break;
}
const body = rest.join("\n").trim();
return (
<>
{rest.join("\n").trimEnd()}
{body && <Markdown text={body} breaks />}
{files.length > 0 && (
<div className="user-files">
{files.map((f) => (
+25
View File
@@ -9,6 +9,7 @@ import type { ImageData, Session } from "../types";
import { ModelSelect } from "./ModelSelect";
import { EffortSelect } from "./Regenerate";
import { VoiceButton } from "./Voice";
import { Markdown } from "./Markdown";
import { confirmDialog, promptDialog, toast } from "../dialogs";
interface Suggestion {
@@ -67,6 +68,8 @@ export function Composer({
const [effort, setEffort] = useState("");
const [interim, setInterim] = useState("");
const [picker, setPicker] = useState(false);
// Rendered Markdown instead of the text field (to check formatting before sending).
const [preview, setPreview] = useState(false);
const models = useModels();
const [prov, ...mid] = session.model.split("/");
const reasoning = !!models.find((p) => p.provider === prov)?.models.find((m) => m.id === mid.join("/"))?.reasoning;
@@ -265,7 +268,20 @@ export function Composer({
))}
</div>
)}
{preview && (
<div
className="composer-preview"
role="button"
tabIndex={0}
title={t("composer.previewEdit")}
onClick={() => (setPreview(false), setTimeout(() => ref.current?.focus(), 0))}
onKeyDown={(e) => e.key === "Enter" && (setPreview(false), setTimeout(() => ref.current?.focus(), 0))}
>
{text.trim() ? <Markdown text={text} breaks /> : <span className="hint">{t("composer.previewEmpty")}</span>}
</div>
)}
<textarea
style={preview ? { display: "none" } : undefined}
ref={ref}
rows={1}
value={text}
@@ -337,6 +353,15 @@ export function Composer({
}}
/>
)}
<button
className={`ghost ${preview ? "active" : ""}`}
style={{ padding: "3px 6px" }}
onClick={() => setPreview(!preview)}
aria-pressed={preview}
title={t(preview ? "composer.previewEdit" : "composer.preview")}
>
<Icon name={preview ? "edit" : "eye"} />
</button>
<VoiceButton onText={(d) => setText(text ? `${text.replace(/\s+$/, "")} ${d}` : d)} onInterim={setInterim} />
<label className="ghost" style={{ cursor: "pointer", padding: "3px 6px" }} title={t("composer.attach")}>
<Icon name="clip" />
+25 -2
View File
@@ -44,7 +44,30 @@ export function SvgBlock({ code, children }: { code: string; children?: ReactNod
);
}
export const Markdown = memo(function Markdown({ text, baseDir }: { text: string; baseDir?: string }) {
/**
* Keep single line breaks as typed (Markdown would join the lines into one paragraph):
* a hard break at the end of every line that continues a paragraph. Code blocks stay as
* they are.
*/
export function keepLineBreaks(text: string) {
let fence: string | null = null;
const lines = text.split("\n");
return lines
.map((line, i) => {
const f = line.match(/^\s*(`{3,}|~{3,})/);
if (f) {
if (!fence) fence = f[1][0];
else if (f[1][0] === fence) fence = null;
return line;
}
const next = lines[i + 1];
if (fence || next === undefined || !line.trim() || !next.trim() || / {2}$|\\$/.test(line)) return line;
return `${line} `;
})
.join("\n");
}
export const Markdown = memo(function Markdown({ text, baseDir, breaks }: { text: string; baseDir?: string; breaks?: boolean }) {
return (
<div className="markdown">
<ReactMarkdown
@@ -81,7 +104,7 @@ export const Markdown = memo(function Markdown({ text, baseDir }: { text: string
},
}}
>
{text}
{breaks ? keepLineBreaks(text) : text}
</ReactMarkdown>
</div>
);