diff --git a/src/backend/services/display_elements_service.py b/src/backend/services/display_elements_service.py index 085496171..8fd28d9b2 100644 --- a/src/backend/services/display_elements_service.py +++ b/src/backend/services/display_elements_service.py @@ -95,6 +95,7 @@ _RELATION_EXTENT_PREDICATE_TOKENS = frozenset({"predicate", "relation"}) _RELATION_EXTENT_ARG2_TOKENS = frozenset({"arg2", "object", "right", "target"}) _TABLE_COLUMN_VISIBILITY_DEFAULT_MODES = frozenset({"compact", "full"}) +_TABLE_PRESENTATION_MODES = frozenset({"augment", "inline_primary"}) def _normalise_text(value: object) -> str | None: @@ -482,7 +483,12 @@ def extract_markdown_tables(text: str | None) -> list[dict[str, Any]]: return [] # Avoid treating pipe-delimited content inside code fences as table rows. - sanitised = _FENCED_CODE_BLOCK_PATTERN.sub("", text) + # Preserve newline positions so source spans continue to identify the + # untouched screen text even when an earlier code fence is removed. + sanitised = _FENCED_CODE_BLOCK_PATTERN.sub( + lambda match: "\n" * match.group(0).count("\n"), + text, + ) lines = sanitised.splitlines() tables: list[dict[str, Any]] = [] @@ -2007,6 +2013,172 @@ def _validate_workflow_view_payload( } +def _validate_display_element_presentation( + *, + element: Mapping[str, Any], + element_type: object, + label: str, + errors: list[str], +) -> None: + presentation = element.get("presentation") + if presentation is None: + return + if element_type != "table": + errors.append(f"{label}.presentation is currently supported only for table elements") + return + if not isinstance(presentation, Mapping): + errors.append(f"{label}.presentation must be a mapping when provided") + return + + mode = presentation.get("mode") + if not isinstance(mode, str) or mode.strip() not in _TABLE_PRESENTATION_MODES: + errors.append( + f"{label}.presentation.mode must be one of {sorted(_TABLE_PRESENTATION_MODES)}" + ) + return + + if mode.strip() != "inline_primary": + return + + source_element_id = presentation.get("source_element_id") + if not isinstance(source_element_id, str) or not source_element_id.strip(): + errors.append( + f"{label}.presentation.source_element_id must be a non-empty string for inline_primary" + ) + + source_span = presentation.get("source_span") + if not isinstance(source_span, Mapping): + errors.append( + f"{label}.presentation.source_span must be a mapping for inline_primary" + ) + return + + start_line = source_span.get("start_line") + end_line = source_span.get("end_line") + if ( + isinstance(start_line, bool) + or not isinstance(start_line, int) + or start_line < 1 + ): + errors.append( + f"{label}.presentation.source_span.start_line must be a positive integer" + ) + if ( + isinstance(end_line, bool) + or not isinstance(end_line, int) + or end_line < 1 + ): + errors.append( + f"{label}.presentation.source_span.end_line must be a positive integer" + ) + if ( + isinstance(start_line, int) + and not isinstance(start_line, bool) + and isinstance(end_line, int) + and not isinstance(end_line, bool) + and end_line < start_line + ): + errors.append( + f"{label}.presentation.source_span.end_line must not precede start_line" + ) + + +def _validate_inline_primary_table_relationships( + *, + elements: Sequence[object], + errors: list[str], +) -> None: + elements_by_id: dict[str, list[tuple[int, Mapping[str, Any]]]] = {} + for index, element in enumerate(elements): + if not isinstance(element, Mapping): + continue + element_id = element.get("element_id") + if not isinstance(element_id, str) or not element_id.strip(): + continue + elements_by_id.setdefault(element_id.strip(), []).append((index, element)) + + for index, element in enumerate(elements): + if not isinstance(element, Mapping) or element.get("element_type") != "table": + continue + presentation = element.get("presentation") + if ( + not isinstance(presentation, Mapping) + or presentation.get("mode") != "inline_primary" + ): + continue + + label = f"elements[{index}].presentation" + source_element_id = presentation.get("source_element_id") + if not isinstance(source_element_id, str) or not source_element_id.strip(): + continue + + source_matches = elements_by_id.get(source_element_id.strip(), []) + if len(source_matches) != 1: + errors.append( + f"{label}.source_element_id must identify exactly one element in the contract" + ) + continue + + _, source_element = source_matches[0] + if ( + source_element.get("element_type") != "text_block" + or source_element.get("channel") != "screen" + ): + errors.append( + f"{label}.source_element_id must reference a screen text_block element" + ) + continue + + source_payload = source_element.get("payload") + source_text = ( + source_payload.get("text") + if isinstance(source_payload, Mapping) + else None + ) + if not isinstance(source_text, str) or not source_text.strip(): + errors.append( + f"{label}.source_element_id must reference a non-empty text payload" + ) + continue + + source_span = presentation.get("source_span") + if not isinstance(source_span, Mapping): + continue + start_line = source_span.get("start_line") + end_line = source_span.get("end_line") + if ( + isinstance(start_line, bool) + or not isinstance(start_line, int) + or start_line < 1 + or isinstance(end_line, bool) + or not isinstance(end_line, int) + or end_line < start_line + ): + continue + + source_line_count = len(source_text.splitlines()) + if end_line > source_line_count: + errors.append( + f"{label}.source_span must fall within the referenced text payload" + ) + continue + + markdown_table_spans = { + ( + candidate_span.get("start_line"), + candidate_span.get("end_line"), + ) + for table_payload in extract_markdown_tables(source_text) + if isinstance(table_payload, Mapping) + for candidate_span in [table_payload.get("source_span")] + if isinstance(candidate_span, Mapping) + } + if (start_line, end_line) not in markdown_table_spans: + errors.append( + f"{label}.source_span must identify a Markdown table in the referenced text" + ) + + def validate_turn_display_elements( contract: Mapping[str, Any] | None, ) -> tuple[bool, list[str]]: @@ -2059,6 +2231,13 @@ def validate_turn_display_elements( if not isinstance(provenance, Mapping): errors.append(f"{label}.provenance must be a mapping") + _validate_display_element_presentation( + element=element, + element_type=element_type, + label=label, + errors=errors, + ) + if element_type in OPTIONAL_PAYLOAD_TITLE_ELEMENT_TYPES: _validate_optional_payload_title( payload=payload, @@ -2075,6 +2254,11 @@ def validate_turn_display_elements( ) continue + _validate_inline_primary_table_relationships( + elements=elements, + errors=errors, + ) + return len(errors) == 0, errors @@ -2137,6 +2321,9 @@ def _normalise_supplied_screen_tables( provenance = metadata.get("provenance") if not isinstance(provenance, Mapping): provenance = {} + presentation = metadata.get("presentation") + if not isinstance(presentation, Mapping): + presentation = {"mode": "augment"} canonical_payload = _with_default_table_column_visibility( _with_optional_payload_title(payload, metadata=metadata) ) @@ -2156,6 +2343,7 @@ def _normalise_supplied_screen_tables( "payload": dict(canonical_payload), "constraints": dict(constraints), "provenance": dict(provenance), + "presentation": dict(presentation), } ], "reason_codes": [], @@ -2173,6 +2361,7 @@ def _normalise_supplied_screen_tables( "payload": dict(canonical_payload), "constraints": dict(constraints), "provenance": dict(provenance), + "presentation": dict(presentation), } ) @@ -3261,6 +3450,11 @@ def _build_structured_screen_specs( "payload": spec.get("payload") or {}, "constraints": spec.get("constraints") or dict(default_constraints), "provenance": provenance, + "presentation": ( + dict(spec["presentation"]) + if isinstance(spec.get("presentation"), Mapping) + else None + ), } ) return structured_specs @@ -3298,18 +3492,20 @@ def _emit_structured_screen_elements( next_default_order += 1 element_id = _next_unique_element_id(str(spec["element_id"]), used_ids) - elements.append( - { - "element_id": element_id, - "element_type": element_type, - "channel": "screen", - "order": int(order), - "intent": str(spec["intent"]), - "payload": dict(spec["payload"]), - "constraints": dict(spec["constraints"]), - "provenance": dict(spec["provenance"]), - } - ) + element = { + "element_id": element_id, + "element_type": element_type, + "channel": "screen", + "order": int(order), + "intent": str(spec["intent"]), + "payload": dict(spec["payload"]), + "constraints": dict(spec["constraints"]), + "provenance": dict(spec["provenance"]), + } + presentation = spec.get("presentation") + if isinstance(presentation, Mapping): + element["presentation"] = dict(presentation) + elements.append(element) def build_turn_display_elements( @@ -3549,6 +3745,7 @@ def build_turn_display_elements( markdown_tables = extract_markdown_tables(effective_screen) for index, table_payload in enumerate(markdown_tables, start=1): + source_span = table_payload.get("source_span") table_specs.append( { "element_id": f"screen_table_{index}", @@ -3565,6 +3762,15 @@ def build_turn_display_elements( "table_index": index, "required_by_user_prompt": False, }, + "presentation": { + "mode": "inline_primary", + "source_element_id": "screen_text", + "source_span": ( + dict(source_span) + if isinstance(source_span, Mapping) + else {} + ), + }, } ) if markdown_tables: diff --git a/src/frontend/web/von_interface/static/js/chatTab.js b/src/frontend/web/von_interface/static/js/chatTab.js index 435179437..06af26f9c 100644 --- a/src/frontend/web/von_interface/static/js/chatTab.js +++ b/src/frontend/web/von_interface/static/js/chatTab.js @@ -12207,6 +12207,7 @@ const RELATION_TRUTH_STATE_MAX_ASSERTIONS_PER_GROUP = 200; const RELATION_TRUTH_STATE_FALLBACK_MAX_LINES = 300; const RELATION_TRUTH_STATE_FALLBACK_MAX_ASSERTIONS = 120; const JIRA_ISSUE_KEY_PATTERN = /^[A-Z][A-Z0-9]+-\d+$/; +const TABLE_PRESENTATION_MODES = new Set(['augment', 'inline_primary']); // Keep heading resolution centralised so new display element families stay consistent. function normaliseOptionalDisplayElementTitle(value) { @@ -12226,6 +12227,71 @@ function resolveDisplayElementSectionTitle(explicitTitle, fallbackLabel, index, return multipleElements ? `${fallbackLabel} ${safeIndex + 1}` : fallbackLabel; } +function normaliseTableSourceSpan(value) { + if (!value || typeof value !== 'object') { + return null; + } + const startLine = Number(value.start_line); + const endLine = Number(value.end_line); + if ( + !Number.isInteger(startLine) + || !Number.isInteger(endLine) + || startLine < 1 + || endLine < startLine + ) { + return null; + } + return { + start_line: startLine, + end_line: endLine + }; +} + +function normaliseTablePresentation(element, payload) { + const presentation = ( + element?.presentation + && typeof element.presentation === 'object' + ) ? element.presentation : null; + const provenance = ( + element?.provenance + && typeof element.provenance === 'object' + ) ? element.provenance : null; + const provenanceSource = typeof provenance?.source === 'string' + ? provenance.source.trim() + : ''; + const explicitMode = typeof presentation?.mode === 'string' + ? presentation.mode.trim() + : ''; + const mode = TABLE_PRESENTATION_MODES.has(explicitMode) + ? explicitMode + : ( + provenanceSource === 'screen_markdown_table' + ? 'inline_primary' + : 'augment' + ); + const sourceSpan = normaliseTableSourceSpan( + presentation?.source_span ?? payload?.source_span + ); + const sourceElementId = typeof presentation?.source_element_id === 'string' + ? presentation.source_element_id.trim() + : ''; + + if (mode === 'inline_primary' && !sourceSpan) { + return { + mode: 'augment', + source_element_id: null, + source_span: null + }; + } + return { + mode, + source_element_id: sourceElementId || ( + mode === 'inline_primary' ? 'screen_text' : null + ), + source_span: sourceSpan + }; +} + function normaliseTableSortMetadata(value, columns) { if (!value || typeof value !== 'object') { return null; @@ -12518,6 +12584,7 @@ function normaliseTableDisplayElement(element) { const sort = normaliseTableSortMetadata(payload.sort, columns); const pagination = normaliseTablePaginationMetadata(payload.pagination, rows.length); const columnVisibility = normaliseTableColumnVisibilityMetadata(payload.column_visibility, columns); + const presentation = normaliseTablePresentation(element, payload); return { element_id: typeof element.element_id === 'string' ? element.element_id : null, @@ -12526,7 +12593,8 @@ function normaliseTableDisplayElement(element) { rows, sort, pagination, - column_visibility: columnVisibility + column_visibility: columnVisibility, + presentation }; } @@ -12547,6 +12615,98 @@ function resolveTableDisplayElements(debugData) { return tables; } +function partitionTableDisplayElementsByPresentation(container, debugData, tableElements) { + const inlinePrimaryCandidates = tableElements.filter( + (tableElement) => tableElement?.presentation?.mode === 'inline_primary' + ); + if (!inlinePrimaryCandidates.length) { + return { + inlinePrimaryTableElements: [], + appendedTableElements: tableElements + }; + } + + const contract = normaliseDisplayElementsContract(debugData?.display_elements); + const contractElements = Array.isArray(contract?.elements) ? contract.elements : []; + const sourceElementsById = new Map(); + contractElements.forEach((element) => { + const elementId = typeof element?.element_id === 'string' + ? element.element_id.trim() + : ''; + if (!elementId) { + return; + } + const existing = sourceElementsById.get(elementId) || []; + existing.push(element); + sourceElementsById.set(elementId, existing); + }); + + const originalText = typeof container?.dataset?.originalText === 'string' + ? container.dataset.originalText + : null; + const claimedSourceSpans = new Set(); + const relationshipsAreValid = originalText !== null && inlinePrimaryCandidates.every( + (tableElement) => { + const sourceElementId = tableElement?.presentation?.source_element_id; + const sourceMatches = sourceElementsById.get(sourceElementId) || []; + if (sourceMatches.length !== 1) { + return false; + } + const sourceElement = sourceMatches[0]; + const sourcePayload = ( + sourceElement?.payload + && typeof sourceElement.payload === 'object' + ) ? sourceElement.payload : null; + const sourceText = typeof sourcePayload?.text === 'string' + ? sourcePayload.text + : null; + if ( + sourceElement?.element_type !== 'text_block' + || sourceElement?.channel !== 'screen' + || sourceText !== originalText + ) { + return false; + } + + const sourceSpan = tableElement?.presentation?.source_span; + const sourceLineCount = sourceText.split(/\r?\n/).length; + if (!sourceSpan || sourceSpan.end_line > sourceLineCount) { + return false; + } + const sourceSpanKey = [ + sourceElementId, + sourceSpan.start_line, + sourceSpan.end_line + ].join(':'); + if (claimedSourceSpans.has(sourceSpanKey)) { + return false; + } + claimedSourceSpans.add(sourceSpanKey); + return true; + } + ); + + const renderedPrimaryTableCount = container.querySelectorAll('table').length; + const primaryPresentationIsComplete = ( + relationshipsAreValid + && renderedPrimaryTableCount === inlinePrimaryCandidates.length + ); + if (!primaryPresentationIsComplete) { + return { + inlinePrimaryTableElements: [], + appendedTableElements: tableElements + }; + } + + const inlinePrimaryIds = new Set(inlinePrimaryCandidates); + return { + inlinePrimaryTableElements: inlinePrimaryCandidates, + appendedTableElements: tableElements.filter( + (tableElement) => !inlinePrimaryIds.has(tableElement) + ) + }; +} + function normaliseWorkflowTaskLink(rawLink) { if (!rawLink || typeof rawLink !== 'object') { return null; @@ -15675,7 +15835,15 @@ function renderTableDisplayElementsIntoContainer(container, debugData) { return; } - const tableElements = resolveTableDisplayElements(debugData); + const resolvedTableElements = resolveTableDisplayElements(debugData); + const { + inlinePrimaryTableElements, + appendedTableElements: tableElements + } = partitionTableDisplayElementsByPresentation( + container, + debugData, + resolvedTableElements + ); const workflowElements = resolveWorkflowDisplayElements(debugData); const taskViewElements = resolveTaskViewDisplayElements(debugData); const kanbanElements = resolveKanbanDisplayElements(debugData); @@ -15703,6 +15871,9 @@ function renderTableDisplayElementsIntoContainer(container, debugData) { try { container.dataset.relationTruthStateSource = relationTruthStateSource; container.dataset.relationTripleParseCount = String(relationTripleParseCount); + container.dataset.inlinePrimaryTableCount = String( + inlinePrimaryTableElements.length + ); } catch (_) { // Ignore dataset assignment failures. } @@ -17901,13 +18072,15 @@ function formatBytesForUi(sizeBytes) { let uploadUiState = { button: null, statusEl: null, - inFlight: 0, - lastStatusTimeoutId: null, + statusRegion: null, + pendingAttachmentEl: null, defaultButtonLabel: null }; const PENDING_FILE_COPY_SESSION_FALLBACK_KEY = '__pending_chat_session__'; const pendingFileCopyConceptIdsBySession = new Map(); +const pendingFileCopyDisplayNamesBySession = new Map(); +const uploadStatesBySession = new Map(); function normaliseTrustedUploadedFileCopyConceptId(value) { if (typeof value !== 'string') return null; @@ -17925,23 +18098,103 @@ function getPendingFileCopySessionKey(sessionId = activeChatSessionId) { return normaliseHistorySessionId(sessionId) || PENDING_FILE_COPY_SESSION_FALLBACK_KEY; } -function rememberPendingUploadedFileCopyConceptId(conceptId, sessionId = activeChatSessionId) { +function normalisePendingAttachmentDisplayName(value) { + if (typeof value !== 'string') return null; + const displayName = value.trim(); + if (!displayName) return null; + return displayName.slice(0, 240); +} + +function resolveUploadUiElement(stateKey, elementId) { + const existing = uploadUiState[stateKey]; + if (existing && existing.isConnected !== false) { + return existing; + } + const resolved = document.getElementById(elementId); + uploadUiState[stateKey] = resolved || null; + return uploadUiState[stateKey]; +} + +function refreshAttachmentStatusRegionVisibility() { + const region = resolveUploadUiElement('statusRegion', 'chatAttachmentStatus'); + if (!region) return; + const statusEl = resolveUploadUiElement('statusEl', 'uploadFileStatus'); + const pendingEl = resolveUploadUiElement( + 'pendingAttachmentEl', + 'pendingAttachmentStatus' + ); + const hasUploadStatus = !!String(statusEl?.textContent || '').trim(); + const hasPendingAttachment = !!( + pendingEl + && !pendingEl.classList.contains('hidden') + && String(pendingEl.textContent || '').trim() + ); + region.classList.toggle( + 'hidden', + !hasUploadStatus && !hasPendingAttachment + ); +} + +function renderPendingAttachmentState() { + const el = resolveUploadUiElement( + 'pendingAttachmentEl', + 'pendingAttachmentStatus' + ); + if (!el) return; + + const sessionKey = getPendingFileCopySessionKey(activeChatSessionId); + const conceptId = pendingFileCopyConceptIdsBySession.get(sessionKey) || null; + const displayName = pendingFileCopyDisplayNamesBySession.get(sessionKey) || null; + if (!conceptId) { + el.textContent = ''; + el.removeAttribute('title'); + el.classList.add('hidden'); + refreshAttachmentStatusRegionVisibility(); + return; + } + + el.textContent = displayName + ? `Attachment ready: ${displayName}` + : 'Attachment ready for the next prompt'; + el.title = 'This attachment will be sent with the next accepted prompt.'; + el.classList.remove('hidden'); + refreshAttachmentStatusRegionVisibility(); +} + +function rememberPendingUploadedFileCopyConceptId( + conceptId, + sessionId = activeChatSessionId, + displayName = null +) { const normalisedConceptId = normaliseTrustedUploadedFileCopyConceptId(conceptId); if (!normalisedConceptId) return; - pendingFileCopyConceptIdsBySession.set( - getPendingFileCopySessionKey(sessionId), - normalisedConceptId - ); + const targetKey = getPendingFileCopySessionKey(sessionId); + pendingFileCopyConceptIdsBySession.set(targetKey, normalisedConceptId); + const normalisedDisplayName = normalisePendingAttachmentDisplayName(displayName); + if (normalisedDisplayName) { + pendingFileCopyDisplayNamesBySession.set(targetKey, normalisedDisplayName); + } else { + pendingFileCopyDisplayNamesBySession.delete(targetKey); + } + renderPendingAttachmentState(); } -function takePendingUploadedFileCopyConceptId(sessionId) { +function takePendingUploadedFileCopyBinding(sessionId) { const targetKey = getPendingFileCopySessionKey(sessionId); const targetConceptId = pendingFileCopyConceptIdsBySession.get(targetKey) || null; - if (targetConceptId) { - pendingFileCopyConceptIdsBySession.delete(targetKey); - return targetConceptId; - } - return null; + if (!targetConceptId) return null; + const displayName = pendingFileCopyDisplayNamesBySession.get(targetKey) || null; + pendingFileCopyConceptIdsBySession.delete(targetKey); + pendingFileCopyDisplayNamesBySession.delete(targetKey); + renderPendingAttachmentState(); + return { + conceptId: targetConceptId, + displayName + }; +} + +function takePendingUploadedFileCopyConceptId(sessionId) { + return takePendingUploadedFileCopyBinding(sessionId)?.conceptId || null; } function restorePendingUploadedFileCopyConceptId( @@ -17959,6 +18212,11 @@ function restorePendingUploadedFileCopyConceptId( return false; } pendingFileCopyConceptIdsBySession.set(targetKey, normalisedConceptId); + const displayName = normalisePendingAttachmentDisplayName(options.displayName); + if (displayName) { + pendingFileCopyDisplayNamesBySession.set(targetKey, displayName); + } + renderPendingAttachmentState(); return true; } @@ -17974,20 +18232,16 @@ function releaseRequestFileCopyBinding(request) { } request.attachmentBindingReleased = restorePendingUploadedFileCopyConceptId( request.pendingFileCopyConceptId, - request.sessionId + request.sessionId, + { displayName: request.pendingFileCopyDisplayName } ); return request.attachmentBindingReleased; } -function setUploadStatus(message, type = 'info') { - const el = uploadUiState.statusEl; +function renderUploadStatus(message, type = 'info') { + const el = resolveUploadUiElement('statusEl', 'uploadFileStatus'); if (!el) return; - if (uploadUiState.lastStatusTimeoutId) { - clearTimeout(uploadUiState.lastStatusTimeoutId); - uploadUiState.lastStatusTimeoutId = null; - } - el.textContent = message || ''; el.classList.remove('is-uploading', 'is-success', 'is-error'); if (type === 'uploading') { @@ -17997,21 +18251,56 @@ function setUploadStatus(message, type = 'info') { } else if (type === 'error') { el.classList.add('is-error'); } + refreshAttachmentStatusRegionVisibility(); +} + +function renderActiveUploadUi() { + const activeSessionKey = getPendingFileCopySessionKey(activeChatSessionId); + const activeUploadState = uploadStatesBySession.get(activeSessionKey) || null; + renderUploadStatus( + activeUploadState?.message || '', + activeUploadState?.tone || 'info' + ); + setUploadButtonBusy(activeUploadState?.inFlight === true); +} + +function setUploadStatusForState(uploadState, message, tone = 'info') { + if (!uploadState) return; + if (uploadState.clearTimerId) { + clearTimeout(uploadState.clearTimerId); + uploadState.clearTimerId = null; + } + uploadState.message = message || ''; + uploadState.tone = tone; + if ( + uploadStatesBySession.get(uploadState.sessionKey) === uploadState + && uploadState.sessionKey === getPendingFileCopySessionKey(activeChatSessionId) + ) { + renderActiveUploadUi(); + } } -function clearUploadStatusAfterDelay(delayMs = 5000) { - if (!uploadUiState.statusEl) return; - if (uploadUiState.lastStatusTimeoutId) { - clearTimeout(uploadUiState.lastStatusTimeoutId); +function clearUploadStatusAfterDelay(uploadState, delayMs = 5000) { + if (!uploadState) return; + if (uploadState.clearTimerId) { + clearTimeout(uploadState.clearTimerId); } - uploadUiState.lastStatusTimeoutId = window.setTimeout(() => { - setUploadStatus(''); - uploadUiState.lastStatusTimeoutId = null; + uploadState.clearTimerId = window.setTimeout(() => { + uploadState.clearTimerId = null; + if (uploadStatesBySession.get(uploadState.sessionKey) !== uploadState) { + return; + } + if (uploadState.inFlight) { + setUploadStatusForState(uploadState, '', 'info'); + return; + } + uploadStatesBySession.delete(uploadState.sessionKey); + renderActiveUploadUi(); }, delayMs); } function setUploadButtonBusy(isBusy) { - const btn = uploadUiState.button; + const btn = resolveUploadUiElement('button', 'uploadFileButton'); if (!btn) return; if (!uploadUiState.defaultButtonLabel) { @@ -18027,6 +18316,37 @@ function setUploadButtonBusy(isBusy) { } } +function rekeyUploadStateForSession(uploadState, sessionId) { + if (!uploadState) return; + const nextSessionId = normaliseHistorySessionId(sessionId); + const nextSessionKey = getPendingFileCopySessionKey(nextSessionId); + if (uploadState.sessionKey !== nextSessionKey) { + if (uploadStatesBySession.get(uploadState.sessionKey) === uploadState) { + uploadStatesBySession.delete(uploadState.sessionKey); + } + uploadState.sessionKey = nextSessionKey; + uploadStatesBySession.set(nextSessionKey, uploadState); + } + uploadState.targetSessionId = nextSessionId; + renderActiveUploadUi(); +} + +function appendUploadMessageForSession(sessionId, ...appendMessageArgs) { + const targetSessionId = normaliseHistorySessionId(sessionId); + if ( + targetSessionId + && targetSessionId === normaliseHistorySessionId(activeChatSessionId) + ) { + appendMessage(...appendMessageArgs); + return true; + } + if (targetSessionId) { + invalidateSessionHistoryCache(targetSessionId); + refreshChatSessionTabActivityIndicators(); + } + return false; +} + function isFileDragEvent(event) { const dt = event?.dataTransfer; if (!dt) return false; @@ -18204,7 +18524,11 @@ function buildUploadFailureDiagnosticPayload(file, error, context = {}) { upload_context: { index: Number.isFinite(context.index) ? Number(context.index) : null, total: Number.isFinite(context.total) ? Number(context.total) : null, - active_chat_session_id: activeChatSessionId || null + active_chat_session_id: ( + normaliseHistorySessionId(context.targetSessionId) + || normaliseHistorySessionId(activeChatSessionId) + || null + ) }, error: { message: String(error?.message || error || 'Upload failed'), @@ -18228,10 +18552,10 @@ function buildTurnDiagnosticDebugPayload(errorMessage, diagnosticPayload) { }; } -async function uploadFilesToVon(files) { - const list = Array.from(files || []).filter(Boolean); - if (!list.length) return; - let uploadTargetSessionId = normaliseHistorySessionId(activeChatSessionId); +async function performUploadFilesToVon(list, uploadState) { + let uploadTargetSessionId = normaliseHistorySessionId( + uploadState?.targetSessionId + ); if (!uploadTargetSessionId) { try { const ensuredTarget = await ensurePromptTargetChatSession(); @@ -18240,20 +18564,24 @@ async function uploadFilesToVon(files) { ); } catch (error) { console.error('[chatTab] Unable to prepare an upload conversation:', error); - setUploadStatus('Unable to prepare a conversation for this upload.', 'error'); - clearUploadStatusAfterDelay(); + setUploadStatusForState( + uploadState, + 'Unable to prepare a conversation for this upload.', + 'error' + ); + clearUploadStatusAfterDelay(uploadState); return; } if (!uploadTargetSessionId) { - setUploadStatus('Unable to prepare a conversation for this upload.', 'error'); - clearUploadStatusAfterDelay(); + setUploadStatusForState( + uploadState, + 'Unable to prepare a conversation for this upload.', + 'error' + ); + clearUploadStatusAfterDelay(uploadState); return; } - } - - uploadUiState.inFlight += 1; - if (uploadUiState.inFlight === 1) { - setUploadButtonBusy(true); + rekeyUploadStateForSession(uploadState, uploadTargetSessionId); } const total = list.length; @@ -18264,9 +18592,16 @@ async function uploadFilesToVon(files) { for (const file of list) { index += 1; const sizeLabel = formatBytesForUi(file.size); - appendMessage('User', `Uploading file: ${file.name}${sizeLabel ? ` (${sizeLabel})` : ''}`); - - setUploadStatus(`Uploading ${index}/${total}: ${file.name}`, 'uploading'); + appendUploadMessageForSession( + uploadTargetSessionId, + 'User', + `Uploading file: ${file.name}${sizeLabel ? ` (${sizeLabel})` : ''}` + ); + setUploadStatusForState( + uploadState, + `Uploading ${index}/${total}: ${file.name}`, + 'uploading' + ); try { const result = await uploadSingleFileToVon(file); @@ -18286,7 +18621,8 @@ async function uploadFilesToVon(files) { if (historyRecorded) details.push('Recorded in Conversation history.'); if (downloadUrl) details.push(`[Download attachment](${downloadUrl})`); - appendMessage( + appendUploadMessageForSession( + uploadTargetSessionId, 'Von', `File uploaded and registered as ${conceptId || '(unknown)'}${details.length ? `\n${details.join('\n')}` : ''}` ); @@ -18296,23 +18632,25 @@ async function uploadFilesToVon(files) { if (conceptId) { rememberPendingUploadedFileCopyConceptId( conceptId, - uploadTargetSessionId + uploadTargetSessionId, + file.name ); - insertTextIntoChatPrompt('Attached file uploaded.'); } } catch (error) { console.error('[chatTab] file upload error', error); const errorMessage = `File upload failed: ${String(error?.message || error)}`; const diagnosticsPayload = buildUploadFailureDiagnosticPayload(file, error, { index, - total + total, + targetSessionId: uploadTargetSessionId }); const errorTurnId = `e-upload-${Date.now()}-${index}`; setLlmDebugDataEntry( errorTurnId, buildTurnDiagnosticDebugPayload(errorMessage, diagnosticsPayload) ); - appendMessage( + appendUploadMessageForSession( + uploadTargetSessionId, 'Error', errorMessage, errorTurnId, @@ -18324,18 +18662,94 @@ async function uploadFilesToVon(files) { { diagnosticsPayload } ); failureCount += 1; - setUploadStatus(`Upload failed: ${file.name}`, 'error'); + setUploadStatusForState( + uploadState, + `Upload failed: ${file.name}`, + 'error' + ); } } const summary = `Upload complete: ${successCount} succeeded${failureCount ? `, ${failureCount} failed` : ''}.`; - setUploadStatus(summary, failureCount ? 'error' : 'success'); - clearUploadStatusAfterDelay(); + setUploadStatusForState( + uploadState, + summary, + failureCount ? 'error' : 'success' + ); + clearUploadStatusAfterDelay(uploadState); +} + +function buildUploadSelectionFingerprint(list) { + return list.map((file) => [ + String(file?.name || ''), + Number.isFinite(file?.size) ? Number(file.size) : '', + Number.isFinite(file?.lastModified) ? Number(file.lastModified) : '', + String(file?.type || '') + ].join('|')).join('\n'); +} - uploadUiState.inFlight = Math.max(0, uploadUiState.inFlight - 1); - if (uploadUiState.inFlight === 0) { - setUploadButtonBusy(false); +function uploadFilesToVon(files) { + const list = Array.from(files || []).filter(Boolean); + if (!list.length) return Promise.resolve(); + + const targetSessionId = normaliseHistorySessionId(activeChatSessionId); + const sessionKey = getPendingFileCopySessionKey(targetSessionId); + const selectionFingerprint = buildUploadSelectionFingerprint(list); + const existingState = uploadStatesBySession.get(sessionKey) || null; + if (existingState?.inFlight && existingState.promise) { + const repeatedSelection = ( + existingState.selectionFingerprint === selectionFingerprint + ); + setUploadStatusForState( + existingState, + repeatedSelection + ? 'Upload already in progress; repeated action ignored.' + : 'Another upload is already in progress; this additional selection was not uploaded.', + 'uploading' + ); + return existingState.promise; } + + const preparingLabel = list.length === 1 + ? `Preparing upload: ${String(list[0]?.name || 'file')}` + : `Preparing ${list.length} uploads…`; + + if (existingState?.clearTimerId) { + clearTimeout(existingState.clearTimerId); + } + const uploadState = { + sessionKey, + targetSessionId, + selectionFingerprint, + message: preparingLabel, + tone: 'uploading', + inFlight: true, + promise: null, + clearTimerId: null + }; + const uploadPromise = Promise.resolve() + .then(() => performUploadFilesToVon(list, uploadState)) + .catch((error) => { + console.error('[chatTab] unexpected file upload error', error); + setUploadStatusForState( + uploadState, + `Upload failed: ${String(error?.message || error)}`, + 'error' + ); + clearUploadStatusAfterDelay(uploadState); + }); + uploadState.promise = uploadPromise; + uploadStatesBySession.set(sessionKey, uploadState); + renderActiveUploadUi(); + + const settleUploadState = () => { + uploadState.inFlight = false; + if (uploadStatesBySession.get(uploadState.sessionKey) === uploadState) { + renderActiveUploadUi(); + } + }; + void uploadPromise.then(settleUploadState, settleUploadState); + return uploadPromise; } function convertJustSayInstructionsToButtons(root) { @@ -21235,6 +21649,8 @@ function setActiveChatSession(sessionId, sessionName) { } refreshConversationInfoCopyButtonState(); + renderPendingAttachmentState(); + renderActiveUploadUi(); } function getChatSessionTabsContainer() { @@ -28592,6 +29008,8 @@ export function initializeChatTab() { const uploadFileButton = document.getElementById('uploadFileButton'); const uploadFileInput = document.getElementById('uploadFileInput'); const uploadFileStatus = document.getElementById('uploadFileStatus'); + const chatAttachmentStatus = document.getElementById('chatAttachmentStatus'); + const pendingAttachmentStatus = document.getElementById('pendingAttachmentStatus'); const chatTab = document.getElementById('chatTab'); const inviteButton = document.getElementById('inviteConversationBtn'); const inviteCloseButton = document.getElementById('closeInviteConversation'); @@ -28636,6 +29054,10 @@ export function initializeChatTab() { uploadUiState.button = uploadFileButton || null; uploadUiState.statusEl = uploadFileStatus || null; + uploadUiState.statusRegion = chatAttachmentStatus || null; + uploadUiState.pendingAttachmentEl = pendingAttachmentStatus || null; + renderPendingAttachmentState(); + renderActiveUploadUi(); if (uploadFileButton && uploadFileInput) { uploadFileButton.addEventListener('click', () => { @@ -29509,6 +29931,9 @@ function retryActiveChatRequest() { const fileCopyConceptId = normaliseTrustedUploadedFileCopyConceptId( request.pendingFileCopyConceptId ); + const fileCopyDisplayName = normalisePendingAttachmentDisplayName( + request.pendingFileCopyDisplayName + ); request.attachmentBindingTransferred = !!fileCopyConceptId; abortActiveChatRequest(); if (!prompt.trim()) { @@ -29520,7 +29945,8 @@ function retryActiveChatRequest() { promptOverride: prompt, sessionId: request.sessionId || null, sessionName: request.sessionName || null, - fileCopyConceptId + fileCopyConceptId, + fileCopyDisplayName }); }, 0); } @@ -30668,11 +31094,15 @@ async function handleSendPrompt(options = {}) { const explicitFileCopyConceptId = normaliseTrustedUploadedFileCopyConceptId( options?.fileCopyConceptId ); - const pendingFileCopyConceptId = explicitFileCopyConceptId || ( - fromQueue - ? null - : takePendingUploadedFileCopyConceptId(targetSessionId) - ); + const pendingFileCopyBinding = (!explicitFileCopyConceptId && !fromQueue) + ? takePendingUploadedFileCopyBinding(targetSessionId) + : null; + const pendingFileCopyConceptId = explicitFileCopyConceptId + || pendingFileCopyBinding?.conceptId + || null; + const pendingFileCopyDisplayName = explicitFileCopyConceptId + ? normalisePendingAttachmentDisplayName(options?.fileCopyDisplayName) + : (pendingFileCopyBinding?.displayName || null); const request = { abortController: new AbortController(), sessionId: targetSessionId, @@ -30683,6 +31113,7 @@ async function handleSendPrompt(options = {}) { selectionStart, selectionEnd, pendingFileCopyConceptId, + pendingFileCopyDisplayName, attachmentBindingAccepted: false, attachmentBindingTransferred: false, attachmentBindingReleased: false, @@ -33867,6 +34298,15 @@ export function __testOnly_resetChatRequestState() { liveChatRequestsBySession.clear(); finishedThinkingCardsBySession.clear(); pendingFileCopyConceptIdsBySession.clear(); + pendingFileCopyDisplayNamesBySession.clear(); + for (const uploadState of uploadStatesBySession.values()) { + if (uploadState?.clearTimerId) { + clearTimeout(uploadState.clearTimerId); + } + } + uploadStatesBySession.clear(); + renderActiveUploadUi(); + renderPendingAttachmentState(); queuedChatPrompts = []; if (queuedChatPromptDrainTimer !== null) { clearTimeout(queuedChatPromptDrainTimer); @@ -34006,7 +34446,7 @@ export function __testOnly_setTranscriptTurns(turns = []) { export function __testOnly_extractImageFilesFromClipboardEvent(event) { return extractImageFilesFromClipboardEvent(event); } -export async function __testOnly_uploadFilesToVon(files) { +export function __testOnly_uploadFilesToVon(files) { return uploadFilesToVon(files); } export { formatChatTimestamp, showLlmDebugPopup, switchToChatSession, updateHistoryLength }; diff --git a/src/frontend/web/von_interface/static/styles.css b/src/frontend/web/von_interface/static/styles.css index e7e508463..a4fcfa980 100644 --- a/src/frontend/web/von_interface/static/styles.css +++ b/src/frontend/web/von_interface/static/styles.css @@ -9323,8 +9323,19 @@ body[data-active-tab="settingsTab"] .tab-content-area { } } +.chat-attachment-status { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + min-height: 1.6rem; +} + +.chat-attachment-status.hidden { + display: none; +} + .upload-file-status { - margin-left: 10px; font-size: 0.85rem; color: #475569; min-height: 1.1em; @@ -9345,6 +9356,24 @@ body[data-active-tab="settingsTab"] .tab-content-area { font-weight: 600; } +.pending-attachment-status { + display: inline-flex; + align-items: center; + max-width: 100%; + padding: 3px 9px; + border: 1px solid #93c5fd; + border-radius: 999px; + background: #eff6ff; + color: #1d4ed8; + font-size: 0.82rem; + font-weight: 600; + overflow-wrap: anywhere; +} + +.pending-attachment-status.hidden { + display: none; +} + /* Legacy loading-indicator-sep removed - replaced by thinking-card design */ .history-banner { diff --git a/src/frontend/web/von_interface/templates/chat_tab.html b/src/frontend/web/von_interface/templates/chat_tab.html index f6f50aabc..64d19cffb 100644 --- a/src/frontend/web/von_interface/templates/chat_tab.html +++ b/src/frontend/web/von_interface/templates/chat_tab.html @@ -141,6 +141,11 @@

