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.
',
+ '
',
+ '
',
+ '
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 = '
';
+
+ __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 = '
';
+
+ __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');
});