action buttons

frdel committed Aug 5, 2025 at 22:08 UTC 41c2bc2f74c2c248653d6a42efc5494d59fde706
5 files changed +178 -201
webui/components/messages/action-buttons/simple-action-buttons.css
+38 -34
@@ -1,52 +1,55 @@
1 /* Simplified Action Buttons - Keeping the Great Look & Feel */
2
3 -/* Main action buttons container - matches original positioning */
3 +/* Main action buttons container - precise positioning */
4 .action-buttons {
5 - position: sticky;
6 - top: var(--spacing-sm);
5 + position: absolute;
6 + top: 0.3em;
7 right: 0;
8 - display: flex;
9 - align-items: center;
10 - gap: var(--spacing-xs);
11 - opacity: 0;
8 + display: none;
9 + flex-direction: row;
10 + gap: 0;
11 + background: var(--color-panel);
12 + border: 1px solid var(--color-border);
13 + border-radius: 6px;
14 transition: opacity var(--transition-speed) ease-in-out;
15 z-index: 10;
14 - align-self: flex-start;
15 - margin-left: auto;
16 - margin-top: var(--spacing-sm);
17 - margin-right: var(--spacing-xs);
16 + overflow: hidden;
17 }
18
20 -/* Individual action button - exact same styling as original */
19 +/* Individual action button - precise hit area */
20 .action-button {
22 - position: relative;
21 display: flex;
22 align-items: center;
23 justify-content: center;
26 - width: 32px;
27 - height: 32px;
28 - background: var(--color-panel);
29 - border: 1px solid var(--color-border);
30 - border-radius: 6px;
24 + width: 26px;
25 + height: 26px;
26 + background: transparent;
27 + border: none;
28 cursor: pointer;
32 - transition: all var(--transition-speed) ease-in-out;
29 + transition: background-color var(--transition-speed) ease-in-out;
30 color: var(--color-text);
31 padding: 0;
35 - font-size: 16px;
32 + font-size: 14px;
33 opacity: 0.7;
34 + margin: 0;
35 +}
36 +
37 +.action-button:first-child {
38 + border-radius: 5px 0 0 5px;
39 +}
40 +
41 +.action-button:last-child {
42 + border-radius: 0 5px 5px 0;
43 }
44
45 .action-button:hover {
46 opacity: 1;
47 background: var(--color-background);
42 - border-color: var(--color-primary);
43 - transform: translateY(-1px);
44 - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
48 + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.2);
49 }
50
51 .action-button:active {
48 - transform: translateY(0);
49 - box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1);
52 + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
53 }
54
55 /* Material icons - same as original */
@@ -96,14 +99,15 @@
99 .msg-content:hover .action-buttons,
100 .kvps-row:hover .action-buttons,
101 .message-text:hover .action-buttons {
99 - opacity: 1;
102 + display: flex;
103 + animation: fadeInAfterDelay 0.3s ease-in-out;
104 + animation-delay: 0.3s;
105 + animation-fill-mode: forwards;
106 + opacity: 0;
107 }
108
102 -/* Ensure message containers are positioned for sticky children */
103 -.msg-content,
104 -.kvps-row,
105 -.message-text {
106 - position: relative;
107 - display: flex;
108 - flex-direction: column;
109 -}
\ No newline at end of file
109 +/* Animation to fade in action buttons after delay */
110 +@keyframes fadeInAfterDelay {
111 + 0% { opacity: 0; }
112 + 100% { opacity: 1; }
113 +}
webui/components/messages/action-buttons/simple-action-buttons.js
+111 -114
@@ -3,121 +3,118 @@ import { store as speechStore } from "/components/chat/speech/speech-store.js";
3
4 // Extract text content from different message types
5 function getTextContent(element) {
6 - if (element.classList.contains("kvps-row")) {
7 - return element.querySelector(".kvps-val")?.innerText || "";
8 - } else if (element.classList.contains("message-text")) {
9 - return element.querySelector("pre")?.innerText || element.querySelector("span")?.innerText || "";
10 - } else {
11 - return element.querySelector("span")?.innerText || "";
12 - }
6 + if (element.classList.contains("kvps-row")) {
7 + return element.querySelector(".kvps-val")?.innerText || "";
8 + } else if (element.classList.contains("message-text")) {
9 + return (
10 + element.querySelector("pre")?.innerText ||
11 + element.querySelector("span")?.innerText ||
12 + ""
13 + );
14 + } else {
15 + return element.querySelector("span")?.innerText || "";
16 + }
17 }
18
19 // Create and add action buttons to element
20 export function addActionButtonsToElement(element) {
17 - // Skip if buttons already exist
18 - if (element.querySelector(".action-buttons")) return;
19 -
20 - // Create container with same styling as original
21 - const container = document.createElement("div");
22 - container.className = "action-buttons";
23 -
24 - // Copy button - matches original design
25 - const copyBtn = document.createElement("button");
26 - copyBtn.className = "action-button copy-action";
27 - copyBtn.setAttribute("aria-label", "Copy text");
28 - copyBtn.innerHTML = '<span class="material-symbols-outlined">content_copy</span>';
29 -
30 - copyBtn.onclick = async (e) => {
31 - e.stopPropagation();
32 - const text = getTextContent(element);
33 - const icon = copyBtn.querySelector(".material-symbols-outlined");
34 -
35 - try {
36 - // Try modern clipboard API
37 - if (navigator.clipboard && window.isSecureContext) {
38 - await navigator.clipboard.writeText(text);
39 - } else {
40 - // Fallback for local dev
41 - const textarea = document.createElement("textarea");
42 - textarea.value = text;
43 - textarea.style.position = "fixed";
44 - textarea.style.left = "-999999px";
45 - document.body.appendChild(textarea);
46 - textarea.select();
47 - document.execCommand("copy");
48 - document.body.removeChild(textarea);
49 - }
50 -
51 - // Visual feedback - matches original
52 - icon.textContent = "check";
53 - copyBtn.classList.add("success");
54 - setTimeout(() => {
55 - icon.textContent = "content_copy";
56 - copyBtn.classList.remove("success");
57 - }, 2000);
58 -
59 - } catch (err) {
60 - console.error("Copy failed:", err);
61 - icon.textContent = "error";
62 - copyBtn.classList.add("error");
63 - setTimeout(() => {
64 - icon.textContent = "content_copy";
65 - copyBtn.classList.remove("error");
66 - }, 2000);
67 - }
68 - };
69 -
70 - // Speak button - matches original design
71 - const speakBtn = document.createElement("button");
72 - speakBtn.className = "action-button speak-action";
73 - speakBtn.setAttribute("aria-label", "Speak text");
74 - speakBtn.innerHTML = '<span class="material-symbols-outlined">volume_up</span>';
75 -
76 - speakBtn.onclick = async (e) => {
77 - e.stopPropagation();
78 - const text = getTextContent(element);
79 - const icon = speakBtn.querySelector(".material-symbols-outlined");
80 -
81 - if (!text || text.trim().length === 0) return;
82 -
83 - try {
84 - // Check if already speaking and stop it
85 - if (speechStore.isSpeaking) {
86 - if (speechStore.stop) speechStore.stop();
87 - else speechSynthesis.cancel();
88 - return;
89 - }
90 -
91 - // Visual feedback - matches original
92 - speakBtn.classList.add("speaking");
93 - icon.textContent = "stop_circle";
94 -
95 - // Use speech store if available, otherwise browser TTS
96 - if (speechStore?.speak) {
97 - const cleanedText = speechStore.cleanText ? speechStore.cleanText(text) : text;
98 - await speechStore.speak(cleanedText);
99 - } else {
100 - const utterance = new SpeechSynthesisUtterance(text);
101 - utterance.lang = 'en-US';
102 - utterance.onend = () => {
103 - icon.textContent = "volume_up";
104 - speakBtn.classList.remove("speaking");
105 - };
106 - speechSynthesis.speak(utterance);
107 - return; // Don't reset immediately for browser TTS
108 - }
109 -
110 - // Reset for speech store
111 - icon.textContent = "volume_up";
112 - speakBtn.classList.remove("speaking");
113 -
114 - } catch (err) {
115 - console.error("Speech failed:", err);
116 - icon.textContent = "volume_up";
117 - speakBtn.classList.remove("speaking");
118 - }
119 - };
120 -
121 - container.append(copyBtn, speakBtn);
122 - element.appendChild(container);
123 -}
\ No newline at end of file
21 + // Skip if buttons already exist
22 + if (element.querySelector(".action-buttons")) return;
23 +
24 + // Create container with same styling as original
25 + const container = document.createElement("div");
26 + container.className = "action-buttons";
27 +
28 + // Copy button - matches original design
29 + const copyBtn = document.createElement("button");
30 + copyBtn.className = "action-button copy-action";
31 + copyBtn.setAttribute("aria-label", "Copy text");
32 + copyBtn.innerHTML =
33 + '<span class="material-symbols-outlined">content_copy</span>';
34 +
35 + copyBtn.onclick = async (e) => {
36 + e.stopPropagation();
37 +
38 + // Check if the button container is still fading in (opacity < 0.5)
39 + if (parseFloat(window.getComputedStyle(container).opacity) < 0.5) return; // Don't proceed if still fading in
40 +
41 + const text = getTextContent(element);
42 + const icon = copyBtn.querySelector(".material-symbols-outlined");
43 +
44 + try {
45 + // Try modern clipboard API
46 + if (navigator.clipboard && window.isSecureContext) {
47 + await navigator.clipboard.writeText(text);
48 + } else {
49 + // Fallback for local dev
50 + const textarea = document.createElement("textarea");
51 + textarea.value = text;
52 + textarea.style.position = "fixed";
53 + textarea.style.left = "-999999px";
54 + document.body.appendChild(textarea);
55 + textarea.select();
56 + document.execCommand("copy");
57 + document.body.removeChild(textarea);
58 + }
59 +
60 + // Visual feedback
61 + icon.textContent = "check";
62 + copyBtn.classList.add("success");
63 + setTimeout(() => {
64 + icon.textContent = "content_copy";
65 + copyBtn.classList.remove("success");
66 + }, 2000);
67 + } catch (err) {
68 + console.error("Copy failed:", err);
69 + icon.textContent = "error";
70 + copyBtn.classList.add("error");
71 + setTimeout(() => {
72 + icon.textContent = "content_copy";
73 + copyBtn.classList.remove("error");
74 + }, 2000);
75 + }
76 + };
77 +
78 + // Speak button - matches original design
79 + const speakBtn = document.createElement("button");
80 + speakBtn.className = "action-button speak-action";
81 + speakBtn.setAttribute("aria-label", "Speak text");
82 + speakBtn.innerHTML =
83 + '<span class="material-symbols-outlined">volume_up</span>';
84 +
85 + speakBtn.onclick = async (e) => {
86 + e.stopPropagation();
87 +
88 + // Check if the button container is still fading in (opacity < 0.5)
89 + if (parseFloat(window.getComputedStyle(container).opacity) < 0.5) return; // Don't proceed if still fading in
90 +
91 + const text = getTextContent(element);
92 + const icon = speakBtn.querySelector(".material-symbols-outlined");
93 +
94 + if (!text || text.trim().length === 0) return;
95 +
96 + try {
97 + // Visual feedback
98 + icon.textContent = "check";
99 + speakBtn.classList.add("success");
100 + setTimeout(() => {
101 + icon.textContent = "volume_up";
102 + speakBtn.classList.remove("success");
103 + }, 2000);
104 +
105 + // Use speech store
106 + await speechStore.speak(text);
107 + } catch (err) {
108 + console.error("Speech failed:", err);
109 + icon.textContent = "error";
110 + speakBtn.classList.add("error");
111 + setTimeout(() => {
112 + icon.textContent = "volume_up";
113 + speakBtn.classList.remove("error");
114 + }, 2000);
115 + }
116 + };
117 +
118 + container.append(copyBtn, speakBtn);
119 + element.appendChild(container);
120 +}
webui/css/messages.css
+15 -4
@@ -1,5 +1,6 @@
1 /* Chat History */
2 #chat-history {
3 + position: relative;
4 display: -webkit-flex;
5 display: flex;
6 flex-direction: column;
@@ -79,20 +80,25 @@
80 .message-user {
81 background-color: #4a4a4a;
82 /* border-bottom-right-radius: var(--spacing-xxs); */
82 - min-width: 195px;
83 + /* min-width: 195px; */
84 text-align: end;
85 }
86
87 .message-user > div {
88 padding-top: var(--spacing-xs);
88 - font-family: "Roboto Mono", monospace;
89 + /* font-family: "Roboto Mono", monospace; */
90 font-optical-sizing: auto;
91 -webkit-font-optical-sizing: auto;
91 - font-size: var(--font-size-small);
92 + font-size: var(--font-size-smaller);
93 }
94
94 -.message-user .message-text{
95 +.message-user .message-text {
96 text-align: start;
97 + font-family: var(--font-family-main);
98 +}
99 +
100 +.message-user .message-text pre {
101 + font-family: var(--font-family-main);
102 }
103
104 .message-ai {
@@ -207,6 +213,7 @@
213 border-radius: 0.5em;
214 margin-top: 0.5em;
215 padding: 0.3em;
216 + font-family: var(--font-family-code);
217 }
218
219 /* Agent and AI Info */
@@ -227,6 +234,10 @@
234 .kvps-val pre {
235 white-space: pre-wrap; /* keep \n, collapse no spaces, allow wrapping */
236 word-break: break-word; /* optional – forces really long “words” to break */
237 + font-family: var(--font-family-code);
238 + font-optical-sizing: auto;
239 + -webkit-font-optical-sizing: auto;
240 + font-size: 0.75rem;
241 }
242
243 .msg-kvps th,
webui/index.css
+13 -16
@@ -60,6 +60,10 @@
60 --font-size-normal: 1rem;
61 --font-size-large: 1.2rem;
62
63 + /* Font families */
64 + --font-family-main: "Rubik", Arial, Helvetica, sans-serif;
65 + --font-family-code: "Roboto Mono", monospace;
66 +
67 /* Other variables */
68 --border-radius: 1.125rem;
69 --transition-speed: 0.3s;
@@ -88,7 +92,7 @@ body,
92 html {
93 background-color: var(--color-background);
94 color: var(--color-text);
91 - font-family: "Rubik", Arial, Helvetica, sans-serif;
95 + font-family: var(--font-family-main);
96 width: 100%;
97 height: 100%;
98 min-width: 320px !important;
@@ -464,13 +468,6 @@ h4 {
468 user-select: all;
469 }
470
467 -pre {
468 - font-family: "Roboto Mono", monospace;
469 - font-optical-sizing: auto;
470 - -webkit-font-optical-sizing: auto;
471 - font-size: 0.75rem;
472 -}
473 -
471
472 /* Logo Container */
473 #logo-container {
@@ -1605,18 +1602,18 @@ input:checked + .slider:before {
1602 flex-shrink: 0; /* prevents SVG from shrinking */
1603 }
1604
1608 - .copy-button {
1605 + /* .copy-button {
1606 display: none !important;
1610 - }
1607 + } */
1608
1612 - .msg-content span,
1609 + /* .msg-content span,
1610 .kvps-val,
1611 .message-text span {
1612 cursor: pointer;
1613 position: relative;
1617 - }
1614 + } */
1615
1619 - .msg-thoughts span::after,
1616 + /* .msg-thoughts span::after,
1617 .msg-content span::after,
1618 .kvps-val::after,
1619 .message-text::after {
@@ -1634,14 +1631,14 @@ input:checked + .slider:before {
1631 border: none;
1632 border-radius: 5px;
1633 color: inherit;
1637 - }
1634 + } */
1635
1639 - .msg-thoughts span.copied::after,
1636 + /* .msg-thoughts span.copied::after,
1637 .msg-content span.copied::after,
1638 .kvps-val.copied::after,
1639 .message-text.copied::after {
1640 opacity: 1;
1644 - }
1641 + } */
1642 }
1643
1644 @media (min-width: 768px) {
webui/js/messages.js
+1 -33
@@ -242,11 +242,6 @@ export function _drawMessage(
242 if (!spanElement) {
243 spanElement = document.createElement("span");
244 preElement.appendChild(spanElement);
245 -
246 - // Add click handler for small screens (only once)
247 - spanElement.addEventListener("click", () => {
248 - copyText(spanElement.textContent, spanElement);
249 - });
245 }
246
247 // reapply scroll position or autoscroll
@@ -429,11 +424,6 @@ export function drawMessageUser(
424 spanElement.innerHTML = escapeHTML(content);
425 textDiv.appendChild(spanElement);
426
432 - // Add click handler
433 - textDiv.addEventListener("click", () => {
434 - copyText(content, textDiv);
435 - });
436 -
427 addActionButtonsToElement(textDiv);
428 messageDiv.appendChild(textDiv);
429 }
@@ -735,12 +725,7 @@ function drawKvps(container, kvps, latex) {
725 span.innerHTML = convertHTML(value);
726 pre.appendChild(span);
727 tdiv.appendChild(pre);
738 - addCopyButtonToElement(row);
739 -
740 - // Add click handler
741 - span.addEventListener("click", () => {
742 - copyText(span.textContent, span);
743 - });
728 + addActionButtonsToElement(row);
729
730 // KaTeX rendering for markdown
731 if (latex) {
@@ -861,11 +846,6 @@ function drawKvpsIncremental(container, kvps, latex) {
846 addActionButtonsToElement(row);
847 }
848
864 - // Add click handler
865 - span.addEventListener("click", () => {
866 - copyText(span.textContent, span);
867 - });
868 -
849 // KaTeX rendering for markdown
850 if (latex) {
851 span.querySelectorAll("latex").forEach((element) => {
@@ -909,18 +889,6 @@ function convertImageTags(content) {
889 return updatedContent;
890 }
891
912 -async function copyText(text, element) {
913 - try {
914 - await navigator.clipboard.writeText(text);
915 - element.classList.add("copied");
916 - setTimeout(() => {
917 - element.classList.remove("copied");
918 - }, 2000);
919 - } catch (err) {
920 - console.error("Failed to copy text:", err);
921 - }
922 -}
923 -
892 function convertHTML(str) {
893 if (typeof str !== "string") str = JSON.stringify(str, null, 2);
894