From d07bf30e81241e939ed32b967a7db1d830e7d323 Mon Sep 17 00:00:00 2001 From: David Kaya Date: Wed, 25 Mar 2026 19:19:58 +0100 Subject: [PATCH] feat: add markdown composer backend foundation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- bun.lock | 96 ++++++++++++++++ package.json | 8 ++ src/main/EryxAppService.ts | 7 +- src/renderer/components/ChatPane.tsx | 8 +- src/renderer/components/MarkdownContent.tsx | 114 +------------------ src/renderer/lib/chatMarkdown.tsx | 114 +++++++++++++++++++ src/renderer/lib/markdownEditor.ts | 97 ++++++++++++++++ src/renderer/lib/runTimelineFormatting.ts | 5 +- src/shared/domain/pattern.ts | 3 +- src/shared/utils/chatMessage.ts | 12 ++ src/shared/utils/markdownText.ts | 58 ++++++++++ tests/renderer/markdownEditor.test.ts | 73 ++++++++++++ tests/renderer/runTimelineFormatting.test.ts | 1 + tests/shared/chatMessage.test.ts | 26 +++++ tests/shared/markdownText.test.ts | 21 ++++ tests/shared/session.test.ts | 17 +++ 16 files changed, 539 insertions(+), 121 deletions(-) create mode 100644 src/renderer/lib/chatMarkdown.tsx create mode 100644 src/renderer/lib/markdownEditor.ts create mode 100644 src/shared/utils/chatMessage.ts create mode 100644 src/shared/utils/markdownText.ts create mode 100644 tests/renderer/markdownEditor.test.ts create mode 100644 tests/shared/chatMessage.test.ts create mode 100644 tests/shared/markdownText.test.ts diff --git a/bun.lock b/bun.lock index 05313a5..1179f64 100644 --- a/bun.lock +++ b/bun.lock @@ -6,8 +6,15 @@ "name": "kopaya", "dependencies": { "@dagrejs/dagre": "^3.0.0", + "@lexical/code": "0.42.0", + "@lexical/link": "0.42.0", + "@lexical/list": "0.42.0", + "@lexical/markdown": "0.42.0", + "@lexical/react": "0.42.0", + "@lexical/rich-text": "0.42.0", "@xyflow/react": "^12.10.1", "keytar": "^7.9.0", + "lexical": "0.42.0", "lucide-react": "^0.577.0", "react": "^19.2.4", "react-dom": "^19.2.4", @@ -15,6 +22,7 @@ "remark-gfm": "^4.0.1", }, "devDependencies": { + "@lexical/headless": "0.42.0", "@tailwindcss/vite": "^4.2.2", "@types/node": "^25.5.0", "@types/react": "^19.2.14", @@ -130,6 +138,16 @@ "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], + "@floating-ui/core": ["@floating-ui/core@1.7.5", "", { "dependencies": { "@floating-ui/utils": "^0.2.11" } }, "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ=="], + + "@floating-ui/dom": ["@floating-ui/dom@1.7.6", "", { "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" } }, "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ=="], + + "@floating-ui/react": ["@floating-ui/react@0.27.19", "", { "dependencies": { "@floating-ui/react-dom": "^2.1.8", "@floating-ui/utils": "^0.2.11", "tabbable": "^6.0.0" }, "peerDependencies": { "react": ">=17.0.0", "react-dom": ">=17.0.0" } }, "sha512-31B8h5mm8YxotlE7/AU/PhNAl8eWxAmjL/v2QOxroDNkTFLk3Uu82u63N3b6TXa4EGJeeZLVcd/9AlNlVqzeog=="], + + "@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.8", "", { "dependencies": { "@floating-ui/dom": "^1.7.6" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A=="], + + "@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="], + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], @@ -140,8 +158,60 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + "@lexical/clipboard": ["@lexical/clipboard@0.42.0", "", { "dependencies": { "@lexical/html": "0.42.0", "@lexical/list": "0.42.0", "@lexical/selection": "0.42.0", "@lexical/utils": "0.42.0", "lexical": "0.42.0" } }, "sha512-D3K2ID0zew/+CKpwxnUTTh/N46yU4IK8bFWV9Htz+g1vFhgUF9UnDOQCmqpJbdP7z+9U1F8rk3fzf9OmP2Fm2w=="], + + "@lexical/code": ["@lexical/code@0.42.0", "", { "dependencies": { "@lexical/code-core": "0.42.0", "@lexical/code-prism": "0.42.0", "lexical": "0.42.0" } }, "sha512-KMu1nWae9pHvA9nl6dlJacbt3QBBNemgalmLJcZ5QhdGEQA1cVIU4gBPJ5TJqgY9XF7WZgj5JvDIPxjrZmf+XQ=="], + + "@lexical/code-core": ["@lexical/code-core@0.42.0", "", { "dependencies": { "lexical": "0.42.0" } }, "sha512-vrZTUPWDJkHjAAvuV2+Qte4vYE80s7hIO7wxipiJmWojGx6lcmQjO+UqJ8AIrqI4Wjy8kXrK74kisApWmwxuCw=="], + + "@lexical/code-prism": ["@lexical/code-prism@0.42.0", "", { "dependencies": { "@lexical/code-core": "0.42.0", "lexical": "0.42.0", "prismjs": "^1.30.0" } }, "sha512-KgngkUtgcgC8ocBnfGyN71CC3EnP5PMFAmH1KcGp/+jSgl11nRpCjwYYIoUHm6AB7jKJ8dLbd/UUmShARjUnGA=="], + + "@lexical/devtools-core": ["@lexical/devtools-core@0.42.0", "", { "dependencies": { "@lexical/html": "0.42.0", "@lexical/link": "0.42.0", "@lexical/mark": "0.42.0", "@lexical/table": "0.42.0", "@lexical/utils": "0.42.0", "lexical": "0.42.0" }, "peerDependencies": { "react": ">=17.x", "react-dom": ">=17.x" } }, "sha512-8nP8eE9i8JImgSrvInkWFfMCmXVKp3w3VaOvbJysdlK/Zal6xd8EWJEi6elj0mUW5T/oycfipPs2Sfl7Z+n14A=="], + + "@lexical/dragon": ["@lexical/dragon@0.42.0", "", { "dependencies": { "@lexical/extension": "0.42.0", "lexical": "0.42.0" } }, "sha512-/TQzP+7PLJMqq9+MlgQWiJsxS9GOOa8Gp0svCD8vNIOciYmXfd28TR1Go+ZnBWwr7k/2W++3XUYVQU2KUcQsDQ=="], + + "@lexical/extension": ["@lexical/extension@0.42.0", "", { "dependencies": { "@lexical/utils": "0.42.0", "@preact/signals-core": "^1.11.0", "lexical": "0.42.0" } }, "sha512-rkZq/h8d1BenKRqU4t/zQUVfY/RinMX1Tz7t+Ee3ss0sk+kzP4W+URXNAxpn7r39Vn6wrFBqmCziah3dLAIqPw=="], + + "@lexical/hashtag": ["@lexical/hashtag@0.42.0", "", { "dependencies": { "@lexical/text": "0.42.0", "@lexical/utils": "0.42.0", "lexical": "0.42.0" } }, "sha512-WOg5nFOfhabNBXzEIutdWDj+TUHtJEezj6w8jyYDGqZ31gu0cgrXSeV8UIynz/1oj+rpzEeEB7P6ODnwgjt7qA=="], + + "@lexical/headless": ["@lexical/headless@0.42.0", "", { "dependencies": { "happy-dom": "^20.0.0", "lexical": "0.42.0" } }, "sha512-/bfeYhRn82IWfZZkn9XW37cfEOVjKZyjVrKfXNgI14gBvdMuX9aN5CSmr4yEusIJJ4svLzKV0STuMWEopn6Iag=="], + + "@lexical/history": ["@lexical/history@0.42.0", "", { "dependencies": { "@lexical/extension": "0.42.0", "@lexical/utils": "0.42.0", "lexical": "0.42.0" } }, "sha512-YfCZ1ICUt6BCg2ncJWFMuS4yftnB7FEHFRf3qqTSTf6oGZ4IZfzabMNEy47xybUuf7FXBbdaCKJrc/zOM+wGxw=="], + + "@lexical/html": ["@lexical/html@0.42.0", "", { "dependencies": { "@lexical/selection": "0.42.0", "@lexical/utils": "0.42.0", "lexical": "0.42.0" } }, "sha512-KgBUDLXehufCsXW3w0XsuoI2xecIhouOishnaNOH4zIA7dAtnNAfdPN/kWrWs0s83gz44OrnqccP+Bprw3UDEQ=="], + + "@lexical/link": ["@lexical/link@0.42.0", "", { "dependencies": { "@lexical/extension": "0.42.0", "@lexical/utils": "0.42.0", "lexical": "0.42.0" } }, "sha512-cdeM/+f+kn7aGwW/3FIi6USjl1gBNdEEwg0/ZS+KlYcsy8gxx2e4cyVjsomBu/WU17Qxa0NC0paSr7qEJ/1Fig=="], + + "@lexical/list": ["@lexical/list@0.42.0", "", { "dependencies": { "@lexical/extension": "0.42.0", "@lexical/selection": "0.42.0", "@lexical/utils": "0.42.0", "lexical": "0.42.0" } }, "sha512-TIezILnmIVuvfqEEbcMnsT4xQRlswI6ysHISqsvKL6l5EBhs1gqmNYjHa/Yrfzaq5y52TM1PAtxbFts+G7N6kg=="], + + "@lexical/mark": ["@lexical/mark@0.42.0", "", { "dependencies": { "@lexical/utils": "0.42.0", "lexical": "0.42.0" } }, "sha512-H1aGjbMEcL4B8GT7bm/ePHm7j3Wema+wIRNPmxMtXGMz5gpVN3gZlvg2UcUHHJb00SrBA95OUVT5I2nu/KP06w=="], + + "@lexical/markdown": ["@lexical/markdown@0.42.0", "", { "dependencies": { "@lexical/code-core": "0.42.0", "@lexical/link": "0.42.0", "@lexical/list": "0.42.0", "@lexical/rich-text": "0.42.0", "@lexical/text": "0.42.0", "@lexical/utils": "0.42.0", "lexical": "0.42.0" } }, "sha512-+mOxgBiumlgVX8Acna+9HjJfSOw1jywufGcAQq3/8S11wZ4gE0u13AaR8LMmU8ydVeOQg09y8PNzGNQ/avZJbg=="], + + "@lexical/offset": ["@lexical/offset@0.42.0", "", { "dependencies": { "lexical": "0.42.0" } }, "sha512-V+4af1KmTOnBZrR+kU3e6eD33W/g3QqMPPp3cpFwyXk/dKRc4K8HfyDsSDrjop1mPd9pl3lKSiEmX6uQG8K9XQ=="], + + "@lexical/overflow": ["@lexical/overflow@0.42.0", "", { "dependencies": { "lexical": "0.42.0" } }, "sha512-wlrHaM27rODJP5m+CTgfZGLg3qWlQ0ptGodcqoGdq6HSbV8nGFY6TvcLMaMtYQ1lm4v9G7Xe9LwjooR6xS3Gug=="], + + "@lexical/plain-text": ["@lexical/plain-text@0.42.0", "", { "dependencies": { "@lexical/clipboard": "0.42.0", "@lexical/dragon": "0.42.0", "@lexical/selection": "0.42.0", "@lexical/utils": "0.42.0", "lexical": "0.42.0" } }, "sha512-YWvBwIxLltrIaZDcv0rK4s44P6Yt17yhOb0E+g3+tjF8GGPrrocox+Pglu0m2RHR+G7zULN3isolmWIm/HhWiw=="], + + "@lexical/react": ["@lexical/react@0.42.0", "", { "dependencies": { "@floating-ui/react": "^0.27.16", "@lexical/devtools-core": "0.42.0", "@lexical/dragon": "0.42.0", "@lexical/extension": "0.42.0", "@lexical/hashtag": "0.42.0", "@lexical/history": "0.42.0", "@lexical/link": "0.42.0", "@lexical/list": "0.42.0", "@lexical/mark": "0.42.0", "@lexical/markdown": "0.42.0", "@lexical/overflow": "0.42.0", "@lexical/plain-text": "0.42.0", "@lexical/rich-text": "0.42.0", "@lexical/table": "0.42.0", "@lexical/text": "0.42.0", "@lexical/utils": "0.42.0", "@lexical/yjs": "0.42.0", "lexical": "0.42.0", "react-error-boundary": "^6.0.0" }, "peerDependencies": { "react": ">=17.x", "react-dom": ">=17.x" } }, "sha512-ujWJXhvlFVVTpwDcnSgEYWRuqUbreZaMB+4bjIDT5r7hkAplUHQndlkeuFHKFiJBasSAreleV7zhXrLL5xa9eA=="], + + "@lexical/rich-text": ["@lexical/rich-text@0.42.0", "", { "dependencies": { "@lexical/clipboard": "0.42.0", "@lexical/dragon": "0.42.0", "@lexical/selection": "0.42.0", "@lexical/utils": "0.42.0", "lexical": "0.42.0" } }, "sha512-v4YgiM3oK3FZcRrfB+LetvLbQ5aee9MRO9tHf0EFweXg19XnSjHV0cfPAW7TyPxRELzB69+K0Q3AybRlTMjG4Q=="], + + "@lexical/selection": ["@lexical/selection@0.42.0", "", { "dependencies": { "lexical": "0.42.0" } }, "sha512-iWTjLA5BSEuUnvWe9Xwu9FSdZFl3Yi0NqalabXKI+7KgCIlIVXE74y4NvWPUSLkSCB/Z1RPKiHmZqZ1vyu/yGQ=="], + + "@lexical/table": ["@lexical/table@0.42.0", "", { "dependencies": { "@lexical/clipboard": "0.42.0", "@lexical/extension": "0.42.0", "@lexical/utils": "0.42.0", "lexical": "0.42.0" } }, "sha512-GKiZyjQsHDXRckq5VBrOowyvds51WoVRECfDgcl8pqLMnKyEdCa58E7fkSJrr5LS80Scod+Cjn6SBRzOcdsrKg=="], + + "@lexical/text": ["@lexical/text@0.42.0", "", { "dependencies": { "lexical": "0.42.0" } }, "sha512-hT3EYVtBmONXyXe4TFVgtFcG1tf6JhLEuAf95+cOjgFGFSgvkZ/64BPbKLNTj2/9n6cU7EGPUNNwVigCSECJ2g=="], + + "@lexical/utils": ["@lexical/utils@0.42.0", "", { "dependencies": { "@lexical/selection": "0.42.0", "lexical": "0.42.0" } }, "sha512-wGNdCW3QWEyVdFiSTLZfFPtiASPyYLcekIiYYZmoRVxVimT/jY+QPfnkO4JYgkO7Z70g/dsg9OhqyQSChQfvkQ=="], + + "@lexical/yjs": ["@lexical/yjs@0.42.0", "", { "dependencies": { "@lexical/offset": "0.42.0", "@lexical/selection": "0.42.0", "lexical": "0.42.0" }, "peerDependencies": { "yjs": ">=13.5.22" } }, "sha512-DplzWnYhfFceGPR+UyDFpZdB287wF/vNOHFuDsBF/nGDdTezvr0Gf60opzyBEF3oXym6p3xTmGygxvO97LZ+vw=="], + "@malept/cross-spawn-promise": ["@malept/cross-spawn-promise@1.1.1", "", { "dependencies": { "cross-spawn": "^7.0.1" } }, "sha512-RTBGWL5FWQcg9orDOCcp4LvItNzUPcyEU9bwaeJX0rJ1IQxzucC48Y0/sQLp/g6t99IQgAlGIaesJS+gTn7tVQ=="], + "@preact/signals-core": ["@preact/signals-core@1.14.0", "", {}, "sha512-AowtCcCU/33lFlh1zRFf/u+12rfrhtNakj7UpaGEsmMwUKpKWMVvcktOGcwBBNiB4lWrZWc01LhiyyzVklJyaQ=="], + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.43", "", {}, "sha512-5Uxg7fQUCmfhax7FJke2+8B6cqgeUJUD9o2uXIKXhD+mG0mL6NObmVoi9wXEU1tY89mZKgAYA6fTbftx3q2ZPQ=="], "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.60.0", "", { "os": "android", "cpu": "arm" }, "sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A=="], @@ -276,6 +346,10 @@ "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], + "@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="], + + "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], + "@types/yauzl": ["@types/yauzl@2.10.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q=="], "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="], @@ -388,6 +462,8 @@ "enhanced-resolve": ["enhanced-resolve@5.20.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA=="], + "entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], + "env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], @@ -436,6 +512,8 @@ "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + "happy-dom": ["happy-dom@20.8.8", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.18.3" } }, "sha512-5/F8wxkNxYtsN0bXfMwIyNLZ9WYsoOYPbmoluqVJqv8KBUbcyKZawJ7uYK4WTX8IHBLYv+VXIwfeNDPy1oKMwQ=="], + "has-property-descriptors": ["has-property-descriptors@1.0.2", "", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="], "hast-util-to-jsx-runtime": ["hast-util-to-jsx-runtime@2.3.6", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "hast-util-whitespace": "^3.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "style-to-js": "^1.0.0", "unist-util-position": "^5.0.0", "vfile-message": "^4.0.0" } }, "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg=="], @@ -472,6 +550,8 @@ "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + "isomorphic.js": ["isomorphic.js@0.2.5", "", {}, "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw=="], + "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], @@ -490,6 +570,10 @@ "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], + "lexical": ["lexical@0.42.0", "", {}, "sha512-GY9Lg3YEIU7nSFaiUlLspZ1fm4NfIcfABaxy9nT+fRVDkX7iV005T5Swil83gXUmxFUNKGal3j+hUxHOUDr+Aw=="], + + "lib0": ["lib0@0.2.117", "", { "dependencies": { "isomorphic.js": "^0.2.4" }, "bin": { "0serve": "bin/0serve.js", "0gentesthtml": "bin/gentesthtml.js", "0ecdsa-generate-keypair": "bin/0ecdsa-generate-keypair.js" } }, "sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw=="], + "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], @@ -654,6 +738,8 @@ "prebuild-install": ["prebuild-install@7.1.3", "", { "dependencies": { "detect-libc": "^2.0.0", "expand-template": "^2.0.3", "github-from-package": "0.0.0", "minimist": "^1.2.3", "mkdirp-classic": "^0.5.3", "napi-build-utils": "^2.0.0", "node-abi": "^3.3.0", "pump": "^3.0.0", "rc": "^1.2.7", "simple-get": "^4.0.0", "tar-fs": "^2.0.0", "tunnel-agent": "^0.6.0" }, "bin": { "prebuild-install": "bin.js" } }, "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug=="], + "prismjs": ["prismjs@1.30.0", "", {}, "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw=="], + "progress": ["progress@2.0.3", "", {}, "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA=="], "property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="], @@ -670,6 +756,8 @@ "react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="], + "react-error-boundary": ["react-error-boundary@6.1.1", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0" } }, "sha512-BrYwPOdXi5mqkk5lw+Uvt0ThHx32rCt3BkukS4X23A2AIWDPSGX6iaWTc0y9TU/mHDA/6qOSGel+B2ERkOvD1w=="], + "react-markdown": ["react-markdown@10.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "html-url-attributes": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" }, "peerDependencies": { "@types/react": ">=18", "react": ">=18" } }, "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ=="], "react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="], @@ -728,6 +816,8 @@ "sumchecker": ["sumchecker@3.0.1", "", { "dependencies": { "debug": "^4.1.0" } }, "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg=="], + "tabbable": ["tabbable@6.4.0", "", {}, "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg=="], + "tailwindcss": ["tailwindcss@4.2.2", "", {}, "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q=="], "tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="], @@ -778,14 +868,20 @@ "vite": ["vite@7.1.10", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-CmuvUBzVJ/e3HGxhg6cYk88NGgTnBoOo7ogtfJJ0fefUWAxN/WDSUa50o+oVBxuIhO8FoEZW0j2eW7sfjs5EtA=="], + "whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="], + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + "ws": ["ws@8.20.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="], + "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], "yauzl": ["yauzl@2.10.0", "", { "dependencies": { "buffer-crc32": "~0.2.3", "fd-slicer": "~1.1.0" } }, "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g=="], + "yjs": ["yjs@13.6.30", "", { "dependencies": { "lib0": "^0.2.99" } }, "sha512-vv/9h42eCMC81ZHDFswuu/MKzkl/vyq1BhaNGfHyOonwlG4CJbQF4oiBBJPvfdeCt/PlVDWh7Nov9D34YY09uQ=="], + "zustand": ["zustand@4.5.7", "", { "dependencies": { "use-sync-external-store": "^1.2.2" }, "peerDependencies": { "@types/react": ">=16.8", "immer": ">=9.0.6", "react": ">=16.8" }, "optionalPeers": ["@types/react", "immer", "react"] }, "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw=="], "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], diff --git a/package.json b/package.json index b67941e..c7e4f1c 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "homepage": "https://github.com/davidkaya/eryx#readme", "packageManager": "bun@1.3.6", "devDependencies": { + "@lexical/headless": "0.42.0", "@tailwindcss/vite": "^4.2.2", "@types/node": "^25.5.0", "@types/react": "^19.2.14", @@ -47,8 +48,15 @@ }, "dependencies": { "@dagrejs/dagre": "^3.0.0", + "@lexical/code": "0.42.0", + "@lexical/link": "0.42.0", + "@lexical/list": "0.42.0", + "@lexical/markdown": "0.42.0", + "@lexical/react": "0.42.0", + "@lexical/rich-text": "0.42.0", "@xyflow/react": "^12.10.1", "keytar": "^7.9.0", + "lexical": "0.42.0", "lucide-react": "^0.577.0", "react": "^19.2.4", "react-dom": "^19.2.4", diff --git a/src/main/EryxAppService.ts b/src/main/EryxAppService.ts index 0f58ec5..3101e24 100644 --- a/src/main/EryxAppService.ts +++ b/src/main/EryxAppService.ts @@ -57,6 +57,7 @@ import { type ChatMessageRecord, type SessionRecord, } from '@shared/domain/session'; +import { prepareChatMessageContent } from '@shared/utils/chatMessage'; import { appendRunActivityEvent, completeSessionRunRecord, @@ -442,8 +443,8 @@ export class EryxAppService extends EventEmitter { const pattern = this.requirePattern(workspace, session.patternId); const effectivePattern = await this.buildEffectivePattern(project, pattern, session); - const trimmed = content.trim(); - if (!trimmed) { + const preparedContent = prepareChatMessageContent(content); + if (!preparedContent) { return; } @@ -455,7 +456,7 @@ export class EryxAppService extends EventEmitter { id: userMessageId, role: 'user', authorName: 'You', - content: trimmed, + content: preparedContent, createdAt: occurredAt, }); session.title = resolveSessionTitle(session, effectivePattern, session.messages); diff --git a/src/renderer/components/ChatPane.tsx b/src/renderer/components/ChatPane.tsx index 81b556e..adbbe4f 100644 --- a/src/renderer/components/ChatPane.tsx +++ b/src/renderer/components/ChatPane.tsx @@ -16,6 +16,7 @@ import { import { reasoningEffortOptions, type PatternDefinition, type ReasoningEffort } from '@shared/domain/pattern'; import { isScratchpadProject, type ProjectRecord } from '@shared/domain/project'; import { resolveSessionToolingSelection, type SessionRecord } from '@shared/domain/session'; +import { hasMeaningfulChatMessageContent, prepareChatMessageContent } from '@shared/utils/chatMessage'; import { listApprovalToolDefinitions, type ApprovalToolDefinition, @@ -694,6 +695,7 @@ export function ChatPane({ const supportedEfforts = getSupportedReasoningEfforts(selectedModel); const scratchpadReasoningEffort = resolveReasoningEffort(selectedModel, primaryAgent?.reasoningEffort); const isComposerDisabled = isSessionBusy || isUpdatingScratchpadConfig; + const canSubmitInput = hasMeaningfulChatMessageContent(input) && !isComposerDisabled; const toolSelection = useMemo(() => resolveSessionToolingSelection(session), [session]); const mcpServers = toolingSettings.mcpServers; @@ -729,7 +731,7 @@ export function ChatPane({ }, [session.id]); async function handleSubmit() { - const text = input.trim(); + const text = prepareChatMessageContent(input); if (!text || isComposerDisabled) return; setInput(''); await onSend(text); @@ -1042,11 +1044,11 @@ export function ChatPane({ /> - -
-        
-          {children}
-        
-      
- - ); -} - -const markdownComponents: Record) => ReactNode> = { - pre({ children, node }: Record) { - const codeChild = (node as Record)?.children?.[0] as - | Record - | undefined; - - if (codeChild && (codeChild as Record).tagName === 'code') { - const classNames = ((codeChild.properties as Record)?.className ?? - []) as string[]; - const hasLanguage = classNames.some((c) => c.startsWith('language-')); - - if (!hasLanguage) { - const text = extractText(codeChild).replace(/\n$/, ''); - return {text}; - } - } - - return <>{children as ReactNode}; - }, - - code({ className, children, ...rest }: Record) { - const match = /language-(\w+)/.exec(String(className ?? '')); - if (match) { - return ( - - {String(children).replace(/\n$/, '')} - - ); - } - - return ( - - {children as ReactNode} - - ); - }, - - a({ href, children }: Record) { - return ( - - {children as ReactNode} - - ); - }, -}; +import { chatMarkdownComponents, chatMarkdownRemarkPlugins } from '@renderer/lib/chatMarkdown'; interface MarkdownContentProps { content: string; @@ -117,8 +9,8 @@ export function MarkdownContent({ content }: MarkdownContentProps) { return (
{content} diff --git a/src/renderer/lib/chatMarkdown.tsx b/src/renderer/lib/chatMarkdown.tsx new file mode 100644 index 0000000..3599d37 --- /dev/null +++ b/src/renderer/lib/chatMarkdown.tsx @@ -0,0 +1,114 @@ +import { useState, type ReactNode } from 'react'; +import type { Components } from 'react-markdown'; +import remarkGfm from 'remark-gfm'; +import { Check, Clipboard } from 'lucide-react'; + +function extractText(node: unknown): string { + if (!node || typeof node !== 'object') return ''; + const n = node as Record; + if (n.type === 'text') return String(n.value ?? ''); + if (Array.isArray(n.children)) { + return (n.children as unknown[]).map(extractText).join(''); + } + return ''; +} + +function CodeBlock({ language, children }: { language: string; children: string }) { + const [copied, setCopied] = useState(false); + + function handleCopy() { + void navigator.clipboard.writeText(children); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + + return ( +
+
+ + {language || 'text'} + + +
+
+        
+          {children}
+        
+      
+
+ ); +} + +export const chatMarkdownRemarkPlugins = [remarkGfm]; + +export const chatMarkdownComponents: Components = { + pre({ children, node }) { + const nodeChildren = + typeof node === 'object' && node !== null && Array.isArray((node as { children?: unknown[] }).children) + ? (node as { children: unknown[] }).children + : undefined; + const codeChild = nodeChildren?.[0] as Record | undefined; + + if (codeChild && (codeChild as Record).tagName === 'code') { + const classNames = ((codeChild.properties as Record)?.className ?? + []) as string[]; + const hasLanguage = classNames.some((className) => className.startsWith('language-')); + + if (!hasLanguage) { + const text = extractText(codeChild).replace(/\n$/, ''); + return {text}; + } + } + + return <>{children as ReactNode}; + }, + + code({ className, children, ...rest }) { + const match = /language-(\w+)/.exec(String(className ?? '')); + if (match) { + return ( + + {String(children).replace(/\n$/, '')} + + ); + } + + return ( + + {children as ReactNode} + + ); + }, + + a({ href, children }) { + return ( + + {children as ReactNode} + + ); + }, +}; diff --git a/src/renderer/lib/markdownEditor.ts b/src/renderer/lib/markdownEditor.ts new file mode 100644 index 0000000..9f7e208 --- /dev/null +++ b/src/renderer/lib/markdownEditor.ts @@ -0,0 +1,97 @@ +import { CodeNode } from '@lexical/code'; +import { AutoLinkNode, LinkNode } from '@lexical/link'; +import { type Transformer, TRANSFORMERS } from '@lexical/markdown'; +import { ListItemNode, ListNode } from '@lexical/list'; +import { HeadingNode, QuoteNode } from '@lexical/rich-text'; +import { type Klass, type LexicalNode } from 'lexical'; + +import { normalizeChatMessageLineEndings } from '@shared/utils/chatMessage'; + +const fencedCodePattern = /(^|\n)```/; +const blockMarkdownPattern = /(^|\n)\s{0,3}(?:#{1,6}\s|>\s|[-*+]\s|\d+\.\s|\[[ xX]\]\s)/; +const tablePattern = /(^|\n)\|.*\|\s*\n\|?[\s:-]+\|/; +const linkPattern = /\[([^\]]+)]\(([^)]+)\)/; +const inlineFormattingPattern = + /(?:`[^`]+`|\*\*[^*]+\*\*|__[^_]+__|~~[^~]+~~|\*[^*\n]+\*|_[^_\n]+_)/; + +export type MarkdownPasteReason = + | 'fenced-code' + | 'block-structure' + | 'table' + | 'link' + | 'inline-format' + | 'plain-text'; + +export interface MarkdownPasteInspection { + normalizedText: string; + shouldImportMarkdown: boolean; + reason: MarkdownPasteReason; +} + +export const markdownEditorNamespace = 'eryx-markdown-composer'; + +export const markdownEditorNodes: ReadonlyArray> = [ + HeadingNode, + QuoteNode, + ListNode, + ListItemNode, + LinkNode, + AutoLinkNode, + CodeNode, +]; + +export const markdownEditorTransformers: ReadonlyArray = TRANSFORMERS; + +export function inspectMarkdownPaste(text: string): MarkdownPasteInspection { + const normalizedText = normalizeChatMessageLineEndings(text); + + if (fencedCodePattern.test(normalizedText)) { + return { + normalizedText, + shouldImportMarkdown: true, + reason: 'fenced-code', + }; + } + + if (blockMarkdownPattern.test(normalizedText)) { + return { + normalizedText, + shouldImportMarkdown: true, + reason: 'block-structure', + }; + } + + if (tablePattern.test(normalizedText)) { + return { + normalizedText, + shouldImportMarkdown: true, + reason: 'table', + }; + } + + if (linkPattern.test(normalizedText)) { + return { + normalizedText, + shouldImportMarkdown: true, + reason: 'link', + }; + } + + if (inlineFormattingPattern.test(normalizedText)) { + return { + normalizedText, + shouldImportMarkdown: true, + reason: 'inline-format', + }; + } + + return { + normalizedText, + shouldImportMarkdown: false, + reason: 'plain-text', + }; +} + +export function shouldImportMarkdownPaste(text: string): boolean { + return inspectMarkdownPaste(text).shouldImportMarkdown; +} diff --git a/src/renderer/lib/runTimelineFormatting.ts b/src/renderer/lib/runTimelineFormatting.ts index a082e32..bb5f457 100644 --- a/src/renderer/lib/runTimelineFormatting.ts +++ b/src/renderer/lib/runTimelineFormatting.ts @@ -4,6 +4,7 @@ import type { SessionRunRecord, SessionRunStatus, } from '@shared/domain/runTimeline'; +import { buildMarkdownExcerpt } from '@shared/utils/markdownText'; export function formatRunTimestamp(isoDate: string): string { try { @@ -156,9 +157,7 @@ export function eventSortKey(event: RunTimelineEventRecord): number { export function truncateContent(content: string | undefined, maxLength = 80): string | undefined { if (!content) return undefined; - const singleLine = content.replace(/\n/g, ' ').trim(); - if (singleLine.length <= maxLength) return singleLine; - return `${singleLine.slice(0, maxLength)}…`; + return buildMarkdownExcerpt(content, maxLength); } export function findLatestRun(runs: readonly SessionRunRecord[]): SessionRunRecord | undefined { diff --git a/src/shared/domain/pattern.ts b/src/shared/domain/pattern.ts index 5a55f2d..58167a4 100644 --- a/src/shared/domain/pattern.ts +++ b/src/shared/domain/pattern.ts @@ -4,6 +4,7 @@ import { type ApprovalPolicy, validateApprovalPolicy, } from '@shared/domain/approval'; +import { buildMarkdownExcerpt } from '@shared/utils/markdownText'; export type OrchestrationMode = | 'single' @@ -1009,5 +1010,5 @@ export function buildSessionTitle(pattern: PatternDefinition, messages: ChatMess return pattern.name; } - return firstUserMessage.content.slice(0, 48).trim() || pattern.name; + return buildMarkdownExcerpt(firstUserMessage.content, 48) ?? pattern.name; } diff --git a/src/shared/utils/chatMessage.ts b/src/shared/utils/chatMessage.ts new file mode 100644 index 0000000..1707b8b --- /dev/null +++ b/src/shared/utils/chatMessage.ts @@ -0,0 +1,12 @@ +export function normalizeChatMessageLineEndings(value: string): string { + return value.replace(/\r\n?/g, '\n'); +} + +export function hasMeaningfulChatMessageContent(value: string): boolean { + return normalizeChatMessageLineEndings(value).trim().length > 0; +} + +export function prepareChatMessageContent(value: string): string | undefined { + const normalized = normalizeChatMessageLineEndings(value); + return normalized.trim().length > 0 ? normalized : undefined; +} diff --git a/src/shared/utils/markdownText.ts b/src/shared/utils/markdownText.ts new file mode 100644 index 0000000..10d46de --- /dev/null +++ b/src/shared/utils/markdownText.ts @@ -0,0 +1,58 @@ +import { normalizeChatMessageLineEndings } from '@shared/utils/chatMessage'; + +const fencedCodeBlockPattern = /```[^\n]*\n([\s\S]*?)```/g; +const inlineCodePattern = /`([^`]+)`/g; +const imagePattern = /!\[([^\]]*)]\([^)]+\)/g; +const linkPattern = /\[([^\]]+)]\([^)]+\)/g; +const autoLinkPattern = /<((?:https?:\/\/|mailto:)[^>]+)>/g; +const headingPattern = /^\s{0,3}#{1,6}\s+/gm; +const blockquotePattern = /^\s{0,3}>\s?/gm; +const listPattern = /^\s{0,3}(?:[-*+]|\d+\.)\s+/gm; +const checklistPattern = /^\s*\[[ xX]\]\s+/gm; +const setextHeadingPattern = /^[=-]{2,}\s*$/gm; +const thematicBreakPattern = /^[ \t]{0,3}(?:[-*_][ \t]*){3,}$/gm; +const tableSeparatorPattern = /^\s*\|?(?:\s*:?-{3,}:?\s*\|)+\s*:?-{3,}:?\s*\|?\s*$/gm; +const emphasisPattern = /(\*\*|__|~~|\*|_)/g; +const markdownEscapePattern = /\\([\\`*_{}\[\]()#+\-.!>])/g; + +export function extractPlainTextFromMarkdown(markdown: string): string { + const normalized = normalizeChatMessageLineEndings(markdown); + + const plain = normalized + .replace(fencedCodeBlockPattern, '$1') + .replace(inlineCodePattern, '$1') + .replace(imagePattern, '$1') + .replace(linkPattern, '$1') + .replace(autoLinkPattern, '$1') + .replace(headingPattern, '') + .replace(blockquotePattern, '') + .replace(listPattern, '') + .replace(checklistPattern, '') + .replace(setextHeadingPattern, ' ') + .replace(thematicBreakPattern, ' ') + .replace(tableSeparatorPattern, ' ') + .replace(/\|/g, ' ') + .replace(emphasisPattern, '') + .replace(markdownEscapePattern, '$1') + .replace(/\s+/g, ' ') + .trim(); + + return plain; +} + +export function buildMarkdownExcerpt(markdown: string | undefined, maxLength = 80): string | undefined { + if (!markdown) { + return undefined; + } + + const plainText = extractPlainTextFromMarkdown(markdown); + if (!plainText) { + return undefined; + } + + if (plainText.length <= maxLength) { + return plainText; + } + + return `${plainText.slice(0, maxLength).trimEnd()}…`; +} diff --git a/tests/renderer/markdownEditor.test.ts b/tests/renderer/markdownEditor.test.ts new file mode 100644 index 0000000..5be5976 --- /dev/null +++ b/tests/renderer/markdownEditor.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, test } from 'bun:test'; +import { createHeadlessEditor } from '@lexical/headless'; +import { $convertFromMarkdownString, $convertToMarkdownString } from '@lexical/markdown'; + +import { + inspectMarkdownPaste, + markdownEditorNamespace, + markdownEditorNodes, + markdownEditorTransformers, +} from '@renderer/lib/markdownEditor'; + +function roundTripMarkdown(markdown: string): string { + const editor = createHeadlessEditor({ + namespace: markdownEditorNamespace, + nodes: [...markdownEditorNodes], + onError(error) { + throw error; + }, + }); + + editor.update(() => { + $convertFromMarkdownString(markdown, [...markdownEditorTransformers], undefined, true, false); + }, { discrete: true }); + + let result = ''; + editor.getEditorState().read(() => { + result = $convertToMarkdownString([...markdownEditorTransformers], undefined, true); + }); + + return result; +} + +describe('markdown editor contract', () => { + test('round-trips markdown structures with the chosen node set', () => { + const markdown = [ + '# Release notes', + '', + '- item one', + '- item two', + '', + '> keep the markdown contract stable', + '', + '```ts', + 'const answer = 42;', + '```', + '', + '[Docs](https://example.com)', + ].join('\n'); + + const roundTripped = roundTripMarkdown(markdown); + + expect(roundTripped).toContain('# Release notes'); + expect(roundTripped).toContain('- item one'); + expect(roundTripped).toContain('```ts'); + expect(roundTripped).toContain('[Docs](https://example.com)'); + }); + + test('classifies fenced code as markdown paste', () => { + expect(inspectMarkdownPaste('```ts\nconst answer = 42;\n```')).toEqual({ + normalizedText: '```ts\nconst answer = 42;\n```', + shouldImportMarkdown: true, + reason: 'fenced-code', + }); + }); + + test('keeps plain prose on the plain-text path', () => { + expect(inspectMarkdownPaste('Just a normal sentence about the release plan.')).toEqual({ + normalizedText: 'Just a normal sentence about the release plan.', + shouldImportMarkdown: false, + reason: 'plain-text', + }); + }); +}); diff --git a/tests/renderer/runTimelineFormatting.test.ts b/tests/renderer/runTimelineFormatting.test.ts index 99fc606..a83ddfc 100644 --- a/tests/renderer/runTimelineFormatting.test.ts +++ b/tests/renderer/runTimelineFormatting.test.ts @@ -86,6 +86,7 @@ describe('run timeline formatting', () => { expect(truncateContent('Short content')).toBe('Short content'); expect(truncateContent('A'.repeat(100), 80)).toBe('A'.repeat(80) + '…'); expect(truncateContent('Line 1\nLine 2')).toBe('Line 1 Line 2'); + expect(truncateContent('**Bold** [Docs](https://example.com)')).toBe('Bold Docs'); }); test('collapses consecutive thinking events from the same agent', () => { diff --git a/tests/shared/chatMessage.test.ts b/tests/shared/chatMessage.test.ts new file mode 100644 index 0000000..9c94163 --- /dev/null +++ b/tests/shared/chatMessage.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, test } from 'bun:test'; + +import { + hasMeaningfulChatMessageContent, + normalizeChatMessageLineEndings, + prepareChatMessageContent, +} from '@shared/utils/chatMessage'; + +describe('chat message helpers', () => { + test('normalizes Windows line endings without trimming meaningful markdown', () => { + expect( + prepareChatMessageContent('```ts\r\n const answer = 42;\r\n```\r\n'), + ).toBe('```ts\n const answer = 42;\n```\n'); + }); + + test('treats whitespace-only input as empty', () => { + expect(hasMeaningfulChatMessageContent(' \r\n\t ')).toBe(false); + expect(prepareChatMessageContent(' \r\n\t ')).toBeUndefined(); + }); + + test('normalizes mixed newline sequences consistently', () => { + expect(normalizeChatMessageLineEndings('line 1\r\nline 2\rline 3')).toBe( + 'line 1\nline 2\nline 3', + ); + }); +}); diff --git a/tests/shared/markdownText.test.ts b/tests/shared/markdownText.test.ts new file mode 100644 index 0000000..e8cf215 --- /dev/null +++ b/tests/shared/markdownText.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, test } from 'bun:test'; + +import { buildMarkdownExcerpt, extractPlainTextFromMarkdown } from '@shared/utils/markdownText'; + +describe('markdown text helpers', () => { + test('extracts readable text from common markdown syntax', () => { + expect( + extractPlainTextFromMarkdown('# Title\n\n**Bold** [Docs](https://example.com) and `code`.'), + ).toBe('Title Bold Docs and code.'); + }); + + test('turns fenced code blocks into readable excerpts', () => { + expect(buildMarkdownExcerpt('```ts\nconst answer = 42;\n```', 48)).toBe('const answer = 42;'); + }); + + test('truncates after stripping markdown syntax', () => { + expect(buildMarkdownExcerpt('**Alpha** beta gamma delta epsilon zeta eta theta', 18)).toBe( + 'Alpha beta gamma d…', + ); + }); +}); diff --git a/tests/shared/session.test.ts b/tests/shared/session.test.ts index 7686e67..293839f 100644 --- a/tests/shared/session.test.ts +++ b/tests/shared/session.test.ts @@ -121,6 +121,23 @@ describe('session title helpers', () => { ]), ).toBe('Release readiness review'); }); + + test('builds auto titles from markdown-heavy first messages', () => { + const pattern = createPattern(); + const session = createSession(); + + expect( + resolveSessionTitle(session, pattern, [ + { + id: 'msg-1', + role: 'user', + authorName: 'You', + content: '```ts\nconst answer = 42;\n```', + createdAt: '2026-03-23T00:00:00.000Z', + }, + ]), + ).toBe('const answer = 42;'); + }); }); describe('session tooling helpers', () => {