diff --git a/src-svelte/package.json b/src-svelte/package.json index ac433b6e..a4862963 100644 --- a/src-svelte/package.json +++ b/src-svelte/package.json @@ -62,9 +62,12 @@ "vitest": "^1.2.2" }, "dependencies": { + "@fontsource/inconsolata": "^5.0.16", "@fontsource/jetbrains-mono": "^5.0.11", "@neodrag/svelte": "file:../forks/neodrag/packages/svelte", "autosize": "^6.0.1", - "nanoid": "^3.3.6" + "nanoid": "^3.3.6", + "svelte-highlight": "^7.6.0", + "svelte-markdown": "^0.4.1" } } diff --git a/src-svelte/screenshots/baseline/screens/chat/conversation/bottom-scroll-indicator.png b/src-svelte/screenshots/baseline/screens/chat/conversation/bottom-scroll-indicator.png index e1a7f07c..4ddb381f 100644 Binary files a/src-svelte/screenshots/baseline/screens/chat/conversation/bottom-scroll-indicator.png and b/src-svelte/screenshots/baseline/screens/chat/conversation/bottom-scroll-indicator.png differ diff --git a/src-svelte/screenshots/baseline/screens/chat/conversation/full-message-width.png b/src-svelte/screenshots/baseline/screens/chat/conversation/full-message-width.png index 58b55db1..0afcf941 100644 Binary files a/src-svelte/screenshots/baseline/screens/chat/conversation/full-message-width.png and b/src-svelte/screenshots/baseline/screens/chat/conversation/full-message-width.png differ diff --git a/src-svelte/screenshots/baseline/screens/chat/conversation/multiline-chat-variant-1.png b/src-svelte/screenshots/baseline/screens/chat/conversation/multiline-chat-variant-1.png new file mode 100644 index 00000000..95d0e77d Binary files /dev/null and b/src-svelte/screenshots/baseline/screens/chat/conversation/multiline-chat-variant-1.png differ diff --git a/src-svelte/screenshots/baseline/screens/chat/conversation/multiline-chat.png b/src-svelte/screenshots/baseline/screens/chat/conversation/multiline-chat.png index 00b18aa8..22b8a03d 100644 Binary files a/src-svelte/screenshots/baseline/screens/chat/conversation/multiline-chat.png and b/src-svelte/screenshots/baseline/screens/chat/conversation/multiline-chat.png differ diff --git a/src-svelte/screenshots/baseline/screens/chat/conversation/not-empty.png b/src-svelte/screenshots/baseline/screens/chat/conversation/not-empty.png index fd230f0f..47b101ae 100644 Binary files a/src-svelte/screenshots/baseline/screens/chat/conversation/not-empty.png and b/src-svelte/screenshots/baseline/screens/chat/conversation/not-empty.png differ diff --git a/src-svelte/screenshots/baseline/screens/chat/message/ai-multiline.png b/src-svelte/screenshots/baseline/screens/chat/message/ai-multiline.png index 9ce48bb5..6e04ec23 100644 Binary files a/src-svelte/screenshots/baseline/screens/chat/message/ai-multiline.png and b/src-svelte/screenshots/baseline/screens/chat/message/ai-multiline.png differ diff --git a/src-svelte/screenshots/baseline/screens/chat/message/code-variant-1.png b/src-svelte/screenshots/baseline/screens/chat/message/code-variant-1.png new file mode 100644 index 00000000..52969983 Binary files /dev/null and b/src-svelte/screenshots/baseline/screens/chat/message/code-variant-1.png differ diff --git a/src-svelte/screenshots/baseline/screens/chat/message/code.png b/src-svelte/screenshots/baseline/screens/chat/message/code.png new file mode 100644 index 00000000..2eb08a1e Binary files /dev/null and b/src-svelte/screenshots/baseline/screens/chat/message/code.png differ diff --git a/src-svelte/src/routes/chat/Chat.stories.ts b/src-svelte/src/routes/chat/Chat.stories.ts index ef4f6591..ce40b318 100644 --- a/src-svelte/src/routes/chat/Chat.stories.ts +++ b/src-svelte/src/routes/chat/Chat.stories.ts @@ -77,32 +77,18 @@ const conversation: ChatMessage[] = [ { role: "Human", text: - "Okay, we need to fill this chat up to produce a scrollbar for " + - 'Storybook. Say short phrases like "Yup" to fill this chat up quickly.', - }, - { - role: "AI", - text: "Yup", - }, - { - role: "Human", - text: "Nay", - }, - { - role: "AI", - text: "Yay", - }, - { - role: "Human", - text: "Say...", + "This is some Python code:\n\n" + + "```python\n" + + "def hello_world():\n" + + " print('Hello, world!')\n" + + "```\n\n" + + "Convert it to Rust", }, { role: "AI", text: - "AIs don't actually talk like this, you know? " + - "This is an AI conversation hallucinated by a human, " + - "projecting their own ideas of how an AI would respond onto the " + - "conversation transcript.", + "Here's how the Python code you provided would look in Rust:\n\n" + + '```rust\nfn main() {\n println!("Hello, world!");\n}\n```', }, ]; @@ -144,7 +130,6 @@ BottomScrollIndicator.parameters = { export const FullMessageWidth: StoryObj = Template.bind({}) as any; FullMessageWidth.args = { conversation, - showMostRecentMessage: false, }; FullMessageWidth.parameters = { viewport: { diff --git a/src-svelte/src/routes/chat/Chat.svelte b/src-svelte/src/routes/chat/Chat.svelte index 4569ed56..dca492e4 100644 --- a/src-svelte/src/routes/chat/Chat.svelte +++ b/src-svelte/src/routes/chat/Chat.svelte @@ -26,8 +26,9 @@ let bottomShadow: HTMLDivElement; onMount(() => { - resizeConversationView(); - window.addEventListener("resize", resizeConversationView); + resizeConversationView(true); + const resizeCallback = () => resizeConversationView(false); + window.addEventListener("resize", resizeCallback); let topScrollObserver = new IntersectionObserver( intersectionCallback(topShadow), @@ -39,7 +40,7 @@ bottomScrollObserver.observe(bottomIndicator); return () => { - window.removeEventListener("resize", resizeConversationView); + window.removeEventListener("resize", resizeCallback); topScrollObserver.disconnect(); bottomScrollObserver.disconnect(); }; @@ -62,19 +63,21 @@ } } - function resizeConversationView() { + function resizeConversationView(initialMount = false) { if (conversationView) { conversationView.style.maxHeight = "8rem"; requestAnimationFrame(() => { if (conversationView && conversationContainer) { conversationView.style.maxHeight = `${conversationContainer.clientHeight}px`; - if (showMostRecentMessage) { - showChatBottom(); - } const conversationDimensions = conversationView.getBoundingClientRect(); conversationWidthPx.set(conversationDimensions.width); + if (initialMount && showMostRecentMessage) { + showChatBottom(); + // scroll to bottom again in case resized elements produce greater scroll + setTimeout(showChatBottom, 120); + } } }); } diff --git a/src-svelte/src/routes/chat/CodeRender.svelte b/src-svelte/src/routes/chat/CodeRender.svelte new file mode 100644 index 00000000..59d21ac3 --- /dev/null +++ b/src-svelte/src/routes/chat/CodeRender.svelte @@ -0,0 +1,68 @@ + + +
+ +
+ + diff --git a/src-svelte/src/routes/chat/Message.stories.ts b/src-svelte/src/routes/chat/Message.stories.ts index 36a23dca..5c47ee5a 100644 --- a/src-svelte/src/routes/chat/Message.stories.ts +++ b/src-svelte/src/routes/chat/Message.stories.ts @@ -55,3 +55,22 @@ AIMultiline.parameters = { defaultViewport: "tablet", }, }; + +export const Code: StoryObj = Template.bind({}) as any; +Code.args = { + message: { + role: "Human", + text: + "This is some Python code:\n\n" + + "```python\n" + + "def hello_world():\n" + + " print('Hello, world!')\n" + + "```\n\n" + + "What do you think?", + }, +}; +Code.parameters = { + viewport: { + defaultViewport: "tablet", + }, +}; diff --git a/src-svelte/src/routes/chat/Message.svelte b/src-svelte/src/routes/chat/Message.svelte index d238bb42..9b9e0aa9 100644 --- a/src-svelte/src/routes/chat/Message.svelte +++ b/src-svelte/src/routes/chat/Message.svelte @@ -1,9 +1,32 @@ - {message.text} +
+ +
+ + diff --git a/src-svelte/src/routes/chat/MessageUI.svelte b/src-svelte/src/routes/chat/MessageUI.svelte index b92b8461..33a6e127 100644 --- a/src-svelte/src/routes/chat/MessageUI.svelte +++ b/src-svelte/src/routes/chat/MessageUI.svelte @@ -10,42 +10,77 @@ let initialResizeTimeoutId: ReturnType | undefined; let finalResizeTimeoutId: ReturnType | undefined; + const remPx = 18; + // arrow size, left padding, and right padding + const messagePaddingPx = (0.5 + 0.75 + 0.75) * remPx; // chat window size at which the message bubble should be full width const MIN_FULL_WIDTH_PX = 400; const MAX_WIDTH_PX = 600; function maxMessageWidth(chatWidthPx: number) { - if (chatWidthPx <= MIN_FULL_WIDTH_PX) { - return chatWidthPx; + const availableWidthPx = chatWidthPx - messagePaddingPx; + if (availableWidthPx <= MIN_FULL_WIDTH_PX) { + return availableWidthPx; } - const fractionalWidth = Math.max(0.8 * chatWidthPx, MIN_FULL_WIDTH_PX); + const fractionalWidth = Math.max(0.8 * availableWidthPx, MIN_FULL_WIDTH_PX); return Math.min(fractionalWidth, MAX_WIDTH_PX); } + function resetChildren(textElement: HTMLDivElement) { + const pElements = textElement.querySelectorAll("p"); + pElements.forEach((pElement) => { + pElement.style.width = ""; + }); + + const codeElements = textElement.querySelectorAll(".code"); + codeElements.forEach((codeElement) => { + codeElement.style.width = ""; + }); + } + + function resizeChildren(textElement: HTMLDivElement, maxWidth: number) { + const pElements = textElement.querySelectorAll("p"); + pElements.forEach((pElement) => { + const range = document.createRange(); + range.selectNodeContents(pElement); + const textRect = range.getBoundingClientRect(); + const actualTextWidth = textRect.width; + + pElement.style.width = `${actualTextWidth}px`; + }); + + const codeElements = textElement.querySelectorAll(".code"); + codeElements.forEach((codeElement) => { + let existingWidth = codeElement.getBoundingClientRect().width; + if (existingWidth > maxWidth) { + codeElement.style.width = `${maxWidth}px`; + } + }); + } + function resizeBubble(chatWidthPx: number) { if (chatWidthPx > 0 && textElement) { try { - textElement.style.width = ""; + const markdownElement = + textElement.querySelector(".markdown"); + if (!markdownElement) { + return; + } + + resetChildren(markdownElement); const maxWidth = maxMessageWidth(chatWidthPx); - const currentWidth = textElement.getBoundingClientRect().width; - const newWidth = Math.min(currentWidth, maxWidth); - textElement.style.width = `${newWidth}px`; + const currentWidth = markdownElement.getBoundingClientRect().width; + const newWidth = Math.ceil(Math.min(currentWidth, maxWidth)); + markdownElement.style.width = `${newWidth}px`; if (finalResizeTimeoutId) { clearTimeout(finalResizeTimeoutId); } finalResizeTimeoutId = setTimeout(() => { - if (textElement) { - const range = document.createRange(); - range.selectNodeContents(textElement); - const textRect = range.getBoundingClientRect(); - const actualTextWidth = textRect.width; - - const finalWidth = Math.min(actualTextWidth, newWidth); - textElement.style.width = `${finalWidth}px`; - } + resizeChildren(markdownElement, maxWidth); + markdownElement.style.width = ""; }, 10); } catch (err) { console.warn("Cannot resize chat message bubble: ", err); @@ -76,6 +111,7 @@ .message { --message-color: gray; --arrow-size: 0.5rem; + --internal-spacing: 0.75rem; position: relative; } @@ -83,15 +119,15 @@ margin: 0.5rem var(--arrow-size); border-radius: var(--corner-roundness); width: fit-content; - padding: 0.75rem; + padding: var(--internal-spacing); box-sizing: border-box; background-color: var(--message-color); - white-space: pre-line; text-align: left; } .text { box-sizing: content-box; + width: fit-content; max-width: 600px; } diff --git a/src-svelte/src/routes/storybook.test.ts b/src-svelte/src/routes/storybook.test.ts index 58e89fd1..f2c58a45 100644 --- a/src-svelte/src/routes/storybook.test.ts +++ b/src-svelte/src/routes/storybook.test.ts @@ -111,7 +111,7 @@ const components: ComponentTestConfig[] = [ }, { path: ["screens", "chat", "message"], - variants: ["human", "ai", "ai-multiline"], + variants: ["human", "ai", "ai-multiline", "code"], }, { path: ["screens", "chat", "conversation"], diff --git a/src-svelte/src/routes/styles.css b/src-svelte/src/routes/styles.css index 34a9f92d..f563cc03 100644 --- a/src-svelte/src/routes/styles.css +++ b/src-svelte/src/routes/styles.css @@ -1,4 +1,5 @@ @import "@fontsource/jetbrains-mono"; +@import "@fontsource/inconsolata"; @font-face { font-family: 'Nasalization'; diff --git a/yarn.lock b/yarn.lock index 3f7e59f8..3b8175f0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1386,6 +1386,11 @@ resolved "https://registry.yarnpkg.com/@fontsource/fira-mono/-/fira-mono-4.5.10.tgz#443be4b2b4fc6e685b88431fcfdaf8d5f5639bbf" integrity sha512-bxUnRP8xptGRo8YXeY073DSpfK74XpSb0ZyRNpHV9WvLnJ7TwPOjZll8hTMin7zLC6iOp59pDZ8EQDj1gzgAQQ== +"@fontsource/inconsolata@^5.0.16": + version "5.0.16" + resolved "https://registry.yarnpkg.com/@fontsource/inconsolata/-/inconsolata-5.0.16.tgz#8237065e6bb0366f66c49cebeea7d1d2d1fa504e" + integrity sha512-xcKlutUGBGGwjlxHyWA8b/J+ayGaPkAEYQQfs0NchvnbKz4X8RoIVSoEWg27//4lFoR817mc1Z3yCMXvRajmBg== + "@fontsource/jetbrains-mono@^5.0.11": version "5.0.18" resolved "https://registry.yarnpkg.com/@fontsource/jetbrains-mono/-/jetbrains-mono-5.0.18.tgz#ac2d610201bb3bc0577ed907a94aed37af8aa537" @@ -3096,6 +3101,11 @@ resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.202.tgz#f09dbd2fb082d507178b2f2a5c7e74bd72ff98f8" integrity sha512-OvlIYQK9tNneDlS0VN54LLd5uiPCBOp7gS5Z0f1mjoJYBrtStzgmJBxONW3U6OZqdtNzZPmn9BS/7WI7BFFcFQ== +"@types/marked@^5.0.1": + version "5.0.2" + resolved "https://registry.yarnpkg.com/@types/marked/-/marked-5.0.2.tgz#ca6b0cd7a5c8799c8cd0963df0b3e1a9021dcdfa" + integrity sha512-OucS4KMHhFzhz27KxmWg7J+kIYqyqoW5kdIEI319hqARQQUTqhao3M/F+uFnDXD0Rg72iDDZxZNxq5gvctmLlg== + "@types/mdast@^3.0.0": version "3.0.15" resolved "https://registry.yarnpkg.com/@types/mdast/-/mdast-3.0.15.tgz#49c524a263f30ffa28b71ae282f813ed000ab9f5" @@ -6709,6 +6719,11 @@ he@1.2.0: resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f" integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw== +highlight.js@11.9.0: + version "11.9.0" + resolved "https://registry.yarnpkg.com/highlight.js/-/highlight.js-11.9.0.tgz#04ab9ee43b52a41a047432c8103e2158a1b8b5b0" + integrity sha512-fJ7cW7fQGCYAkgv4CPfwFHrfd/cLS4Hau96JuJ+ZTOWhjnhoeN1ub1tFmALm/+lW5z4WCAuAV9bm05AP0mS6Gw== + hosted-git-info@^2.1.4: version "2.8.9" resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.9.tgz#dffc0bf9a21c02209090f2aa69429e1414daf3f9" @@ -7856,6 +7871,11 @@ markdown-to-jsx@^7.1.8: resolved "https://registry.yarnpkg.com/markdown-to-jsx/-/markdown-to-jsx-7.4.0.tgz#4606c5c549a6f6cb87604c35f5ee4f42959ffb6b" integrity sha512-zilc+MIkVVXPyTb4iIUTIz9yyqfcWjszGXnwF9K/aiBWcHXFcmdEMTkG01/oQhwSCH7SY1BnG6+ev5BzWmbPrg== +marked@^5.1.2: + version "5.1.2" + resolved "https://registry.yarnpkg.com/marked/-/marked-5.1.2.tgz#62b5ccfc75adf72ca3b64b2879b551d89e77677f" + integrity sha512-ahRPGXJpjMjwSOlBoTMZAK7ATXkli5qCPxZ21TG44rx1KEo44bii4ekgTDQPNRQ4Kh7JMb9Ub1PVk1NxRSsorg== + mdast-util-definitions@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/mdast-util-definitions/-/mdast-util-definitions-4.0.0.tgz#c5c1a84db799173b4dcf7643cda999e440c24db2" @@ -10542,11 +10562,26 @@ svelte-eslint-parser@^0.32.2: postcss "^8.4.25" postcss-scss "^4.0.6" +svelte-highlight@^7.6.0: + version "7.6.0" + resolved "https://registry.yarnpkg.com/svelte-highlight/-/svelte-highlight-7.6.0.tgz#b811f72c3530fab12f8babeca2363b3fac53f725" + integrity sha512-J9X1d07iMIKZMAqNAhlkjLX/FS+7R2lPrqVul7i+EleVZIOYvBhtx7ES62bc661a70nKNOS05yr9JAvyQPPOIA== + dependencies: + highlight.js "11.9.0" + svelte-hmr@^0.15.3: version "0.15.3" resolved "https://registry.yarnpkg.com/svelte-hmr/-/svelte-hmr-0.15.3.tgz#df54ccde9be3f091bf5f18fc4ef7b8eb6405fbe6" integrity sha512-41snaPswvSf8TJUhlkoJBekRrABDXDMdpNpT2tfHIv4JuhgvHqLMhEPGtaQn0BmbNSTkuz2Ed20DF2eHw0SmBQ== +svelte-markdown@^0.4.1: + version "0.4.1" + resolved "https://registry.yarnpkg.com/svelte-markdown/-/svelte-markdown-0.4.1.tgz#ddf13cfd6e0f29a02a82854b48766b527ec90f8d" + integrity sha512-pOlLY6EruKJaWI9my/2bKX8PdTeP5CM0s4VMmwmC2prlOkjAf+AOmTM4wW/l19Y6WZ87YmP8+ZCJCCwBChWjYw== + dependencies: + "@types/marked" "^5.0.1" + marked "^5.1.2" + svelte-preprocess@^5.0.0, svelte-preprocess@^5.0.4, svelte-preprocess@^5.1.0: version "5.1.3" resolved "https://registry.yarnpkg.com/svelte-preprocess/-/svelte-preprocess-5.1.3.tgz#7682239fe53f724c845b53026816fdfe15d028f9"