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

+8
View File
@@ -3,6 +3,14 @@
All notable changes to CodeCraft. Versions follow [Semantic Versioning](https://semver.org/); All notable changes to CodeCraft. Versions follow [Semantic Versioning](https://semver.org/);
until 1.0, minor versions may contain breaking changes. until 1.0, minor versions may contain breaking changes.
## [Unreleased]
### Added
- **Markdown in your messages:** your messages are shown formatted like the model's answers
(headings, lists, tables, code blocks, quotes, bold/italic); single line breaks stay as typed.
The eye button next to the text field shows a preview of the formatted message before sending.
## [v202611-00016] – 2026-11-10 ## [v202611-00016] – 2026-11-10
### Fixed ### Fixed
+2 -1
View File
@@ -608,9 +608,10 @@ function UserText({ text }: { text: string }) {
const last = rest.pop()!; const last = rest.pop()!;
if (last.trim().endsWith(":")) break; if (last.trim().endsWith(":")) break;
} }
const body = rest.join("\n").trim();
return ( return (
<> <>
{rest.join("\n").trimEnd()} {body && <Markdown text={body} breaks />}
{files.length > 0 && ( {files.length > 0 && (
<div className="user-files"> <div className="user-files">
{files.map((f) => ( {files.map((f) => (
+25
View File
@@ -9,6 +9,7 @@ import type { ImageData, Session } from "../types";
import { ModelSelect } from "./ModelSelect"; import { ModelSelect } from "./ModelSelect";
import { EffortSelect } from "./Regenerate"; import { EffortSelect } from "./Regenerate";
import { VoiceButton } from "./Voice"; import { VoiceButton } from "./Voice";
import { Markdown } from "./Markdown";
import { confirmDialog, promptDialog, toast } from "../dialogs"; import { confirmDialog, promptDialog, toast } from "../dialogs";
interface Suggestion { interface Suggestion {
@@ -67,6 +68,8 @@ export function Composer({
const [effort, setEffort] = useState(""); const [effort, setEffort] = useState("");
const [interim, setInterim] = useState(""); const [interim, setInterim] = useState("");
const [picker, setPicker] = useState(false); 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 models = useModels();
const [prov, ...mid] = session.model.split("/"); const [prov, ...mid] = session.model.split("/");
const reasoning = !!models.find((p) => p.provider === prov)?.models.find((m) => m.id === mid.join("/"))?.reasoning; const reasoning = !!models.find((p) => p.provider === prov)?.models.find((m) => m.id === mid.join("/"))?.reasoning;
@@ -265,7 +268,20 @@ export function Composer({
))} ))}
</div> </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 <textarea
style={preview ? { display: "none" } : undefined}
ref={ref} ref={ref}
rows={1} rows={1}
value={text} 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} /> <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")}> <label className="ghost" style={{ cursor: "pointer", padding: "3px 6px" }} title={t("composer.attach")}>
<Icon name="clip" /> <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 ( return (
<div className="markdown"> <div className="markdown">
<ReactMarkdown <ReactMarkdown
@@ -81,7 +104,7 @@ export const Markdown = memo(function Markdown({ text, baseDir }: { text: string
}, },
}} }}
> >
{text} {breaks ? keepLineBreaks(text) : text}
</ReactMarkdown> </ReactMarkdown>
</div> </div>
); );
+3
View File
@@ -663,6 +663,9 @@ export const de: Messages = {
"composer.mode": "Modus", "composer.mode": "Modus",
"composer.model": "Modell", "composer.model": "Modell",
"composer.permissionMode": "Berechtigungsmodus", "composer.permissionMode": "Berechtigungsmodus",
"composer.preview": "Vorschau (Markdown)",
"composer.previewEdit": "Zurück zum Bearbeiten",
"composer.previewEmpty": "Noch nichts zum Anzeigen.",
"composer.attach": "Dateien anhängen", "composer.attach": "Dateien anhängen",
"command.compact": "Unterhaltung zusammenfassen, um Kontext freizugeben", "command.compact": "Unterhaltung zusammenfassen, um Kontext freizugeben",
+3
View File
@@ -662,6 +662,9 @@ export const en = {
"composer.mode": "Mode", "composer.mode": "Mode",
"composer.model": "Model", "composer.model": "Model",
"composer.permissionMode": "Permission mode", "composer.permissionMode": "Permission mode",
"composer.preview": "Preview (Markdown)",
"composer.previewEdit": "Back to editing",
"composer.previewEmpty": "Nothing to preview yet.",
"composer.attach": "Attach files", "composer.attach": "Attach files",
"command.compact": "Summarize the conversation to free up context", "command.compact": "Summarize the conversation to free up context",
+14
View File
@@ -527,6 +527,20 @@ button.session-group:hover {
word-break: break-word; word-break: break-word;
position: relative; position: relative;
} }
/* User messages are Markdown too; the line breaks come from keepLineBreaks. */
.msg-user .markdown {
white-space: normal;
}
.composer-preview {
min-height: 44px;
max-height: 40vh;
overflow-y: auto;
padding: 10px 12px;
border-radius: var(--radius);
border: 1px dashed var(--bg3);
background: var(--bg1);
cursor: text;
}
.msg-user .msg-actions { .msg-user .msg-actions {
position: absolute; position: absolute;
top: 4px; top: 4px;
+4
View File
@@ -3,6 +3,10 @@
* under "unreleased"; scripts/release.sh renames the key to the release version. * under "unreleased"; scripts/release.sh renames the key to the release version.
*/ */
export const WHATS_NEW: Record<string, { en: string[]; de: string[] }> = { export const WHATS_NEW: Record<string, { en: string[]; de: string[] }> = {
unreleased: {
en: ["Your messages are shown as Markdown; the eye button next to the text field previews it before sending"],
de: ["Deine Nachrichten werden als Markdown dargestellt; das Augen-Symbol neben dem Textfeld zeigt vor dem Senden eine Vorschau"],
},
"202611-00014": { "202611-00014": {
en: ["Folder picker (new automation, new chat): create a new folder right there"], en: ["Folder picker (new automation, new chat): create a new folder right there"],
de: ["Ordnerauswahl (neue Automatisierung, neuer Chat): direkt einen neuen Ordner anlegen"], de: ["Ordnerauswahl (neue Automatisierung, neuer Chat): direkt einen neuen Ordner anlegen"],