diff --git a/application/single_app/config.py b/application/single_app/config.py index 675ef29d8..657017bd0 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -96,7 +96,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.250.209" +VERSION = "0.250.210" IS_DEVELOPMENT = is_development_env_enabled() SESSION_COOKIE_SAMESITE = os.getenv('SESSION_COOKIE_SAMESITE', 'Lax') diff --git a/application/single_app/static/css/chats.css b/application/single_app/static/css/chats.css index 2fea70a68..541a8e3fc 100644 --- a/application/single_app/static/css/chats.css +++ b/application/single_app/static/css/chats.css @@ -809,6 +809,30 @@ img.chat-searchable-select-item-icon { white-space: nowrap; } +/* Document rows stack the title above the file name when the two differ */ +#document-dropdown-items .dropdown-item .chat-document-option-text { + display: flex; + flex: 1 1 auto; + flex-direction: column; + min-width: 0; + overflow: hidden; +} + +#document-dropdown-items .dropdown-item .chat-document-option-title, +#document-dropdown-items .dropdown-item .chat-document-option-filename { + display: block; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +#document-dropdown-items .dropdown-item .chat-document-option-filename { + font-size: 0.75rem; + line-height: 1.2; + opacity: 0.85; +} + /* Document dropdown items must be explicitly displayed */ #document-dropdown .dropdown-item { align-items: center; diff --git a/application/single_app/static/js/chat/chat-documents.js b/application/single_app/static/js/chat/chat-documents.js index 62ad623c1..8386f3d7a 100644 --- a/application/single_app/static/js/chat/chat-documents.js +++ b/application/single_app/static/js/chat/chat-documents.js @@ -1435,6 +1435,10 @@ function getDocumentDisplayName(documentItem) { return (documentItem.title || documentItem.file_name || 'Untitled Document').trim() || 'Untitled Document'; } +function getDocumentFileName(documentItem) { + return String((documentItem || {}).file_name || '').trim(); +} + function createDropdownHeader(label) { const header = document.createElement('div'); header.classList.add('dropdown-header', 'small', 'text-muted', 'px-2', 'pt-2', 'pb-1'); @@ -1449,10 +1453,16 @@ function createDropdownDivider() { } function buildDocumentDescriptor(documentItem, sectionLabel) { + const displayName = getDocumentDisplayName(documentItem); + const fileName = getDocumentFileName(documentItem); + const showsFileName = Boolean(fileName) && fileName.toLowerCase() !== displayName.toLowerCase(); + return { id: documentItem.id, - label: getDocumentDisplayName(documentItem), - searchLabel: `${getDocumentDisplayName(documentItem)} ${sectionLabel}`.trim(), + label: displayName, + fileName: fileName, + secondaryLabel: showsFileName ? fileName : '', + searchLabel: [displayName, fileName, sectionLabel].filter(Boolean).join(' '), tags: documentItem.tags || [], classification: documentItem.document_classification || '', }; @@ -1484,7 +1494,7 @@ function appendDocumentSection(sectionLabel, documents, sectionIndex) { dropdownItem.classList.add('dropdown-item', 'd-flex', 'align-items-center'); dropdownItem.setAttribute('data-document-id', doc.id); dropdownItem.setAttribute('data-search-role', 'item'); - dropdownItem.setAttribute('title', doc.label); + dropdownItem.setAttribute('title', doc.secondaryLabel ? `${doc.label}\n${doc.secondaryLabel}` : doc.label); dropdownItem.dataset.searchLabel = doc.searchLabel; dropdownItem.dataset.tags = JSON.stringify(doc.tags || []); dropdownItem.dataset.classification = doc.classification || ''; @@ -1498,14 +1508,23 @@ function appendDocumentSection(sectionLabel, documents, sectionIndex) { checkbox.style.pointerEvents = 'none'; checkbox.style.minWidth = '16px'; - const label = document.createElement('span'); - label.textContent = doc.label; - label.style.overflow = 'hidden'; - label.style.textOverflow = 'ellipsis'; - label.style.whiteSpace = 'nowrap'; + const labelWrapper = document.createElement('span'); + labelWrapper.classList.add('chat-document-option-text'); + + const primaryLabel = document.createElement('span'); + primaryLabel.classList.add('chat-document-option-title'); + primaryLabel.textContent = doc.label; + labelWrapper.appendChild(primaryLabel); + + if (doc.secondaryLabel) { + const secondaryLabel = document.createElement('span'); + secondaryLabel.classList.add('chat-document-option-filename', 'text-muted'); + secondaryLabel.textContent = doc.secondaryLabel; + labelWrapper.appendChild(secondaryLabel); + } dropdownItem.appendChild(checkbox); - dropdownItem.appendChild(label); + dropdownItem.appendChild(labelWrapper); docDropdownItems.appendChild(dropdownItem); }); } @@ -2605,8 +2624,11 @@ function syncDropdownButtonText() { textEl.textContent = isExplicitDocumentSelectionMode() ? "Select Documents" : "All Documents"; } else if (count === 1) { const selectedDocumentId = selectedDocumentOptions[0].value; - const labelSpan = docDropdownItems - ? docDropdownItems.querySelector(`.dropdown-item[data-document-id="${selectedDocumentId}"] span`) + const selectedItem = docDropdownItems + ? docDropdownItems.querySelector(`.dropdown-item[data-document-id="${selectedDocumentId}"]`) + : null; + const labelSpan = selectedItem + ? (selectedItem.querySelector('.chat-document-option-title') || selectedItem.querySelector('span')) : null; textEl.textContent = labelSpan ? labelSpan.textContent : "1 document selected"; } else { diff --git a/application/single_app/static/js/chat/chat-onload.js b/application/single_app/static/js/chat/chat-onload.js index ecb057605..0e0a94652 100644 --- a/application/single_app/static/js/chat/chat-onload.js +++ b/application/single_app/static/js/chat/chat-onload.js @@ -331,8 +331,11 @@ window.addEventListener('DOMContentLoaded', async () => { if (textEl) { if (docIdsToSelect.length === 1) { // Find the label from the dropdown item - const matchItem = docDropdownItems - ? docDropdownItems.querySelector(`.dropdown-item[data-document-id="${docIdsToSelect[0]}"] span`) + const matchRow = docDropdownItems + ? docDropdownItems.querySelector(`.dropdown-item[data-document-id="${docIdsToSelect[0]}"]`) + : null; + const matchItem = matchRow + ? (matchRow.querySelector('.chat-document-option-title') || matchRow.querySelector('span')) : null; textEl.textContent = matchItem ? matchItem.textContent : "1 document selected"; } else { diff --git a/application/single_app/static/js/chat/chat-searchable-select.js b/application/single_app/static/js/chat/chat-searchable-select.js index 2ccf571a4..07a96e3b8 100644 --- a/application/single_app/static/js/chat/chat-searchable-select.js +++ b/application/single_app/static/js/chat/chat-searchable-select.js @@ -1,5 +1,28 @@ // chat-searchable-select.js +const SEARCH_ROLE_ACTION = 'action'; +const SEARCH_SEPARATOR_PATTERN = /[_\-.]+/g; +const SEARCH_WHITESPACE_PATTERN = /\s+/g; + +export function normalizeSearchText(value) { + return String(value ?? '') + .toLowerCase() + .replace(SEARCH_SEPARATOR_PATTERN, ' ') + .replace(SEARCH_WHITESPACE_PATTERN, ' ') + .trim(); +} + +export function matchesSearchTokens(searchText, searchTerm) { + const normalizedTerm = normalizeSearchText(searchTerm); + + if (!normalizedTerm) { + return true; + } + + const normalizedText = normalizeSearchText(searchText); + return normalizedTerm.split(' ').every(token => normalizedText.includes(token)); +} + function createNoMatchesElement(message) { const noMatchesEl = document.createElement('div'); noMatchesEl.className = 'no-matches text-center text-muted py-2'; @@ -14,12 +37,89 @@ function removeNoMatchesElement(itemsContainerEl) { } } +function isHiddenElement(el) { + return Boolean(el && el.classList.contains('d-none')); +} + +function isDividerElement(el) { + return Boolean(el && el.classList.contains('dropdown-divider')); +} + +function isHeaderElement(el) { + return Boolean(el && el.classList.contains('dropdown-header')); +} + function isVisibleItem(el) { - return Boolean( - el && - !el.classList.contains('d-none') && - !el.classList.contains('dropdown-divider') - ); + return Boolean(el && !isHiddenElement(el) && !isDividerElement(el)); +} + +// Section content is what a divider is allowed to separate. Always-visible action rows +// are excluded so an orphaned divider cannot anchor itself to the "Select All" row. +function isVisibleSectionContent(el) { + if (!el || isHiddenElement(el) || isDividerElement(el)) { + return false; + } + + if (isHeaderElement(el)) { + return true; + } + + return el.classList.contains('dropdown-item') + && el.getAttribute('data-search-role') !== SEARCH_ROLE_ACTION; +} + +// A divider is "bound" when it introduces a section header, so its visibility follows +// that header instead of whatever unrelated row happens to still be visible above it. +function findDividerBoundHeader(children, dividerIndex) { + for (let index = dividerIndex + 1; index < children.length; index += 1) { + const candidate = children[index]; + + if (isDividerElement(candidate)) { + continue; + } + + return isHeaderElement(candidate) ? candidate : null; + } + + return null; +} + +function collapseRedundantDividers(children) { + let sawVisibleContent = false; + + children.forEach(child => { + if (isDividerElement(child)) { + if (isHiddenElement(child)) { + return; + } + + if (!sawVisibleContent) { + child.classList.add('d-none'); + return; + } + + sawVisibleContent = false; + return; + } + + if (!isHiddenElement(child)) { + sawVisibleContent = true; + } + }); + + for (let index = children.length - 1; index >= 0; index -= 1) { + const child = children[index]; + + if (isHiddenElement(child)) { + continue; + } + + if (!isDividerElement(child)) { + break; + } + + child.classList.add('d-none'); + } } function updateDropdownStructure(itemsContainerEl) { @@ -30,14 +130,14 @@ function updateDropdownStructure(itemsContainerEl) { const children = Array.from(itemsContainerEl.children).filter(child => !child.classList.contains('no-matches')); children.forEach(child => { - if (!child.classList.contains('dropdown-header')) { + if (!isHeaderElement(child)) { return; } let hasVisibleItem = false; let next = child.nextElementSibling; - while (next && !next.classList.contains('dropdown-header')) { + while (next && !isHeaderElement(next)) { if (next.classList.contains('dropdown-item') && isVisibleItem(next)) { hasVisibleItem = true; break; @@ -48,33 +148,27 @@ function updateDropdownStructure(itemsContainerEl) { child.classList.toggle('d-none', !hasVisibleItem); }); - children.forEach(child => { - if (!child.classList.contains('dropdown-divider')) { + children.forEach((child, index) => { + if (!isDividerElement(child)) { return; } - let previousVisible = null; - let previous = child.previousElementSibling; - while (previous) { - if (!previous.classList.contains('no-matches') && isVisibleItem(previous)) { - previousVisible = previous; - break; - } - previous = previous.previousElementSibling; - } + const boundHeader = findDividerBoundHeader(children, index); + const hasSectionContentBefore = children.slice(0, index).some(sibling => isVisibleSectionContent(sibling)); + let keepDivider; - let nextVisible = null; - let next = child.nextElementSibling; - while (next) { - if (!next.classList.contains('no-matches') && isVisibleItem(next)) { - nextVisible = next; - break; - } - next = next.nextElementSibling; + if (boundHeader) { + keepDivider = !isHiddenElement(boundHeader) && hasSectionContentBefore; + } else { + const hasVisibleContentBefore = children.slice(0, index).some(sibling => isVisibleItem(sibling)); + const hasSectionContentAfter = children.slice(index + 1).some(sibling => isVisibleSectionContent(sibling)); + keepDivider = hasVisibleContentBefore && hasSectionContentAfter; } - child.classList.toggle('d-none', !(previousVisible && nextVisible)); + child.classList.toggle('d-none', !keepDivider); }); + + collapseRedundantDividers(children); } function createDropdownHeader(label) { @@ -160,14 +254,13 @@ export function initializeFilterableDropdownSearch({ const isAlwaysVisible = isAlwaysVisibleItem || (() => false); const applyFilter = (rawSearchTerm = '') => { - const searchTerm = rawSearchTerm.toLowerCase().trim(); + const searchTerm = normalizeSearchText(rawSearchTerm); const items = Array.from(itemsContainerEl.querySelectorAll(itemSelector)); let visibleMatchCount = 0; items.forEach(item => { const keepVisible = isAlwaysVisible(item); - const searchText = String(readSearchText(item) || '').toLowerCase(); - const matches = !searchTerm || keepVisible || searchText.includes(searchTerm); + const matches = keepVisible || matchesSearchTokens(readSearchText(item), searchTerm); item.classList.toggle('d-none', !matches); @@ -289,7 +382,7 @@ export function createSearchableSingleSelect({ }; const renderOptions = () => { - const searchTerm = searchInputEl.value.toLowerCase().trim(); + const searchTerm = normalizeSearchText(searchInputEl.value); const options = Array.from(selectEl.options); const optionIndexMap = new Map(options.map((option, index) => [option, index])); const selectedIndex = selectEl.selectedIndex; @@ -311,7 +404,7 @@ export function createSearchableSingleSelect({ const index = optionIndexMap.get(option); const optionLabel = readOptionLabel(option); const optionSearchText = String(readOptionSearchText(option) || optionLabel).toLowerCase(); - const matches = !searchTerm || optionSearchText.includes(searchTerm); + const matches = matchesSearchTokens(optionSearchText, searchTerm); const item = document.createElement('button'); item.type = 'button'; diff --git a/docs/explanation/fixes/CHAT_DOCUMENT_SEARCH_FILENAME_AND_DIVIDER_FIX.md b/docs/explanation/fixes/CHAT_DOCUMENT_SEARCH_FILENAME_AND_DIVIDER_FIX.md new file mode 100644 index 000000000..55b12f483 --- /dev/null +++ b/docs/explanation/fixes/CHAT_DOCUMENT_SEARCH_FILENAME_AND_DIVIDER_FIX.md @@ -0,0 +1,224 @@ +# Chat Document Search File Name and Divider Artifact Fix + +**Fixed in version: 0.250.210** + +**Related issue:** [#1256](https://github.com/microsoft/simplechat/issues/1256) + +## Issue Description + +Two defects affected the chat page grounded-search document picker (`#search-documents-container`). + +1. **File names could not be searched.** The document picker matched only on the document's + display name, which is `title || file_name`. As soon as a document had an extracted `title`, + its `file_name` became completely unsearchable. A user who only remembered a fragment of the + file name — for example `200` inside `Quarterly_Report_200_final.pdf` — received + "No Matching Documents". + +2. **Filtering left orphaned separator lines behind.** When a search filtered out the leading + workspace sections, the section separator lines belonging to those sections survived, usually + as two stacked horizontal rules directly under the "Select All" / "Clear All" row. This + affected the Document, Scope, and Tags dropdowns. + +A third, related usability gap was addressed at the same time: matching was a single raw +substring test, so multi-word queries and separator-heavy file names did not match natural +typing (`report 200` versus `Quarterly_Report_200_final.pdf`). + +## Root Cause Analysis + +### File name matching + +`buildDocumentDescriptor()` in `chat-documents.js` built the searchable text from the display +name and the section label only: + +```javascript +searchLabel: `${getDocumentDisplayName(documentItem)} ${sectionLabel}`.trim(), +``` + +Because `getDocumentDisplayName()` returns `title || file_name`, the file name never reached the +matcher for any document that had a title. + +### Separator artifacts + +`updateDropdownStructure()` in `chat-searchable-select.js` decided divider visibility by scanning +outward for the nearest visible sibling: + +```javascript +let previousVisible = null; +let previous = child.previousElementSibling; +while (previous) { + if (!previous.classList.contains('no-matches') && isVisibleItem(previous)) { + previousVisible = previous; + break; + } + previous = previous.previousElementSibling; +} +``` + +`isVisibleItem()` reported dividers as "not visible", so the scan walked straight through other +dividers, and the always-visible `data-search-role="action"` row ("Select All" / "Clear All") +counted as valid content. Every orphaned divider therefore found the same action row above it and +the first surviving section header below it, so all of them stayed visible. + +Reproduced against the pre-fix logic: + +``` +DOC: filter "beta" TAGS: filter "confidential" + - Select All - Clear All + ------------------ ------------------ + ------------------ ------------------ <- leftover artifacts + # [Public] Beta # Classifications + - Beta Handbook - Confidential +``` + +### Matching strictness + +Both selector code paths used a raw substring test (`searchText.includes(searchTerm)` and +`optionSearchText.includes(searchTerm)`), which cannot match a query whose words are separated +differently from the source text. + +## Technical Details + +### Files Modified + +| File | Change | +|------|--------| +| `application/single_app/static/js/chat/chat-searchable-select.js` | Added `normalizeSearchText()` and `matchesSearchTokens()`; rewrote divider visibility with section-aware rules | +| `application/single_app/static/js/chat/chat-documents.js` | Added `getDocumentFileName()`; extended the document descriptor; render a muted file-name line; read the title span for the button label | +| `application/single_app/static/js/chat/chat-onload.js` | Read the title span when restoring a deep-linked document selection | +| `application/single_app/static/css/chats.css` | Added `.chat-document-option-text` / `-title` / `-filename` rules | +| `application/single_app/config.py` | Version bumped to `0.250.210` | + +### Token matching + +Search text and the typed query are normalized identically: lowercased, with `_`, `-`, and `.` +treated as word breaks and whitespace collapsed. A row matches when **every** token in the query +appears somewhere in the normalized text. + +```javascript +export function normalizeSearchText(value) { + return String(value ?? '') + .toLowerCase() + .replace(SEARCH_SEPARATOR_PATTERN, ' ') + .replace(SEARCH_WHITESPACE_PATTERN, ' ') + .trim(); +} + +export function matchesSearchTokens(searchText, searchTerm) { + const normalizedTerm = normalizeSearchText(searchTerm); + + if (!normalizedTerm) { + return true; + } + + const normalizedText = normalizeSearchText(searchText); + return normalizedTerm.split(' ').every(token => normalizedText.includes(token)); +} +``` + +Matching remains position-independent, so `200` still matches mid-file-name. The helper is shared, +so the scope, tags, document, prompt, model, and agent selectors all behave the same way. + +### Searchable document text + +```javascript +searchLabel: [displayName, fileName, sectionLabel].filter(Boolean).join(' '), +``` + +The file name is also surfaced visually as muted secondary text, but only when it differs from +the title, so documents without extracted metadata do not render the same string twice. Both +lines are written with `textContent` and appear in the row tooltip. + +### Divider rules + +Each `.dropdown-divider` in the items container is now classified: + +- **Bound divider** — the next non-divider sibling is a `.dropdown-header`, so the divider + introduces a section. Visible only when that header is visible **and** visible *section content* + exists before it. +- **Unbound divider** — anything else, such as the static divider under "All" / "Clear All". + Visible only when a visible non-divider element exists before it **and** visible *section + content* exists after it. +- *Section content* is a visible `.dropdown-header`, or a visible `.dropdown-item` whose + `data-search-role` is not `action`. Always-visible action rows never qualify, so an orphaned + divider can no longer anchor itself to the "Select All" row. +- A final pass (`collapseRedundantDividers`) hides any leading, trailing, or adjacent visible + divider, guaranteeing separator lines can never stack regardless of future markup. + +## Before and After + +| Scenario | Before | After | +|----------|--------|-------| +| Search `200` where the title is `Fiscal Overview` and the file is `Quarterly_Report_200_final.pdf` | `No Matching Documents` | The document is listed under its workspace section | +| Search `report 200` | No match | The document is listed | +| Search matching only the last workspace section | Two stacked separator lines under the action row | No separator lines | +| Tags search matching only a classification | Two stacked separator lines | One separator line above `Classifications` | +| Document row with a distinct file name | Title only | Title with the file name as a smaller muted line | + +Filtered dropdown output after the fix: + +``` +DOC: filter "beta" TAGS: filter "confidential" + - Select All - Clear All + # [Public] Beta ------------------ + - Beta Handbook # Classifications + - Confidential +``` + +## Testing + +### Functional test + +`functional_tests/test_chat_document_search_filename_matching.py` — 8 checks covering the +descriptor wiring, safe row rendering, button-label decoupling, token matching, section-aware +divider rules, stylesheet rules, and the version bump. The divider and matching checks are +executable rather than static: the test loads the production `chat-searchable-select.js` into a +minimal DOM shim through Node and asserts the exact filtered output for the document, scope, and +tags dropdowns, including that no leading, trailing, or adjacent separator lines survive. The +executable section is skipped with a warning when Node.js is unavailable. + +``` +📊 Results: 8/8 tests passed +``` + +### UI test + +`ui_tests/test_chat_document_search_filename_and_dividers.py` — 4 Playwright regression tests +that mount the real `chat-documents.js` and `chat-searchable-select.js` modules with production +CSS, stub `fetch` with personal, group, and public documents, and drive `loadAllDocs()`: + +- the muted file-name line renders only when it differs from the title, stacks under the title, + uses a smaller font, and appears in the tooltip; +- searching matches file-name fragments, multi-word queries, literal file names, and titles; +- filtering produces no orphaned separator lines and clearing the search restores the structure; +- the mobile drawer filters correctly without horizontal overflow. + +``` +4 passed +``` + +All four fail against the pre-fix source (the file-name search returns +`No Matching Documents`), confirming genuine regression coverage. + +### Regression suite + +`test_chat_search_panel_document_row_layout.py`, `test_chat_document_dropdown_viewport_fit.py`, +`test_chat_scope_selector_sync.py`, and `test_chat_document_action_selector_labels.py` all pass +(12 passed, 5 skipped — the skips require `SIMPLECHAT_UI_BASE_URL`). + +## Impact Analysis + +- Users can now locate documents by any fragment of the title **or** the file name, matching the + behavior the backend already provides for the workspace document list. +- The Compare modal document picker inherits both fixes because `chat-messages.js` relocates the + same `#document-dropdown` field into `#document-comparison-picker-controls`. +- The shared token matcher also improves the prompt, model, and agent selectors. +- No backend change was required — `/api/documents`, `/api/group_documents`, and + `/api/public_workspace_documents` already return `file_name`. +- No new browser assets were introduced; all changes are in existing local static JS and CSS, so + the local-only asset policy and CSP are unaffected. + +### Known trade-off + +Normalizing `.` as a word break means a query such as `a.b` matches text containing `a` and `b` +separately. This is acceptable for a filename-oriented picker and is what makes `report 200` +match `Quarterly_Report_200_final.pdf`. diff --git a/docs/explanation/fixes/index.md b/docs/explanation/fixes/index.md index ebd43a090..3fa9c7f67 100644 --- a/docs/explanation/fixes/index.md +++ b/docs/explanation/fixes/index.md @@ -6,6 +6,7 @@ order: 120 category: Version History --- +- [Chat Document Search File Name and Divider Artifact Fix](CHAT_DOCUMENT_SEARCH_FILENAME_AND_DIVIDER_FIX.md) - [Data Management Restore Route Endpoint Collision Fix](DATA_MANAGEMENT_RESTORE_ROUTE_ENDPOINT_COLLISION_FIX.md) - [Font Size and 200 Percent Zoom Fix](FONT_SIZE_AND_200_PERCENT_ZOOM_FIX.md) - [Public Workspace Prompt Migration Fix](PUBLIC_WORKSPACE_PROMPT_MIGRATION_FIX.md) diff --git a/docs/explanation/release_notes.md b/docs/explanation/release_notes.md index 7122cd9b5..6fd25b120 100644 --- a/docs/explanation/release_notes.md +++ b/docs/explanation/release_notes.md @@ -2,6 +2,28 @@ For feature-focused and fix-focused drill-downs by version, see [Features by Version](/explanation/features/) and [Fixes by Version](/explanation/fixes/). +### **(v0.250.210)** + +#### Bug Fixes + +* **Chat Document Search Now Matches File Names** + * Fixed the chat grounded-search document picker only matching on a document's title, which made file names completely unsearchable for any document that had extracted title metadata. + * Typing any fragment of a file name now surfaces the document, anywhere in the name — searching `200` finds `Quarterly_Report_200_final.pdf`. + * Multi-word queries are also supported, with `_`, `-`, and `.` treated as word breaks, so `report 200` matches `Quarterly_Report_200_final.pdf`. The same improvement applies to the scope, tags, prompt, model, and agent selectors. + * (Ref: #1256, `chat-documents.js`, `chat-searchable-select.js`, chat grounded search, document picker) + +* **Leftover Separator Lines in Filtered Dropdowns** + * Fixed filtered dropdowns leaving orphaned workspace separator lines behind — commonly two stacked horizontal rules directly under the "Select All" / "Clear All" row — when a search removed the leading sections. + * Divider visibility now follows the section it separates instead of the nearest visible row, and separator lines can no longer be leading, trailing, or stacked. Affects the Document, Scope, and Tags dropdowns, plus the Compare modal document picker. + * (Ref: #1256, `chat-searchable-select.js`, dropdown filtering, section dividers) + +#### User Interface Enhancements + +* **File Name Shown in Document Picker Rows** + * Document rows in the chat grounded-search picker now show the file name as a smaller muted line beneath the title whenever the two differ, so it is clear which file a search matched. + * Rows without distinct titles are unchanged, and the row tooltip carries both the title and the file name. + * (Ref: #1256, `chat-documents.js`, `chats.css`, document picker rows) + ### **(v0.250.209)** #### Bug Fixes diff --git a/functional_tests/test_chat_document_search_filename_matching.py b/functional_tests/test_chat_document_search_filename_matching.py new file mode 100644 index 000000000..a8e46b50e --- /dev/null +++ b/functional_tests/test_chat_document_search_filename_matching.py @@ -0,0 +1,478 @@ +#!/usr/bin/env python3 +# test_chat_document_search_filename_matching.py +""" +Functional test for chat document search file-name matching and dropdown divider cleanup. +Version: 0.250.210 +Implemented in: 0.250.210 + +This test ensures that the chat grounded-search document picker matches on both the +document title and its file name (anywhere in the string, including multi-word queries +where `_`, `-`, and `.` act as word breaks), renders the file name as muted secondary +text when it differs from the title, and that filtering never leaves orphaned section +separator lines behind in the document, scope, or tags dropdowns. + +Refs: https://github.com/microsoft/simplechat/issues/1256 +""" + +import json +import os +import shutil +import subprocess +import sys +import tempfile + +sys.path.append(os.path.dirname(os.path.abspath(__file__))) + +from test_support.versioning import assert_app_version_at_least + + +ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + +CHAT_DOCUMENTS_FILE = os.path.join( + ROOT_DIR, 'application', 'single_app', 'static', 'js', 'chat', 'chat-documents.js', +) +CHAT_SEARCHABLE_SELECT_FILE = os.path.join( + ROOT_DIR, 'application', 'single_app', 'static', 'js', 'chat', 'chat-searchable-select.js', +) +CHAT_ONLOAD_FILE = os.path.join( + ROOT_DIR, 'application', 'single_app', 'static', 'js', 'chat', 'chat-onload.js', +) +CHAT_CSS_FILE = os.path.join( + ROOT_DIR, 'application', 'single_app', 'static', 'css', 'chats.css', +) + +# Runs the production searchable-select logic against a minimal DOM shim so the divider +# and token-matching rules are exercised for real instead of only asserted as source text. +NODE_HARNESS = r""" +const fs = require('fs'); +const vm = require('vm'); + +const source = fs.readFileSync(process.argv[2], 'utf8').replace(/^export /gm, ''); +const sandbox = { module: { exports: {} }, console }; +vm.runInNewContext( + source + '\nmodule.exports = { updateDropdownStructure, matchesSearchTokens };', + sandbox +); +const { updateDropdownStructure, matchesSearchTokens } = sandbox.module.exports; + +class El { + constructor(classes, role, label) { + this.classes = new Set(classes); + this.role = role || null; + this.label = label || ''; + this.parent = null; + this.classList = { + contains: c => this.classes.has(c), + add: c => this.classes.add(c), + remove: c => this.classes.delete(c), + toggle: (c, force) => { if (force) { this.classes.add(c); } else { this.classes.delete(c); } }, + }; + } + getAttribute(name) { return name === 'data-search-role' ? this.role : null; } + get index() { return this.parent.kids.indexOf(this); } + get nextElementSibling() { return this.parent.kids[this.index + 1] || null; } + get previousElementSibling() { return this.parent.kids[this.index - 1] || null; } +} + +class Container { + constructor() { this.kids = []; } + add(el) { el.parent = this; this.kids.push(el); return el; } + get children() { return this.kids; } +} + +function applyFilter(container, term) { + container.kids + .filter(kid => kid.classList.contains('dropdown-item')) + .forEach(item => { + const alwaysVisible = item.role === 'action'; + const matches = alwaysVisible || matchesSearchTokens(item.label, term); + item.classList.toggle('d-none', !matches); + }); + updateDropdownStructure(container); +} + +function render(container) { + return container.kids + .filter(kid => !kid.classList.contains('d-none')) + .map(kid => { + if (kid.classList.contains('dropdown-divider')) { return '---'; } + if (kid.classList.contains('dropdown-header')) { return '#' + kid.label; } + return kid.label; + }); +} + +const TITLED_DOC = 'Fiscal Overview Quarterly_Report_200_final.pdf [Public] Beta'; + +function buildDocumentDropdown() { + const c = new Container(); + c.add(new El(['dropdown-item'], 'action', 'Select All')); + c.add(new El(['dropdown-header'], null, 'Personal')); + c.add(new El(['dropdown-item'], 'item', 'Budget Notes Budget_Notes.docx Personal')); + c.add(new El(['dropdown-divider'], null, '')); + c.add(new El(['dropdown-header'], null, '[Group] Alpha')); + c.add(new El(['dropdown-item'], 'item', 'Alpha Charter Alpha_Charter.pdf [Group] Alpha')); + c.add(new El(['dropdown-divider'], null, '')); + c.add(new El(['dropdown-header'], null, '[Public] Beta')); + c.add(new El(['dropdown-item'], 'item', TITLED_DOC)); + return c; +} + +function buildTagsDropdown() { + const c = new Container(); + c.add(new El(['dropdown-item'], 'action', 'Clear All')); + c.add(new El(['dropdown-divider'], null, '')); + c.add(new El(['dropdown-item'], 'item', 'finance')); + c.add(new El(['dropdown-item'], 'item', 'hr')); + c.add(new El(['dropdown-divider'], null, '')); + c.add(new El(['dropdown-header'], null, 'Classifications')); + c.add(new El(['dropdown-item'], 'item', 'Confidential')); + return c; +} + +function buildScopeDropdown() { + const c = new Container(); + c.add(new El(['dropdown-item'], 'action', 'All')); + c.add(new El(['dropdown-divider'], null, '')); + c.add(new El(['dropdown-item'], 'item', 'Personal')); + c.add(new El(['dropdown-header'], null, 'Groups')); + c.add(new El(['dropdown-item'], 'item', 'Engineering')); + c.add(new El(['dropdown-header'], null, 'Public Workspaces')); + c.add(new El(['dropdown-item'], 'item', 'Marketing WS')); + return c; +} + +const builders = { + document: buildDocumentDropdown, + tags: buildTagsDropdown, + scope: buildScopeDropdown, +}; + +const cases = [ + ['document', ''], + ['document', '200'], + ['document', 'report 200'], + ['document', 'charter'], + ['document', 'budget'], + ['document', 'zzzz'], + ['tags', ''], + ['tags', 'confidential'], + ['tags', 'finance'], + ['scope', ''], + ['scope', 'marketing'], + ['scope', 'zzzz'], +]; + +const results = {}; +cases.forEach(([dropdown, term]) => { + const container = builders[dropdown](); + applyFilter(container, term); + results[dropdown + '|' + term] = render(container); +}); + +process.stdout.write(JSON.stringify(results)); +""" + + +def read_file(path): + with open(path, 'r', encoding='utf-8') as file_handle: + return file_handle.read() + + +def test_document_descriptor_includes_file_name(): + """Verify the document descriptor carries the file name for search and display.""" + print('🔍 Testing document descriptor file-name wiring...') + + try: + content = read_file(CHAT_DOCUMENTS_FILE) + + required_snippets = [ + 'function getDocumentFileName(documentItem) {', + "return String((documentItem || {}).file_name || '').trim();", + 'const fileName = getDocumentFileName(documentItem);', + "const showsFileName = Boolean(fileName) && fileName.toLowerCase() !== displayName.toLowerCase();", + "searchLabel: [displayName, fileName, sectionLabel].filter(Boolean).join(' '),", + "secondaryLabel: showsFileName ? fileName : '',", + ] + + missing = [snippet for snippet in required_snippets if snippet not in content] + assert not missing, f'Missing document descriptor file-name wiring: {missing}' + + legacy_search_label = "searchLabel: `${getDocumentDisplayName(documentItem)} ${sectionLabel}`" + assert legacy_search_label not in content, \ + 'Document search label must no longer be built from the display name alone' + + print('✅ Document descriptor file-name wiring passed') + return True + + except Exception as exc: + print(f'❌ Test failed: {exc}') + import traceback + traceback.print_exc() + return False + + +def test_document_row_renders_file_name_safely(): + """Verify the document row renders a muted file name using textContent.""" + print('🔍 Testing document row file-name rendering...') + + try: + content = read_file(CHAT_DOCUMENTS_FILE) + + required_snippets = [ + "labelWrapper.classList.add('chat-document-option-text');", + "primaryLabel.classList.add('chat-document-option-title');", + 'primaryLabel.textContent = doc.label;', + 'if (doc.secondaryLabel) {', + "secondaryLabel.classList.add('chat-document-option-filename', 'text-muted');", + 'secondaryLabel.textContent = doc.secondaryLabel;', + "dropdownItem.setAttribute('title', doc.secondaryLabel ? `${doc.label}\\n${doc.secondaryLabel}` : doc.label);", + ] + + missing = [snippet for snippet in required_snippets if snippet not in content] + assert not missing, f'Missing document row file-name rendering: {missing}' + + assert 'secondaryLabel.innerHTML' not in content, 'File name must be rendered with textContent' + assert 'primaryLabel.innerHTML' not in content, 'Document title must be rendered with textContent' + + print('✅ Document row file-name rendering passed') + return True + + except Exception as exc: + print(f'❌ Test failed: {exc}') + import traceback + traceback.print_exc() + return False + + +def test_selected_document_label_reads_the_title_span(): + """Verify the button label reads the title span, not the stacked wrapper.""" + print('🔍 Testing selected document button label wiring...') + + try: + documents_content = read_file(CHAT_DOCUMENTS_FILE) + onload_content = read_file(CHAT_ONLOAD_FILE) + + for label, content in (('chat-documents.js', documents_content), ('chat-onload.js', onload_content)): + assert ".querySelector('.chat-document-option-title')" in content, \ + f'{label} must read the document title span for the dropdown button label' + assert '[data-document-id="${selectedDocumentId}"] span' not in content, \ + f'{label} must not read the first descendant span of a document row' + assert '[data-document-id="${docIdsToSelect[0]}"] span' not in content, \ + f'{label} must not read the first descendant span of a document row' + + print('✅ Selected document button label wiring passed') + return True + + except Exception as exc: + print(f'❌ Test failed: {exc}') + import traceback + traceback.print_exc() + return False + + +def test_shared_search_helper_uses_token_matching(): + """Verify the shared helper exposes and uses normalized token matching.""" + print('🔍 Testing shared search helper token matching...') + + try: + content = read_file(CHAT_SEARCHABLE_SELECT_FILE) + + required_snippets = [ + 'export function normalizeSearchText(value) {', + 'export function matchesSearchTokens(searchText, searchTerm) {', + 'const SEARCH_SEPARATOR_PATTERN = /[_\\-.]+/g;', + "return normalizedTerm.split(' ').every(token => normalizedText.includes(token));", + 'const matches = keepVisible || matchesSearchTokens(readSearchText(item), searchTerm);', + 'const matches = matchesSearchTokens(optionSearchText, searchTerm);', + 'const searchTerm = normalizeSearchText(rawSearchTerm);', + 'const searchTerm = normalizeSearchText(searchInputEl.value);', + ] + + missing = [snippet for snippet in required_snippets if snippet not in content] + assert not missing, f'Missing shared token matching wiring: {missing}' + + assert 'searchText.includes(searchTerm)' not in content, \ + 'Filterable dropdown search must not use raw substring matching' + assert 'optionSearchText.includes(searchTerm)' not in content, \ + 'Searchable single select must not use raw substring matching' + + print('✅ Shared search helper token matching passed') + return True + + except Exception as exc: + print(f'❌ Test failed: {exc}') + import traceback + traceback.print_exc() + return False + + +def test_divider_visibility_is_section_aware(): + """Verify divider visibility follows section content instead of nearest sibling.""" + print('🔍 Testing section-aware divider visibility...') + + try: + content = read_file(CHAT_SEARCHABLE_SELECT_FILE) + + required_snippets = [ + "const SEARCH_ROLE_ACTION = 'action';", + 'function isVisibleSectionContent(el) {', + "&& el.getAttribute('data-search-role') !== SEARCH_ROLE_ACTION;", + 'function findDividerBoundHeader(children, dividerIndex) {', + 'function collapseRedundantDividers(children) {', + 'const boundHeader = findDividerBoundHeader(children, index);', + 'keepDivider = !isHiddenElement(boundHeader) && hasSectionContentBefore;', + 'keepDivider = hasVisibleContentBefore && hasSectionContentAfter;', + 'collapseRedundantDividers(children);', + ] + + missing = [snippet for snippet in required_snippets if snippet not in content] + assert not missing, f'Missing section-aware divider wiring: {missing}' + + assert 'let previousVisible = null;' not in content, \ + 'Divider visibility must no longer be resolved by scanning for the nearest visible sibling' + assert 'let nextVisible = null;' not in content, \ + 'Divider visibility must no longer be resolved by scanning for the nearest visible sibling' + + print('✅ Section-aware divider visibility passed') + return True + + except Exception as exc: + print(f'❌ Test failed: {exc}') + import traceback + traceback.print_exc() + return False + + +def test_document_row_styles_are_defined(): + """Verify the stacked document row styles exist in the chat stylesheet.""" + print('🔍 Testing document row stylesheet rules...') + + try: + content = read_file(CHAT_CSS_FILE) + + required_snippets = [ + '#document-dropdown-items .dropdown-item .chat-document-option-text {', + '#document-dropdown-items .dropdown-item .chat-document-option-title,', + '#document-dropdown-items .dropdown-item .chat-document-option-filename {', + 'flex-direction: column;', + ] + + missing = [snippet for snippet in required_snippets if snippet not in content] + assert not missing, f'Missing document row stylesheet rules: {missing}' + + print('✅ Document row stylesheet rules passed') + return True + + except Exception as exc: + print(f'❌ Test failed: {exc}') + import traceback + traceback.print_exc() + return False + + +def test_filtered_dropdown_output_has_no_orphaned_dividers(): + """Execute the production filter logic and verify filtered dropdown output.""" + print('🔍 Testing filtered dropdown output against the production logic...') + + node_executable = shutil.which('node') + if not node_executable: + print('⚠️ Node.js not available, skipping executable dropdown filter check') + return True + + try: + with tempfile.TemporaryDirectory() as temp_dir: + harness_path = os.path.join(temp_dir, 'dropdown_filter_harness.cjs') + with open(harness_path, 'w', encoding='utf-8') as harness_file: + harness_file.write(NODE_HARNESS) + + completed = subprocess.run( + [node_executable, harness_path, CHAT_SEARCHABLE_SELECT_FILE], + capture_output=True, + text=True, + timeout=60, + ) + + assert completed.returncode == 0, f'Dropdown harness failed: {completed.stderr}' + results = json.loads(completed.stdout) + + titled_document = 'Fiscal Overview Quarterly_Report_200_final.pdf [Public] Beta' + expected = { + 'document|': [ + 'Select All', + '#Personal', 'Budget Notes Budget_Notes.docx Personal', + '---', + '#[Group] Alpha', 'Alpha Charter Alpha_Charter.pdf [Group] Alpha', + '---', + '#[Public] Beta', titled_document, + ], + 'document|200': ['Select All', '#[Public] Beta', titled_document], + 'document|report 200': ['Select All', '#[Public] Beta', titled_document], + 'document|charter': ['Select All', '#[Group] Alpha', 'Alpha Charter Alpha_Charter.pdf [Group] Alpha'], + 'document|budget': ['Select All', '#Personal', 'Budget Notes Budget_Notes.docx Personal'], + 'document|zzzz': ['Select All'], + 'tags|': ['Clear All', '---', 'finance', 'hr', '---', '#Classifications', 'Confidential'], + 'tags|confidential': ['Clear All', '---', '#Classifications', 'Confidential'], + 'tags|finance': ['Clear All', '---', 'finance'], + 'scope|': ['All', '---', 'Personal', '#Groups', 'Engineering', '#Public Workspaces', 'Marketing WS'], + 'scope|marketing': ['All', '---', '#Public Workspaces', 'Marketing WS'], + 'scope|zzzz': ['All'], + } + + for key, expected_rows in expected.items(): + assert results.get(key) == expected_rows, \ + f'Unexpected dropdown output for "{key}": {results.get(key)} != {expected_rows}' + + for key, rows in results.items(): + for index in range(1, len(rows)): + assert not (rows[index] == '---' and rows[index - 1] == '---'), \ + f'Adjacent separator lines survived filtering for "{key}": {rows}' + assert rows[0] != '---', f'Leading separator line survived filtering for "{key}": {rows}' + assert rows[-1] != '---', f'Trailing separator line survived filtering for "{key}": {rows}' + + print('✅ Filtered dropdown output passed') + return True + + except Exception as exc: + print(f'❌ Test failed: {exc}') + import traceback + traceback.print_exc() + return False + + +def test_version_bumped_for_chat_document_search_change(): + """Verify config version covers the chat document search change.""" + print('🔍 Testing config version bump...') + + try: + assert_app_version_at_least("0.250.210") + + print('✅ Config version bump passed') + return True + + except Exception as exc: + print(f'❌ Test failed: {exc}') + import traceback + traceback.print_exc() + return False + + +if __name__ == '__main__': + tests = [ + test_document_descriptor_includes_file_name, + test_document_row_renders_file_name_safely, + test_selected_document_label_reads_the_title_span, + test_shared_search_helper_uses_token_matching, + test_divider_visibility_is_section_aware, + test_document_row_styles_are_defined, + test_filtered_dropdown_output_has_no_orphaned_dividers, + test_version_bumped_for_chat_document_search_change, + ] + + results = [] + for test in tests: + print(f"\n🧪 Running {test.__name__}...") + results.append(test()) + + success = all(results) + print(f"\n📊 Results: {sum(results)}/{len(results)} tests passed") + sys.exit(0 if success else 1) diff --git a/ui_tests/test_chat_document_search_filename_and_dividers.py b/ui_tests/test_chat_document_search_filename_and_dividers.py new file mode 100644 index 000000000..cfc666c78 --- /dev/null +++ b/ui_tests/test_chat_document_search_filename_and_dividers.py @@ -0,0 +1,381 @@ +# test_chat_document_search_filename_and_dividers.py +""" +UI test for chat document search file-name matching and dropdown divider cleanup. + +Version: 0.250.210 +Implemented in: 0.250.210 + +This test ensures the chat grounded-search document picker renders the file name as +muted secondary text when it differs from the title, matches typed text against both +the title and the file name (including multi-word queries where `_`, `-`, and `.` act +as word breaks), and leaves no orphaned section separator lines behind when filtering +removes the leading workspace sections. + +Refs: https://github.com/microsoft/simplechat/issues/1256 +""" + +import json +from pathlib import Path + +import pytest + + +REPO_ROOT = Path(__file__).resolve().parents[1] +BOOTSTRAP_CSS_PATH = REPO_ROOT / "application" / "single_app" / "static" / "css" / "bootstrap.min.css" +CHATS_CSS_PATH = REPO_ROOT / "application" / "single_app" / "static" / "css" / "chats.css" +CHAT_DOCUMENTS_JS_PATH = REPO_ROOT / "application" / "single_app" / "static" / "js" / "chat" / "chat-documents.js" +CHAT_SEARCHABLE_SELECT_JS_PATH = REPO_ROOT / "application" / "single_app" / "static" / "js" / "chat" / "chat-searchable-select.js" +CHAT_TOAST_JS_PATH = REPO_ROOT / "application" / "single_app" / "static" / "js" / "chat" / "chat-toast.js" + +DESKTOP_VIEWPORT = {"width": 1280, "height": 900} +MOBILE_VIEWPORT = {"width": 430, "height": 932} + +PERSONAL_DOCUMENTS = [ + {"id": "doc-personal-notes", "file_name": "Budget_Notes.docx", "tags": []}, +] +GROUP_DOCUMENTS = [ + { + "id": "doc-group-charter", + "title": "Alpha Charter", + "file_name": "Alpha_Charter.pdf", + "group_id": "group-1", + "tags": [], + }, +] +PUBLIC_DOCUMENTS = [ + { + "id": "doc-public-fiscal", + "title": "Fiscal Overview", + "file_name": "Quarterly_Report_200_final.pdf", + "public_workspace_id": "ws-1", + "tags": [], + }, +] + + +def _load_document_picker_fixture(page): + """Render the real chat document picker with production CSS and modules.""" + bootstrap_css = BOOTSTRAP_CSS_PATH.read_text(encoding="utf-8") + chats_css = CHATS_CSS_PATH.read_text(encoding="utf-8") + + fixture_html = f""" + + + + + + Chat Document Search Regression + + + + + +
+ +
+
+
+ +
+
+ +
+
+
+ + +
+ + +""".strip() + + def route_js_file(file_path): + return lambda route: route.fulfill(path=str(file_path), content_type="application/javascript") + + page.route("http://simplechat.test/chat-documents.js", route_js_file(CHAT_DOCUMENTS_JS_PATH)) + page.route("http://simplechat.test/chat-searchable-select.js", route_js_file(CHAT_SEARCHABLE_SELECT_JS_PATH)) + page.route("http://simplechat.test/chat-toast.js", route_js_file(CHAT_TOAST_JS_PATH)) + page.route( + "http://simplechat.test/document-search-fixture", + lambda route: route.fulfill(body=fixture_html, content_type="text/html"), + ) + + console_errors = [] + page.on("console", lambda message: console_errors.append(message.text) if message.type == "error" else None) + + page.goto("http://simplechat.test/document-search-fixture") + + module_uri = json.dumps("http://simplechat.test/chat-documents.js") + personal_json = json.dumps(PERSONAL_DOCUMENTS) + group_json = json.dumps(GROUP_DOCUMENTS) + public_json = json.dumps(PUBLIC_DOCUMENTS) + + page.add_script_tag( + type="module", + content=f""" + window.userGroups = [{{ id: 'group-1', name: 'Alpha' }}]; + window.userVisiblePublicWorkspaces = [{{ id: 'ws-1', name: 'Beta' }}]; + window.bootstrap = {{ + Dropdown: class {{ + constructor() {{}} + static getInstance() {{ return null; }} + static getOrCreateInstance() {{ return {{ hide() {{}}, show() {{}}, update() {{}} }}; }} + }}, + Offcanvas: {{ + getOrCreateInstance() {{ return {{ hide() {{}}, show() {{}} }}; }} + }} + }}; + + const documentsByEndpoint = {{ + '/api/documents': {personal_json}, + '/api/group_documents': {group_json}, + '/api/public_workspace_documents': {public_json}, + }}; + + window.fetch = (requestUrl) => {{ + const requested = String(requestUrl); + const endpoint = Object.keys(documentsByEndpoint).find(key => requested.startsWith(key)); + const payload = {{ documents: endpoint ? documentsByEndpoint[endpoint] : [] }}; + return Promise.resolve({{ + ok: true, + status: 200, + json: () => Promise.resolve(payload), + }}); + }}; + + try {{ + const chatDocuments = await import({module_uri}); + await chatDocuments.loadAllDocs(); + + const menu = document.getElementById('document-dropdown-menu'); + menu.classList.add('show'); + menu.style.display = 'block'; + + window.__chatDocumentsModuleReady = true; + }} catch (error) {{ + window.__chatDocumentsModuleError = String(error && (error.stack || error.message || error)); + }} + """, + ) + + page.wait_for_function("window.__chatDocumentsModuleReady === true || Boolean(window.__chatDocumentsModuleError)") + module_error = page.evaluate("window.__chatDocumentsModuleError || null") + assert module_error is None, module_error + + page.wait_for_selector('#document-dropdown-items .dropdown-item[data-document-id="doc-public-fiscal"]') + + return console_errors + + +def _read_visible_rows(page): + """Return the visible dropdown structure as an ordered list of markers.""" + return page.evaluate( + """ + () => Array.from(document.querySelectorAll('#document-dropdown-items > *')) + .filter(element => element.offsetParent !== null || element.getClientRects().length > 0) + .map(element => { + if (element.classList.contains('dropdown-divider')) { return '---'; } + if (element.classList.contains('dropdown-header')) { return '#' + element.textContent.trim(); } + const title = element.querySelector('.chat-document-option-title'); + return title ? title.textContent.trim() : element.textContent.trim(); + }) + """ + ) + + +def _search_documents(page, term): + page.fill("#document-search-input", term) + page.wait_for_timeout(50) + return _read_visible_rows(page) + + +@pytest.mark.ui +def test_document_rows_show_file_name_only_when_it_differs_from_the_title(page): + """Validate the muted file-name line renders only when it adds information.""" + page.set_viewport_size(DESKTOP_VIEWPORT) + console_errors = _load_document_picker_fixture(page) + + metrics = page.evaluate( + """ + () => { + const readRow = documentId => { + const row = document.querySelector(`#document-dropdown-items .dropdown-item[data-document-id="${documentId}"]`); + const title = row.querySelector('.chat-document-option-title'); + const fileName = row.querySelector('.chat-document-option-filename'); + const titleRect = title.getBoundingClientRect(); + const menuRect = document.getElementById('document-dropdown-menu').getBoundingClientRect(); + return { + title: title.textContent.trim(), + fileName: fileName ? fileName.textContent.trim() : null, + fileNameVisible: Boolean(fileName && fileName.getClientRects().length > 0), + fileNameBelowTitle: fileName ? fileName.getBoundingClientRect().top >= titleRect.bottom - 1 : null, + fileNameFontSize: fileName ? parseFloat(getComputedStyle(fileName).fontSize) : null, + titleFontSize: parseFloat(getComputedStyle(title).fontSize), + tooltip: row.getAttribute('title'), + rowRight: row.getBoundingClientRect().right, + menuRight: menuRect.right, + }; + }; + + return { + titled: readRow('doc-public-fiscal'), + untitled: readRow('doc-personal-notes'), + }; + } + """ + ) + + titled = metrics["titled"] + untitled = metrics["untitled"] + + assert titled["title"] == "Fiscal Overview", f"Unexpected title row, got {titled}" + assert titled["fileName"] == "Quarterly_Report_200_final.pdf", f"Expected the file name line, got {titled}" + assert titled["fileNameVisible"], f"Expected the file name line to be visible, got {titled}" + assert titled["fileNameBelowTitle"], f"Expected the file name to stack under the title, got {titled}" + assert titled["fileNameFontSize"] < titled["titleFontSize"], ( + f"Expected the file name to render smaller than the title, got {titled}" + ) + assert titled["tooltip"] == "Fiscal Overview\nQuarterly_Report_200_final.pdf", ( + f"Expected the tooltip to carry both lines, got {titled}" + ) + assert titled["rowRight"] <= titled["menuRight"] + 1, f"Expected the row to stay inside the menu, got {titled}" + + assert untitled["title"] == "Budget_Notes.docx", f"Unexpected untitled row, got {untitled}" + assert untitled["fileName"] is None, ( + f"Expected no duplicate file name line when the title is the file name, got {untitled}" + ) + assert untitled["tooltip"] == "Budget_Notes.docx", f"Expected a single-line tooltip, got {untitled}" + + assert not console_errors, f"Unexpected console errors: {console_errors}" + + +@pytest.mark.ui +def test_document_search_matches_file_names_and_titles(page): + """Validate searching matches file-name fragments, multi-word queries, and titles.""" + page.set_viewport_size(DESKTOP_VIEWPORT) + console_errors = _load_document_picker_fixture(page) + + assert _search_documents(page, "200") == [ + "Select All Searched", + "#[Public] Beta", + "Fiscal Overview", + ], "Expected a mid-file-name fragment to surface the document" + + assert _search_documents(page, "report 200") == [ + "Select All Searched", + "#[Public] Beta", + "Fiscal Overview", + ], "Expected a multi-word query to match across file-name separators" + + assert _search_documents(page, "Quarterly_Report") == [ + "Select All Searched", + "#[Public] Beta", + "Fiscal Overview", + ], "Expected the literal file name to match" + + assert _search_documents(page, "fiscal") == [ + "Select All Searched", + "#[Public] Beta", + "Fiscal Overview", + ], "Expected title matching to keep working" + + assert _search_documents(page, "charter") == [ + "Select All Searched", + "#[Group] Alpha", + "Alpha Charter", + ], "Expected a middle-section document to match on its title" + + assert _search_documents(page, "budget") == [ + "Select All Searched", + "#Personal", + "Budget_Notes.docx", + ], "Expected the first-section document to match on its file name" + + assert not console_errors, f"Unexpected console errors: {console_errors}" + + +@pytest.mark.ui +def test_filtered_document_dropdown_has_no_orphaned_separator_lines(page): + """Validate filtering never leaves stray or stacked section separator lines.""" + page.set_viewport_size(DESKTOP_VIEWPORT) + console_errors = _load_document_picker_fixture(page) + + unfiltered = _read_visible_rows(page) + assert unfiltered == [ + "All Documents", + "#Personal", + "Budget_Notes.docx", + "---", + "#[Group] Alpha", + "Alpha Charter", + "---", + "#[Public] Beta", + "Fiscal Overview", + ], f"Unexpected unfiltered dropdown structure, got {unfiltered}" + + for term in ["200", "charter", "budget", "zzzz"]: + rows = _search_documents(page, term) + assert "---" not in rows, f'Orphaned separator line survived filtering on "{term}": {rows}' + + page.fill("#document-search-input", "") + page.wait_for_timeout(50) + restored = _read_visible_rows(page) + assert restored == unfiltered, f"Expected clearing the search to restore the structure, got {restored}" + + assert not console_errors, f"Unexpected console errors: {console_errors}" + + +@pytest.mark.ui +def test_document_picker_search_behaves_in_the_mobile_drawer(page): + """Validate the mobile drawer keeps rows contained while filtering cleanly.""" + page.set_viewport_size(MOBILE_VIEWPORT) + console_errors = _load_document_picker_fixture(page) + + rows = _search_documents(page, "200") + assert rows == [ + "Select All Searched", + "#[Public] Beta", + "Fiscal Overview", + ], f"Unexpected mobile filter result, got {rows}" + + metrics = page.evaluate( + """ + () => { + const row = document.querySelector('#document-dropdown-items .dropdown-item[data-document-id="doc-public-fiscal"]'); + const menu = document.getElementById('document-dropdown-menu'); + return { + rowRight: row.getBoundingClientRect().right, + menuRight: menu.getBoundingClientRect().right, + bodyScrollWidth: document.body.scrollWidth, + viewportWidth: window.innerWidth, + }; + } + """ + ) + + assert metrics["rowRight"] <= metrics["menuRight"] + 1, ( + f"Expected the document row to stay inside the mobile menu, got {metrics}" + ) + assert metrics["bodyScrollWidth"] <= metrics["viewportWidth"] + 1, ( + f"Expected no mobile horizontal overflow, got {metrics}" + ) + + assert not console_errors, f"Unexpected console errors: {console_errors}"