Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions docs/engineering/unified_conversations_and_participant_profiles.md
Original file line number Diff line number Diff line change
Expand Up @@ -259,3 +259,26 @@ resolution, renamed/reordered sources and unavailable/denied turns;
`tests/browser/turnReferenceMenu.cjs` checks the production frontend modules in
a local HTTP fixture with real browser clipboard access and reloads. That
fixture does not claim live authenticated-server or public deployment acceptance.

### Shared conversation draft editor

Von, Messaging replies and the new-message dialog use `conversationDraft.js`
for concept autocomplete, cartouche editing, desktop Enter/mobile newline
behaviour, draft sizing and history navigation. Autocomplete consumes its
selection keys before history or submission. The shared control presentation
and compact layout use `conversationDraftControls.js` and `compactComposer.js`;
Messaging dictation uses the same recorded, streaming and browser controller as
Von, including finish-before-send, cancellation and retry.

Carrier adapters retain draft scope, attachment ownership and delivery recovery.
Cartouche IDs are normalised before message and recipient submission. Restoring
an existing draft refreshes its overlay without invalidating its delivery key.
Von-only execution actions (model selection, steering, reset and conversational
voice replies) remain with the Von carrier; direct messages retain addressed
message delivery rather than acquiring an AI execution session.

Local fixture browser coverage: `tests/browser/messageComposer.cjs` exercises
production Messaging modules/CSS with fixture APIs at desktop and phone widths.
It checks concept selection without accidental submission, normalised delivery,
new-message editing, shared controls and compact layout. This is not evidence of
public authentication, live recipient delivery or audio-provider availability.
105 changes: 22 additions & 83 deletions src/frontend/web/von_interface/static/js/chatTab.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { initialiseDraftControls } from './components/conversationDraftControls.js';
import { initialiseConversationDraft, resizeConversationDraft, navigateConversationDraftHistory } from './components/conversationDraft.js';
import { createTurnModelPicker } from './components/turnModelPicker.js';
import { copyCompactConversationReference, isConversationConceptReference, conversationReferenceMetadata, readConversationReference } from './utils/conversationReference.js';
import { bindConversationRowMenu } from './components/conversationRowMenu.js';
import { initialiseConversationActions } from './components/conversationActions.js';
import { createLatestMessageButton, setNavigationVisible, focusConversationTarget } from './components/conversationNavigation.js';
import { initialiseCompactChatComposer, isCompactComposer, resizeCompactDraft, shouldSubmitComposerKey } from './components/compactComposer.js';
import { initialiseCompactChatComposer, isCompactComposer } from './components/compactComposer.js';
import { updateExecutionCost } from './components/executionCost.js';
import { canUseWorkflowStudio } from './workflowStudioAccess.js';
import { createChatSteeringControls } from './components/chatSteeringControls.js';
Expand All @@ -26,10 +28,9 @@ import {
isPremiumModelProvider,
resolveLocalRequestedLlm,
} from './utils/localModelPreferences.js';
import { initializeConceptAutocomplete } from './components/conceptAutocomplete.js';
import { initializeMessagePanel, loadUnreadCount } from './components/messagePanel.js';
import { loadMyOrganisations, synchroniseOrganisationContext } from './components/orgSelector.js';
import { initializePromptCartoucheOverlay, normaliseVontologyIdsForBackend } from './components/promptCartoucheOverlay.js';
import { normaliseVontologyIdsForBackend } from './components/promptCartoucheOverlay.js';
import { initializeTaskPanel, isTaskPanelVisible, setCurrentSession as setTaskPanelSession, toggleTaskPanel } from './components/taskPanel.js';
import {
decorateInspectableReferences,
Expand Down Expand Up @@ -21305,55 +21306,14 @@ function rehydrateLastSubmittedUserPromptFromHistory(historyMessages) {
}

function handlePromptInputHistoryNavigation(event) {
if (!event) return;
if (event.key !== 'ArrowUp' && event.key !== 'ArrowDown') return;
if (event.ctrlKey || event.altKey || event.metaKey) return;

const promptInput = event.currentTarget || getPromptInputElement();
if (!promptInput) return;

const history = getPromptHistoryForActiveSession();
if (history.length === 0) return;

let cursor = getPromptHistoryCursorForActiveSession();
let nextValue;
const isShiftJump = event.shiftKey;
const promptValue = String(promptInput.value || '');
const isCurrentHistoryValue = cursor >= 0 && cursor < history.length && history[cursor] === promptValue;
const allowsHistoryNavigation = promptValue === '' || isCurrentHistoryValue;
if (!allowsHistoryNavigation) return;

if (event.key === 'ArrowUp') {
cursor = isShiftJump ? 0 : Math.max(0, cursor - 1);
nextValue = history[cursor];
} else {
if (isShiftJump) {
cursor = Math.max(0, history.length - 1);
} else {
if (cursor >= history.length) return;
cursor = cursor + 1;
}
if (cursor >= history.length) {
setPromptHistoryCursorForActiveSession(cursor);
event.preventDefault();
setPromptComposerValue('', { promptInput });
return;
}
nextValue = history[cursor];
}

if (!nextValue) return;

setPromptHistoryCursorForActiveSession(cursor);

event.preventDefault();
setPromptComposerValue(nextValue, { promptInput });
try {
const len = promptInput.value.length;
promptInput.setSelectionRange(len, len);
} catch (_) {
// Ignore browsers/environments without setSelectionRange support.
}
const input = event?.currentTarget || getPromptInputElement();
if (!input) return;
const next = navigateConversationDraftHistory(event, input,
getPromptHistoryForActiveSession(), getPromptHistoryCursorForActiveSession());
if (!next) return;
setPromptHistoryCursorForActiveSession(next.cursor);
setPromptComposerValue(next.value, { promptInput: input });
input.setSelectionRange(input.value.length, input.value.length);
}

function dispatchPromptComposerInputEvent(promptInput = getPromptInputElement()) {
Expand Down Expand Up @@ -31153,14 +31113,7 @@ function handleConversationScrollPositionChange(scrollableField) {
}

function resizePromptComposer(promptInput) {
if (!(promptInput instanceof HTMLTextAreaElement)) return;
if (isCompactComposer()) { resizeCompactDraft(promptInput); return; }
promptInput.style.height = 'auto';
const minHeight = 44;
const maxHeight = 144;
const nextHeight = Math.min(maxHeight, Math.max(minHeight, Number(promptInput.scrollHeight) || minHeight));
promptInput.style.height = `${nextHeight}px`;
promptInput.style.overflowY = (Number(promptInput.scrollHeight) || 0) > maxHeight ? 'auto' : 'hidden';
resizeConversationDraft(promptInput);
}

function setLatestUnreadBoundary(messageElement) {
Expand Down Expand Up @@ -34317,6 +34270,7 @@ export function initializeChatTab() {
}

// Shared mobile/desktop recording lifecycle; browser recognition is explicit.
initialiseDraftControls({ sendButton, attachmentButton: document.getElementById('attachImageButton'), dictateButton });
dictationController?.dispose();
if (dictateButton && promptInput) {
dictationController = createDictationController({
Expand Down Expand Up @@ -34368,7 +34322,7 @@ export function initializeChatTab() {
updateSendButtonForCurrentChatState();
if (!chatSteeringControls?.activate(event)) void handleSendPrompt();
};
sendButton.addEventListener('click', submitComposer);

resetButton.addEventListener('click', handleResetContext);

ensureAbortButtonBound();
Expand All @@ -34380,30 +34334,15 @@ export function initializeChatTab() {
setSelectedChatPromptQueueEntryId(null);
});

promptInput.addEventListener('input', function () {
setSelectedChatPromptQueueEntryId(null);
resizePromptComposer(promptInput);
updateSendButtonDraftReadiness();
});
resizePromptComposer(promptInput);

promptInput.addEventListener('keypress', function (event) {
if (shouldSubmitComposerKey(event)) {
event.preventDefault();
submitComposer(event);
}
initialiseConversationDraft({
input: promptInput, sendButton, onSubmit: submitComposer,
onInput: () => {
setSelectedChatPromptQueueEntryId(null);
updateSendButtonDraftReadiness();
},
onHistory: handlePromptInputHistoryNavigation
});

// JVNAUTOSCI-2128/2163: ArrowUp/ArrowDown on an empty composer recalls
// historical submitted prompts with cursor navigation.
promptInput.addEventListener('keydown', handlePromptInputHistoryNavigation);

// Initialize concept autocomplete for #V# trigger
initializeConceptAutocomplete(promptInput);

// Render non-trigger (#V\u200B#...) concept tokens as cartouches in the prompt.
initializePromptCartoucheOverlay(promptInput);

void Promise.allSettled([
loadRecentChatPair({
scrollToBottom: true,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export function resizeCompactDraft(input) {
input.style.overflowY = input.scrollHeight > height ? 'auto' : 'hidden';
}

export function initialiseCompactChatComposer(composer, resizeDraft = resizeCompactDraft) {
export function initialiseCompactChatComposer(composer, resizeDraft = resizeCompactDraft, elements = {}) {
if (!composer || composer.dataset.compactBound) return;
composer.dataset.compactBound = 'true';
const menu = composer.querySelector('.chat-composer-more-actions');
Expand Down Expand Up @@ -80,19 +80,19 @@ export function initialiseCompactChatComposer(composer, resizeDraft = resizeComp
if (menu.isConnected && isCompactComposer() && !event.composedPath().includes(menu)) dismiss();
});
// Keyboard activation also dismisses before the existing submission handler.
composer.querySelector('#sendButton')?.addEventListener('click', () => {
(elements.send || composer.querySelector('#sendButton'))?.addEventListener('click', () => {
if (isCompactComposer()) dismiss();
}, { capture: true });
const active = document.createElement('div');
active.className = 'compact-composer-active';
composer.append(active);
const input = composer.querySelector('#promptInput');
const mic = composer.querySelector('#dictateButton');
const voice = composer.querySelector('#voiceConversationButton');
const status = composer.querySelector('#dictationStatus');
const voiceStatus = composer.querySelector('#voiceConversationStatus');
const cancel = composer.querySelector('#cancelDictationButton');
const retry = composer.querySelector('#retryDictationButton');
const input = elements.input || composer.querySelector('#promptInput');
const mic = elements.mic || composer.querySelector('#dictateButton');
const voice = elements.voice || composer.querySelector('#voiceConversationButton');
const status = elements.status || composer.querySelector('#dictationStatus');
const voiceStatus = elements.voiceStatus || composer.querySelector('#voiceConversationStatus');
const cancel = elements.cancel || composer.querySelector('#cancelDictationButton');
const retry = elements.retry || composer.querySelector('#retryDictationButton');
const homes = new Map([mic, voice, status, voiceStatus, cancel, retry].filter(Boolean).map(node => {
const home = document.createComment(`desktop ${node.id}`);
node.before(home);
Expand Down Expand Up @@ -139,7 +139,7 @@ export function initialiseCompactChatComposer(composer, resizeDraft = resizeComp
}
});
menu.addEventListener('click', event => {
if (isCompactComposer() && event.target.closest('#dictateButton, #voiceConversationButton, #uploadFileButton')) {
if (isCompactComposer() && event.target.closest('#dictateButton, .conversation-dictate-button, #voiceConversationButton, #uploadFileButton')) {
// Recording controls become visible on the controller's next render.
dismiss(true);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -627,7 +627,8 @@ export function initializeConceptAutocomplete(textareaElement) {
const state = getOrCreateState(textareaElement);
// Delay to allow click on dropdown items
setTimeout(() => {
if (!state.dropdownPointerDown) {
if (activeTextarea === textareaElement && document.activeElement !== textareaElement
&& !state.dropdownPointerDown) {
closeAutocomplete();
}
}, 150);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { initializeConceptAutocomplete } from './conceptAutocomplete.js';
import { initializePromptCartoucheOverlay, normaliseVontologyIdsForBackend } from './promptCartoucheOverlay.js';
import { isCompactComposer, resizeCompactDraft, shouldSubmitComposerKey } from './compactComposer.js';

export { normaliseVontologyIdsForBackend };

// All conversation drafts use the Von editor, including its token rendering and
// keyboard ordering. Delivery, attachment ownership and history stay with callers.
export function resizeConversationDraft(input) {
if (!(input instanceof HTMLTextAreaElement)) return;
input.dataset.hasDraft = String(Boolean(input.value.trim()));
if (isCompactComposer()) { resizeCompactDraft(input); return; }
input.style.height = 'auto';
const height = Math.min(144, Math.max(44, Number(input.scrollHeight) || 44));
input.style.height = `${height}px`;
input.style.overflowY = input.scrollHeight > 144 ? 'auto' : 'hidden';
}

export function setConversationDraft(input, value) {
if (!input) return;
input.value = value;
const event = new Event('input', { bubbles: true });
event.conversationDraftRestored = true;
input.dispatchEvent(event);
}

export function initialiseConversationDraft({ input, sendButton, onSubmit, onInput, onHistory }) {
if (!input || input.dataset.conversationDraftBound) return;
input.dataset.conversationDraftBound = 'true';
input.classList.add('prompt-input');
input.rows = 1;
initializeConceptAutocomplete(input);
initializePromptCartoucheOverlay(input);
// Autocomplete must get first refusal: Enter selects a concept, never sends it.
input.addEventListener('keydown', event => {
if (event.defaultPrevented) return;
onHistory?.(event);
if (!event.defaultPrevented && shouldSubmitComposerKey(event)) {
event.preventDefault();
onSubmit(event);
}
});
sendButton?.addEventListener('click', onSubmit);
input.addEventListener('input', event => {
resizeConversationDraft(input);
if (!event.conversationDraftRestored) onInput?.();
});
const resize = () => { if (input.isConnected) resizeConversationDraft(input); };
window.addEventListener('resize', resize);
window.visualViewport?.addEventListener('resize', resize);
resize();
}

export function navigateConversationDraftHistory(event, input, history, cursor) {
if (!event || !['ArrowUp', 'ArrowDown'].includes(event.key)
|| event.ctrlKey || event.altKey || event.metaKey || event.isComposing || !history.length) return null;
const value = input.value || '';
if (value !== '' && history[cursor] !== value) return null;
let next = event.key === 'ArrowUp'
? (event.shiftKey ? 0 : Math.max(0, cursor - 1))
: (event.shiftKey ? history.length - 1 : cursor + 1);
next = Math.min(history.length, next);
if (event.key === 'ArrowDown' && cursor >= history.length && !event.shiftKey) return null;
event.preventDefault();
return { cursor: next, value: history[next] || '' };
}
Loading
Loading