📁 feat: Surface Stateful Workspace Downloads (#14984)

* feat: surface stateful workspace downloads

* fix: sort workspace change imports

* fix: reuse workspace button primitives

* fix: hide collapsed workspace actions
This commit is contained in:
Danny Avila 2026-08-18 22:05:13 -04:00 committed by GitHub
parent a33b128c47
commit e4d6bb71f9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 533 additions and 10 deletions

View file

@ -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 () => {

View file

@ -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<UsageMetadata>} 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,
};
}

View file

@ -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

View file

@ -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() },

View file

@ -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<string, ToolCallGroupExpansionState>());
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)}
/>
<WorkspaceChanges attachments={workspaceChanges} />
</SearchContext.Provider>
</ApprovalProvider>
);
@ -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({
)
),
)}
<WorkspaceChanges attachments={workspaceChanges} />
</SearchContext.Provider>
</ApprovalProvider>
);
@ -598,6 +612,7 @@ const ContentParts = memo(function ContentParts({
contentIndexOffset={contentIndexOffset}
contentIndices={contentIndices}
/>
{!nestedActivityPhase && <WorkspaceChanges attachments={workspaceChanges} />}
</>
);
return nestedActivityPhase ? (
@ -660,6 +675,7 @@ const ContentParts = memo(function ContentParts({
);
return nodes;
})}
{!nestedActivityPhase && <WorkspaceChanges attachments={workspaceChanges} />}
</SearchContext.Provider>
);
if (nestedActivityPhase) {

View file

@ -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<string, StatefulWorkspaceAttachment>();
for (const attachment of attachments ?? []) {
const change = attachment.workspaceChange;
if (change?.profile !== 'stateful' || !attachment.filepath) {
inlineAttachments.push(attachment);
continue;
}
const file = attachment as Partial<TFile> & { 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 (
<div className="flex min-w-0 items-center gap-2 rounded-lg bg-surface-secondary px-3 py-2">
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-text-primary" title={filename}>
{filename}
</div>
{path !== filename && (
<div className="truncate text-xs text-text-secondary" title={path}>
{path}
</div>
)}
</div>
<IconButton
onClick={(event) => void handleDownload(event)}
label={`${localize('com_ui_download')} ${filename}`}
title={localize('com_ui_download')}
variant="ghost"
size="sm"
shape="square"
className="text-text-secondary"
>
<Download className="size-4" aria-hidden="true" />
</IconButton>
</div>
);
});
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 (
<div className="my-2 max-w-xl">
<Button
variant="ghost"
aria-expanded={isExpanded}
aria-controls={panelId}
aria-label={`${localize('com_ui_workspace_changes')}: ${countLabel}`}
onClick={() => setIsExpanded((previous) => !previous)}
className="h-auto max-w-full justify-start py-1 pl-0 pr-2 font-normal text-text-secondary"
>
<Files className="size-4 shrink-0" aria-hidden="true" />
<span className="shrink-0 font-medium">{localize('com_ui_workspace_changes')}</span>
<span className="min-w-0 truncate text-xs" title={summary}>
{'— '}
{countLabel}
</span>
<ChevronDown
className={cn(
'size-4 shrink-0 transition-transform duration-200 ease-out',
isExpanded && 'rotate-180',
)}
aria-hidden="true"
/>
</Button>
<div id={panelId} style={style} inert={!isExpanded ? '' : undefined}>
<div className="overflow-hidden" ref={ref} aria-hidden={!isExpanded}>
<div className="flex flex-col gap-2 pt-2">
{attachments.map((attachment) => (
<WorkspaceChange
key={`${attachment.filepath}:${attachment.workspaceChange.path}`}
attachment={attachment}
/>
))}
</div>
</div>
</div>
</div>
);
}

View file

@ -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<number, string>) => {
const translations: Record<string, string> = {
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<string | false | undefined>) => 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(<WorkspaceChanges attachments={changes} />);
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);
});
});

View file

@ -54,6 +54,18 @@ jest.mock('../Parts/PendingSkillCall', () => ({
),
}));
jest.mock('../Parts/WorkspaceChanges', () => ({
__esModule: true,
default: ({ attachments }: { attachments: TAttachment[] }) =>
attachments.length > 0 ? (
<div data-testid="workspace-changes" data-count={attachments.length} />
) : 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(<ContentParts {...baseProps} content={content} attachments={[attachment]} />);
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(<ContentParts {...baseProps} content={undefined} attachments={[attachment]} />);
expect(screen.getByTestId('workspace-changes')).toHaveAttribute('data-count', '1');
});
it('renders a PendingSkillCall per manual skill on assistant messages', () => {
render(<ContentParts {...baseProps} manualSkills={['brand-guidelines', 'pptx']} />);
const cards = screen.getAllByTestId('pending-skill-call');

View file

@ -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",

View file

@ -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;