Talk to Von Neumarkt

+ +
@@ -158,7 +163,6 @@

Talk to Von Neumarkt

- {% if expert_tabs_enabled %} diff --git a/tests/backend/test_display_elements_service.py b/tests/backend/test_display_elements_service.py index 57219e104..24df2d8e7 100644 --- a/tests/backend/test_display_elements_service.py +++ b/tests/backend/test_display_elements_service.py @@ -137,6 +137,29 @@ def test_extract_markdown_tables_parses_rows_and_typed_cells() -> None: assert second_row_types == ["text", "boolean", "date", "number"] +def test_extract_markdown_tables_preserves_source_lines_after_code_fences() -> None: + tables = extract_markdown_tables( + ( + "Before\n" + "```text\n" + "| not | a table |\n" + "| --- | --- |\n" + "```\n" + "Between\n" + "| Task | Status |\n" + "| --- | --- |\n" + "| Alpha | done |\n" + "After\n" + ) + ) + + assert len(tables) == 1 + assert tables[0]["source_span"] == { + "start_line": 7, + "end_line": 9, + } + + def test_build_turn_display_elements_includes_table_elements_for_markdown_tables() -> None: contract = build_turn_display_elements( response_text=( @@ -166,6 +189,15 @@ def test_build_turn_display_elements_includes_table_elements_for_markdown_tables table_element = table_elements[0] assert table_element["payload"]["columns"][0]["label"] == "Task" assert table_element["payload"]["rows"][0]["cells"][1]["value_raw"] == "done" + assert table_element["presentation"] == { + "mode": "inline_primary", + "source_element_id": "screen_text", + "source_span": { + "start_line": 1, + "end_line": 4, + }, + } + assert table_element["provenance"]["source"] == "screen_markdown_table" assert "screen_markdown_tables_detected" in contract["reason_codes"] assert contract["validation"]["valid"] is True @@ -210,6 +242,8 @@ def test_build_turn_display_elements_includes_supplied_table_elements() -> None: table_element["payload"]["rows"][0]["provenance"]["source_concept_id"] == "#V#task_alpha" ) + assert table_element["presentation"] == {"mode": "augment"} + assert table_element["provenance"]["source"] == "screen_structured_table" assert "screen_structured_tables_supplied" in contract["reason_codes"] assert contract["validation"]["valid"] is True @@ -1328,6 +1362,121 @@ def test_validate_turn_display_elements_rejects_invalid_table_shape() -> None: assert any("must reference a declared column" in message for message in errors) +def _table_presentation_validation_contract( + *, + source_element_id: str = "screen_text", + source_element_type: str = "text_block", + source_channel: str = "screen", + source_text: str = "| Task | Status |\n| --- | --- |\n| Alpha | done |", + source_span: dict[str, int] | None = None, +) -> dict[str, object]: + return { + "schema_version": "turn_display_elements_v1", + "elements": [ + { + "element_id": "screen_text", + "element_type": source_element_type, + "channel": source_channel, + "order": 10, + "intent": "primary_response", + "payload": {"text": source_text}, + "provenance": {"source": "response_text"}, + }, + { + "element_id": "screen_table_1", + "element_type": "table", + "channel": "screen", + "order": 16, + "intent": "structured_tabular_view", + "payload": { + "columns": [ + { + "column_id": "task", + "label": "Task", + "data_type": "text", + } + ], + "rows": [ + { + "row_id": "row_1", + "cells": [ + { + "column_id": "task", + "value_raw": "Alpha", + "value_display": "Alpha", + "value_type": "text", + } + ], + } + ], + }, + "presentation": { + "mode": "inline_primary", + "source_element_id": source_element_id, + "source_span": source_span or { + "start_line": 1, + "end_line": 3, + }, + }, + "provenance": {"source": "screen_markdown_table"}, + }, + ], + "reason_codes": [], + } + + +def test_validate_turn_display_elements_rejects_reversed_table_presentation_span() -> None: + valid, errors = validate_turn_display_elements( + _table_presentation_validation_contract( + source_span={"start_line": 4, "end_line": 2}, + ) + ) + + assert valid is False + assert any("must not precede start_line" in message for message in errors) + + +def test_validate_turn_display_elements_rejects_missing_table_presentation_source() -> None: + valid, errors = validate_turn_display_elements( + _table_presentation_validation_contract(source_element_id="missing_text") + ) + + assert valid is False + assert any("must identify exactly one element" in message for message in errors) + + +def test_validate_turn_display_elements_rejects_wrong_table_presentation_source_type() -> None: + valid, errors = validate_turn_display_elements( + _table_presentation_validation_contract(source_element_type="json_block") + ) + + assert valid is False + assert any("must reference a screen text_block" in message for message in errors) + + +def test_validate_turn_display_elements_rejects_table_presentation_span_outside_source() -> None: + valid, errors = validate_turn_display_elements( + _table_presentation_validation_contract( + source_span={"start_line": 1, "end_line": 4}, + ) + ) + + assert valid is False + assert any("must fall within the referenced text" in message for message in errors) + + +def test_validate_turn_display_elements_rejects_span_without_markdown_table() -> None: + valid, errors = validate_turn_display_elements( + _table_presentation_validation_contract( + source_text="Before\nNot a table\nAfter", + source_span={"start_line": 1, "end_line": 3}, + ) + ) + + assert valid is False + assert any("must identify a Markdown table" in message for message in errors) + + def test_validate_turn_display_elements_rejects_invalid_workflow_links() -> None: valid, errors = validate_turn_display_elements( { diff --git a/tests/frontend/chatAttachmentWorkflowInput.test.js b/tests/frontend/chatAttachmentWorkflowInput.test.js index 1666eb0f6..8cdde17b5 100644 --- a/tests/frontend/chatAttachmentWorkflowInput.test.js +++ b/tests/frontend/chatAttachmentWorkflowInput.test.js @@ -45,6 +45,11 @@ describe('chat attachment workflow input binding', () => {
+ + `; @@ -130,13 +135,20 @@ describe('chat attachment workflow input binding', () => { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' } ); + const promptInput = document.getElementById('promptInput'); + promptInput.value = 'Represent the PhD students and supervision information.'; await __testOnly_uploadFilesToVon([uploadedFile]); - const promptInput = document.getElementById('promptInput'); - expect(promptInput.value).toContain('Attached file uploaded.'); + expect(promptInput.value).toBe( + 'Represent the PhD students and supervision information.' + ); expect(promptInput.value).not.toContain('supervision.xlsx'); expect(promptInput.value).not.toContain(fileCopyConceptId); - promptInput.value += '\nRepresent the PhD students and supervision information.'; + const pendingAttachmentStatus = document.getElementById( + 'pendingAttachmentStatus' + ); + expect(pendingAttachmentStatus.classList.contains('hidden')).toBe(false); + expect(pendingAttachmentStatus.textContent).toContain('supervision.xlsx'); await sendMessage(); @@ -146,6 +158,7 @@ describe('chat attachment workflow input binding', () => { }); expect(generateBodies[0].prompt).not.toContain(fileCopyConceptId); expect(generateBodies[0].prompt).not.toContain('supervision.xlsx'); + expect(pendingAttachmentStatus.classList.contains('hidden')).toBe(true); promptInput.value = 'A separate follow-up without an attachment.'; await sendMessage(); @@ -154,6 +167,255 @@ describe('chat attachment workflow input binding', () => { expect(generateBodies[1]).not.toHaveProperty('workflow_inputs'); }, 15000); + test('a slow upload exposes progress and coalesces repeated attachment actions', async () => { + const { __testOnly_uploadFilesToVon } = require(chatTabModulePath); + const fileCopyConceptId = '#V#uploaded_file_copy_slow_guard_test'; + let resolveUpload; + let uploadFetchCount = 0; + const uploadResponse = new Promise((resolve) => { + resolveUpload = resolve; + }); + + global.fetch = jest.fn((url, options = {}) => { + if (url === '/von/api/files/upload') { + uploadFetchCount += 1; + return uploadResponse; + } + if (typeof url === 'string' && url.startsWith('/von/api/render_markdown')) { + const body = JSON.parse(options.body || '{}'); + return Promise.resolve({ + ok: true, + json: async () => ({ html: String(body.text || '') }) + }); + } + if (typeof url === 'string' && url.startsWith('/von/history/length')) { + return Promise.resolve({ + ok: true, + json: async () => ({ history_length: 0, authenticated: true }) + }); + } + return Promise.resolve({ ok: true, json: async () => ({}) }); + }); + + const uploadedFile = new File( + ['candidate,supervisor\nExample,Professor Example\n'], + 'slow-supervision.xlsx', + { + type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + lastModified: 1785167279463 + } + ); + const promptInput = document.getElementById('promptInput'); + promptInput.value = 'Keep this draft exactly as written.'; + + const firstUpload = __testOnly_uploadFilesToVon([uploadedFile]); + await Promise.resolve(); + const repeatedUpload = __testOnly_uploadFilesToVon([uploadedFile]); + + expect(repeatedUpload).toBe(firstUpload); + expect(uploadFetchCount).toBe(1); + expect(promptInput.value).toBe('Keep this draft exactly as written.'); + expect(document.getElementById('chatAttachmentStatus').classList.contains('hidden')).toBe(false); + expect(document.getElementById('uploadFileStatus').textContent).toContain( + 'already in progress' + ); + expect(document.getElementById('uploadFileButton').disabled).toBe(true); + + resolveUpload({ + ok: true, + json: async () => ({ + success: true, + uploaded: { + concept_id: fileCopyConceptId, + type_concept_id: '#V#computer_file_copy' + }, + storage: { + backend: 'test', + key: 'uploads/test/slow-supervision.xlsx' + }, + chat_history_recorded: true + }) + }); + await Promise.all([firstUpload, repeatedUpload]); + await Promise.resolve(); + + expect(uploadFetchCount).toBe(1); + expect(promptInput.value).toBe('Keep this draft exactly as written.'); + expect(document.getElementById('uploadFileButton').disabled).toBe(false); + expect(document.getElementById('pendingAttachmentStatus').textContent).toContain( + 'slow-supervision.xlsx' + ); + const confirmationCount = ( + document.getElementById('scrollableField').textContent.match( + /File uploaded and registered as/g + ) || [] + ).length; + expect(confirmationCount).toBe(1); + }, 15000); + + test('uploads independently in two conversations without discarding either file', async () => { + const { + __testOnly_setActiveChatSession, + __testOnly_uploadFilesToVon + } = require(chatTabModulePath); + const deferredUploads = new Map(); + const uploadFileNames = []; + + global.fetch = jest.fn((url, options = {}) => { + if (url === '/von/api/files/upload') { + const fileName = options.body.get('file').name; + uploadFileNames.push(fileName); + return new Promise((resolve) => { + deferredUploads.set(fileName, resolve); + }); + } + if (typeof url === 'string' && url.startsWith('/von/api/render_markdown')) { + const body = JSON.parse(options.body || '{}'); + return Promise.resolve({ + ok: true, + json: async () => ({ html: String(body.text || '') }) + }); + } + return Promise.resolve({ ok: true, json: async () => ({}) }); + }); + + const fileA = new File(['A'], 'conversation-a.xlsx', { + type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + }); + const fileB = new File(['B'], 'conversation-b.xlsx', { + type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + }); + + const uploadA = __testOnly_uploadFilesToVon([fileA]); + await Promise.resolve(); + __testOnly_setActiveChatSession('conversation-b', 'Conversation B'); + expect(document.getElementById('uploadFileButton').disabled).toBe(false); + + const uploadB = __testOnly_uploadFilesToVon([fileB]); + await Promise.resolve(); + + expect(uploadB).not.toBe(uploadA); + expect(uploadFileNames).toEqual([ + 'conversation-a.xlsx', + 'conversation-b.xlsx' + ]); + + deferredUploads.get('conversation-b.xlsx')({ + ok: true, + json: async () => ({ + success: true, + uploaded: { + concept_id: '#V#uploaded_file_copy_conversation_b', + type_concept_id: '#V#computer_file_copy' + }, + storage: { + backend: 'test', + key: 'uploads/test/conversation-b.xlsx' + }, + chat_history_recorded: true + }) + }); + await uploadB; + expect(document.getElementById('pendingAttachmentStatus').textContent).toContain( + 'conversation-b.xlsx' + ); + + deferredUploads.get('conversation-a.xlsx')({ + ok: true, + json: async () => ({ + success: true, + uploaded: { + concept_id: '#V#uploaded_file_copy_conversation_a', + type_concept_id: '#V#computer_file_copy' + }, + storage: { + backend: 'test', + key: 'uploads/test/conversation-a.xlsx' + }, + chat_history_recorded: true + }) + }); + await uploadA; + + expect(document.getElementById('pendingAttachmentStatus').textContent).toContain( + 'conversation-b.xlsx' + ); + __testOnly_setActiveChatSession('attachment-session', 'Attachment test'); + expect(document.getElementById('pendingAttachmentStatus').textContent).toContain( + 'conversation-a.xlsx' + ); + }, 15000); + + test('does not leak upload progress or completion into another conversation', async () => { + const { + __testOnly_setActiveChatSession, + __testOnly_uploadFilesToVon + } = require(chatTabModulePath); + let resolveUpload; + const uploadResponse = new Promise((resolve) => { + resolveUpload = resolve; + }); + + global.fetch = jest.fn((url, options = {}) => { + if (url === '/von/api/files/upload') { + return uploadResponse; + } + if (typeof url === 'string' && url.startsWith('/von/api/render_markdown')) { + const body = JSON.parse(options.body || '{}'); + return Promise.resolve({ + ok: true, + json: async () => ({ html: String(body.text || '') }) + }); + } + return Promise.resolve({ ok: true, json: async () => ({}) }); + }); + + const uploadedFile = new File(['A'], 'stay-in-a.xlsx', { + type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + }); + const upload = __testOnly_uploadFilesToVon([uploadedFile]); + await Promise.resolve(); + expect(document.getElementById('uploadFileStatus').textContent).toContain( + 'stay-in-a.xlsx' + ); + + __testOnly_setActiveChatSession('conversation-b', 'Conversation B'); + document.getElementById('scrollableField').textContent = ''; + expect(document.getElementById('uploadFileStatus').textContent).toBe(''); + expect(document.getElementById('uploadFileButton').disabled).toBe(false); + + resolveUpload({ + ok: true, + json: async () => ({ + success: true, + uploaded: { + concept_id: '#V#uploaded_file_copy_stay_in_a', + type_concept_id: '#V#computer_file_copy' + }, + storage: { + backend: 'test', + key: 'uploads/test/stay-in-a.xlsx' + }, + chat_history_recorded: true + }) + }); + await upload; + + expect(document.getElementById('uploadFileStatus').textContent).toBe(''); + expect(document.getElementById('pendingAttachmentStatus').classList.contains('hidden')).toBe(true); + expect(document.getElementById('scrollableField').textContent).not.toContain( + 'File uploaded and registered as' + ); + + __testOnly_setActiveChatSession('attachment-session', 'Attachment test'); + expect(document.getElementById('uploadFileStatus').textContent).toContain( + 'Upload complete' + ); + expect(document.getElementById('pendingAttachmentStatus').textContent).toContain( + 'stay-in-a.xlsx' + ); + }, 15000); + test('a rejected generate request retains the attachment for retry', async () => { const { __testOnly_uploadFilesToVon, @@ -220,11 +482,17 @@ describe('chat attachment workflow input binding', () => { await __testOnly_uploadFilesToVon([uploadedFile]); const promptInput = document.getElementById('promptInput'); - promptInput.value += '\nRepresent this spreadsheet.'; + const pendingAttachmentStatus = document.getElementById( + 'pendingAttachmentStatus' + ); + promptInput.value = 'Represent this spreadsheet.'; await sendMessage(); + expect(pendingAttachmentStatus.classList.contains('hidden')).toBe(false); + expect(pendingAttachmentStatus.textContent).toContain('retry.xlsx'); promptInput.value = 'Retry the spreadsheet representation.'; await sendMessage(); + expect(pendingAttachmentStatus.classList.contains('hidden')).toBe(true); expect(generateBodies).toHaveLength(2); expect(generateBodies[0].workflow_inputs).toEqual({ @@ -295,11 +563,18 @@ describe('chat attachment workflow input binding', () => { await __testOnly_uploadFilesToVon([uploadedFile]); const promptInput = document.getElementById('promptInput'); + const pendingAttachmentStatus = document.getElementById( + 'pendingAttachmentStatus' + ); + expect(pendingAttachmentStatus.textContent).toContain('session.xlsx'); __testOnly_setActiveChatSession('unrelated-session', 'Unrelated'); + expect(pendingAttachmentStatus.classList.contains('hidden')).toBe(true); promptInput.value = 'An unrelated question.'; await sendMessage(); __testOnly_setActiveChatSession('attachment-session', 'Attachment test'); + expect(pendingAttachmentStatus.classList.contains('hidden')).toBe(false); + expect(pendingAttachmentStatus.textContent).toContain('session.xlsx'); promptInput.value = 'Represent the uploaded spreadsheet.'; await sendMessage(); diff --git a/tests/frontend/chatDisplayElementOwnership.test.js b/tests/frontend/chatDisplayElementOwnership.test.js new file mode 100644 index 000000000..d8d48339a --- /dev/null +++ b/tests/frontend/chatDisplayElementOwnership.test.js @@ -0,0 +1,320 @@ +/** @jest-environment jsdom */ + +const chatTabModulePath = '../../src/frontend/web/von_interface/static/js/chatTab.js'; + +jest.mock('../../src/frontend/web/von_interface/static/js/apiService.js', () => ({ + annotateTurn: jest.fn(), + getUserContext: jest.fn(), + getWindowSessionId: jest.fn(() => 'window-display-ownership-test') +})); + +jest.mock('../../src/frontend/web/von_interface/static/js/domUtils.js', () => ({ + elements: {}, + renderSpanSuggestions: jest.fn(), + getCurrentUserConceptId: jest.fn(() => null) +})); + +jest.mock('../../src/frontend/web/von_interface/static/js/utils/textDecorator.js', () => ({ + applyCartoucheAppearance: jest.fn(), + cartouchifyElementText: jest.fn(), + cartouchifyVontologyTokensInElement: jest.fn(), + createVontologyAliasCartouche: jest.fn(), + createVontologyCartouche: jest.fn(), + findPotentialConceptAliasMatches: jest.fn(() => []), + getCartoucheAppearanceSettings: jest.fn(() => ({})), + linkifyVontologyTokensInElement: jest.fn(), + normalisePotentialConceptAlias: jest.fn((value) => value), + normalisePotentialConceptId: jest.fn((value) => value), + replaceTextNodeWithVontologyAliasCartouches: jest.fn(() => []) +})); + +const { + __testOnly_renderDisplayElementsIntoContainer +} = require(chatTabModulePath); + +function buildTablePayload(label, value) { + return { + columns: [ + { column_id: 'name', label: 'Name', data_type: 'text' }, + { column_id: 'status', label: 'Status', data_type: 'text' } + ], + rows: [ + { + row_id: `row_${label}`, + cells: [ + { + column_id: 'name', + value_raw: label, + value_display: label, + value_type: 'text' + }, + { + column_id: 'status', + value_raw: value, + value_display: value, + value_type: 'text' + } + ] + } + ] + }; +} + +describe('chat display-element renderer ownership', () => { + test('keeps inline-derived tables primary while rendering explicit structured tables', () => { + const container = document.createElement('div'); + container.dataset.renderMode = 'rendered'; + container.dataset.originalText = [ + 'Before the tables.', + '| Name | Status |', + '| --- | --- |', + '| Alpha | **Yes** |', + '', + '| Name | Status |', + '| --- | --- |', + '| Beta | `pending` |', + 'After the tables.' + ].join('\n'); + container.innerHTML = [ + '

Before the tables.

', + '
AlphaYes
', + '
Betapending
', + '

After the tables.

' + ].join(''); + + __testOnly_renderDisplayElementsIntoContainer(container, { + display_elements: { + schema_version: 'turn_display_elements_v1', + elements: [ + { + element_id: 'screen_text', + element_type: 'text_block', + channel: 'screen', + order: 10, + intent: 'primary_response', + payload: { text: container.dataset.originalText }, + provenance: { source: 'response_text' } + }, + { + element_id: 'screen_table_1', + element_type: 'table', + channel: 'screen', + order: 16, + intent: 'structured_tabular_view', + payload: { + ...buildTablePayload('Alpha', '**Yes**'), + source_span: { start_line: 2, end_line: 4 } + }, + presentation: { + mode: 'inline_primary', + source_element_id: 'screen_text', + source_span: { start_line: 2, end_line: 4 } + }, + provenance: { + source: 'screen_markdown_table', + table_index: 1 + } + }, + { + element_id: 'screen_table_2', + element_type: 'table', + channel: 'screen', + order: 17, + intent: 'structured_tabular_view', + payload: { + ...buildTablePayload('Beta', '`pending`'), + source_span: { start_line: 6, end_line: 8 } + }, + provenance: { + source: 'screen_markdown_table', + table_index: 2 + } + }, + { + element_id: 'screen_structured_table_1', + element_type: 'table', + channel: 'screen', + order: 18, + intent: 'structured_tabular_view', + payload: { + ...buildTablePayload('Alpha', 'Yes with provenance'), + title: 'Explicit comparison' + }, + presentation: { mode: 'augment' }, + provenance: { + source: 'screen_structured_table', + table_index: 1 + } + } + ] + } + }); + + expect(container.dataset.inlinePrimaryTableCount).toBe('2'); + expect(container.querySelectorAll('.inline-primary-table')).toHaveLength(2); + expect(container.querySelectorAll('.chat-display-elements-table')).toHaveLength(1); + expect( + container.querySelector('.chat-display-elements-table-title').textContent + ).toBe('Explicit comparison'); + expect(container.textContent).toContain('Before the tables.'); + expect(container.textContent).toContain('After the tables.'); + expect(container.textContent).toContain('Yes with provenance'); + expect(container.textContent).not.toContain('**Yes**'); + expect(container.textContent).not.toContain('`pending`'); + }); + + test('raw view remains faithful and never appends display cards', () => { + const originalText = [ + 'Before.', + '| Name | Status |', + '| --- | --- |', + '| Alpha | **Yes** |', + 'After.' + ].join('\n'); + const container = document.createElement('div'); + container.dataset.renderMode = 'text'; + container.dataset.originalText = originalText; + container.textContent = originalText; + + __testOnly_renderDisplayElementsIntoContainer(container, { + display_elements: { + schema_version: 'turn_display_elements_v1', + elements: [ + { + element_id: 'screen_table_1', + element_type: 'table', + channel: 'screen', + order: 16, + intent: 'structured_tabular_view', + payload: { + ...buildTablePayload('Alpha', '**Yes**'), + source_span: { start_line: 2, end_line: 4 } + }, + presentation: { + mode: 'inline_primary', + source_element_id: 'screen_text', + source_span: { start_line: 2, end_line: 4 } + }, + provenance: { source: 'screen_markdown_table' } + } + ] + } + }); + + expect(container.textContent).toBe(originalText); + expect(container.querySelector('.chat-display-elements')).toBeNull(); + }); + + test('keeps structured fallback when the declared inline source is missing', () => { + const originalText = [ + '| Name | Status |', + '| --- | --- |', + '| Alpha | Yes |' + ].join('\n'); + const container = document.createElement('div'); + container.dataset.renderMode = 'rendered'; + container.dataset.originalText = originalText; + container.innerHTML = '
AlphaYes
'; + + __testOnly_renderDisplayElementsIntoContainer(container, { + display_elements: { + schema_version: 'turn_display_elements_v1', + elements: [ + { + element_id: 'screen_table_1', + element_type: 'table', + channel: 'screen', + order: 16, + intent: 'structured_tabular_view', + payload: { + ...buildTablePayload('Alpha', 'Yes'), + source_span: { start_line: 1, end_line: 3 } + }, + presentation: { + mode: 'inline_primary', + source_element_id: 'missing_text', + source_span: { start_line: 1, end_line: 3 } + }, + provenance: { source: 'screen_markdown_table' } + } + ] + } + }); + + expect(container.dataset.inlinePrimaryTableCount).toBe('0'); + expect(container.querySelectorAll('table')).toHaveLength(2); + expect(container.querySelector('.chat-display-elements-table')).not.toBeNull(); + }); + + test('keeps all structured fallbacks when inline table rendering is incomplete', () => { + const originalText = [ + '| Name | Status |', + '| --- | --- |', + '| Alpha | Yes |', + '', + '| Name | Status |', + '| --- | --- |', + '| Beta | pending |' + ].join('\n'); + const container = document.createElement('div'); + container.dataset.renderMode = 'rendered'; + container.dataset.originalText = originalText; + container.innerHTML = '
AlphaYes
'; + + __testOnly_renderDisplayElementsIntoContainer(container, { + display_elements: { + schema_version: 'turn_display_elements_v1', + elements: [ + { + element_id: 'screen_text', + element_type: 'text_block', + channel: 'screen', + order: 10, + intent: 'primary_response', + payload: { text: originalText }, + provenance: { source: 'response_text' } + }, + { + element_id: 'screen_table_1', + element_type: 'table', + channel: 'screen', + order: 16, + intent: 'structured_tabular_view', + payload: { + ...buildTablePayload('Alpha', 'Yes'), + source_span: { start_line: 1, end_line: 3 } + }, + presentation: { + mode: 'inline_primary', + source_element_id: 'screen_text', + source_span: { start_line: 1, end_line: 3 } + }, + provenance: { source: 'screen_markdown_table' } + }, + { + element_id: 'screen_table_2', + element_type: 'table', + channel: 'screen', + order: 17, + intent: 'structured_tabular_view', + payload: { + ...buildTablePayload('Beta', 'pending'), + source_span: { start_line: 5, end_line: 7 } + }, + presentation: { + mode: 'inline_primary', + source_element_id: 'screen_text', + source_span: { start_line: 5, end_line: 7 } + }, + provenance: { source: 'screen_markdown_table' } + } + ] + } + }); + + expect(container.dataset.inlinePrimaryTableCount).toBe('0'); + expect(container.querySelectorAll('.chat-display-elements-table')).toHaveLength(2); + expect(container.textContent).toContain('Alpha'); + expect(container.textContent).toContain('Beta'); + }); +}); diff --git a/tests/frontend/chatTabMarkdownRender.test.js b/tests/frontend/chatTabMarkdownRender.test.js index e71b67be4..9720daee5 100644 --- a/tests/frontend/chatTabMarkdownRender.test.js +++ b/tests/frontend/chatTabMarkdownRender.test.js @@ -767,6 +767,63 @@ describe('chat markdown rendering (assistant)', () => { '', '' ].join(''); + const displayElements = { + schema_version: 'turn_display_elements_v1', + elements: [ + { + element_id: 'screen_text', + element_type: 'text_block', + channel: 'screen', + order: 10, + intent: 'primary_response', + payload: { text: assistantResponse }, + provenance: { source: 'response_text' } + }, + { + element_id: 'screen_table_1', + element_type: 'table', + channel: 'screen', + order: 16, + intent: 'structured_tabular_view', + payload: { + columns: [ + { column_id: 'name', label: 'Name', data_type: 'text' }, + { column_id: 'degree', label: 'Degree', data_type: 'text' }, + { column_id: 'role', label: 'Role', data_type: 'text' } + ], + rows: [ + { + row_id: 'row_1', + cells: [ + { column_id: 'name', value_raw: 'Alice', value_display: 'Alice', value_type: 'text' }, + { column_id: 'degree', value_raw: 'PhD', value_display: 'PhD', value_type: 'text' }, + { column_id: 'role', value_raw: 'Student', value_display: 'Student', value_type: 'text' } + ] + }, + { + row_id: 'row_2', + cells: [ + { column_id: 'name', value_raw: 'Bob', value_display: 'Bob', value_type: 'text' }, + { column_id: 'degree', value_raw: 'MSc', value_display: 'MSc', value_type: 'text' }, + { column_id: 'role', value_raw: 'Tutor', value_display: 'Tutor', value_type: 'text' } + ] + } + ] + }, + presentation: { + mode: 'inline_primary', + source_element_id: 'screen_text', + source_span: { start_line: 1, end_line: 4 } + }, + provenance: { + source: 'screen_markdown_table', + table_index: 1 + } + } + ], + reason_codes: ['screen_markdown_tables_detected'], + validation: { valid: true, errors: [] } + }; global.fetch = jest.fn((url) => { const common = mockCommonChatSessionResponse(url); @@ -791,7 +848,11 @@ describe('chat markdown rendering (assistant)', () => { ok: true, json: async () => ({ response: assistantResponse, - llm_debug: { model: 'gpt-5.4-mini-2026-03-17' } + display_elements: displayElements, + llm_debug: { + model: 'gpt-5.4-mini-2026-03-17', + display_elements: displayElements + } }) }); } @@ -806,6 +867,9 @@ describe('chat markdown rendering (assistant)', () => { const assistantMarkdown = scrollableField.querySelector('.chat-markdown.markdown-rendered'); expect(assistantMarkdown).not.toBeNull(); expect(assistantMarkdown.querySelector('table')).not.toBeNull(); + expect(assistantMarkdown.querySelectorAll('table')).toHaveLength(1); + expect(assistantMarkdown.querySelector('.chat-display-elements-table')).toBeNull(); + expect(assistantMarkdown.dataset.inlinePrimaryTableCount).toBe('1'); expect(assistantMarkdown.textContent).toContain('Alice'); const assistantContainer = assistantMarkdown.closest('.message-container'); @@ -829,6 +893,8 @@ describe('chat markdown rendering (assistant)', () => { expect(assistantMarkdown.dataset.renderMode).toBe('rendered'); expect(assistantMarkdown.classList.contains('chat-markdown')).toBe(true); expect(assistantMarkdown.querySelector('table')).not.toBeNull(); + expect(assistantMarkdown.querySelectorAll('table')).toHaveLength(1); + expect(assistantMarkdown.querySelector('.chat-display-elements-table')).toBeNull(); expect(renderBadge.textContent).toContain('View: Rendered'); expect(toggleButton.textContent).toBe('Text'); });