From 33968e56eab2da500b82c3d8cfd3048bdd70b24d Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Thu, 8 Oct 2026 11:42:05 -0400 Subject: [PATCH 1/2] List every file a conversation produced in the V2 Documents drawer The drawer's Generated section only ran for shared conversations and only listed SimpleChat upload documents, so a plan's CSV (and every other rendered file, export or Analyze summary) was only reachable from its card in the thread. - Collect the files replies produced from the loaded messages with the thread's own readers (plan outputs and committed receipts, exports, analysis files), including live run state, with status for files that are not ready yet. - List SimpleChat upload documents in personal conversations through new owner-only /api/conversations//generated-documents list and download routes. - Merge both into one Generated section with preview, download and Show in conversation, and count them in the header's Documents badge. Version 0.261.302. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- application/single_app/config.py | 2 +- ...tions_collaboration_generated_documents.py | 9 +- .../single_app/route_backend_conversations.py | 153 ++++++ .../components/chat/ConversationDrawer.tsx | 36 +- .../src/components/chat/DrawerAssets.tsx | 378 +++++++++----- .../components/chat/GeneratedArtifactCard.tsx | 24 +- application/v2_ui/src/lib/collaboration.ts | 32 +- .../src/lib/conversationGeneratedFiles.ts | 468 ++++++++++++++++++ .../v2_ui/src/lib/generatedDocuments.ts | 72 +++ application/v2_ui/src/pages/ChatPage.tsx | 29 +- application/v2_ui/src/stores/chatStore.ts | 2 +- .../src/stores/generatedDocumentsStore.ts | 130 +++++ .../src/stores/generatedExportRunStore.ts | 27 + .../features/V2_COLLABORATION_UX.md | 8 + .../DOCUMENTS_DRAWER_GENERATED_FILES_FIX.md | 208 ++++++++ docs/reference/chat-controls.md | 12 +- docs/reference/logging-tags.md | 1 + ...rsonal_conversation_generated_documents.py | 315 ++++++++++++ .../test_v2_drawer_generated_files_logic.mjs | 284 +++++++++++ ui_tests/test_v2_collaboration_ux.py | 16 +- ui_tests/test_v2_drawer_generated_files.py | 341 +++++++++++++ 21 files changed, 2350 insertions(+), 197 deletions(-) create mode 100644 application/v2_ui/src/lib/conversationGeneratedFiles.ts create mode 100644 application/v2_ui/src/lib/generatedDocuments.ts create mode 100644 application/v2_ui/src/stores/generatedDocumentsStore.ts create mode 100644 application/v2_ui/src/stores/generatedExportRunStore.ts create mode 100644 docs/explanation/fixes/DOCUMENTS_DRAWER_GENERATED_FILES_FIX.md create mode 100644 functional_tests/test_personal_conversation_generated_documents.py create mode 100644 functional_tests/test_v2_drawer_generated_files_logic.mjs create mode 100644 ui_tests/test_v2_drawer_generated_files.py diff --git a/application/single_app/config.py b/application/single_app/config.py index 143f847b8..ef6a7d47c 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.301" +VERSION = "0.261.302" 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_collaboration_generated_documents.py b/application/single_app/functions_collaboration_generated_documents.py index 2c397ebec..46a9c099a 100644 --- a/application/single_app/functions_collaboration_generated_documents.py +++ b/application/single_app/functions_collaboration_generated_documents.py @@ -1,11 +1,12 @@ # functions_collaboration_generated_documents.py -"""Documents that agents generated in a shared conversation, and who may download them. +"""Documents that agents generated in a conversation, and who may download them. An agent creates a document with the SimpleChat action's upload functions. Each stores the file in the requester's personal workspace or in a group workspace and returns its id, which stays on -the message in the agent citation. That is what lets a shared conversation list what was produced -in it. Listing reveals only file names; downloading follows the workspace's own download rules, -whichever group the reader currently has active. +the message in the agent citation. That is what lets a conversation list what was produced in it, +whether it is a shared conversation or a personal one. Listing reveals only file names; +downloading follows the workspace's own download rules, whichever group the reader currently has +active. """ import json diff --git a/application/single_app/route_backend_conversations.py b/application/single_app/route_backend_conversations.py index 8f13518b6..b1682541c 100644 --- a/application/single_app/route_backend_conversations.py +++ b/application/single_app/route_backend_conversations.py @@ -61,7 +61,13 @@ from flask import Response, current_app, request, stream_with_context from werkzeug.utils import secure_filename from functions_debug import debug_print +from functions_collaboration_generated_documents import ( + authorize_generated_document_download, + can_download_generated_document, + collect_generated_documents, +) from functions_documents import ( + build_document_download_response, delete_chat_upload_workspace_documents_for_conversation, serialize_chat_upload_workspace_documents_for_conversation, ) @@ -969,6 +975,54 @@ def hydrate_m365_pending_action_cards(messages, viewer_user_id, conversation_id) ) +def _list_personal_conversation_generated_documents(conversation_item): + """Return the documents SimpleChat upload actions created in a personal conversation. + + ``conversation_item`` must already be authorized with ``_authorize_personal_conversation_read``. + Only messages the thread shows are read, filtered the way ``/api/get_messages`` filters them: + deleted messages, the assistant artifact store, hidden generated chat files and replaced + thread attempts are left out. A citation stored in compact form is rebuilt from its artifact + record first, because the created document's id is in the full tool result. + """ + conversation_id = conversation_item['id'] + raw_messages = list(cosmos_messages_container.query_items( + query='SELECT * FROM c WHERE c.conversation_id = @conversation_id', + parameters=[{'name': '@conversation_id', 'value': conversation_id}], + partition_key=conversation_id, + )) + raw_messages.sort(key=lambda item: ( + str(item.get('timestamp') or ''), + int(item.get('fork_sequence')) if str(item.get('fork_sequence') or '').isdigit() else 0, + str(item.get('id') or ''), + )) + raw_messages = exclude_soft_deleted_messages(raw_messages) + artifact_payload_map = build_message_artifact_payload_map(raw_messages) + + visible_messages = [] + for item in filter_assistant_artifact_items(raw_messages): + metadata = item.get('metadata') if isinstance(item.get('metadata'), dict) else {} + if metadata.get('is_generated_chat_artifact', False): + continue + thread_info = metadata.get('thread_info') if isinstance(metadata.get('thread_info'), dict) else {} + active_thread = thread_info.get('active_thread') + if active_thread is True or active_thread is None: + visible_messages.append(item) + + documents = collect_generated_documents( + hydrate_agent_citations_from_artifacts(visible_messages, artifact_payload_map), + ) + log_event( + '[CONVERSATION_GENERATED_DOCUMENTS] Listed generated documents for a personal conversation.', + extra={ + 'conversation_id': conversation_id, + 'message_count': len(visible_messages), + 'document_count': len(documents), + }, + debug_only=True, + ) + return documents + + def _rebuild_authorized_personal_conversation_used_documents( user_id, conversation_id, @@ -2278,6 +2332,105 @@ def get_conversation_metadata_api(conversation_id): print(f"Error retrieving conversation metadata: {e}") return jsonify({'error': 'Failed to retrieve conversation metadata'}), 500 + @bp.route('/api/conversations//generated-documents', methods=['GET']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + def list_personal_conversation_generated_documents_api(conversation_id): + """List the documents agents created in this conversation, and which the reader may download. + + The personal counterpart of the shared conversation list: the same documents, read from the + SimpleChat upload actions' results, under the same workspace download rules. + """ + user_id = get_current_user_id() + if not user_id: + return jsonify({'error': 'User not authenticated'}), 401 + + try: + conversation_item = _authorize_personal_conversation_read(user_id, conversation_id) + settings = get_settings() + documents = [ + { + 'document_id': document['document_id'], + 'file_name': document['file_name'], + 'workspace_scope': document['workspace_scope'], + 'preview': document['preview'], + 'message_id': document['message_id'], + 'created_at': document['created_at'], + 'can_download': can_download_generated_document(user_id, document, settings=settings), + } + for document in _list_personal_conversation_generated_documents(conversation_item) + ] + return jsonify({'documents': documents}), 200 + except LookupError: + return jsonify({'error': 'Conversation not found'}), 404 + except PermissionError: + return jsonify({'error': 'Forbidden'}), 403 + except Exception as exc: + log_event( + '[CONVERSATION_GENERATED_DOCUMENTS] Failed to list generated documents.', + extra={'conversation_id': conversation_id, 'exception_type': type(exc).__name__}, + level=logging.ERROR, + exceptionTraceback=True, + ) + return jsonify({'error': 'Failed to list generated documents'}), 500 + + @bp.route( + '/api/conversations//generated-documents//download', + methods=['GET'], + ) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + def download_personal_conversation_generated_document_api(conversation_id, document_id): + """Download a document an agent created in this conversation, under its workspace's rules.""" + user_id = get_current_user_id() + if not user_id: + return jsonify({'error': 'User not authenticated'}), 401 + + try: + conversation_item = _authorize_personal_conversation_read(user_id, conversation_id) + except LookupError: + return jsonify({'error': 'Conversation not found'}), 404 + except PermissionError: + return jsonify({'error': 'Forbidden'}), 403 + + try: + # Only a document this conversation produced can be fetched through it. + document = next( + ( + candidate + for candidate in _list_personal_conversation_generated_documents(conversation_item) + if candidate['document_id'] == document_id + ), + None, + ) + if not document: + return jsonify({'error': 'Document not found'}), 404 + try: + document_record, group_id = authorize_generated_document_download(user_id, document) + except LookupError: + return jsonify({'error': 'Document not found or access denied'}), 404 + return build_document_download_response( + document_record, + user_id=user_id, + group_id=group_id, + ) + except FileNotFoundError: + return jsonify({'error': 'This document is not available yet.'}), 404 + except PermissionError: + return jsonify({'error': 'You do not have permission to download this document'}), 403 + except ScreeningError as error: + return jsonify({'error': error.public_message, 'error_code': error.code}), error.status_code + except Exception as exc: + log_event( + '[CONVERSATION_GENERATED_DOCUMENTS] Failed to download a generated document.', + extra={'conversation_id': conversation_id, 'exception_type': type(exc).__name__}, + level=logging.ERROR, + exceptionTraceback=True, + ) + return jsonify({'error': 'Unable to download document'}), 500 + @bp.route('/api/conversations//kind', methods=['GET']) @swagger_route(security=get_auth_security()) @login_required diff --git a/application/v2_ui/src/components/chat/ConversationDrawer.tsx b/application/v2_ui/src/components/chat/ConversationDrawer.tsx index d16bb0aff..fcaf09166 100644 --- a/application/v2_ui/src/components/chat/ConversationDrawer.tsx +++ b/application/v2_ui/src/components/chat/ConversationDrawer.tsx @@ -2,9 +2,10 @@ // Right-hand drawer with three modes, mirroring the legacy offcanvas that hosts both a // table of contents and the documents used in the conversation, plus the orchestration plan. // -// Contents lists the user's turns so a long thread can be navigated; Documents lists the -// document-level citation aggregates the server records on the conversation; Plan hosts the -// orchestration plan surface when the feature is on. +// Contents lists the user's turns so a long thread can be navigated; Documents lists what the +// conversation produced (generated files and media) above the document-level citation +// aggregates the server records on the conversation; Plan hosts the orchestration plan surface +// when the feature is on. import { useEffect, useMemo } from 'react'; import { clsx } from 'clsx'; @@ -19,13 +20,17 @@ import { } from 'lucide-react'; import { useChatStore, type DrawerMode } from '../../stores/chatStore'; import { useBootstrapStore } from '../../stores/bootstrapStore'; +import { + useConversationGeneratedDocuments, + useConversationGeneratedFiles, +} from '../../stores/generatedDocumentsStore'; import { collectConversationMedia } from '../../lib/conversationMedia'; +import { mergeGeneratedEntries } from '../../lib/conversationGeneratedFiles'; import { EmptyState, Skeleton } from '../ui/primitives'; import { - GeneratedDocumentsSection, + GeneratedSection, MediaSection, SectionHeading, - useGeneratedDocuments, } from './DrawerAssets'; import { OrchestrationPlanPanel } from './OrchestrationPlanPanel'; import type { UsedDocument } from '../../lib/types'; @@ -157,10 +162,15 @@ function DocumentRow({ document }: { document: UsedDocument }) { function DocumentsMode() { const { metadata, metadataLoading, metadataError, activeConversationId, loadMetadata } = useChatStore(); - const collaborative = useChatStore((state) => state.activeConversationKind === 'collaborative'); + const kind = useChatStore((state) => state.activeConversationKind); const messages = useChatStore((state) => state.messages); const media = useMemo(() => collectConversationMedia(messages), [messages]); - const generated = useGeneratedDocuments(activeConversationId, collaborative); + const files = useConversationGeneratedFiles(); + const generated = useConversationGeneratedDocuments(); + const entries = useMemo( + () => mergeGeneratedEntries(files, generated.documents, messages), + [files, generated.documents, messages], + ); useEffect(() => { if (activeConversationId && !metadata && !metadataLoading && !metadataError) { @@ -191,13 +201,15 @@ function DocumentsMode() { return [...merged.values()]; }, [metadata]); - const hasAssets = generated.documents.length > 0 || Boolean(generated.error) || media.length > 0; + const hasAssets = entries.length > 0 || Boolean(generated.error) || media.length > 0; const assets = hasAssets && activeConversationId ? ( <> - @@ -232,8 +244,8 @@ function DocumentsMode() { return ( } - title="No documents used yet" - description="Documents referenced while answering will be listed here." + title="No documents yet" + description="Documents used or created while answering will be listed here." /> ); } diff --git a/application/v2_ui/src/components/chat/DrawerAssets.tsx b/application/v2_ui/src/components/chat/DrawerAssets.tsx index 56ec7c724..0aee50f51 100644 --- a/application/v2_ui/src/components/chat/DrawerAssets.tsx +++ b/application/v2_ui/src/components/chat/DrawerAssets.tsx @@ -1,28 +1,49 @@ // DrawerAssets.tsx // The Generated and Media sections of the conversation drawer's Documents tab. // -// Generated lists the documents agents created in a shared conversation, with Download where the -// workspace's own download rules allow it and a preview for Markdown. Media gathers every image, -// video and audio clip the thread shows, including signed links an action fetched from a remote -// service, which are otherwise hard to find again in a long thread. Images and clips are tiles, -// three to a row, that open one viewer stepping through them all; recordings are players, each -// with Download and a button that scrolls to its message. +// Generated lists every file the conversation produced, in conversation order, for both personal +// and shared conversations: the files a plan rendered and the exports and analysis files replies +// wrote, read off the thread itself, and the documents agents created with the SimpleChat upload +// actions, which the server lists along with whether the workspace's download rules let this +// reader have them. A file that is still being written, failed or waits for approval is listed +// with its status, and Download appears once it is ready; Markdown and files with an inline +// preview open in a preview, and every row can scroll to the reply that produced it. +// +// Media gathers every image, video and audio clip the thread shows, including signed links an +// action fetched from a remote service, which are otherwise hard to find again in a long thread. +// Images and clips are tiles, three to a row, that open one viewer stepping through them all; +// recordings are players, each with Download and a button that scrolls to its message. import { useCallback, useEffect, useMemo, useState } from 'react'; -import { Download, Eye, FileText, Loader2 } from 'lucide-react'; -import { ApiError } from '../../lib/apiClient'; +import { clsx } from 'clsx'; import { - fetchCollaborationGeneratedDocument, - fetchCollaborationGeneratedDocuments, - type GeneratedDocument, -} from '../../lib/collaboration'; -import { saveBlob } from '../../lib/endpoints'; + Download, + Eye, + File as GenericFileIcon, + FileCode, + FileJson, + FileSpreadsheet, + FileText, + Loader2, + LocateFixed, + Presentation, + type LucideIcon, +} from 'lucide-react'; import type { ConversationMediaItem } from '../../lib/conversationMedia'; -import { useChatStore } from '../../stores/chatStore'; +import { + generatedFileDetails, + type ConversationGeneratedFile, + type GeneratedEntry, + type GeneratedFileStatus, +} from '../../lib/conversationGeneratedFiles'; +import { downloadGeneratedArtifact, saveBlob, type ConversationKind } from '../../lib/endpoints'; +import { hasArtifactPreview, type GeneratedArtifact } from '../../lib/generatedArtifacts'; +import { fetchGeneratedDocument, type GeneratedDocument } from '../../lib/generatedDocuments'; import { toast } from '../../stores/toastStore'; import { Modal } from '../ui/Modal'; import { PlainMarkdown } from '../ui/PlainMarkdown'; import { GlassButton } from '../ui/primitives'; +import { ArtifactPreviewDialog } from './GeneratedArtifactCard'; import { InlineAudioPlayer } from './InlineAudioPlayer'; import { MediaViewer, type MediaViewerItem } from './MediaViewer'; import { ImageTile, VideoTile } from './MediaTiles'; @@ -30,6 +51,43 @@ import { ImageTile, VideoTile } from './MediaTiles'; /** Largest Markdown file previewed in the app; anything bigger is better read downloaded. */ const PREVIEW_MAX_BYTES = 2 * 1024 * 1024; +const ROW_BUTTON = + 'shrink-0 rounded-lg p-1.5 text-text-3 transition-colors hover:bg-surface-2 hover:text-text-1 disabled:opacity-50'; + +/** Spreadsheets, documents, decks and structured data each get an icon a person recognises. */ +const FORMAT_ICONS: Readonly> = { + csv: FileSpreadsheet, + tsv: FileSpreadsheet, + xls: FileSpreadsheet, + xlsx: FileSpreadsheet, + docx: FileText, + pdf: FileText, + md: FileText, + txt: FileText, + pptx: Presentation, + json: FileJson, + xml: FileCode, + yaml: FileCode, +}; + +/** Status pills share the tones of the plan's own file cards. */ +const STATUS_TONES: Readonly, string>> = { + pending: 'bg-surface-3 text-text-2', + cancelled: 'bg-surface-3 text-text-2', + failed: 'bg-danger-soft text-danger', + withheld: 'bg-warn-soft text-warn', + unavailable: 'bg-warn-soft text-warn', +}; + +function FormatIcon({ format }: { format: string }) { + const Icon = FORMAT_ICONS[format] ?? GenericFileIcon; + return