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