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

+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" />