diff --git a/api/server/controllers/agents/__tests__/callbacks.spec.js b/api/server/controllers/agents/__tests__/callbacks.spec.js index d37f393367..b13eae0f61 100644 --- a/api/server/controllers/agents/__tests__/callbacks.spec.js +++ b/api/server/controllers/agents/__tests__/callbacks.spec.js @@ -7,6 +7,7 @@ jest.mock('nanoid', () => ({ jest.mock('@librechat/api', () => ({ sendEvent: jest.fn(), + writeAttachmentEvent: jest.fn(), GenerationJobManager: { emitChunk: jest.fn(), }, @@ -444,6 +445,7 @@ describe('createToolEndCallback', () => { name, toolName = 'execute_code', hostFileAuthoring = false, + created, codeExecutionContext, }) { return { @@ -452,6 +454,8 @@ describe('createToolEndCallback', () => { tool_call_id: toolCallId, artifact: { ...(hostFileAuthoring ? { __librechat_file_authoring: true } : {}), + ...(created === undefined ? {} : { created }), + path: name, session_id: 'sess-1', files: [{ id: fileId, name, session_id: 'sess-1' }], }, @@ -667,8 +671,17 @@ describe('createToolEndCallback', () => { conversationId: 'thread789', messageId: 'run-create', toolCallId: 'tool-create', - status: 'ready', + status: 'pending', }, + finalize: jest.fn().mockResolvedValue({ + file_id: 'fid-created', + filename: 'created.txt', + filepath: '/uploads/created.txt', + type: 'text/plain', + conversationId: 'thread789', + messageId: 'run-create', + status: 'ready', + }), }); const toolEndCallback = createToolEndCallback({ req, res, artifactPromises }); @@ -680,6 +693,7 @@ describe('createToolEndCallback', () => { name: 'created.txt', toolName: 'create_file', hostFileAuthoring: true, + created: true, codeExecutionContext: { baseUrl: 'https://code-stateful.example.com', executionProfile: 'stateful', @@ -687,6 +701,7 @@ describe('createToolEndCallback', () => { }); await toolEndCallback({ output: event.output }, event.metadata); await Promise.all(artifactPromises); + await new Promise((resolve) => setImmediate(resolve)); expect(processCodeOutput).toHaveBeenCalledWith( expect.objectContaining({ @@ -699,7 +714,139 @@ describe('createToolEndCallback', () => { executionProfile: 'stateful', }), ); + expect(res.write).toHaveBeenCalledTimes(2); + expect(parseSseAttachment(res.write.mock.calls[0]).workspaceChange).toEqual({ + profile: 'stateful', + operation: 'created', + path: 'created.txt', + }); + expect(parseSseAttachment(res.write.mock.calls[1]).workspaceChange).toEqual({ + profile: 'stateful', + operation: 'created', + path: 'created.txt', + }); + await expect(artifactPromises[0]).resolves.toEqual( + expect.objectContaining({ + workspaceChange: { + profile: 'stateful', + operation: 'created', + path: 'created.txt', + }, + }), + ); + }); + + it('does not mark stateless file authoring outputs as stateful workspace changes', async () => { + res.headersSent = true; + processCodeOutput.mockResolvedValue({ + file: { + file_id: 'fid-default', + filename: 'default.txt', + filepath: '/uploads/default.txt', + type: 'text/plain', + conversationId: 'thread789', + messageId: 'run-default', + toolCallId: 'tool-default', + status: 'ready', + }, + }); + + const toolEndCallback = createToolEndCallback({ req, res, artifactPromises }); + const event = makeCodeExecutionEvent({ + runId: 'run-default', + threadId: 'thread789', + toolCallId: 'tool-default', + fileId: 'fid-default', + name: 'default.txt', + toolName: 'create_file', + hostFileAuthoring: true, + created: true, + codeExecutionContext: { + baseUrl: 'https://code-default.example.com', + executionProfile: 'default', + }, + }); + await toolEndCallback({ output: event.output }, event.metadata); + await Promise.all(artifactPromises); + expect(res.write).toHaveBeenCalledTimes(1); + expect(parseSseAttachment(res.write.mock.calls[0]).workspaceChange).toBeUndefined(); + }); + + it('preserves stateful workspace changes in Open Responses attachment events', async () => { + const { writeAttachmentEvent } = require('@librechat/api'); + const { createResponsesToolEndCallback } = require('../callbacks'); + res.headersSent = true; + res.writableEnded = false; + processCodeOutput.mockResolvedValue({ + file: { + file_id: 'fid-responses', + filename: 'summary.csv', + filepath: '/uploads/summary.csv', + type: 'text/csv', + conversationId: 'thread789', + messageId: 'run-responses', + toolCallId: 'tool-responses', + status: 'pending', + }, + finalize: jest.fn().mockResolvedValue({ + file_id: 'fid-responses', + filename: 'summary.csv', + filepath: '/uploads/summary.csv', + type: 'text/csv', + conversationId: 'thread789', + messageId: 'run-responses', + status: 'ready', + }), + }); + + const tracker = { nextSequence: jest.fn().mockReturnValueOnce(1).mockReturnValueOnce(2) }; + const toolEndCallback = createResponsesToolEndCallback({ + req, + res, + tracker, + artifactPromises, + }); + const event = makeCodeExecutionEvent({ + runId: 'run-responses', + threadId: 'thread789', + toolCallId: 'tool-responses', + fileId: 'fid-responses', + name: 'summary.csv', + toolName: 'edit_file', + hostFileAuthoring: true, + created: false, + codeExecutionContext: { + baseUrl: 'https://code-stateful.example.com', + executionProfile: 'stateful', + }, + }); + event.output.artifact.path = 'reports/summary.csv'; + + await toolEndCallback({ output: event.output }, event.metadata); + await Promise.all(artifactPromises); + await new Promise((resolve) => setImmediate(resolve)); + + expect(writeAttachmentEvent).toHaveBeenCalledTimes(2); + expect(writeAttachmentEvent.mock.calls[0][2].workspaceChange).toEqual({ + profile: 'stateful', + operation: 'updated', + path: 'reports/summary.csv', + }); + expect(writeAttachmentEvent.mock.calls[1][2].workspaceChange).toEqual({ + profile: 'stateful', + operation: 'updated', + path: 'reports/summary.csv', + }); + await expect(artifactPromises[0]).resolves.toEqual( + expect.objectContaining({ + workspaceChange: { + profile: 'stateful', + operation: 'updated', + path: 'reports/summary.csv', + }, + }), + ); }); it('does not process arbitrary user tool artifacts named create_file as code outputs', async () => { diff --git a/api/server/controllers/agents/callbacks.js b/api/server/controllers/agents/callbacks.js index cb72f2ae37..ae9683b3e7 100644 --- a/api/server/controllers/agents/callbacks.js +++ b/api/server/controllers/agents/callbacks.js @@ -40,6 +40,25 @@ function isCodeArtifactToolOutput(output) { return isCodeSessionToolName(output.name) || isHostFileAuthoringArtifact(output.artifact); } +function addStatefulWorkspaceChange(attachment, artifact, executionProfile) { + if (!attachment || executionProfile !== 'stateful' || !isHostFileAuthoringArtifact(artifact)) { + return attachment; + } + const path = + typeof artifact.path === 'string' && artifact.path.length > 0 + ? artifact.path + : attachment.filename; + if (typeof path !== 'string' || path.length === 0) { + return attachment; + } + attachment.workspaceChange = { + profile: 'stateful', + operation: artifact.created === true ? 'created' : 'updated', + path, + }; + return attachment; +} + class ModelEndHandler { /** * @param {Array} collectedUsage @@ -979,7 +998,11 @@ function createToolEndCallback({ req, res, artifactPromises, streamId = null, jo codeApiBaseUrl: metadata.codeExecutionContext?.baseUrl, executionProfile: metadata.codeExecutionContext?.executionProfile, }); - const fileMetadata = result?.file ?? null; + const fileMetadata = addStatefulWorkspaceChange( + result?.file ?? null, + output.artifact, + metadata.codeExecutionContext?.executionProfile, + ); const finalize = result?.finalize; if (!fileMetadata) { return null; @@ -1027,6 +1050,9 @@ function createToolEndCallback({ req, res, artifactPromises, streamId = null, jo ...updated, messageId: metadata.run_id, toolCallId, + ...(fileMetadata.workspaceChange + ? { workspaceChange: fileMetadata.workspaceChange } + : {}), }, jobCreatedAt, ); @@ -1303,7 +1329,11 @@ function createResponsesToolEndCallback({ req, res, tracker, artifactPromises }) codeApiBaseUrl: metadata.codeExecutionContext?.baseUrl, executionProfile: metadata.codeExecutionContext?.executionProfile, }); - const fileMetadata = result?.file ?? null; + const fileMetadata = addStatefulWorkspaceChange( + result?.file ?? null, + output.artifact, + metadata.codeExecutionContext?.executionProfile, + ); const finalize = result?.finalize; if (!fileMetadata) { return null; @@ -1336,7 +1366,12 @@ function createResponsesToolEndCallback({ req, res, tracker, artifactPromises }) writeResponsesAttachment( res, tracker, - buildResponsesAttachment(updated, toolCallId), + buildResponsesAttachment( + fileMetadata.workspaceChange + ? { ...updated, workspaceChange: fileMetadata.workspaceChange } + : updated, + toolCallId, + ), metadata, ); }, @@ -1371,6 +1406,7 @@ function buildResponsesAttachment(fileMetadata, toolCallId) { textFormat: fileMetadata.textFormat ?? null, status: fileMetadata.status, previewError: fileMetadata.previewError, + workspaceChange: fileMetadata.workspaceChange, }; } diff --git a/api/server/routes/files/files.js b/api/server/routes/files/files.js index 72295c4a9b..4f0caee52a 100644 --- a/api/server/routes/files/files.js +++ b/api/server/routes/files/files.js @@ -367,7 +367,10 @@ router.get('/code/download/:session_id/:fileId', async (req, res) => { req, { baseUrl, executionProfile }, ); - res.set(response.headers); + res.setHeader('Content-Disposition', 'attachment'); + res.setHeader('Content-Type', 'application/octet-stream'); + res.setHeader('X-Content-Type-Options', 'nosniff'); + res.setHeader('Cache-Control', 'private, no-store'); response.data.pipe(res); } catch (error) { /* `logAxiosError` redacts buffer/stream response bodies — without diff --git a/api/server/routes/files/files.test.js b/api/server/routes/files/files.test.js index 4fce9cb30e..4fcdd3a62a 100644 --- a/api/server/routes/files/files.test.js +++ b/api/server/routes/files/files.test.js @@ -1097,7 +1097,10 @@ describe('File Routes - Delete with Agent Access', () => { describe('GET /files/code/download/:session_id/:fileId', () => { it('routes a persisted stateful fallback through the stateful Code API', async () => { const getDownloadStream = jest.fn().mockResolvedValue({ - headers: { 'content-type': 'text/plain' }, + headers: { + 'content-type': 'text/html', + 'set-cookie': 'internal-service-cookie=secret', + }, data: Readable.from(['stateful output']), }); getStrategyFunctions.mockReturnValue({ getDownloadStream }); @@ -1111,7 +1114,12 @@ describe('File Routes - Delete with Agent Access', () => { ); expect(response.status).toBe(200); - expect(response.text).toBe('stateful output'); + expect(response.body.toString()).toBe('stateful output'); + expect(response.headers['content-disposition']).toBe('attachment'); + expect(response.headers['content-type']).toBe('application/octet-stream'); + expect(response.headers['x-content-type-options']).toBe('nosniff'); + expect(response.headers['cache-control']).toBe('private, no-store'); + expect(response.headers['set-cookie']).toBeUndefined(); expect(getDownloadStream).toHaveBeenCalledWith( `${sessionId}/${codeFileId}`, { kind: 'user', id: otherUserId.toString() }, diff --git a/client/src/components/Chat/Messages/Content/ContentParts.tsx b/client/src/components/Chat/Messages/Content/ContentParts.tsx index 0afa4ab1be..a09a9ff5bd 100644 --- a/client/src/components/Chat/Messages/Content/ContentParts.tsx +++ b/client/src/components/Chat/Messages/Content/ContentParts.tsx @@ -9,6 +9,7 @@ import type { import type { ReactNode, ReactElement } from 'react'; import type { ToolCallGroupExpansionState } from './ToolCallGroup'; import { mapAttachments, filterAttachmentsForPart, groupSequentialToolCalls } from '~/utils'; +import WorkspaceChanges, { partitionWorkspaceChanges } from './Parts/WorkspaceChanges'; import { groupActivityPhases, lastVisibleContentIdx } from '~/utils/activityLabels'; import { ParallelContentRenderer, type PartWithIndex } from './ParallelContent'; import MemoryArtifacts, { hasMemoryArtifacts } from './MemoryArtifacts'; @@ -163,6 +164,8 @@ type ContentPartsProps = { | undefined; /** Internal recursion guard for nested phase segments. */ nestedActivityPhase?: boolean; + /** Internal signal that the parent already removed message-level workspace attachments. */ + workspaceAttachmentsPartitioned?: boolean; /** Absolute transcript index represented by `content[0]` in a phase slice. */ contentIndexOffset?: number; /** Absolute transcript index for each compacted sparse segment entry. */ @@ -197,12 +200,20 @@ const ContentParts = memo(function ContentParts({ isLatestMessage, createdAt, nestedActivityPhase = false, + workspaceAttachmentsPartitioned = false, contentIndexOffset = 0, contentIndices, resumeAuthors, toolGroupExpansionState, }: ContentPartsProps) { - const attachmentMap = useMemo(() => mapAttachments(attachments ?? []), [attachments]); + const { inlineAttachments, workspaceChanges } = useMemo( + () => + workspaceAttachmentsPartitioned + ? { inlineAttachments: attachments ?? [], workspaceChanges: [] } + : partitionWorkspaceChanges(attachments), + [attachments, workspaceAttachmentsPartitioned], + ); + const attachmentMap = useMemo(() => mapAttachments(inlineAttachments), [inlineAttachments]); const effectiveIsSubmitting = isLatestMessage ? isSubmitting : false; const localToolGroupExpansionRef = useRef(new Map()); const expansionState = toolGroupExpansionState ?? localToolGroupExpansionRef.current; @@ -459,7 +470,7 @@ const ContentParts = memo(function ContentParts({ ); // Early return: no content to render AND no pending skill cards - if (!content && !hasPendingSkills) { + if (!content && !hasPendingSkills && workspaceChanges.length === 0) { return null; } @@ -479,6 +490,7 @@ const ContentParts = memo(function ContentParts({ setSiblingIdx={setSiblingIdx} renderReadOnlyPart={(part, idx, isLastPart) => renderPart(part, idx, isLastPart)} /> + ); @@ -502,13 +514,14 @@ const ContentParts = memo(function ContentParts({ createdAt={createdAt} authorHeader={authorHeader} conversationId={conversationId} - attachments={attachments} + attachments={inlineAttachments} searchResults={searchResults} isCreatedByUser={isCreatedByUser} isLast={isLast && segmentIndices.includes(globalLastContentIdx)} isSubmitting={isSubmitting} isLatestMessage={isLatestMessage} nestedActivityPhase + workspaceAttachmentsPartitioned contentIndexOffset={segmentStartIndex} contentIndices={segmentIndices} resumeAuthors={postSteerAuthors} @@ -559,6 +572,7 @@ const ContentParts = memo(function ContentParts({ ) ), )} + ); @@ -598,6 +612,7 @@ const ContentParts = memo(function ContentParts({ contentIndexOffset={contentIndexOffset} contentIndices={contentIndices} /> + {!nestedActivityPhase && } ); return nestedActivityPhase ? ( @@ -660,6 +675,7 @@ const ContentParts = memo(function ContentParts({ ); return nodes; })} + {!nestedActivityPhase && } ); if (nestedActivityPhase) { diff --git a/client/src/components/Chat/Messages/Content/Parts/WorkspaceChanges.tsx b/client/src/components/Chat/Messages/Content/Parts/WorkspaceChanges.tsx new file mode 100644 index 0000000000..d49cc137b0 --- /dev/null +++ b/client/src/components/Chat/Messages/Content/Parts/WorkspaceChanges.tsx @@ -0,0 +1,142 @@ +import { memo, useId, useMemo, useState } from 'react'; +import { Button, IconButton } from '@librechat/client'; +import { ChevronDown, Download, Files } from 'lucide-react'; +import type { + TAttachment, + TFile, + WorkspaceChange as WorkspaceChangeMetadata, +} from 'librechat-data-provider'; +import { useExpandCollapse, useLocalize } from '~/hooks'; +import { useAttachmentLink } from './LogLink'; +import { cn } from '~/utils'; + +type StatefulWorkspaceAttachment = TAttachment & { + workspaceChange: WorkspaceChangeMetadata; +}; + +export function partitionWorkspaceChanges(attachments?: TAttachment[]): { + inlineAttachments: TAttachment[]; + workspaceChanges: StatefulWorkspaceAttachment[]; +} { + const inlineAttachments: TAttachment[] = []; + const changesByFile = new Map(); + + for (const attachment of attachments ?? []) { + const change = attachment.workspaceChange; + if (change?.profile !== 'stateful' || !attachment.filepath) { + inlineAttachments.push(attachment); + continue; + } + + const file = attachment as Partial & { agentId?: string }; + const key = file.file_id ?? `${file.agentId ?? ''}:${change.path}`; + changesByFile.delete(key); + changesByFile.set(key, attachment as StatefulWorkspaceAttachment); + } + + return { inlineAttachments, workspaceChanges: Array.from(changesByFile.values()) }; +} + +const WorkspaceChange = memo(({ attachment }: { attachment: StatefulWorkspaceAttachment }) => { + const localize = useLocalize(); + const file = attachment as TFile; + const path = attachment.workspaceChange.path; + const filename = path.split('/').pop() || path; + const { handleDownload } = useAttachmentLink({ + href: attachment.filepath ?? '', + filename, + file_id: file.file_id, + user: file.user, + source: file.source, + }); + + return ( +
+
+
+ {filename} +
+ {path !== filename && ( +
+ {path} +
+ )} +
+ void handleDownload(event)} + label={`${localize('com_ui_download')} ${filename}`} + title={localize('com_ui_download')} + variant="ghost" + size="sm" + shape="square" + className="text-text-secondary" + > + +
+ ); +}); + +WorkspaceChange.displayName = 'WorkspaceChange'; + +export default function WorkspaceChanges({ + attachments, +}: { + attachments: StatefulWorkspaceAttachment[]; +}) { + const localize = useLocalize(); + const panelId = useId(); + const [isExpanded, setIsExpanded] = useState(false); + const { style, ref } = useExpandCollapse(isExpanded); + const count = attachments.length; + const countLabel = localize(count === 1 ? 'com_ui_one_file_changed' : 'com_ui_n_files_changed', { + 0: String(count), + }); + const summary = useMemo( + () => attachments.map((attachment) => attachment.workspaceChange.path).join(', '), + [attachments], + ); + + if (count === 0) { + return null; + } + + return ( +
+ +
+
+
+ {attachments.map((attachment) => ( + + ))} +
+
+
+
+ ); +} diff --git a/client/src/components/Chat/Messages/Content/Parts/__tests__/WorkspaceChanges.test.tsx b/client/src/components/Chat/Messages/Content/Parts/__tests__/WorkspaceChanges.test.tsx new file mode 100644 index 0000000000..4566e2d031 --- /dev/null +++ b/client/src/components/Chat/Messages/Content/Parts/__tests__/WorkspaceChanges.test.tsx @@ -0,0 +1,107 @@ +import React from 'react'; +import { FileSources } from 'librechat-data-provider'; +import { fireEvent, render, screen } from '@testing-library/react'; +import type { TAttachment } from 'librechat-data-provider'; +import WorkspaceChanges, { partitionWorkspaceChanges } from '../WorkspaceChanges'; + +const mockHandleDownload = jest.fn(); + +jest.mock('../LogLink', () => ({ + useAttachmentLink: () => ({ handleDownload: mockHandleDownload }), +})); + +jest.mock('~/hooks', () => ({ + useExpandCollapse: () => ({ style: {}, ref: { current: null } }), + useLocalize: () => (key: string, values?: Record) => { + const translations: Record = { + com_ui_download: 'Download', + com_ui_n_files_changed: `${values?.[0]} files changed`, + com_ui_one_file_changed: '1 file changed', + com_ui_workspace_changes: 'Workspace changes', + }; + return translations[key] ?? key; + }, +})); + +jest.mock('~/utils', () => ({ + cn: (...classes: Array) => classes.filter(Boolean).join(' '), +})); + +function makeAttachment({ + fileId, + path, + profile = 'stateful', + filepath, +}: { + fileId: string; + path: string; + profile?: 'stateful' | 'default'; + filepath?: string; +}): TAttachment { + return { + file_id: fileId, + filename: path, + filepath: filepath ?? `/uploads/${fileId}`, + source: FileSources.local, + user: 'user-1', + conversationId: 'conversation-1', + messageId: 'message-1', + toolCallId: `tool-${fileId}`, + workspaceChange: { + profile, + operation: 'updated', + path, + }, + } as TAttachment; +} + +describe('WorkspaceChanges', () => { + beforeEach(() => { + mockHandleDownload.mockReset(); + }); + + it('partitions only downloadable stateful changes and keeps the latest file entry', () => { + const first = makeAttachment({ fileId: 'shared', path: 'reports/result.csv' }); + const latest = { + ...makeAttachment({ fileId: 'shared', path: 'reports/result.csv' }), + filepath: '/uploads/latest', + } as TAttachment; + const stateless = makeAttachment({ + fileId: 'default', + path: 'default.txt', + profile: 'default', + }); + const unavailable = makeAttachment({ fileId: 'missing', path: 'missing.txt' }); + unavailable.filepath = ''; + + const result = partitionWorkspaceChanges([first, stateless, unavailable, latest]); + + expect(result.inlineAttachments).toEqual([stateless, unavailable]); + expect(result.workspaceChanges).toEqual([latest]); + }); + + it('renders one collapsed row and downloads through the existing attachment handler', () => { + const changes = partitionWorkspaceChanges([ + makeAttachment({ fileId: 'one', path: 'reports/summary.csv' }), + makeAttachment({ fileId: 'two', path: 'notes.txt' }), + ]).workspaceChanges; + + render(); + + const toggle = screen.getByRole('button', { + name: 'Workspace changes: 2 files changed', + }); + const panel = document.getElementById(toggle.getAttribute('aria-controls') ?? ''); + expect(toggle).toHaveAttribute('aria-expanded', 'false'); + expect(panel).toHaveAttribute('inert'); + + fireEvent.click(toggle); + expect(toggle).toHaveAttribute('aria-expanded', 'true'); + expect(panel).not.toHaveAttribute('inert'); + expect(screen.getByText('summary.csv')).toBeInTheDocument(); + expect(screen.getByText('reports/summary.csv')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Download summary.csv' })); + expect(mockHandleDownload).toHaveBeenCalledTimes(1); + }); +}); diff --git a/client/src/components/Chat/Messages/Content/__tests__/ContentParts.test.tsx b/client/src/components/Chat/Messages/Content/__tests__/ContentParts.test.tsx index b735bc0493..f4e1fbc3bc 100644 --- a/client/src/components/Chat/Messages/Content/__tests__/ContentParts.test.tsx +++ b/client/src/components/Chat/Messages/Content/__tests__/ContentParts.test.tsx @@ -54,6 +54,18 @@ jest.mock('../Parts/PendingSkillCall', () => ({ ), })); +jest.mock('../Parts/WorkspaceChanges', () => ({ + __esModule: true, + default: ({ attachments }: { attachments: TAttachment[] }) => + attachments.length > 0 ? ( +
+ ) : null, + partitionWorkspaceChanges: (attachments?: TAttachment[]) => ({ + inlineAttachments: (attachments ?? []).filter((attachment) => !attachment.workspaceChange), + workspaceChanges: (attachments ?? []).filter((attachment) => attachment.workspaceChange), + }), +})); + jest.mock('../ToolCallGroup', () => ({ __esModule: true, default: ({ @@ -144,6 +156,48 @@ beforeEach(() => { }); describe('ContentParts — interim skill cards', () => { + it('renders stateful workspace changes once at message level', () => { + const content: TMessageContentParts[] = [ + { type: ContentTypes.TEXT, text: 'done' } as TMessageContentParts, + ]; + const attachment = { + filename: 'report.csv', + filepath: '/uploads/report.csv', + conversationId: 'conversation-1', + messageId: 'msg-1', + toolCallId: 'tool-1', + workspaceChange: { + profile: 'stateful', + operation: 'created', + path: 'report.csv', + }, + } as TAttachment; + + render(); + + expect(screen.getAllByTestId('workspace-changes')).toHaveLength(1); + expect(screen.getByTestId('workspace-changes')).toHaveAttribute('data-count', '1'); + }); + + it('renders stateful workspace changes when the assistant message has no content yet', () => { + const attachment = { + filename: 'report.csv', + filepath: '/uploads/report.csv', + conversationId: 'conversation-1', + messageId: 'msg-1', + toolCallId: 'tool-1', + workspaceChange: { + profile: 'stateful', + operation: 'created', + path: 'report.csv', + }, + } as TAttachment; + + render(); + + expect(screen.getByTestId('workspace-changes')).toHaveAttribute('data-count', '1'); + }); + it('renders a PendingSkillCall per manual skill on assistant messages', () => { render(); const cards = screen.getAllByTestId('pending-skill-call'); diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index f4fa831120..3f2405e8a5 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -1595,6 +1595,7 @@ "com_ui_my_prompts": "My Prompts", "com_ui_my_skills": "My Skills", "com_ui_n_files": "{{0}} files", + "com_ui_n_files_changed": "{{0}} files changed", "com_ui_name": "Name", "com_ui_name_sort": "Sort by Name", "com_ui_navigate_results": "Navigate results", @@ -1658,6 +1659,7 @@ "com_ui_offline": "Offline", "com_ui_omitted": "Omitted", "com_ui_on": "On", + "com_ui_one_file_changed": "1 file changed", "com_ui_open_archived_chat_new_tab_title": "{{title}} (opens in new tab)", "com_ui_open_artifact": "Open artifact", "com_ui_open_as_artifact": "Open as artifact", @@ -2294,6 +2296,7 @@ "com_ui_web_searched": "Searched the web", "com_ui_web_searching": "Searching the web", "com_ui_web_searching_again": "Searching the web again", + "com_ui_workspace_changes": "Workspace changes", "com_ui_write": "Writing", "com_ui_writing_command": "Writing command", "com_ui_x_selected": "{{0}} selected", diff --git a/packages/data-provider/src/schemas.ts b/packages/data-provider/src/schemas.ts index 97fbd700d2..ba40501687 100644 --- a/packages/data-provider/src/schemas.ts +++ b/packages/data-provider/src/schemas.ts @@ -895,10 +895,17 @@ export type UIResource = { [key: string]: unknown; }; +export type WorkspaceChange = { + profile: 'stateful'; + operation: 'created' | 'updated'; + path: string; +}; + export type TAttachmentMetadata = { type?: Tools; messageId: string; toolCallId: string; + workspaceChange?: WorkspaceChange; [Tools.memory]?: MemoryArtifact; [Tools.ui_resources]?: UIResource[]; [Tools.web_search]?: SearchResultData;