user message attachments progress

frdel committed Jan 21, 2026 at 16:47 UTC 060f631f3ea64ec6c076a788b0248b8bb80f4476
8 files changed +74 -107
python/api/api_message.py
+1 -1
@@ -136,7 +136,7 @@ class ApiMessage(ApiHandler):
136 # Add user message to chat history so it's visible in the UI
137 context.log.log(
138 type="user",
139 - heading="User message",
139 + heading="",
140 content=message,
141 kvps={"attachments": attachment_filenames},
142 )
python/api/message.py
+1 -1
@@ -84,7 +84,7 @@ class Message(ApiHandler):
84 # Log the message with message_id and attachments
85 context.log.log(
86 type="user",
87 - heading="User message",
87 + heading="",
88 content=message,
89 kvps={"attachments": attachment_filenames},
90 id=message_id,
python/helpers/task_scheduler.py
+1 -1
@@ -874,7 +874,7 @@ class TaskScheduler:
874 # Log the message with message_id and attachments
875 context.log.log(
876 type="user",
877 - heading="User message",
877 + heading="",
878 content=task_prompt,
879 kvps={"attachments": attachment_filenames},
880 id=str(uuid.uuid4()),
webui/components/messages/process-group/process-group.css
+2 -2
@@ -634,7 +634,7 @@
634 -webkit-overflow-scrolling: touch;
635 scrollbar-width: thin;
636 scrollbar-color: rgba(255,255,255,0.2) transparent;
637 - overscroll-behavior: contain;
637 + overscroll-behavior-x: contain;
638 }
639
640 .process-step-detail-content .terminal-output::-webkit-scrollbar {
@@ -777,7 +777,7 @@
777 }
778
779 .process-step-detail-content {
780 - margin-left: 16px;
780 + margin-left: 0;
781 }
782 }
783
webui/css/messages.css
+6
@@ -77,6 +77,12 @@
77 width: 100%;
78 }
79
80 +.message-user .message-user-heading {
81 + margin-top: var(--spacing-sm);
82 + font-size: var(--font-size-smaller);
83 + color: var(--color-text-muted);
84 +}
85 +
86 .message-user .message-text {
87 display: inline-block;
88 padding: var(--spacing-sm);
webui/index.css
+8 -8
@@ -1029,7 +1029,7 @@ input:checked + .slider:before {
1029 gap: 6px;
1030 padding: var(--spacing-xs);
1031 }
1032 -
1032 +/*
1033 .preview-item,
1034 .attachment-item {
1035 width: 90px;
@@ -1047,24 +1047,24 @@ input:checked + .slider:before {
1047 width: 32px;
1048 height: 32px;
1049 margin-bottom: 4px;
1050 - }
1050 + } */
1051
1052 - .preview-item.file-preview .filename,
1052 + /* .preview-item.file-preview .filename,
1053 .attachment-item.file-type .file-title,
1054 .attachment-name,
1055 .filename {
1056 font-size: 0.65rem;
1057 -webkit-line-clamp: 1;
1058 - }
1058 + } */
1059
1060 - .preview-item.file-preview .extension,
1060 + /* .preview-item.file-preview .extension,
1061 .attachment-item.file-type .file-extension,
1062 .attachment-item.image-type .image-badge,
1063 .attachment-ext,
1064 .extension {
1065 font-size: 0.6rem;
1066 padding: 1px 4px;
1067 - }
1067 + } */
1068
1069 /* .remove-attachment,
1070 .remove-image {
@@ -1074,11 +1074,11 @@ input:checked + .slider:before {
1074 right: 2px;
1075 } */
1076
1077 - .attachment-item.image-type .image-badge {
1077 + /* .attachment-item.image-type .image-badge {
1078 bottom: 4px;
1079 font-size: 0.55rem;
1080 padding: 1px 6px;
1081 - }
1081 + } */
1082 }
1083
1084 @media (max-width: 640px) {
webui/index.js
+1 -1
@@ -63,7 +63,7 @@ export async function sendMessage() {
63 const heading =
64 attachmentsWithUrls.length > 0
65 ? "Uploading attachments..."
66 - : "User message";
66 + : "";
67
68 // Render user message with attachments
69 setMessage(messageId, "user", heading, message, false, {
webui/js/messages.js
+54 -93
@@ -121,9 +121,8 @@ export function setMessage(id, type, heading, content, temp, kvps = null, timest
121 let messageContainer = document.getElementById(`message-${id}`);
122 let processStepElement = document.getElementById(`process-step-${id}`);
123
124 - // For user/info messages, close current process group FIRST (start fresh for next interaction)
125 - // Info messages (like nudge) should also split groups to mark a new processing phase
126 - if (type === "user" || type === "info") {
124 + // For user messages, close current process group FIRST (start fresh for next interaction)
125 + if (type === "user") {
126 currentProcessGroup = null;
127 currentDelegationSteps = {}; // Clear delegation tracking
128 }
@@ -336,8 +335,8 @@ export function _drawMessage(
335 });
336 }
337
339 - // Ensure action buttons exist - pass content directly
340 - addActionButtonsToElement(bodyDiv, { contentRef: content });
338 + // Ensure action buttons exist
339 + // addActionButtonsToElement(bodyDiv);
340 adjustMarkdownRender(contentDiv);
341
342 } else {
@@ -361,8 +360,8 @@ export function _drawMessage(
360
361 spanElement.innerHTML = convertHTML(content);
362
364 - // Ensure action buttons exist - pass content directly
365 - addActionButtonsToElement(bodyDiv, { contentRef: content });
363 + // Ensure action buttons exist
364 + // addActionButtonsToElement(bodyDiv);
365
366 }
367 } else {
@@ -531,12 +530,6 @@ export function drawMessageUser(
530 messageDiv.className = "message message-user";
531 }
532
534 - // Remove heading element if it exists (user messages no longer show label per target design)
535 - let headingElement = messageDiv.querySelector(".msg-heading");
536 - if (headingElement) {
537 - headingElement.remove();
538 - }
539 -
533 // Handle content
534 let textDiv = messageDiv.querySelector(".message-text");
535 if (content && content.trim().length > 0) {
@@ -551,6 +544,7 @@ export function drawMessageUser(
544 textDiv.appendChild(spanElement);
545 }
546 spanElement.innerHTML = escapeHTML(content);
547 + // addActionButtonsToElement(textDiv);
548 } else {
549 if (textDiv) textDiv.remove();
550 }
@@ -614,8 +608,18 @@ export function drawMessageUser(
608 if (attachmentsContainer) attachmentsContainer.remove();
609 }
610
617 - // Add action buttons below text and attachments (hover for pointer, always for touch - via CSS)
618 - addActionButtonsToElement(messageDiv, { contentRef: content });
611 + // Render heading below message, if provided
612 + let headingElement = messageDiv.querySelector(".message-user-heading");
613 + if (heading && heading.trim() && heading.trim() !== "User message") {
614 + if (!headingElement) {
615 + headingElement = document.createElement("div");
616 + headingElement.className = "message-user-heading shiny-text";
617 + }
618 + headingElement.textContent = heading;
619 + messageDiv.appendChild(headingElement);
620 + } else if (headingElement) {
621 + headingElement.remove();
622 + }
623 // The messageDiv is already appended or updated, no need to append again
624 }
625
@@ -853,7 +857,7 @@ export function drawMessageError(
857 messageDiv.appendChild(errorGroup);
858
859 // Check detail mode and expand if needed
856 - const detailMode = preferencesStore.detailMode || "current";
860 + const detailMode = window.Alpine?.store("preferences")?.detailMode || "current";
861 if (detailMode === "current" || detailMode === "expanded") {
862 errorGroup.classList.add("expanded");
863 }
@@ -1427,27 +1431,19 @@ function addProcessStep(group, id, type, heading, content, kvps, timestamp = nul
1431 // Get step info from heading (single source of truth: backend)
1432 const title = getStepTitle(heading, kvps, type);
1433
1430 - // Determine if this new step should be expanded
1431 - const detailMode = preferencesStore.detailMode;
1432 - let shouldExpand = false;
1434 + // Check if step should be expanded
1435 + const isActiveStep = !isGroupCompleted && group.id === activeProcessGroupId;
1436 + const isStepExpanded = processGroupStore.expandStep(groupId, id, isActiveStep);
1437
1434 - if (detailMode === "expanded") {
1435 - shouldExpand = true;
1436 - } else if (detailMode === "current" && !isGroupCompleted) {
1437 - // In "current" mode: expand new step, delay-collapse previous
1438 - shouldExpand = true;
1439 -
1440 - // Find the currently expanded step (the previous one) to collapse after delay
1441 - const previousExpandedStep = stepsContainer.querySelector(".process-step.step-expanded");
1442 - if (previousExpandedStep) {
1443 - setTimeout(() => {
1444 - previousExpandedStep.classList.remove("step-expanded");
1445 - }, 2000);
1446 - }
1438 + // In "current" mode, collapse all other steps
1439 + const detailMode = preferencesStore.detailMode;
1440 + if (detailMode === "current" && isStepExpanded) {
1441 + document.querySelectorAll(".process-step.step-expanded").forEach(s => {
1442 + s.classList.remove("step-expanded");
1443 + });
1444 }
1448 - // In "collapsed" mode: shouldExpand stays false
1445
1450 - if (shouldExpand) {
1446 + if (isStepExpanded) {
1447 step.classList.add("step-expanded");
1448 }
1449
@@ -1507,9 +1503,9 @@ function addProcessStep(group, id, type, heading, content, kvps, timestamp = nul
1503 toolName: toolNameToUse
1504 };
1505
1510 - // Add step action buttons (view details, copy, speak) for full modal view
1511 - const stepActionBtns = createStepActionButtons(step);
1512 - detail.appendChild(stepActionBtns);
1506 + // Add "View Details" button for full modal view (reads fresh data from step._stepData)
1507 + const viewDetailsBtn = createViewDetailsButton(step);
1508 + detail.appendChild(viewDetailsBtn);
1509
1510 step.appendChild(detail);
1511
@@ -1591,15 +1587,9 @@ function updateProcessStep(stepElement, id, type, heading, content, kvps, durati
1587 let skipFullRender = false;
1588
1589 if (detailContent) {
1594 - // Capture scroll state before re-render
1590 + // Capture scroll state before re-render (uses existing Scroller pattern)
1591 const terminal = detailContent.querySelector(".terminal-output");
1596 - let wasAtBottom = true; // Default to true for new terminals
1597 - let previousScrollTop = 0;
1598 - if (terminal) {
1599 - const distanceFromBottom = terminal.scrollHeight - terminal.clientHeight - terminal.scrollTop;
1600 - wasAtBottom = distanceFromBottom <= 10;
1601 - previousScrollTop = terminal.scrollTop;
1602 - }
1592 + const scroller = terminal ? new Scroller(terminal) : null;
1593
1594 // For browser, update image src incrementally to avoid flashing
1595 if (type === "browser" && kvps?.screenshot) {
@@ -1618,17 +1608,10 @@ function updateProcessStep(stepElement, id, type, heading, content, kvps, durati
1608 if (!skipFullRender) {
1609 renderStepDetailContent(detailContent, content, kvps, type);
1610
1621 - // Restore scroll position after re-render
1611 + // Re-apply scroll (stays at bottom if was at bottom)
1612 const newTerminal = detailContent.querySelector(".terminal-output");
1623 - if (newTerminal) {
1624 - if (wasAtBottom) {
1625 - // Pin to bottom if was at bottom
1626 - newTerminal.scrollTop = newTerminal.scrollHeight;
1627 - } else {
1628 - // Restore previous position (clamped to max)
1629 - const maxScroll = Math.max(newTerminal.scrollHeight - newTerminal.clientHeight, 0);
1630 - newTerminal.scrollTop = Math.min(previousScrollTop, maxScroll);
1631 - }
1613 + if (newTerminal && scroller?.wasAtBottom) {
1614 + newTerminal.scrollTop = newTerminal.scrollHeight;
1615 }
1616 }
1617 }
@@ -1657,20 +1640,17 @@ function updateProcessStep(stepElement, id, type, heading, content, kvps, durati
1640 * Get a concise title for a process step
1641 */
1642 function getStepTitle(heading, kvps, type) {
1660 - // Frontend-only: hide distracting leading session index like "[0] " in titles
1661 - const stripSessionPrefix = (s) => String(s || "").replace(/^\s*\[\d+\]\s*/, "");
1662 -
1643 // code_exe: show command when finished
1644 const showCommand = type === "code_exe" && kvps?.code &&
1645 /done_all|code_execution_tool/.test(heading || "");
1646 if (showCommand) {
1667 - return `${kvps.runtime || "bash"}> ${kvps.code.trim()}`;
1647 + const s = kvps.session ?? kvps.Session;
1648 + return `${s != null ? `[${s}] ` : ""}${kvps.runtime || "bash"}> ${kvps.code.trim()}`;
1649 }
1650
1651 // Try to get a meaningful title from heading or kvps
1652 if (heading && heading.trim()) {
1672 - const cleaned = cleanStepTitle(heading, 100);
1673 - return type === "code_exe" ? stripSessionPrefix(cleaned) : cleaned;
1653 + return cleanStepTitle(heading, 100);
1654 }
1655
1656 // For warnings/errors without heading, use content preview as title
@@ -1977,37 +1957,26 @@ function renderThoughts(container, value) {
1957 }
1958
1959 /**
1980 - * Create step action buttons (view details, copy, speak) using unified action buttons
1960 + * Create "View Details" button for opening step detail modal
1961 * @param {HTMLElement} stepElement - The step DOM element containing _stepData property
1962 */
1983 -function createStepActionButtons(stepElement) {
1963 +function createViewDetailsButton(stepElement) {
1964 const btnContainer = document.createElement("div");
1965 btnContainer.classList.add("step-detail-actions");
1966
1987 - // Use unified action buttons with step-specific options
1988 - addActionButtonsToElement(btnContainer, {
1989 - contentRef: () => {
1990 - // Get text content from step data at action time
1991 - const data = stepElement._stepData || {};
1992 - const parts = [];
1993 - if (data.heading) parts.push(data.heading);
1994 - if (data.content) parts.push(data.content);
1995 - if (data.kvps) {
1996 - for (const [key, value] of Object.entries(data.kvps)) {
1997 - if (key === "reasoning" || key === "finished" || key === "attachments") continue;
1998 - const valStr = typeof value === "object" ? JSON.stringify(value, null, 2) : String(value);
1999 - parts.push(`${key}: ${valStr}`);
2000 - }
2001 - }
2002 - return parts.join("\n\n");
2003 - },
2004 - onViewDetails: () => {
2005 - // Read fresh data from the step element at click time
2006 - const freshData = stepElement._stepData || {};
2007 - stepDetailStore.showStepDetail(freshData);
2008 - }
1967 + const btn = document.createElement("button");
1968 + btn.classList.add("btn", "text-button");
1969 + btn.innerHTML = '<span class="material-symbols-outlined">open_in_full</span> View Details';
1970 + btn.title = "Open full step details in modal";
1971 +
1972 + btn.addEventListener("click", (e) => {
1973 + e.stopPropagation();
1974 + // Read fresh data from the step element at click time
1975 + const freshData = stepElement._stepData || {};
1976 + stepDetailStore.showStepDetail(freshData);
1977 });
1978
1979 + btnContainer.appendChild(btn);
1980 return btnContainer;
1981 }
1982
@@ -2190,14 +2159,6 @@ function markProcessGroupComplete(group, responseTitle) {
2159 // Add completed class to group
2160 group.classList.add("process-group-completed");
2161
2193 - // Collapse the last expanded step when processing is done (in "current" mode)
2194 - const detailMode = preferencesStore.detailMode;
2195 - if (detailMode === "current") {
2196 - const expandedStep = group.querySelector(".process-step.step-expanded");
2197 - if (expandedStep) {
2198 - expandedStep.classList.remove("step-expanded");
2199 - }
2200 - }
2162
2163 // Calculate final duration from backend data (sum of all step durations)
2164 const steps = group.querySelectorAll(".process-step");