Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca14ba3338 | ||
|
|
97e66d2118 | ||
|
|
b3a298fb7c | ||
|
|
61219dbca2 |
No files matched your search
@@ -3,6 +3,23 @@
|
||||
All notable changes to CodeCraft. Versions follow [Semantic Versioning](https://semver.org/);
|
||||
until 1.0, minor versions may contain breaking changes.
|
||||
|
||||
## [v202611-00017] – 2026-11-10
|
||||
|
||||
### 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
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Phone: page stayed shifted after the keyboard closed** – the top bar (menu) was out of
|
||||
reach. On Android the keyboard now shrinks the page instead of shifting it
|
||||
(`interactive-widget=resizes-content`; the chat stays at its latest message); browsers that
|
||||
still shift the page (iOS Safari) get it scrolled back once the keyboard closes.
|
||||
|
||||
## [v202611-00015] – 2026-11-10
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img alt="Version v202611-00015" src="https://img.shields.io/badge/version-v202611--00015-d69e2e?style=flat-square">
|
||||
<img alt="Version v202611-00017" src="https://img.shields.io/badge/version-v202611--00017-d69e2e?style=flat-square">
|
||||
<img alt="License: MIT" src="https://img.shields.io/badge/license-MIT-689d6a?style=flat-square">
|
||||
<img alt="Rust backend" src="https://img.shields.io/badge/backend-Rust-b7410e?style=flat-square&logo=rust">
|
||||
<img alt="React PWA" src="https://img.shields.io/badge/frontend-React%20PWA-2d81ff?style=flat-square&logo=react">
|
||||
|
||||
+3
-1
@@ -2,7 +2,9 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<!-- interactive-widget: the on-screen keyboard shrinks the page (Android) instead of
|
||||
shifting it up – the top bar stays in place. -->
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-content" />
|
||||
<meta name="theme-color" content="#282828" />
|
||||
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
|
||||
@@ -202,6 +202,17 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
|
||||
const el = scrollRef.current;
|
||||
if (el && stick.current) el.scrollTop = el.scrollHeight;
|
||||
});
|
||||
// … also when the pane gets smaller (on-screen keyboard opens) without a render.
|
||||
const hasSession = !!session;
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el || typeof ResizeObserver === "undefined") return;
|
||||
const ro = new ResizeObserver(() => {
|
||||
if (stick.current) el.scrollTop = el.scrollHeight;
|
||||
});
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, [hasSession, sessionId]);
|
||||
|
||||
if (loadError) return <div className="empty">{loadError}</div>;
|
||||
if (!session) return <div className="empty">{t("common.loading")}</div>;
|
||||
@@ -597,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) => (
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -663,6 +663,9 @@ export const de: Messages = {
|
||||
"composer.mode": "Modus",
|
||||
"composer.model": "Modell",
|
||||
"composer.permissionMode": "Berechtigungsmodus",
|
||||
"composer.preview": "Vorschau (Markdown)",
|
||||
"composer.previewEdit": "Zurück zum Bearbeiten",
|
||||
"composer.previewEmpty": "Noch nichts zum Anzeigen.",
|
||||
"composer.attach": "Dateien anhängen",
|
||||
|
||||
"command.compact": "Unterhaltung zusammenfassen, um Kontext freizugeben",
|
||||
|
||||
@@ -662,6 +662,9 @@ export const en = {
|
||||
"composer.mode": "Mode",
|
||||
"composer.model": "Model",
|
||||
"composer.permissionMode": "Permission mode",
|
||||
"composer.preview": "Preview (Markdown)",
|
||||
"composer.previewEdit": "Back to editing",
|
||||
"composer.previewEmpty": "Nothing to preview yet.",
|
||||
"composer.attach": "Attach files",
|
||||
|
||||
"command.compact": "Summarize the conversation to free up context",
|
||||
|
||||
@@ -10,6 +10,43 @@ import { captureShare } from "./share";
|
||||
applyTheme();
|
||||
captureShare();
|
||||
registerSW({ immediate: true });
|
||||
undoKeyboardShift();
|
||||
|
||||
/**
|
||||
* Browsers that do not resize the page for the on-screen keyboard (iOS Safari, older
|
||||
* Android) scroll the whole page up to show the input – and do not always scroll it back
|
||||
* when the keyboard closes, leaving the top bar (menu) out of reach. The app never
|
||||
* scrolls the page itself (only its panes), so any page offset is undone once the
|
||||
* visible area grows again.
|
||||
*/
|
||||
function undoKeyboardShift() {
|
||||
const vv = window.visualViewport;
|
||||
if (!vv) return;
|
||||
let last = vv.height;
|
||||
const reset = () => {
|
||||
const el = document.scrollingElement ?? document.documentElement;
|
||||
if (window.scrollY !== 0 || el.scrollTop !== 0 || document.body.scrollTop !== 0) {
|
||||
window.scrollTo(0, 0);
|
||||
el.scrollTop = 0;
|
||||
document.body.scrollTop = 0;
|
||||
}
|
||||
};
|
||||
vv.addEventListener("resize", () => {
|
||||
// Grown back (keyboard closed or closing): after the browser's own adjustment.
|
||||
if (vv.height > last + 1) {
|
||||
requestAnimationFrame(reset);
|
||||
setTimeout(reset, 350);
|
||||
}
|
||||
last = vv.height;
|
||||
});
|
||||
// Keyboard closed by leaving the input (no resize on some browsers).
|
||||
document.addEventListener("focusout", () =>
|
||||
setTimeout(() => {
|
||||
const a = document.activeElement;
|
||||
if (!a || !(a instanceof HTMLInputElement || a instanceof HTMLTextAreaElement || (a as HTMLElement).isContentEditable)) reset();
|
||||
}, 300),
|
||||
);
|
||||
}
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
|
||||
@@ -527,6 +527,20 @@ button.session-group:hover {
|
||||
word-break: break-word;
|
||||
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 {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
* under "unreleased"; scripts/release.sh renames the key to the release version.
|
||||
*/
|
||||
export const WHATS_NEW: Record<string, { en: string[]; de: string[] }> = {
|
||||
"202611-00017": {
|
||||
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": {
|
||||
en: ["Folder picker (new automation, new chat): create a new folder right there"],
|
||||
de: ["Ordnerauswahl (neue Automatisierung, neuer Chat): direkt einen neuen Ordner anlegen"],
|
||||
|
||||
Reference in new issue
Block a user