diff --git a/application/single_app/config.py b/application/single_app/config.py
index 90a366090..dda8fe66a 100644
--- a/application/single_app/config.py
+++ b/application/single_app/config.py
@@ -101,7 +101,7 @@
EXECUTOR_TYPE = 'thread'
EXECUTOR_MAX_WORKERS = 30
SESSION_TYPE = 'filesystem'
-VERSION = "0.261.326"
+VERSION = "0.261.327"
IS_DEVELOPMENT = is_development_env_enabled()
# Opt-out for deployments where App Service Easy Auth is active but the platform
diff --git a/application/single_app/functions_citation_tracking.py b/application/single_app/functions_citation_tracking.py
index 8c27534c9..847c464f7 100644
--- a/application/single_app/functions_citation_tracking.py
+++ b/application/single_app/functions_citation_tracking.py
@@ -510,6 +510,7 @@ def _merge_document_record(
for field_name in (
"title",
"file_name",
+ "file_type",
"classification",
"scope",
):
@@ -583,6 +584,7 @@ def build_used_documents(
incoming_metadata = {
"title": citation.get("title") or citation.get("file_name"),
"file_name": citation.get("file_name") or citation.get("title"),
+ "file_type": citation.get("file_type"),
"classification": (
citation.get("classification")
or citation.get("document_classification")
diff --git a/application/single_app/functions_orchestration_execution.py b/application/single_app/functions_orchestration_execution.py
index 9f557d67d..d326be308 100644
--- a/application/single_app/functions_orchestration_execution.py
+++ b/application/single_app/functions_orchestration_execution.py
@@ -1283,13 +1283,13 @@ def _gathered_m365_records(self):
_log_failure("Microsoft 365 citations could not be read for the answer.", self.record, exc)
return []
- def _validate_citations(self, citations):
+ def _validate_citations(self, citations, *, enrich_display=False):
document_ids = sorted({
citation["document_id"] for citation in citations
if isinstance(citation, dict) and citation.get("document_id")
})
if not document_ids:
- return
+ return citations
manifest = resolve_context_source_manifest(
self.context, document_ids, settings=self._bootstrap.get_settings(),
user_id=self.record["user_id"],
@@ -1299,6 +1299,27 @@ def _validate_citations(self, citations):
if source.get("authorization_status") == "authorized"
}:
raise HarnessExecutionError("context_unavailable")
+ if not enrich_display:
+ return citations
+ sources = {
+ source["document_id"]: source for source in manifest
+ if source.get("authorization_status") == "authorized"
+ }
+ enriched = deepcopy(citations)
+ for citation in enriched:
+ if not isinstance(citation, dict) or not citation.get("document_id"):
+ continue
+ source = sources[citation["document_id"]]
+ # Lineage-only Analyze/Compare references have identity, but no display metadata.
+ # Keep strict retained-result descriptors unchanged; name only the published copy.
+ for field, source_field in (("title", "display_name"), ("file_name", "file_name")):
+ value = source.get(source_field)
+ if isinstance(value, str) and value.strip():
+ citation[field] = value.strip()
+ extension = source.get("extension")
+ if isinstance(extension, str) and extension.strip():
+ citation["file_type"] = extension.strip().lstrip(".").lower()
+ return enriched
def _touch_conversation(self, documents, message=None):
# Cache/citation integrations belong after application initialization and
@@ -1425,7 +1446,7 @@ def _finalize(self, result, error, *, refreshes=0):
citations = read_result_document_citations(self.context, reference)
if not self._delivery_only:
citations = result.get("citations") or []
- self._validate_citations(citations)
+ citations = self._validate_citations(citations, enrich_display=True)
if prepared:
# The answer is published only while every result it read is still the user's.
workflow_result_contexts = self._workflow_result_lineage(current)
diff --git a/application/v2_ui/src/components/chat/ConversationDrawer.tsx b/application/v2_ui/src/components/chat/ConversationDrawer.tsx
index 2d4582349..d4ab71f73 100644
--- a/application/v2_ui/src/components/chat/ConversationDrawer.tsx
+++ b/application/v2_ui/src/components/chat/ConversationDrawer.tsx
@@ -10,7 +10,6 @@ import { useEffect, useMemo } from 'react';
import { clsx } from 'clsx';
import {
CalendarDays,
- FileText,
Files,
ListOrdered,
ListTree,
@@ -28,6 +27,12 @@ import {
import { collectConversationMedia } from '../../lib/conversationMedia';
import { mergeGeneratedEntries } from '../../lib/conversationGeneratedFiles';
import { m365SecondaryLine, readUsedM365Items } from '../../lib/m365Citations';
+import {
+ conversationDocuments,
+ conversationDocumentName,
+ conversationDocumentSource,
+} from '../../lib/conversationDocuments';
+import { DocumentIcon } from '../documents/documentPresentation';
import { EmptyState, Skeleton } from '../ui/primitives';
import {
GeneratedSection,
@@ -93,26 +98,6 @@ function ContentsMode() {
);
}
-function scopeLabel(document: UsedDocument): string {
- const scope = document.scope;
- if (!scope?.type) {
- return '';
- }
- if (scope.name) {
- return scope.name;
- }
- if (scope.type === 'personal') {
- return 'Personal workspace';
- }
- if (scope.type === 'group') {
- return 'Group workspace';
- }
- if (scope.type === 'public') {
- return 'Public workspace';
- }
- return scope.type;
-}
-
/** Summarize where in a document the citations landed. */
function locationLabel(document: UsedDocument): string {
const pages = (document.page_numbers ?? []).filter(
@@ -129,22 +114,34 @@ function locationLabel(document: UsedDocument): string {
}
function DocumentRow({ document }: { document: UsedDocument }) {
- const name =
- document.title || document.file_name || document.document_id || 'Untitled document';
+ const { title, filename } = conversationDocumentName(document);
+ const workspaces = useBootstrapStore((state) => state.data?.scope);
// A document can be attached to the conversation without having been cited yet, so
// the badge distinguishes "used as grounding" from "actually referenced".
const isCited = (document.citation_ids ?? []).length > 0;
- const scope = scopeLabel(document);
+ const scope = conversationDocumentSource(
+ document, workspaces?.groups ?? [], workspaces?.public_workspaces ?? [],
+ );
const location = locationLabel(document);
return (
-
+
-
+
-
- {name}
+
+ {title}
+ {filename && (
+
+ {filename}
+
+ )}
+ {scope && (
+
+ {scope}
+
+ )}
{isCited && (
@@ -153,7 +150,6 @@ function DocumentRow({ document }: { document: UsedDocument }) {
)}
{location && {location}}
- {scope && {scope}}
{document.classification && {document.classification}}
@@ -253,28 +249,7 @@ function DocumentsMode() {
}
}, [activeConversationId, metadata, metadataLoading, metadataError, loadMetadata]);
- const documents = useMemo(() => {
- if (!metadata) {
- return [] as UsedDocument[];
- }
- // Three sources, all document-shaped: current aggregates, the pre-v2 tracking
- // list, and files uploaded straight into the conversation. De-duplicated by id so
- // a document present in more than one does not appear twice.
- const merged = new Map
();
- for (const list of [
- metadata.used_documents,
- metadata.legacy_used_documents,
- metadata.linked_workspace_documents,
- ]) {
- for (const document of list ?? []) {
- const id = String(document?.document_id ?? '').trim();
- if (id && !merged.has(id)) {
- merged.set(id, document);
- }
- }
- }
- return [...merged.values()];
- }, [metadata]);
+ const documents = useMemo(() => conversationDocuments(metadata), [metadata]);
// Microsoft 365 items are listed only to their owner; the server sends no others.
const m365 = useMemo(() => readUsedM365Items(metadata), [metadata]);
diff --git a/application/v2_ui/src/components/documents/documentPresentation.tsx b/application/v2_ui/src/components/documents/documentPresentation.tsx
index 4e6e7be19..1fcc1c989 100644
--- a/application/v2_ui/src/components/documents/documentPresentation.tsx
+++ b/application/v2_ui/src/components/documents/documentPresentation.tsx
@@ -92,7 +92,7 @@ const EXTENSION_TONES: Record = {
pptx: 'text-warn',
};
-export function documentExtension(document: WorkspaceDocument): string {
+export function documentExtension(document: Pick): string {
const explicit = String(document.file_type ?? '').replace('.', '').toLowerCase();
if (explicit) {
return explicit;
@@ -107,7 +107,7 @@ export function DocumentIcon({
size = 18,
className,
}: {
- document: WorkspaceDocument;
+ document: Pick;
size?: number;
className?: string;
}) {
diff --git a/application/v2_ui/src/components/layout/Sidebar.tsx b/application/v2_ui/src/components/layout/Sidebar.tsx
index f1da133fc..aaa0507a9 100644
--- a/application/v2_ui/src/components/layout/Sidebar.tsx
+++ b/application/v2_ui/src/components/layout/Sidebar.tsx
@@ -432,7 +432,10 @@ export function Sidebar({ mobile = false }: { mobile?: boolean }) {
aria-label={mobile ? 'Close navigation' : 'Collapse navigation'}
aria-expanded="true"
aria-controls="primary-navigation"
- className="ml-auto rounded-lg p-1.5 text-text-3 transition-colors hover:bg-surface-2 hover:text-text-1"
+ className={clsx(
+ 'rounded-lg p-1.5 text-text-3 transition-colors hover:bg-surface-2 hover:text-text-1',
+ mobile && 'ml-auto',
+ )}
>
diff --git a/application/v2_ui/src/lib/conversationDocuments.ts b/application/v2_ui/src/lib/conversationDocuments.ts
new file mode 100644
index 000000000..464e5e2c7
--- /dev/null
+++ b/application/v2_ui/src/lib/conversationDocuments.ts
@@ -0,0 +1,64 @@
+// conversationDocuments.ts
+
+import type { ConversationMetadata, UsedDocument, WorkspaceRef } from './types';
+
+function displayText(value: unknown, documentId = ''): string {
+ if (typeof value !== 'string') return '';
+ const text = value.trim();
+ return text === documentId || ['unknown document', 'unknown', 'untitled document'].includes(text.toLowerCase())
+ ? '' : text;
+}
+
+/** Preserve the used-document set while filling gaps from other records of the same document. */
+export function conversationDocuments(metadata: ConversationMetadata | null): UsedDocument[] {
+ const merged = new Map();
+ for (const list of [
+ metadata?.used_documents, metadata?.legacy_used_documents, metadata?.linked_workspace_documents,
+ ]) {
+ for (const document of list ?? []) {
+ const id = String(document?.document_id ?? '').trim();
+ if (!id) continue;
+ const previous = merged.get(id);
+ if (!previous) {
+ merged.set(id, { ...document });
+ continue;
+ }
+ const combined = { ...previous };
+ for (const field of ['title', 'file_name', 'file_type'] as const) {
+ if (!displayText(previous[field], id) && displayText(document[field], id)) {
+ combined[field] = document[field];
+ }
+ }
+ if (!previous.scope?.type) {
+ combined.scope = document.scope;
+ } else if (previous.scope.type === document.scope?.type && previous.scope.id === document.scope.id) {
+ combined.scope = { ...document.scope, ...previous.scope };
+ if (!displayText(previous.scope.name, previous.scope.id)) {
+ combined.scope.name = document.scope.name;
+ }
+ }
+ merged.set(id, combined);
+ }
+ }
+ return [...merged.values()];
+}
+
+export function conversationDocumentName(document: UsedDocument): { title: string; filename: string } {
+ const filename = displayText(document.file_name, document.document_id);
+ const title = displayText(document.title, document.document_id) || filename || 'Untitled document';
+ return { title, filename: filename === title ? '' : filename };
+}
+
+export function conversationDocumentSource(
+ document: UsedDocument,
+ groups: readonly WorkspaceRef[],
+ publicWorkspaces: readonly WorkspaceRef[],
+): string {
+ const scope = document.scope;
+ if (scope?.type === 'personal') return 'Personal workspace';
+ if (scope?.type !== 'group' && scope?.type !== 'public') return '';
+ const catalog = scope.type === 'group' ? groups : publicWorkspaces;
+ const name = displayText(scope.name, scope.id)
+ || displayText(catalog.find((workspace) => workspace.id === scope.id)?.name, scope.id);
+ return name || (scope.type === 'group' ? 'Group workspace' : 'Public workspace');
+}
diff --git a/application/v2_ui/src/lib/types.ts b/application/v2_ui/src/lib/types.ts
index e755d2ce9..7ad31f0d8 100644
--- a/application/v2_ui/src/lib/types.ts
+++ b/application/v2_ui/src/lib/types.ts
@@ -676,6 +676,7 @@ export interface UsedDocument {
document_id: string;
title?: string;
file_name?: string;
+ file_type?: string;
classification?: string;
scope?: UsedDocumentScope;
chunk_ids?: string[];
diff --git a/docs/explanation/fixes/CHAT_DOCUMENT_METADATA_AND_NOTIFICATION_ALIGNMENT_FIX.md b/docs/explanation/fixes/CHAT_DOCUMENT_METADATA_AND_NOTIFICATION_ALIGNMENT_FIX.md
new file mode 100644
index 000000000..e790ad22a
--- /dev/null
+++ b/docs/explanation/fixes/CHAT_DOCUMENT_METADATA_AND_NOTIFICATION_ALIGNMENT_FIX.md
@@ -0,0 +1,103 @@
+# Chat document metadata and notification alignment fix
+
+Fixed in version: **0.261.327**
+
+The application version is recorded in `application/single_app/config.py`.
+
+## Issue and root causes
+
+The V2 chat Documents pane could show document GUIDs instead of recognizable
+names after an Orchestrate Analyze or Compare answer. Those answers can derive
+document references from retained-result lineage rather than search chunks.
+Lineage carries the document and workspace identity, but not the title or
+filename. Finalization checked current source authorization without copying the
+authorized source's readable metadata into the published references.
+
+The drawer then fell back to the document ID, always displayed a generic file
+icon, and did not render a distinct filename. Its first-record-wins merge could
+also discard complementary metadata supplied by another existing record.
+
+The desktop notification bell had a separate cause: responsive navigation work
+added an auto-margin to the collapse arrow while the bell already had one.
+The two margins divided the available header space, separating the controls.
+
+## Changes
+
+New orchestration publications copy title, filename and file type from the
+already-authorized source manifest before saving the run, assistant message and
+conversation used-document list. This reuses the existing authorization read;
+there is no extra lookup per document. The last pre-publication authorization
+check remains in place. Document identity, citation IDs, page/sheet locations and
+strict retained-result descriptors are unchanged. Recovery delivery uses the
+same publication path.
+
+`ConversationDrawer.tsx` uses `conversationDocuments.ts` to merge complementary
+display metadata from its existing used, legacy and linked document records.
+It displays a title, a distinct filename, and the existing document explorer's
+file-family icon. Personal sources say **Personal workspace** rather than the
+owner's name. Group/public sources use the matching saved workspace name or the
+authorized bootstrap catalog. Missing names use a readable type label, not an ID.
+Missing document titles and filenames use **Untitled document**.
+
+The desktop bell keeps the only auto-margin in the expanded desktop header.
+The collapse button retains its own auto-margin only in mobile navigation.
+Collapsed navigation, the mobile header bell and notification behavior remain
+unchanged.
+
+### Files
+
+- `application/single_app/functions_orchestration_execution.py`
+- `application/single_app/functions_citation_tracking.py`
+- `application/v2_ui/src/lib/conversationDocuments.ts` and `types.ts`
+- `application/v2_ui/src/components/chat/ConversationDrawer.tsx`
+- `application/v2_ui/src/components/documents/documentPresentation.tsx`
+- `application/v2_ui/src/components/layout/Sidebar.tsx`
+
+## Scope and compatibility
+
+This is a forward-looking persistence fix. No migration, historical rebuild,
+backfill, or drawer-opening metadata lookup was added. Existing saved records
+remain unchanged. The presentation can use complementary metadata that is
+already present, but does not fetch missing historical data.
+
+Generated files, media, Microsoft 365 items, citation indicators and document
+access controls retain their existing behavior. No new routes, settings,
+dependencies or externally hosted browser assets are introduced.
+
+## Validation
+
+- `functional_tests/test_orchestration_harness_execution.py` verifies real
+ publication and recovery delivery: both search citations and lineage-only
+ references retain readable metadata in the message, completion event and
+ used-document cache. Source-free answers stay source-free; unavailable sources
+ and storage uncertainty retain their existing failure behavior.
+- `functional_tests/test_chat_cited_source_tracking.py` and
+ `test_orchestration_citation_persistence.py` cover citation identity and
+ location preservation.
+- `functional_tests/test_v2_conversation_documents.mjs` covers metadata merging,
+ stable document counts, filename fallback, matching workspace names and
+ nonmutation of input records.
+- `ui_tests/test_v2_chat_document_metadata.py` exercises real cards at desktop
+ and mobile widths, file-family SVGs, personal/group/public source labels,
+ long filenames, literal markup-like text and absence of historical hydration.
+- `ui_tests/test_v2_mobile_navigation.py` measures an 8px desktop bell/arrow gap
+ across custom branding and preserves collapsed/mobile navigation behavior.
+- Existing generated-file drawer and mobile chat layout regressions pass.
+- The production TypeScript check and Vite build pass. The build reports the
+ existing large-bundle warning.
+
+The initial backend run was blocked by incompatible packages in the machine's
+shared Python environment. Publication and SDK-shaped storage regressions passed
+using an isolated test environment; application dependency manifests were not
+changed.
+
+The final browser batch passed **29 tests**. The offline Cosmos boundary gate
+passed **693 tests and 231 subtests**, with **0 review gaps**, using the pinned
+Cosmos SDK and compatible Flask/Werkzeug versions:
+
+```powershell
+python scripts\check_cosmos_response_boundaries.py --run-contracts
+```
+
+Documentation surface coverage passed all **7 checks** and site quality passed
+all **6 checks**. Existing dependency deprecation warnings remain.
diff --git a/docs/reference/chat-controls.md b/docs/reference/chat-controls.md
index 5fe7bd124..ac9c42177 100644
--- a/docs/reference/chat-controls.md
+++ b/docs/reference/chat-controls.md
@@ -490,6 +490,16 @@ These controls exist only in the V2 interface, so they are not part of the gener
These controls exist only in the V2 interface, so they are not part of the generated inventory above, which is taken from the classic chat page. The drawer's **Documents** tab gathers what a conversation used and what it produced, in personal and shared conversations alike, so a file or source is not lost in a long thread. See [Documents drawer generated files fix]({{ '/explanation/fixes/DOCUMENTS_DRAWER_GENERATED_FILES_FIX/' | relative_url }}) for how generated files are found.
+Since **0.261.327**, workspace document cards show the title, a separate filename
+when it differs, and a file-type icon using the same visual vocabulary as the
+workspace document explorer. The source line says **Personal workspace**, or the
+group/public workspace name when available; it never substitutes a workspace ID
+for a missing name. Citation indicators and page/sheet locations remain available.
+New Orchestrate answers retain readable source metadata even when Analyze or
+Compare supplies document references without individual chunk citations. Existing
+conversations are not backfilled. See [Chat document metadata and notification
+alignment fix]({{ '/explanation/fixes/CHAT_DOCUMENT_METADATA_AND_NOTIFICATION_ALIGNMENT_FIX/' | relative_url }}).
+
| Control | What it does | Why you would use it | Enabled by |
| --- | --- | --- | --- |
| Documents button badge | Counts the documents answers used plus every file and document the conversation produced, each once. Media is not counted. | Use it to see at a glance that a reply produced a file, even with the drawer closed. | Always available |
diff --git a/functional_tests/cosmos_response_boundaries.json b/functional_tests/cosmos_response_boundaries.json
index 413d4bbe8..161afd3c5 100644
--- a/functional_tests/cosmos_response_boundaries.json
+++ b/functional_tests/cosmos_response_boundaries.json
@@ -12490,10 +12490,10 @@
],
"queries": [],
"batches": [],
- "module_sha256": "809037c25bcd0d4c4392bc931c33fcb5d265dc252b0bea2b95ef9c9980294894"
+ "module_sha256": "610ff1cab4877e0fbd7a4ccfffa110fec3078c057ef0789c45d84faf70af2264"
},
"classification": "sdk-compatible",
- "contract": "Execution receives the normalized lease record; direct point message reads use fields/fingerprints, while strict settings and event checks are domain values.",
+ "contract": "Saved snapshot point responses are consumed through Mapping fields and normalize_history_message before strict snapshot validation; publication metadata enrichment does not alter this boundary.",
"evidence_scope": "owner-runtime",
"tests": [
"functional_tests/test_orchestration_cosmos_response_contract.py"
@@ -12511,10 +12511,10 @@
],
"queries": [],
"batches": [],
- "module_sha256": "809037c25bcd0d4c4392bc931c33fcb5d265dc252b0bea2b95ef9c9980294894"
+ "module_sha256": "610ff1cab4877e0fbd7a4ccfffa110fec3078c057ef0789c45d84faf70af2264"
},
"classification": "sdk-compatible",
- "contract": "Execution receives the normalized lease record; direct point message reads use fields/fingerprints, while strict settings and event checks are domain values.",
+ "contract": "Delivery metadata point responses are read through Mapping fields and projected into plain model/reasoning dictionaries; restored publication retains SDK ETags and conditional guards.",
"evidence_scope": "owner-runtime",
"tests": [
"functional_tests/test_orchestration_cosmos_response_contract.py"
@@ -12532,10 +12532,10 @@
],
"queries": [],
"batches": [],
- "module_sha256": "809037c25bcd0d4c4392bc931c33fcb5d265dc252b0bea2b95ef9c9980294894"
+ "module_sha256": "610ff1cab4877e0fbd7a4ccfffa110fec3078c057ef0789c45d84faf70af2264"
},
"classification": "sdk-compatible",
- "contract": "Execution receives the normalized lease record; direct point message reads use fields/fingerprints, while strict settings and event checks are domain values.",
+ "contract": "Regeneration reads the question's Mapping role and nested plain thread metadata; no exact top-level dictionary contract consumes the SDK point response.",
"evidence_scope": "owner-runtime",
"tests": [
"functional_tests/test_orchestration_cosmos_response_contract.py"
@@ -12553,18 +12553,19 @@
],
"queries": [],
"batches": [],
- "module_sha256": "809037c25bcd0d4c4392bc931c33fcb5d265dc252b0bea2b95ef9c9980294894"
+ "module_sha256": "610ff1cab4877e0fbd7a4ccfffa110fec3078c057ef0789c45d84faf70af2264"
},
"classification": "sdk-compatible",
- "contract": "Execution receives the normalized lease record; direct point message reads use fields/fingerprints, while strict settings and event checks are domain values.",
+ "contract": "The bootstrap supplies a normalized owned conversation; conditional replace responses go to the Mapping-compatible cache invalidator. New readable citation metadata is merged into nested plain document records.",
"evidence_scope": "owner-runtime",
"tests": [
- "functional_tests/test_orchestration_cosmos_response_contract.py"
+ "functional_tests/test_orchestration_cosmos_response_contract.py",
+ "functional_tests/test_orchestration_harness_execution.py::test_selected_result_citations_reach_headless_message_cache_and_delivery"
]
},
"application/single_app/functions_orchestration_execution.py::HarnessExecution._finalize": {
"boundary": {
- "sha256": "5e58999e21d9ff52aa15f3ec083c53ad13fedd2a027251d353478f6cffa9d2f6",
+ "sha256": "82d887da7806a9fef3ee21004f3de469ce8305cb55ebb07870e42951fc576469",
"points": [
{
"method": "read_item",
@@ -12574,13 +12575,14 @@
],
"queries": [],
"batches": [],
- "module_sha256": "809037c25bcd0d4c4392bc931c33fcb5d265dc252b0bea2b95ef9c9980294894"
+ "module_sha256": "610ff1cab4877e0fbd7a4ccfffa110fec3078c057ef0789c45d84faf70af2264"
},
"classification": "sdk-compatible",
- "contract": "Execution receives the normalized lease record; direct point message reads use fields/fingerprints, while strict settings and event checks are domain values.",
+ "contract": "Finalization reads the already-published SDK message through Mapping fields and publishes a plain message DTO. Display metadata is copied only from authorized source manifests, outside strict retained-result identity descriptors.",
"evidence_scope": "owner-runtime",
"tests": [
- "functional_tests/test_orchestration_cosmos_response_contract.py"
+ "functional_tests/test_orchestration_cosmos_response_contract.py",
+ "functional_tests/test_orchestration_harness_execution.py::test_selected_result_citations_reach_headless_message_cache_and_delivery"
]
},
"application/single_app/functions_orchestration_operations.py::OperationJournal._change": {
diff --git a/functional_tests/test_orchestration_harness_execution.py b/functional_tests/test_orchestration_harness_execution.py
index be0eef1a3..432658d3a 100644
--- a/functional_tests/test_orchestration_harness_execution.py
+++ b/functional_tests/test_orchestration_harness_execution.py
@@ -1,7 +1,7 @@
# test_orchestration_harness_execution.py
"""Real-boundary headless V2 preparation, execution and publication regressions.
-Version: 0.261.321
+Version: 0.261.327
Implemented in: 0.261.127
Invocation, catalog, and citation regressions implemented in: 0.261.129
Direct initial-binding scope coverage implemented in: 0.261.130
@@ -20,6 +20,7 @@
legitimate step-start/input-checkpoint writes, without normalizing missing fields.
Selected-answer citations retain their exact lineage during initial publication
and model-free delivery, without adopting ambient or stale citation metadata.
+Readable source titles, filenames and file types survive publication in: 0.261.327
CSV/JSON delivery reopens authorized committed bytes and checks complete records,
including the final row, rather than relying only on file-card counts.
The four private external bindings include the actual invocation preflight.
@@ -366,6 +367,7 @@ def prepare(selected):
monkeypatch.setattr(search, "hybrid_search", search_documents)
with harness.native_io(row_count=2) as source:
source.document["file_name"] = "Original source.pdf"
+ source.document["title"] = "Source document title"
try:
yield SimpleNamespace(prepare=prepare, queries=queries, source=source)
finally:
@@ -3388,6 +3390,12 @@ def test_selected_result_citations_reach_headless_message_cache_and_delivery(
assert all(citation["document_id"] == "document-1" for citation in citations)
assert all(citation["scope"] == {"type": "personal", "id": "owner"} for citation in citations)
assert len(used) == 1 and used[0]["document_id"] == "document-1"
+ assert used[0]["title"] == "Source document title"
+ assert used[0]["file_name"] == "Original source.pdf"
+ assert used[0]["file_type"] == "pdf"
+ assert all(citation["title"] == "Source document title" for citation in citations)
+ assert all(citation["file_name"] == "Original source.pdf" for citation in citations)
+ assert all(citation["file_type"] == "pdf" for citation in citations)
if selected == "prepared":
assert [citation["citation_id"] for citation in citations] == [
"first-selected-chunk", "last-selected-chunk",
diff --git a/functional_tests/test_v2_conversation_documents.mjs b/functional_tests/test_v2_conversation_documents.mjs
new file mode 100644
index 000000000..c684c15e5
--- /dev/null
+++ b/functional_tests/test_v2_conversation_documents.mjs
@@ -0,0 +1,60 @@
+// test_v2_conversation_documents.mjs
+// Version: 0.261.327
+// Implemented in: 0.261.327
+// Validates readable drawer metadata, complementary records and workspace names without I/O.
+
+import assert from 'node:assert/strict';
+import './test_support/tsResolve.mjs';
+
+const { conversationDocuments, conversationDocumentName, conversationDocumentSource } =
+ await import('../application/v2_ui/src/lib/conversationDocuments.ts');
+
+const id = 'a064bcf3-4386-497d-afa1-0054fbb59c9f';
+const metadata = {
+ used_documents: [{
+ document_id: id, title: id, scope: { type: 'group', id: 'group-1' },
+ citation_ids: ['chunk-1'], page_numbers: [0],
+ }],
+ legacy_used_documents: [{
+ document_id: id, title: 'Aircraft registration', file_name: 'Elliott Hendricks.pdf',
+ scope: { type: 'group', id: 'group-1', name: 'Aircraft records' },
+ }],
+ linked_workspace_documents: [{ document_id: id, file_type: 'pdf' }],
+ tags: [{ category: 'document', document_id: 'unrelated' }],
+};
+const before = JSON.stringify(metadata);
+const documents = conversationDocuments(metadata);
+assert.equal(documents.length, 1);
+assert.deepEqual(documents[0].citation_ids, ['chunk-1']);
+assert.deepEqual(documents[0].page_numbers, [0]);
+assert.equal(documents[0].file_type, 'pdf');
+assert.equal(JSON.stringify(metadata), before);
+assert.deepEqual(conversationDocumentName(documents[0]), {
+ title: 'Aircraft registration', filename: 'Elliott Hendricks.pdf',
+});
+assert.equal(conversationDocumentSource(documents[0], [], []), 'Aircraft records');
+assert.equal(conversationDocumentSource({ scope: { type: 'personal', name: 'Pat Reader' } }, [], []), 'Personal workspace');
+for (const [type, catalog, fallback] of [
+ ['group', 'Engineering', 'Group workspace'], ['public', 'Published research', 'Public workspace'],
+]) {
+ const document = { scope: { type, id, name: id } };
+ const entries = [{ id, name: catalog }, { id: 'active-workspace', name: 'Wrong workspace' }];
+ assert.equal(conversationDocumentSource(document, type === 'group' ? entries : [], type === 'public' ? entries : []), catalog);
+ assert.equal(conversationDocumentSource(document, [], []), fallback);
+}
+assert.deepEqual(conversationDocumentName({ document_id: id, title: id }), {
+ title: 'Untitled document', filename: '',
+});
+assert.deepEqual(conversationDocumentName({ document_id: id, title: 'Unknown Document', file_name: 'report.pdf' }), {
+ title: 'report.pdf', filename: '',
+});
+assert.deepEqual(conversationDocumentName({ document_id: id, title: ' report.pdf ', file_name: 'report.pdf' }), {
+ title: 'report.pdf', filename: '',
+});
+const incompatible = conversationDocuments({
+ used_documents: [{ document_id: id, scope: { type: 'group', id: 'group-1' } }],
+ legacy_used_documents: [{ document_id: id, scope: { type: 'group', id: 'group-2', name: 'Wrong group' } }],
+});
+assert.equal(incompatible[0].scope.name, undefined);
+assert.deepEqual(conversationDocuments(null), []);
+console.log('Conversation document presentation checks passed.');
diff --git a/ui_tests/test_v2_chat_document_metadata.py b/ui_tests/test_v2_chat_document_metadata.py
new file mode 100644
index 000000000..12ee462cf
--- /dev/null
+++ b/ui_tests/test_v2_chat_document_metadata.py
@@ -0,0 +1,85 @@
+# test_v2_chat_document_metadata.py
+"""
+Real-component chat document card regressions.
+Version: 0.261.327
+Implemented in: 0.261.327
+
+Checks title, filename, source, file-family icons, complementary metadata and
+inert text in desktop/mobile drawers. No historical hydration API is stubbed.
+Uses the existing Azure Playwright or local Chromium connection fixtures.
+"""
+
+import pytest
+from playwright.sync_api import expect
+
+from test_v2_drawer_generated_files import ( # noqa: F401
+ CONVERSATION, drawer_ui, editor_assets, editor_browser, connect_options, mount,
+)
+
+pytestmark = pytest.mark.ui
+
+
+@pytest.mark.parametrize("width", [393, 1280])
+def test_document_cards_show_names_file_types_and_workspace_sources(drawer_ui, width):
+ page, api = drawer_ui
+ page.set_viewport_size({"width": width, "height": 852})
+ api.agent_documents = []
+ documents = [
+ {"document_id": "personal-guid", "title": "Azure hierarchy", "file_name": "Hierarchy.docx",
+ "scope": {"type": "personal", "id": "ui-user", "name": "Pat Reader"}},
+ {"document_id": "group-guid", "scope": {"type": "group", "id": "group-a"},
+ "citation_ids": ["group-chunk"], "page_numbers": [2]},
+ {"document_id": "public-guid", "title": "Annual figures", "file_name": "Figures.XLSX",
+ "scope": {"type": "public", "id": "public-a"}},
+ {"document_id": "missing-guid"},
+ {"document_id": "same-guid", "title": "notes.txt", "file_name": "notes.txt"},
+ {"document_id": "markup-guid", "title": '
',
+ "file_name": "Long" + " filename" * 30 + ".pdf",
+ "scope": {"type": "group", "id": "group-b", "name": "Literal workspace"}},
+ ]
+ mount(page, api, "ConversationDrawer", [], drawer="documents", used_documents=documents)
+ page.evaluate("""() => {
+ const H = window.OrchHarness;
+ H.stores.bootstrap.useBootstrapStore.setState((state) => ({data: {
+ ...state.data, scope: {
+ groups: [{id: 'group-a', name: 'Aircraft records'}],
+ public_workspaces: [{id: 'public-a', name: 'Published research'}],
+ },
+ }}));
+ H.stores.chat.useChatStore.setState((state) => ({metadata: {
+ ...state.metadata, legacy_used_documents: [{
+ document_id: 'group-guid', title: 'Aircraft registration',
+ file_name: 'Elliott Hendricks.pdf', scope: {type: 'group', id: 'group-a'},
+ }],
+ }}));
+ }""")
+ rows = page.locator("[data-used-document]")
+ expect(rows).to_have_count(6)
+ for identifier, title, filename, source, icon in [
+ ("personal-guid", "Azure hierarchy", "Hierarchy.docx", "Personal workspace", "file-text"),
+ ("group-guid", "Aircraft registration", "Elliott Hendricks.pdf", "Aircraft records", "file-type"),
+ ("public-guid", "Annual figures", "Figures.XLSX", "Published research", "file-spreadsheet"),
+ ]:
+ row = page.locator(f'[data-used-document="{identifier}"]')
+ expect(row.get_by_text(title, exact=True)).to_be_visible()
+ expect(row.get_by_text(filename, exact=True)).to_be_visible()
+ expect(row.get_by_text(source, exact=True)).to_be_visible()
+ expect(row.locator(f"svg.lucide-{icon}")).to_be_visible()
+ assert identifier not in row.inner_text()
+ expect(rows.filter(has_text="Cited")).to_have_count(1)
+ expect(page.locator('[data-used-document="group-guid"]')).to_contain_text("Page 2")
+ expect(page.locator('[data-used-document="missing-guid"]')).to_have_text("Untitled document")
+ expect(page.locator('[data-used-document="same-guid"]').get_by_text("notes.txt", exact=True)).to_have_count(1)
+ markup = page.locator('[data-used-document="markup-guid"]')
+ expect(markup).to_contain_text("Literal workspace")
+ expect(markup.locator("img, b, script")).to_have_count(0)
+ assert page.evaluate("window.__injected === undefined")
+ drawer = page.get_by_role("complementary", name="Conversation details")
+ bounds = drawer.bounding_box()
+ assert bounds and bounds["width"] <= width
+ for row in rows.all():
+ assert row.evaluate("element => element.scrollWidth <= element.clientWidth")
+ assert not [
+ call for call in api.requests
+ if call["path"].startswith(("/api/documents/", "/api/group_documents/", "/api/public-workspaces/"))
+ ], "opening the drawer must not repair old conversation metadata"
diff --git a/ui_tests/test_v2_mobile_navigation.py b/ui_tests/test_v2_mobile_navigation.py
index 7574745de..a07d58ac4 100644
--- a/ui_tests/test_v2_mobile_navigation.py
+++ b/ui_tests/test_v2_mobile_navigation.py
@@ -1,11 +1,12 @@
# test_v2_mobile_navigation.py
"""
Real-component regression tests for the space-saving V2 mobile navigation.
-Version: 0.261.325
+Version: 0.261.327
Implemented in: 0.261.323
Reuses production components/CSS and the closed HTTP rail harness. Local Chromium
and the shared Azure Playwright connection fixture are supported.
+Desktop bell alignment regression implemented in: 0.261.327
"""
import pytest
@@ -76,3 +77,32 @@ def test_home_uses_one_fallback_header_and_mobile_drawer_scrolls(open_page):
assert region.evaluate("element => getComputedStyle(element).overflowY") == "auto"
page.keyboard.press("Escape")
expect(page.get_by_role("button", name="Open navigation", exact=True)).to_be_focused()
+
+
+@pytest.mark.parametrize("branding", [
+ {"app_title": "SimpleChat"},
+ {"app_title": "A long custom application title"},
+ {"app_title": "SimpleChat", "hide_app_title": True},
+])
+def test_desktop_bell_stays_next_to_collapse_arrow(open_page, branding):
+ page, api = open_page()
+ mount(page, api, rows=0)
+ page.evaluate("""(branding) => {
+ const store = window.OrchHarness.stores.bootstrap.useBootstrapStore;
+ store.setState((state) => ({data: {...state.data, branding}}));
+ }""", branding)
+ nav = page.get_by_role("navigation", name="Primary")
+ bell = nav.locator("[data-notification-bell]")
+ collapse = nav.get_by_role("button", name="Collapse navigation", exact=True)
+ expect(bell).to_be_visible()
+ expect(collapse).to_be_visible()
+ bell_box, collapse_box = bell.bounding_box(), collapse.bounding_box()
+ assert bell_box and collapse_box
+ gap = collapse_box["x"] - (bell_box["x"] + bell_box["width"])
+ assert 7 <= gap <= 9, f"Bell and collapse arrow should have one 8px gap, not {gap}px"
+ assert abs(
+ bell_box["y"] + bell_box["height"] / 2 - collapse_box["y"] - collapse_box["height"] / 2
+ ) < 1
+ collapse.click()
+ expect(nav.locator("[data-notification-bell]")).to_have_count(1)
+ expect(nav.get_by_role("button", name="Expand navigation", exact=True)).to_be_visible()