From 028c41809da6123a8e95c93996a00bb3b46078b4 Mon Sep 17 00:00:00 2001 From: witbrock Date: Sun, 13 Sep 2026 19:54:18 -0400 Subject: [PATCH] Compact conversation actions and unify steering submission controls --- .../engineering/chat_steering_and_queueing.md | 53 ++++++-- .../web/von_interface/static/js/chatTab.js | 46 +++---- .../js/components/chatSteeringControls.js | 128 +++++++++++++++++- .../static/js/components/compactComposer.js | 14 +- .../js/components/conversationActions.js | 24 ++++ .../static/js/components/messagePanel.js | 13 +- .../web/von_interface/static/styles.css | 47 +++++++ .../web/von_interface/templates/chat_tab.html | 89 ++++++------ tests/browser/conversationActions.cjs | 128 ++++++++++++++++++ tests/frontend/chatSteeringControls.test.js | 81 +++++++++++ 10 files changed, 527 insertions(+), 96 deletions(-) create mode 100644 src/frontend/web/von_interface/static/js/components/conversationActions.js create mode 100644 tests/browser/conversationActions.cjs diff --git a/docs/engineering/chat_steering_and_queueing.md b/docs/engineering/chat_steering_and_queueing.md index afe29b1a8..482a13e3f 100644 --- a/docs/engineering/chat_steering_and_queueing.md +++ b/docs/engineering/chat_steering_and_queueing.md @@ -4,7 +4,7 @@ - **Lifecycle:** Active - **Scope:** Ordinary adaptive chat turns; text steering and existing prompt queue - **Owner:** Von maintainers -- **Last reviewed:** 11 September 2026 +- **Last reviewed:** 13 September 2026 - **Review trigger:** Changes to turn admission, model continuation or composer submission “Q” means queueing here. The existing chat code provides a durable FIFO prompt @@ -19,17 +19,32 @@ authority. No represented workflow or new prompt policy is needed. ## Interaction -During a running turn, **Steer** sends composer text to that exact attempt. -**Queue Prompt** (including the ordinary Enter/send action) continues to submit -an independent FIFO request. Steering neither consumes nor reorders the queue. -When idle, the ordinary Send Prompt action starts a turn. - -The composer shows pending steering and allows **Cancel steer** until the active -turn has taken it. After delivery, withdrawal is rejected: another steer can -correct it, or the existing Stop action can request turn cancellation. Neither -steering nor Stop claims to undo completed tool effects. Steering waits for the -next model boundary, including completion of a running tool batch; it does not -interrupt an in-flight provider request or tool execution. +The icon-only submit arrow uses a saved **Queue / Steering** default while Von +is working. Queue remains the initial default. The setting is in **More actions** +and explicitly applies to this browser and signed-in user, with an in-memory +fallback when storage is unavailable. The current user-preferences endpoint +supports language settings, so this slice does not claim cross-device sync. +Only an explicit settings choice changes the preference; Shift-click selects the +opposite action once. Idle submit remains ordinary Send. Desktop Enter follows +the default, Shift+Enter inserts a newline, and compact/touch Enter remains a +newline with IME protection. More actions exposes named one-shot steering and +queue buttons for keyboard and touch access. + +The arrow has a curved variant for steering and a queue mark for queueing, with +an accessible action name and explanatory tooltip/help. Unavailable steering +retains the draft and explains the explicit Queue alternative. Steering never +silently becomes queued work. Activation captures the exact current attempt; +background queue dispatch keeps its existing path. + +Short, polite overlay notices announce steering transitions without consuming +draft width. **More actions → Steering activity** retains receipt text and +**Cancel steer** for pending guidance. A dot on More actions indicates pending +work or an error. Unchanged polling does not repeat notices or replace focused +receipt controls. Errors remain in Activity after the notice expires. After +delivery, withdrawal is rejected: another steer can correct it, or the existing +Stop action can request cancellation. Neither action undoes completed tools. +Steering waits for the next model boundary, including completion of a running +tool batch; it does not interrupt an in-flight provider request or tool execution. Text-only steering is disabled while attachments or unfinished dictation are present. The established queue/send path handles those inputs. Imported @@ -95,3 +110,17 @@ The real Flask generation route is exercised with a test model and mock storage; no live model is required. A Chromium fixture uses the repository composer markup and styles at 375px to exercise submit, pending feedback and cancellation. This is fixture-backed acceptance, not authenticated public-server validation. + +Conversation headers use a native **Conversation actions** disclosure with +ordinary Tab navigation, Escape dismissal and focus return. Chat context remains +its existing disclosure; secondary situation, task, invitation, export and +profile actions have named entries. Imports remain in conversation navigation's +new-conversation menu. The message-exchange menu preserves Refresh, referenced +tasks and the distinct participant/own profile destinations. + +`tests/browser/conversationActions.cjs` exercises production template/styles and +chat steering, compact composer and message-panel modules at desktop, phone and +landscape sizes with deterministic API fixtures. It checks long-receipt draft +width, action reachability, preferences, one-shot alternatives, cancellation, +keyboard dismissal and exchange menu bounds. This is local fixture-backed UI +evidence, not public authentication, physical-device or deployment acceptance. diff --git a/src/frontend/web/von_interface/static/js/chatTab.js b/src/frontend/web/von_interface/static/js/chatTab.js index e5031fac4..a1fe45a69 100644 --- a/src/frontend/web/von_interface/static/js/chatTab.js +++ b/src/frontend/web/von_interface/static/js/chatTab.js @@ -1,3 +1,4 @@ +import { initialiseConversationActions } from './components/conversationActions.js'; import { createLatestMessageButton, setNavigationVisible, focusConversationTarget } from './components/conversationNavigation.js'; import { initialiseCompactChatComposer, isCompactComposer, resizeCompactDraft, shouldSubmitComposerKey } from './components/compactComposer.js'; import { updateExecutionCost } from './components/executionCost.js'; @@ -6694,6 +6695,7 @@ function updateSendButtonForCurrentChatState() { return; } updateSendButtonDraftReadiness(); + sendButton.dataset.submitMode = 'send'; const chatCreationInFlight = Boolean(newChatCreationInFlight); const uploadInFlight = !!getInFlightUploadStateForSession(activeChatSessionId); const imagePreparationBlocked = imagesBlocked(activeChatSessionId); @@ -6739,7 +6741,9 @@ function updateSendButtonForCurrentChatState() { const input = getPromptInputElement(); if (input?.value === submitted) setPromptComposerValue('', { promptInput: input }); }, - createId: createClientRequestId + createId: createClientRequestId, + onQueue: () => { setSelectedChatPromptQueueEntryId(null); return handleSendPrompt(); }, + onModeChange: updateSendButtonForCurrentChatState }); } const steeringRecord = queuedChatPrompts.find(entry => @@ -6749,7 +6753,10 @@ function updateSendButtonForCurrentChatState() { const steeringTarget = steeringRecord || (liveRequest?.promptQueueRecordId && liveRequest?.attemptId ? { queueId: liveRequest.promptQueueRecordId, attemptId: liveRequest.attemptId } : null); chatSteeringControls.update({ - scopeKey: `${chatOrganisationGeneration}:${activeChatSessionId}`, + scopeKey: `${getCurrentUserConceptId()}:${chatOrganisationGeneration}:${activeChatSessionId}`, + actorKey: getCurrentUserConceptId(), + busy: sessionBusy && !readOnly && !conceptQaSession && !getSelectedChatPromptQueueEntryForSend(), + queueDisabled: sendButton.disabled || pendingChatOrganisationSwitchId !== null, target: readOnly || conceptQaSession ? null : steeringTarget, disabled: sendButton.disabled || pendingChatOrganisationSwitchId !== null || pendingFileCopyConceptIdsBySession.has(getPendingFileCopySessionKey(activeChatSessionId)) @@ -6801,6 +6808,7 @@ function updateSendButtonForCurrentChatState() { ? 'Queue a separate request after the current turn finishes. Use Steer to guide the active turn.' : 'Send Prompt'; } + chatSteeringControls.present(); } function syncActiveChatSessionThinkingState() { @@ -33793,7 +33801,8 @@ export function initializeChatTab() { sessionTabsCache = normaliseConversationSessionViewModels([...new Map([...pinned, ...(selected ? [selected] : []), ...rows.map(row => ({ ...sessionTabsCache.find(previous => previous.session_id === row.session_id), ...row }))].map(row => [row.session_id, row])).values()]); reconcileServerConversationPreferences(sessionTabsCache); }, currentChat: () => sessionTabsCache.find(row => row.session_id === activeChatSessionId) }); - document.querySelector('.chat-header-controls')?.append(profileButton()); + document.querySelector('#conversationActions .conversation-actions-panel')?.append(profileButton()); + initialiseConversationActions(); console.log("Initializing chat tab..."); bindFocusedConversationEvents(); @@ -33894,9 +33903,7 @@ export function initializeChatTab() { const inviteProjectFilter = document.getElementById('inviteProjectFilter'); const incomingInvitesButton = document.getElementById('incomingInvitesBtn'); const incomingInvitesCloseButton = document.getElementById('closeIncomingInvites'); - const importExternalConversationButton = document.getElementById('importExternalConversationBtn'); const importExternalConversationInput = document.getElementById('importExternalConversationInput'); - const importAllExternalConversationsButton = document.getElementById('importAllExternalConversationsBtn'); const continueImportedConversationButton = document.getElementById('continueImportedConversationBtn'); if (!sendButton || !resetButton || !promptInput) { @@ -33949,39 +33956,22 @@ export function initializeChatTab() { renderPendingAttachmentState(); renderActiveUploadUi(); - if (importExternalConversationButton && importExternalConversationInput) { - importExternalConversationButton.addEventListener('click', () => { - importExternalConversationInput.click(); - }); + if (importExternalConversationInput) { importExternalConversationInput.addEventListener('change', async () => { const file = importExternalConversationInput.files?.[0] || null; importExternalConversationInput.value = ''; if (!file) { return; } - importExternalConversationButton.disabled = true; try { await handleExternalConversationFile(file); } catch (error) { console.error('[chatTab] External conversation import failed:', error); showToast(error?.message || 'Unable to import this conversation.', 'error'); - } finally { - importExternalConversationButton.disabled = false; } }); } - if (importAllExternalConversationsButton) { - importAllExternalConversationsButton.addEventListener('click', async () => { - importAllExternalConversationsButton.disabled = true; - try { - await startExternalConversationBulkImport(); - } catch (error) { - console.error('[chatTab] Machine conversation import failed:', error); - showToast(error?.message || 'Unable to start the machine conversation import.', 'error'); - } finally { - importAllExternalConversationsButton.disabled = false; - } - }); + if (document.getElementById('externalConversationBulkImportPanel')) { document.getElementById('pauseExternalConversationBulkImportBtn')?.addEventListener('click', () => { void controlExternalConversationBulkImport('pause').catch((error) => showToast(error?.message || 'Unable to pause import.', 'error')); }); @@ -34293,7 +34283,11 @@ export function initializeChatTab() { initialiseCompactChatComposer(promptInput.closest('.chat-composer'), resizePromptComposer); // Add event listeners - sendButton.addEventListener('click', handleSendPrompt); + const submitComposer = (event) => { + updateSendButtonForCurrentChatState(); + if (!chatSteeringControls?.activate(event)) void handleSendPrompt(); + }; + sendButton.addEventListener('click', submitComposer); resetButton.addEventListener('click', handleResetContext); ensureAbortButtonBound(); @@ -34315,7 +34309,7 @@ export function initializeChatTab() { promptInput.addEventListener('keypress', function (event) { if (shouldSubmitComposerKey(event)) { event.preventDefault(); - handleSendPrompt(); + submitComposer(event); } }); diff --git a/src/frontend/web/von_interface/static/js/components/chatSteeringControls.js b/src/frontend/web/von_interface/static/js/components/chatSteeringControls.js index 54393f5df..d4e21ae21 100644 --- a/src/frontend/web/von_interface/static/js/components/chatSteeringControls.js +++ b/src/frontend/web/von_interface/static/js/components/chatSteeringControls.js @@ -1,16 +1,73 @@ /** Steering is bound to the displayed actor/session and exact active attempt. */ -export function createChatSteeringControls({ sendButton, request, getDraft, clearDraft, createId }) { +export function createChatSteeringControls({ sendButton, request, getDraft, clearDraft, createId, onQueue, onModeChange = () => {} }) { const button = document.createElement('button'); button.type = 'button'; button.id = 'steerButton'; button.className = 'btn btn-secondary'; button.textContent = 'Steer'; button.title = 'Guide the active turn at its next model boundary. Running tools are not undone. Text only; use Queue Prompt for attachments.'; - sendButton.before(button); + const composer = sendButton.closest('.chat-composer') || sendButton.parentElement; + const actions = composer.querySelector('.chat-composer-more-actions .button-row') || composer; + const modeLabel = document.createElement('label'); + modeLabel.textContent = 'When Von is working, submit defaults to (this browser): '; + const modeSelect = document.createElement('select'); + modeSelect.id = 'chatSubmitMode'; + for (const [value, text] of [['queue', 'Queue'], ['steer', 'Steering']]) { + modeSelect.add(new Option(text, value)); + } + modeLabel.append(modeSelect); + const help = document.createElement('p'); + help.id = 'chatSubmitModeHelp'; + help.className = 'compact-speech-help'; + help.textContent = 'Queue starts separate work afterwards. Steering guides the active turn at its next opportunity; it does not undo tools. Shift-click the submit arrow for the opposite action once. Shift+Enter still inserts a newline. Both actions are available here.'; + modeSelect.setAttribute('aria-describedby', help.id); + sendButton.setAttribute('aria-describedby', help.id); + const queueButton = document.createElement('button'); + queueButton.type = 'button'; + queueButton.id = 'queueDraftButton'; + queueButton.className = 'btn btn-secondary'; + queueButton.textContent = 'Queue this message'; + queueButton.onclick = () => { if (!queueButton.disabled) onQueue?.(); }; + actions.prepend(modeLabel, help, button, queueButton); + let actorKey; + let defaultMode = 'queue'; + const modes = new Map(); + modeSelect.onchange = () => { + defaultMode = modeSelect.value === 'steer' ? 'steer' : 'queue'; + modes.set(actorKey, defaultMode); + try { if (actorKey) localStorage.setItem(`von:chat-submit-mode:${actorKey}`, defaultMode); } catch (_) { /* In-memory preference remains usable. */ } + onModeChange(); + }; + const activity = document.createElement('details'); + activity.className = 'chat-steering-activity'; + const activityTitle = document.createElement('summary'); + activityTitle.textContent = 'Steering activity'; + activity.append(activityTitle); + actions.append(activity); + const toast = document.createElement('div'); + toast.className = 'chat-steering-toast'; + toast.setAttribute('role', 'status'); + toast.hidden = true; + composer.before(toast); + function positionToast() { + toast.style.bottom = `${Math.max(8, Math.min(window.innerHeight - toast.offsetHeight - 8, window.innerHeight - composer.getBoundingClientRect().top + 8))}px`; + } + window.addEventListener('resize', positionToast); + window.visualViewport?.addEventListener('resize', positionToast); + const composerObserver = typeof ResizeObserver === 'function' ? new ResizeObserver(positionToast) : null; + composerObserver?.observe(composer); + let toastTimer; + let announced = new Map(); + function announce(text) { + clearTimeout(toastTimer); + toast.textContent = text; + toast.hidden = false; + positionToast(); + toastTimer = setTimeout(() => { toast.hidden = true; }, 5000); + } const feedback = document.createElement('div'); feedback.className = 'chat-steering-feedback'; - feedback.setAttribute('aria-live', 'polite'); - sendButton.parentElement.after(feedback); + activity.append(feedback); let current = {}; let scopeKey; let records = new Map(); @@ -20,6 +77,7 @@ export function createChatSteeringControls({ sendButton, request, getDraft, clea let retry = null; let notice = ''; let generation = 0; + let renderedReceipts; function receiptItems(data) { if (!Array.isArray(data?.items)) throw new Error('Invalid steering status response'); return data.items; @@ -35,7 +93,18 @@ export function createChatSteeringControls({ sendButton, request, getDraft, clea button.hidden = !current.target; button.disabled = sending || current.disabled; button.textContent = sending ? 'Steering…' : 'Steer'; + queueButton.hidden = !current.busy; + queueButton.disabled = sending || current.queueDisabled; + modeSelect.value = defaultMode; + const receiptSignature = JSON.stringify([scopeKey, notice, [...records]]); + if (receiptSignature === renderedReceipts) return; + renderedReceipts = receiptSignature; + const focusId = feedback.contains(document.activeElement) ? document.activeElement.dataset.submissionId : null; feedback.replaceChildren(); + const pending = [...records.values()].flat().filter(item => item.status === 'pending').length; + activityTitle.textContent = `Steering activity${pending ? ` (${pending} pending)` : ''}${notice ? ' — needs attention' : ''}`; + if (notice && announced.get('notice') !== notice) { announce(notice); announced.set('notice', notice); } + if (!notice) announced.delete('notice'); if (notice) { const status = document.createElement('p'); status.textContent = notice; @@ -43,6 +112,11 @@ export function createChatSteeringControls({ sendButton, request, getDraft, clea } for (const [queueId, items] of records) { for (const item of items) { + const receiptKey = `${queueId}:${item.id}`; + if (announced.get(receiptKey) !== item.status) { + announce(labels[item.status] || item.status); + announced.set(receiptKey, item.status); + } const row = document.createElement('div'); const label = document.createElement('span'); label.textContent = `${labels[item.status] || item.status}: ${item.text}`; @@ -50,6 +124,7 @@ export function createChatSteeringControls({ sendButton, request, getDraft, clea if (item.status === 'pending') { const cancel = document.createElement('button'); cancel.type = 'button'; + cancel.dataset.submissionId = item.id; cancel.textContent = 'Cancel steer'; cancel.className = 'btn-mini'; cancel.onclick = async () => { @@ -71,6 +146,13 @@ export function createChatSteeringControls({ sendButton, request, getDraft, clea } } feedback.hidden = !feedback.childElementCount; + activity.hidden = !feedback.childElementCount; + composer.dataset.steeringAttention = String(Boolean(notice) || pending > 0); + if (notice) activity.open = true; + if (focusId) { + const replacement = [...feedback.querySelectorAll('button')].find(node => node.dataset.submissionId === focusId); + (replacement || activityTitle).focus(); + } } async function refresh() { clearTimeout(timer); @@ -94,7 +176,7 @@ export function createChatSteeringControls({ sendButton, request, getDraft, clea timer = setTimeout(refresh, 1500); } } - button.onclick = async () => { + async function submit() { if (!current.target || current.disabled || sending) return; const text = getDraft(); if (!text.trim()) return; @@ -126,13 +208,24 @@ export function createChatSteeringControls({ sendButton, request, getDraft, clea void refresh(); } } - }; + } + button.onclick = submit; return { update(options) { + if (actorKey !== options.actorKey) { + actorKey = options.actorKey; + let stored; + try { if (actorKey) stored = localStorage.getItem(`von:chat-submit-mode:${actorKey}`); } catch (_) { /* Storage may be disabled. */ } + defaultMode = modes.get(actorKey) || (stored === 'steer' ? 'steer' : 'queue'); + } const changed = scopeKey !== options.scopeKey; const targetChanged = current.target?.queueId !== options.target?.queueId; if (changed) { generation += 1; + clearTimeout(toastTimer); + toast.hidden = true; + announced = new Map(); + activity.open = false; records = recordsByScope.get(options.scopeKey) || new Map(); recordsByScope.set(options.scopeKey, records); retry = null; @@ -144,7 +237,28 @@ export function createChatSteeringControls({ sendButton, request, getDraft, clea render(); if (changed || targetChanged) void refresh(); }, + // Only user composer activation calls this; background queue dispatch is unchanged. + activate(event = {}) { + if (!current.busy) return false; + const mode = event.shiftKey ? (defaultMode === 'queue' ? 'steer' : 'queue') : defaultMode; + if (mode !== 'steer') return false; + if (!current.target || current.disabled) { + notice = 'Steering is unavailable for this draft or turn. Draft retained; choose Queue this message in More actions to send separate work.'; + render(); + } else void submit(); + return true; + }, + present() { + const mode = current.busy ? defaultMode : 'send'; + sendButton.dataset.submitMode = mode; + if (!current.busy || current.queueDisabled) return; + const label = mode === 'steer' ? 'Guide active turn' : 'Queue message'; + sendButton.setAttribute('aria-label', label); + const text = sendButton.querySelector('.button-label'); + if (text) text.textContent = label; + sendButton.title = `${label}. Shift-click for ${mode === 'steer' ? 'Queue' : 'Steering'} once. Change the default or choose either action in More actions.`; + }, isSending() { return sending; }, - dispose() { generation += 1; clearTimeout(timer); button.remove(); feedback.remove(); } + dispose() { generation += 1; clearTimeout(timer); clearTimeout(toastTimer); composerObserver?.disconnect(); window.removeEventListener('resize', positionToast); window.visualViewport?.removeEventListener('resize', positionToast); button.remove(); activity.remove(); toast.remove(); modeLabel.remove(); help.remove(); queueButton.remove(); } }; } diff --git a/src/frontend/web/von_interface/static/js/components/compactComposer.js b/src/frontend/web/von_interface/static/js/components/compactComposer.js index e81277d2c..67f2f5c5f 100644 --- a/src/frontend/web/von_interface/static/js/components/compactComposer.js +++ b/src/frontend/web/von_interface/static/js/components/compactComposer.js @@ -48,6 +48,18 @@ export function initialiseCompactChatComposer(composer, resizeDraft = resizeComp const name = menu.open ? 'Close actions' : 'More actions'; if (label) label.textContent = name; if (summary) summary.title = name; + // Keep the enlarged settings/activity disclosure within short desktop views. + if (menu.open && !isCompactComposer()) { + const bounds = summary.getBoundingClientRect(); + const above = bounds.top >= 160; + panel.style.bottom = above ? 'calc(100% + 8px)' : 'auto'; + panel.style.top = above ? 'auto' : 'calc(100% + 8px)'; + panel.style.maxHeight = `${Math.max(44, Math.min(420, (above ? bounds.top : window.innerHeight - bounds.bottom) - 16))}px`; + } else { + panel.style.removeProperty('bottom'); + panel.style.removeProperty('top'); + panel.style.removeProperty('max-height'); + } // Use the browser's close-request stack (including Android Back where // supported), without adding synthetic entries to conversation history. if (menu.open && isCompactComposer() && menu.isConnected) { @@ -64,7 +76,7 @@ export function initialiseCompactChatComposer(composer, resizeDraft = resizeComp // Wait for the completed click: collapsing during pointer/focus events can // move Send before touch activation reaches it. document.addEventListener('click', event => { - if (menu.isConnected && isCompactComposer() && !menu.contains(event.target)) dismiss(); + if (menu.isConnected && isCompactComposer() && !event.composedPath().includes(menu)) dismiss(); }); // Keyboard activation also dismisses before the existing submission handler. composer.querySelector('#sendButton')?.addEventListener('click', () => { diff --git a/src/frontend/web/von_interface/static/js/components/conversationActions.js b/src/frontend/web/von_interface/static/js/components/conversationActions.js new file mode 100644 index 000000000..4a1923fb3 --- /dev/null +++ b/src/frontend/web/von_interface/static/js/components/conversationActions.js @@ -0,0 +1,24 @@ +/** Native disclosures use ordinary Tab navigation, not partial ARIA menu semantics. */ +export function initialiseConversationActions(root = document) { + root.querySelectorAll('details.conversation-actions').forEach(disclosure => { + if (disclosure.dataset.bound) return; + disclosure.dataset.bound = 'true'; + const summary = disclosure.querySelector('summary'); + disclosure.addEventListener('keydown', event => { + if (event.key === 'Escape' && disclosure.open) { + event.preventDefault(); + disclosure.open = false; + summary.focus(); + } + }); + disclosure.addEventListener('click', event => { + if (!event.target.closest('button')) return; + // Let the action run first, preserving focus when it opens a dialog. + queueMicrotask(() => { + const restoreFocus = disclosure.contains(document.activeElement); + disclosure.open = false; + if (restoreFocus) summary.focus(); + }); + }); + }); +} diff --git a/src/frontend/web/von_interface/static/js/components/messagePanel.js b/src/frontend/web/von_interface/static/js/components/messagePanel.js index 829c8b707..9d8cce407 100644 --- a/src/frontend/web/von_interface/static/js/components/messagePanel.js +++ b/src/frontend/web/von_interface/static/js/components/messagePanel.js @@ -1,3 +1,4 @@ +import { initialiseConversationActions } from './conversationActions.js'; import { createLatestMessageButton, setNavigationVisible, focusConversationTarget } from './conversationNavigation.js'; import { bindAttachmentComposer, imageItems, imagesBlocked, renderImageAttachments, removeSentAttachments } from '../utils/conversationImages.js'; import { getWindowSessionId, WINDOW_SESSION_HEADER } from '../apiService.js'; @@ -99,7 +100,7 @@ export async function openMessageExchange(row) { header?.querySelectorAll('.exchange-profile-button').forEach(el => el.remove()); for (const id of row.participant_ids) { const b = profileButton(id, id === row.viewer_id ? 'Your profile' : 'Participant profile'); - b.className = 'exchange-profile-button'; header?.append(b); + b.className = 'exchange-profile-button'; header?.querySelector('.conversation-actions-panel')?.append(b); } const input = _messagesContainer?.querySelector('#messageInput'); if (input) { @@ -413,10 +414,15 @@ function renderMessagesTabContent() {
@@ -482,6 +488,7 @@ function renderMessagesTabContent() { // Attach event listeners attachMessagesEventListeners(); + initialiseConversationActions(_messagesContainer); initializeConceptAutocomplete(_messagesContainer.querySelector('#newMessageRecipient')); restoreNewMessageDeliveryAttemptForCurrentScope(); renderComposePendingUi(COMPOSE_SCOPE_REPLY); diff --git a/src/frontend/web/von_interface/static/styles.css b/src/frontend/web/von_interface/static/styles.css index 5e5cd2154..52868c235 100644 --- a/src/frontend/web/von_interface/static/styles.css +++ b/src/frontend/web/von_interface/static/styles.css @@ -18746,3 +18746,50 @@ html[data-footer-uptime="false"][data-footer-runtimecost="false"][data-footer-bu .conversation-visual.equation:not(.visual-display) .visual-source > button:last-child { display: none; } .conversation-visual.equation:not(.visual-display) .visual-source:focus-within > button:last-child { display: inline; } .visual-error { display: block; padding: .5em; border-inline-start: 3px solid #b86615; } + +/* Conversation actions retain their owners and handlers inside a tap/Tab disclosure. */ +.conversation-actions { position: relative; flex-shrink: 0; font-size: 0.875rem; } +.conversation-actions > summary { + cursor: pointer; min-height: 44px; padding: 10px; box-sizing: border-box; + border: 1px solid #cbd5e1; border-radius: 8px; background: #fff; color: #334155; +} +.conversation-actions-panel { + position: absolute; right: 0; top: 100%; z-index: 110; + display: flex; flex-direction: column; align-items: stretch; gap: 6px; + width: min(300px, calc(100vw - 32px)); max-height: 65dvh; overflow: auto; + padding: 10px; box-sizing: border-box; background: #fff; color: #334155; + border: 1px solid #cbd5e1; border-radius: 8px; box-shadow: 0 4px 16px #0f172a26; +} +.conversation-actions:not([open]) > .conversation-actions-panel { display: none; } +.conversation-actions-panel button { min-height: 44px; text-align: left; white-space: normal; } +.conversation-actions summary:focus-visible, +.conversation-actions-panel button:focus-visible { outline: 2px solid #2563eb; outline-offset: 2px; } +.chat-conversation-masthead .chat-header-controls { overflow: visible; align-items: flex-start; } +.message-view-header { min-height: 0; padding-block: 8px; gap: 8px; } +.message-view-header .conversation-title { min-width: 0; overflow: hidden; text-overflow: ellipsis; } +.chat-steering-activity { flex-basis: 100%; min-width: 0; } +.chat-steering-activity > summary { cursor: pointer; min-height: 44px; padding: 10px; box-sizing: border-box; } +.chat-steering-activity:not([open]) > .chat-steering-feedback { display: none; } +.chat-steering-feedback > div { flex-wrap: wrap; } +.chat-steering-toast { + position: fixed; z-index: 120; left: max(8px, env(safe-area-inset-left)); right: max(8px, env(safe-area-inset-right)); + max-height: 6rem; overflow: auto; overflow-wrap: anywhere; pointer-events: none; + padding: 10px 14px; border-radius: 8px; background: #0f172a; color: #fff; + box-shadow: 0 2px 8px #0f172a33; font-size: 0.875rem; +} +.chat-steering-toast[hidden], .chat-steering-activity[hidden] { display: none; } +#sendButton[data-submit-mode="steer"] svg path { d: path('M6 19v-6a6 6 0 0 1 6-6h6m-5-5 5 5-5 5'); } +#sendButton[data-submit-mode="queue"] svg { border-bottom: 2px solid currentColor; } +.chat-composer[data-steering-attention="true"] .composer-more-icon::after, +.chat-composer[data-steering-attention="true"] .composer-add-icon::after { content: '•'; } +#chatSubmitMode { min-height: 44px; max-width: 100%; } +@media (max-width: 800px), (max-width: 1024px) and (pointer: coarse) { + .chat-conversation-masthead { grid-template-columns: minmax(0, 1fr) auto; } + .conversation-actions > summary { max-width: 130px; } + .message-view-header { flex-direction: row; align-items: center; } +} + +#steerButton[hidden], #queueDraftButton[hidden] { display: none; } +#chatSubmitModeHelp { display: block; flex-basis: 100%; font-size: 12px; margin: 0; } + +.unified-message-content .message-view-header { flex-wrap: nowrap; } diff --git a/src/frontend/web/von_interface/templates/chat_tab.html b/src/frontend/web/von_interface/templates/chat_tab.html index 70848e3f4..55dbc2a75 100644 --- a/src/frontend/web/von_interface/templates/chat_tab.html +++ b/src/frontend/web/von_interface/templates/chat_tab.html @@ -34,16 +34,6 @@ aria-label="Concept Q&A controls" aria-live="polite">
- - - - - - - - - +
+ Conversation actions +
+ + + + + + + +
+
@@ -333,7 +328,7 @@

Workflow Discovery