diff --git a/app/src/components/chat/ChatFilesChip.tsx b/app/src/components/chat/ChatFilesChip.tsx
new file mode 100644
index 000000000..60c5f21b1
--- /dev/null
+++ b/app/src/components/chat/ChatFilesChip.tsx
@@ -0,0 +1,76 @@
+import { useMemo, useState } from 'react';
+
+import { useT } from '../../lib/i18n/I18nContext';
+import { useAppSelector } from '../../store/hooks';
+import ChatFilesPanel from './ChatFilesPanel';
+
+/**
+ * Header chip surfacing the count of ready artifacts for a thread (#3024).
+ *
+ * - Hidden when the count is zero (no chrome cost on chats that never
+ * generated a file).
+ * - Click opens the {@link ChatFilesPanel} popover; clicking again or
+ * pressing Esc closes it (panel owns Esc + click-outside).
+ *
+ * Reads from the persisted `chatRuntime.artifactsByThread` slice — entries
+ * survive app restarts via the `artifactsReadyOnlyTransform` configured in
+ * `store/index.ts`.
+ */
+export interface ChatFilesChipProps {
+ threadId: string;
+}
+
+export default function ChatFilesChip({ threadId }: ChatFilesChipProps) {
+ const { t } = useT();
+ const [open, setOpen] = useState(false);
+ const artifactsByThread = useAppSelector(state => state.chatRuntime.artifactsByThread);
+ const allForThread = artifactsByThread[threadId] ?? [];
+ // Only ready artifacts are listable — in_progress / failed live above the
+ // composer until they resolve.
+ const readyArtifacts = useMemo(
+ () => allForThread.filter(a => a.status === 'ready'),
+ [allForThread]
+ );
+ const count = readyArtifacts.length;
+
+ if (count === 0) return null;
+
+ return (
+
+
+ {open && (
+
setOpen(false)}
+ />
+ )}
+
+ );
+}
diff --git a/app/src/components/chat/ChatFilesPanel.tsx b/app/src/components/chat/ChatFilesPanel.tsx
new file mode 100644
index 000000000..d80ec06ce
--- /dev/null
+++ b/app/src/components/chat/ChatFilesPanel.tsx
@@ -0,0 +1,380 @@
+import { useEffect, useRef, useState } from 'react';
+
+import { formatFileSize } from '../../lib/attachments';
+import { useT } from '../../lib/i18n/I18nContext';
+import {
+ type ArtifactErrorCode,
+ deleteArtifact,
+ downloadArtifact,
+ revealArtifactInFileManager,
+} from '../../services/artifactDownloadService';
+import {
+ type ArtifactSnapshot,
+ removeArtifactForThread,
+ upsertArtifactReadyForThread,
+} from '../../store/chatRuntimeSlice';
+import { useAppDispatch } from '../../store/hooks';
+
+/**
+ * Popover panel listing every `ready` artifact for a thread (#3024).
+ *
+ * Mounted by {@link ChatFilesChip}. Renders one row per artifact with:
+ * - kind icon + title + human-readable size
+ * - Download (Tauri `download_artifact_to_downloads`)
+ * - Show-in-folder (only after a successful download in this session)
+ * - Delete with a confirm-step (optimistic slice removal + RPC call,
+ * re-upsert on failure with a toast).
+ *
+ * Closes on Esc + click-outside. Empty state copy is the panel's only
+ * fallback (the chip itself is hidden when count is zero, so the empty
+ * state only shows if the user deletes the last artifact while the
+ * panel is open).
+ */
+export interface ChatFilesPanelProps {
+ threadId: string;
+ artifacts: ArtifactSnapshot[];
+ onClose: () => void;
+}
+
+/**
+ * Map a structured {@link ArtifactErrorCode} to a localized headline.
+ * Caller passes the raw `outcome.error` as a fallback — if the service
+ * returns no code (e.g. older callers), the raw text wins. Routing all
+ * codes through `t(...)` keeps non-English locales from leaking English
+ * error copy into the panel.
+ */
+function localizeErrorCode(
+ t: (key: string, fallback?: string) => string,
+ code: ArtifactErrorCode | undefined,
+ fallback: string | undefined
+): string {
+ if (!code) return fallback ?? '';
+ switch (code) {
+ case 'NOT_DESKTOP':
+ return t('chat.files.error.not_desktop');
+ case 'MISSING_ARTIFACT_ID':
+ return t('chat.files.error.missing_artifact_id');
+ case 'MISSING_ARTIFACT_PATH':
+ return t('chat.files.error.missing_artifact_path');
+ case 'RESOLVE_FAILED':
+ return t('chat.files.error.resolve_failed');
+ case 'DOWNLOAD_FAILED':
+ return t('chat.files.error.download_failed');
+ case 'DELETE_FAILED':
+ return t('chat.files.error.delete_failed');
+ default: {
+ // Exhaustive guard: a new code added to ArtifactErrorCode without a
+ // matching arm here will fail to type-check.
+ const _exhaustive: never = code;
+ return _exhaustive;
+ }
+ }
+}
+
+function extensionFor(kind: ArtifactSnapshot['kind'], title: string): string {
+ const dot = title.lastIndexOf('.');
+ if (dot > 0 && dot < title.length - 1) {
+ return title.slice(dot + 1).toLowerCase();
+ }
+ switch (kind) {
+ case 'presentation':
+ return 'pptx';
+ case 'document':
+ return 'pdf';
+ case 'image':
+ return 'png';
+ default:
+ return 'bin';
+ }
+}
+
+function KindIcon({ kind }: { kind: ArtifactSnapshot['kind'] }) {
+ const stroke = 'currentColor';
+ switch (kind) {
+ case 'presentation':
+ return (
+
+ );
+ case 'document':
+ return (
+
+ );
+ case 'image':
+ return (
+
+ );
+ default:
+ return (
+
+ );
+ }
+}
+
+interface RowDownloadState {
+ state: 'idle' | 'downloading' | 'done' | 'error';
+ path?: string;
+ /** Already-localized error headline ready for direct render. */
+ error?: string;
+}
+
+export default function ChatFilesPanel({ threadId, artifacts, onClose }: ChatFilesPanelProps) {
+ const { t } = useT();
+ const dispatch = useAppDispatch();
+ const containerRef = useRef(null);
+ const [confirmDeleteId, setConfirmDeleteId] = useState(null);
+ const [downloadState, setDownloadState] = useState>({});
+ const [deleteError, setDeleteError] = useState(null);
+
+ // Esc closes. Use keydown not keyup so a panel that opened via Enter
+ // doesn't immediately re-trigger its trigger on the same release.
+ useEffect(() => {
+ const onKey = (e: KeyboardEvent) => {
+ if (e.key === 'Escape') {
+ if (confirmDeleteId) {
+ setConfirmDeleteId(null);
+ } else {
+ onClose();
+ }
+ }
+ };
+ window.addEventListener('keydown', onKey);
+ return () => window.removeEventListener('keydown', onKey);
+ }, [confirmDeleteId, onClose]);
+
+ // Click-outside closes. Pointerdown fires before click, so the chip's
+ // toggle handler doesn't immediately re-open the just-closed panel.
+ useEffect(() => {
+ const onPointer = (e: PointerEvent) => {
+ const node = containerRef.current;
+ if (!node) return;
+ const target = e.target as Node | null;
+ if (target && !node.contains(target)) {
+ onClose();
+ }
+ };
+ document.addEventListener('pointerdown', onPointer);
+ return () => document.removeEventListener('pointerdown', onPointer);
+ }, [onClose]);
+
+ const handleDownload = async (artifact: ArtifactSnapshot) => {
+ setDownloadState(prev => ({ ...prev, [artifact.artifactId]: { state: 'downloading' } }));
+ const ext = extensionFor(artifact.kind, artifact.title);
+ const outcome = await downloadArtifact(artifact.artifactId, artifact.title, ext);
+ setDownloadState(prev => ({
+ ...prev,
+ [artifact.artifactId]: outcome.ok
+ ? { state: 'done', path: outcome.path }
+ : {
+ state: 'error',
+ // Prefer the localized headline; only fall back to the raw
+ // detail when the service didn't supply a code (defensive —
+ // every documented failure path returns one).
+ error: localizeErrorCode(t, outcome.code, outcome.error),
+ },
+ }));
+ };
+
+ const handleReveal = async (path: string) => {
+ if (path) {
+ await revealArtifactInFileManager(path);
+ }
+ };
+
+ const handleDeleteConfirm = async (artifact: ArtifactSnapshot) => {
+ setConfirmDeleteId(null);
+ setDeleteError(null);
+ // Optimistic remove — re-upsert on failure so the row reappears.
+ dispatch(removeArtifactForThread({ threadId, artifactId: artifact.artifactId }));
+ const outcome = await deleteArtifact(artifact.artifactId);
+ if (!outcome.ok) {
+ // Re-insert the snapshot so the user sees it again. Keep updatedAt
+ // fresh so the row sorts in the same spot it left.
+ dispatch(
+ upsertArtifactReadyForThread({
+ threadId,
+ artifactId: artifact.artifactId,
+ kind: artifact.kind,
+ title: artifact.title,
+ path: artifact.path ?? '',
+ sizeBytes: artifact.sizeBytes ?? 0,
+ })
+ );
+ // Prefer the localized headline mapped from `code`; if neither
+ // code nor a raw detail came back, fall back to the generic
+ // delete-failed copy so the user always sees something.
+ setDeleteError(
+ localizeErrorCode(t, outcome.code, outcome.error) || t('chat.files.delete.failed')
+ );
+ }
+ };
+
+ return (
+
+
+
+ {t('chat.files.panel.title').replace('{count}', String(artifacts.length))}
+
+
+
+
+ {artifacts.length === 0 ? (
+
+ {t('chat.files.panel.empty')}
+
+ ) : (
+
+ {artifacts.map(artifact => {
+ const row = downloadState[artifact.artifactId] ?? { state: 'idle' as const };
+ const isConfirming = confirmDeleteId === artifact.artifactId;
+ return (
+ -
+
+
+
+ {artifact.title}
+
+ {artifact.sizeBytes != null ? formatFileSize(artifact.sizeBytes) : ''}
+
+
+
+ {isConfirming ? (
+
+
+ {t('chat.files.delete.confirm')}
+
+
+
+
+ ) : (
+
+
+ {row.state === 'done' && row.path && (
+
+ )}
+
+
+ )}
+ {row.state === 'error' && row.error && (
+
+ {t('chat.artifact.download_failed').replace('{reason}', row.error)}
+
+ )}
+
+ );
+ })}
+
+ )}
+ {deleteError && (
+
+ {deleteError}
+
+ )}
+
+ );
+}
diff --git a/app/src/components/chat/__tests__/ArtifactCard.test.tsx b/app/src/components/chat/__tests__/ArtifactCard.test.tsx
new file mode 100644
index 000000000..a4d5c0979
--- /dev/null
+++ b/app/src/components/chat/__tests__/ArtifactCard.test.tsx
@@ -0,0 +1,250 @@
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import {
+ downloadArtifact,
+ revealArtifactInFileManager,
+} from '../../../services/artifactDownloadService';
+import type { ArtifactSnapshot } from '../../../store/chatRuntimeSlice';
+import ArtifactCard from '../ArtifactCard';
+
+vi.mock('../../../services/artifactDownloadService', () => ({
+ downloadArtifact: vi.fn(),
+ revealArtifactInFileManager: vi.fn(),
+}));
+
+function inProgress(overrides: Partial = {}): ArtifactSnapshot {
+ return {
+ artifactId: 'art-1',
+ kind: 'presentation',
+ title: 'Climate Deck',
+ status: 'in_progress',
+ updatedAt: Date.now(),
+ ...overrides,
+ };
+}
+
+function ready(overrides: Partial = {}): ArtifactSnapshot {
+ return {
+ artifactId: 'art-1',
+ kind: 'presentation',
+ title: 'Climate Deck',
+ status: 'ready',
+ path: 'artifacts/art-1.pptx',
+ sizeBytes: 4096,
+ updatedAt: Date.now(),
+ ...overrides,
+ };
+}
+
+function failed(overrides: Partial = {}): ArtifactSnapshot {
+ return {
+ artifactId: 'art-1',
+ kind: 'presentation',
+ title: 'Climate Deck',
+ status: 'failed',
+ error: 'producer crashed',
+ updatedAt: Date.now(),
+ ...overrides,
+ };
+}
+
+describe('ArtifactCard', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ // ─── in_progress ────────────────────────────────────────────────────────
+
+ it('renders the in-progress label and no download button', () => {
+ render();
+ expect(screen.getByText(/Generating presentation/)).toBeInTheDocument();
+ // No download button while in progress
+ expect(screen.queryByRole('button', { name: /Download/ })).toBeNull();
+ // role=group + aria carries the title
+ expect(screen.getByRole('group', { name: /Climate Deck/ })).toBeInTheDocument();
+ });
+
+ // ─── ready ──────────────────────────────────────────────────────────────
+
+ it('renders the size + Download button when ready', () => {
+ render();
+ expect(screen.getByText(/Ready/)).toBeInTheDocument();
+ // 4096 bytes → "4.0 KB" per formatFileSize
+ expect(screen.getByText(/4\.0 KB/)).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Download' })).toBeInTheDocument();
+ });
+
+ it('on Download click → calls downloadArtifact with title-derived extension on success', async () => {
+ vi.mocked(downloadArtifact).mockResolvedValueOnce({
+ ok: true,
+ path: '/Users/me/Downloads/Climate Deck.pptx',
+ });
+ render();
+ fireEvent.click(screen.getByRole('button', { name: 'Download' }));
+ await waitFor(() => {
+ expect(downloadArtifact).toHaveBeenCalledWith('art-1', 'climate-deck.pptx', 'pptx');
+ });
+ // Saved-to label appears with the resolved path
+ await waitFor(() => {
+ expect(screen.getByText(/Saved to/)).toBeInTheDocument();
+ });
+ // Reveal button appears and the original Download button is gone
+ expect(screen.getByRole('button', { name: 'Show in folder' })).toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: 'Download' })).toBeNull();
+ });
+
+ it('on Reveal click → calls revealArtifactInFileManager with the saved path', async () => {
+ vi.mocked(downloadArtifact).mockResolvedValueOnce({
+ ok: true,
+ path: '/Users/me/Downloads/Climate Deck.pptx',
+ });
+ render();
+ fireEvent.click(screen.getByRole('button', { name: 'Download' }));
+ await waitFor(() => {
+ expect(screen.getByRole('button', { name: 'Show in folder' })).toBeInTheDocument();
+ });
+ fireEvent.click(screen.getByRole('button', { name: 'Show in folder' }));
+ await waitFor(() => {
+ expect(revealArtifactInFileManager).toHaveBeenCalledWith(
+ '/Users/me/Downloads/Climate Deck.pptx'
+ );
+ });
+ });
+
+ it('on Download failure → surfaces the error reason and leaves the Download button in place', async () => {
+ vi.mocked(downloadArtifact).mockResolvedValueOnce({
+ ok: false,
+ code: 'NOT_DESKTOP',
+ error: 'Downloads are only available in the desktop app',
+ });
+ render();
+ fireEvent.click(screen.getByRole('button', { name: 'Download' }));
+ await waitFor(() => {
+ expect(screen.getByText(/Download failed:/)).toBeInTheDocument();
+ });
+ // Original Download button is still there for retry
+ expect(screen.getByRole('button', { name: 'Download' })).toBeInTheDocument();
+ // No "Show in folder" affordance on failure
+ expect(screen.queryByRole('button', { name: 'Show in folder' })).toBeNull();
+ });
+
+ it.each([
+ ['document' as const, 'pdf'],
+ ['image' as const, 'png'],
+ ['other' as const, 'bin'],
+ ['presentation' as const, 'pptx'],
+ ])(
+ 'falls back to per-kind extension when title lacks one (kind=%s → ext=%s)',
+ async (kind, expectedExt) => {
+ vi.mocked(downloadArtifact).mockResolvedValueOnce({ ok: true, path: '/d/x' });
+ render();
+ fireEvent.click(screen.getByRole('button', { name: 'Download' }));
+ await waitFor(() => {
+ expect(downloadArtifact).toHaveBeenCalledWith('art-1', 'no-extension', expectedExt);
+ });
+ }
+ );
+
+ it('treats a trailing-dot title as having no extension (falls through to kind default)', async () => {
+ vi.mocked(downloadArtifact).mockResolvedValueOnce({ ok: true, path: '/d/x' });
+ render();
+ fireEvent.click(screen.getByRole('button', { name: 'Download' }));
+ await waitFor(() => {
+ expect(downloadArtifact).toHaveBeenCalledWith('art-1', 'trailing.', 'pptx');
+ });
+ });
+
+ it('Download button is disabled while a download is in flight', async () => {
+ let resolveDownload: (v: { ok: true; path: string }) => void = () => {};
+ vi.mocked(downloadArtifact).mockImplementationOnce(
+ () =>
+ new Promise(r => {
+ resolveDownload = r;
+ })
+ );
+ render();
+ const btn = screen.getByRole('button', { name: 'Download' });
+ fireEvent.click(btn);
+ // While in-flight, button text flips to "Downloading…" and is disabled.
+ await waitFor(() => {
+ expect(screen.getByRole('button', { name: 'Downloading…' })).toBeDisabled();
+ });
+ // Finish the download to settle the promise.
+ resolveDownload({ ok: true, path: '/d/x.pptx' });
+ await waitFor(() => {
+ expect(screen.getByRole('button', { name: 'Show in folder' })).toBeInTheDocument();
+ });
+ });
+
+ // ─── failed ─────────────────────────────────────────────────────────────
+
+ it('renders the failed label and the producer-supplied reason', () => {
+ render();
+ expect(screen.getByText(/Generation failed/)).toBeInTheDocument();
+ expect(screen.getByText('pip install crashed')).toBeInTheDocument();
+ });
+
+ it('renders Retry only when onRetry is provided; clicking it fires the callback', () => {
+ const onRetry = vi.fn();
+ const a = failed();
+ const { rerender } = render();
+ // No Retry when onRetry is absent.
+ expect(screen.queryByRole('button', { name: 'Retry' })).toBeNull();
+
+ rerender();
+ const retryBtn = screen.getByRole('button', { name: 'Retry' });
+ fireEvent.click(retryBtn);
+ expect(onRetry).toHaveBeenCalledWith('art-1');
+ });
+
+ it('long error reason is truncated by default and expands via Show more', () => {
+ const longError = 'x'.repeat(400);
+ render();
+ // Truncated preview ends with ellipsis.
+ const para = screen.getByText((_content, el) => {
+ return !!el && el.tagName === 'P' && (el.textContent ?? '').endsWith('…');
+ });
+ expect(para).toBeInTheDocument();
+ expect((para.textContent ?? '').length).toBeLessThan(longError.length);
+
+ // Show more → full error visible + button flips to Show less.
+ fireEvent.click(screen.getByRole('button', { name: 'Show more' }));
+ expect(screen.getByText(longError)).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Show less' })).toBeInTheDocument();
+
+ // Show less → re-collapses.
+ fireEvent.click(screen.getByRole('button', { name: 'Show less' }));
+ expect(screen.getByRole('button', { name: 'Show more' })).toBeInTheDocument();
+ });
+
+ it('short error reason (≤ preview cap) does NOT show the Show more affordance', () => {
+ render();
+ expect(screen.getByText('short oops')).toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: 'Show more' })).toBeNull();
+ });
+
+ it('failed status uses the failed icon (not the kind icon) and no Download button', () => {
+ render();
+ expect(screen.queryByRole('button', { name: 'Download' })).toBeNull();
+ });
+
+ // ─── kind variants (icon paths) ─────────────────────────────────────────
+
+ it.each(['presentation', 'document', 'image', 'other'] as const)(
+ 'renders the in-progress spinner for kind=%s without crashing',
+ kind => {
+ render();
+ expect(screen.getByText(/Generating /)).toBeInTheDocument();
+ }
+ );
+
+ it.each(['presentation', 'document', 'image', 'other'] as const)(
+ 'renders the kind icon when ready for kind=%s',
+ kind => {
+ render();
+ // Ready label is present regardless of kind.
+ expect(screen.getByText(/Ready/)).toBeInTheDocument();
+ }
+ );
+});
diff --git a/app/src/components/chat/__tests__/ChatFilesChip.test.tsx b/app/src/components/chat/__tests__/ChatFilesChip.test.tsx
new file mode 100644
index 000000000..b2fdcff40
--- /dev/null
+++ b/app/src/components/chat/__tests__/ChatFilesChip.test.tsx
@@ -0,0 +1,120 @@
+import { configureStore } from '@reduxjs/toolkit';
+import { fireEvent, render, screen } from '@testing-library/react';
+import { Provider } from 'react-redux';
+import { describe, expect, it } from 'vitest';
+
+import chatRuntimeReducer, {
+ type ArtifactSnapshot,
+ upsertArtifactInProgressForThread,
+ upsertArtifactReadyForThread,
+} from '../../../store/chatRuntimeSlice';
+import ChatFilesChip from '../ChatFilesChip';
+
+const THREAD = 't-chip-1';
+
+function mkStore() {
+ return configureStore({ reducer: { chatRuntime: chatRuntimeReducer } });
+}
+
+function readyArtifact(
+ idx: number
+): Omit & { path: string; sizeBytes: number } {
+ return {
+ artifactId: `art-${idx}`,
+ kind: 'presentation' as const,
+ title: `Deck ${idx}`,
+ path: `artifacts/art-${idx}.pptx`,
+ sizeBytes: 1024 * idx,
+ };
+}
+
+describe('ChatFilesChip', () => {
+ it('renders nothing when the thread has zero ready artifacts', () => {
+ const store = mkStore();
+ const { container } = render(
+
+
+
+ );
+ // Empty render, no chip in DOM.
+ expect(container.firstChild).toBeNull();
+ expect(screen.queryByTestId('chat-files-chip')).toBeNull();
+ });
+
+ it('hides itself when only in_progress artifacts exist (those live above composer)', () => {
+ const store = mkStore();
+ store.dispatch(
+ upsertArtifactInProgressForThread({
+ threadId: THREAD,
+ artifactId: 'in-flight',
+ kind: 'presentation',
+ title: 'In flight',
+ })
+ );
+ render(
+
+
+
+ );
+ expect(screen.queryByTestId('chat-files-chip')).toBeNull();
+ });
+
+ it('shows the chip + numeric count when the thread has ready artifacts', () => {
+ const store = mkStore();
+ store.dispatch(upsertArtifactReadyForThread({ threadId: THREAD, ...readyArtifact(1) }));
+ store.dispatch(upsertArtifactReadyForThread({ threadId: THREAD, ...readyArtifact(2) }));
+ store.dispatch(upsertArtifactReadyForThread({ threadId: THREAD, ...readyArtifact(3) }));
+ render(
+
+
+
+ );
+ expect(screen.getByTestId('chat-files-chip')).toBeInTheDocument();
+ expect(screen.getByTestId('chat-files-chip-count')).toHaveTextContent('3');
+ });
+
+ it('renders the singular aria-label when exactly one ready artifact exists', () => {
+ const store = mkStore();
+ store.dispatch(upsertArtifactReadyForThread({ threadId: THREAD, ...readyArtifact(1) }));
+ render(
+
+
+
+ );
+ // Singular form — no trailing "s" on "file".
+ expect(screen.getByTestId('chat-files-chip')).toHaveAttribute(
+ 'aria-label',
+ '1 file in this chat'
+ );
+ });
+
+ it('renders the plural aria-label when the count is greater than one', () => {
+ const store = mkStore();
+ store.dispatch(upsertArtifactReadyForThread({ threadId: THREAD, ...readyArtifact(1) }));
+ store.dispatch(upsertArtifactReadyForThread({ threadId: THREAD, ...readyArtifact(2) }));
+ render(
+
+
+
+ );
+ expect(screen.getByTestId('chat-files-chip')).toHaveAttribute(
+ 'aria-label',
+ '2 files in this chat'
+ );
+ });
+
+ it('opens the panel on chip click and exposes per-row download/delete actions', () => {
+ const store = mkStore();
+ store.dispatch(upsertArtifactReadyForThread({ threadId: THREAD, ...readyArtifact(1) }));
+ render(
+
+
+
+ );
+ expect(screen.queryByTestId('chat-files-panel')).toBeNull();
+ fireEvent.click(screen.getByTestId('chat-files-chip'));
+ expect(screen.getByTestId('chat-files-panel')).toBeInTheDocument();
+ expect(screen.getByTestId('chat-files-download-art-1')).toBeInTheDocument();
+ expect(screen.getByTestId('chat-files-delete-art-1')).toBeInTheDocument();
+ });
+});
diff --git a/app/src/components/chat/__tests__/ChatFilesPanel.test.tsx b/app/src/components/chat/__tests__/ChatFilesPanel.test.tsx
new file mode 100644
index 000000000..a43deec11
--- /dev/null
+++ b/app/src/components/chat/__tests__/ChatFilesPanel.test.tsx
@@ -0,0 +1,415 @@
+import { configureStore } from '@reduxjs/toolkit';
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { Provider } from 'react-redux';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import {
+ deleteArtifact,
+ downloadArtifact,
+ revealArtifactInFileManager,
+} from '../../../services/artifactDownloadService';
+import chatRuntimeReducer, {
+ type ArtifactSnapshot,
+ upsertArtifactReadyForThread,
+} from '../../../store/chatRuntimeSlice';
+import ChatFilesPanel from '../ChatFilesPanel';
+
+vi.mock('../../../services/artifactDownloadService', () => ({
+ downloadArtifact: vi.fn(),
+ deleteArtifact: vi.fn(),
+ revealArtifactInFileManager: vi.fn(),
+}));
+
+const THREAD = 't-panel-1';
+
+function mkStore(artifactPayloads: Array[0]>) {
+ const store = configureStore({ reducer: { chatRuntime: chatRuntimeReducer } });
+ for (const p of artifactPayloads) {
+ store.dispatch(upsertArtifactReadyForThread(p));
+ }
+ return store;
+}
+
+function readyArtifact(id: string, title: string): ArtifactSnapshot {
+ return {
+ artifactId: id,
+ kind: 'presentation',
+ title,
+ status: 'ready',
+ path: `artifacts/${id}.pptx`,
+ sizeBytes: 4096,
+ updatedAt: Date.now(),
+ };
+}
+
+describe('ChatFilesPanel', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('renders an empty-state message when the panel is opened with no artifacts', () => {
+ const store = mkStore([]);
+ render(
+
+ {}} />
+
+ );
+ expect(screen.getByText('No files yet. Ask the agent to generate one.')).toBeInTheDocument();
+ });
+
+ it('lists rows + per-row actions when populated', () => {
+ const a = readyArtifact('art-1', 'Climate Deck');
+ const b = readyArtifact('art-2', 'Q2 Report');
+ const store = mkStore([
+ {
+ threadId: THREAD,
+ artifactId: a.artifactId,
+ kind: a.kind,
+ title: a.title,
+ path: a.path!,
+ sizeBytes: a.sizeBytes!,
+ },
+ {
+ threadId: THREAD,
+ artifactId: b.artifactId,
+ kind: b.kind,
+ title: b.title,
+ path: b.path!,
+ sizeBytes: b.sizeBytes!,
+ },
+ ]);
+ render(
+
+ {}} />
+
+ );
+ expect(screen.getByText('Climate Deck')).toBeInTheDocument();
+ expect(screen.getByText('Q2 Report')).toBeInTheDocument();
+ expect(screen.getByTestId('chat-files-download-art-1')).toBeInTheDocument();
+ expect(screen.getByTestId('chat-files-delete-art-2')).toBeInTheDocument();
+ });
+
+ it('on Download click → calls downloadArtifact + surfaces a Show-in-folder button on success', async () => {
+ const a = readyArtifact('art-1', 'Climate Deck');
+ vi.mocked(downloadArtifact).mockResolvedValueOnce({
+ ok: true,
+ path: '/Users/me/Downloads/Climate Deck.pptx',
+ });
+ const store = mkStore([
+ {
+ threadId: THREAD,
+ artifactId: a.artifactId,
+ kind: a.kind,
+ title: a.title,
+ path: a.path!,
+ sizeBytes: a.sizeBytes!,
+ },
+ ]);
+ render(
+
+ {}} />
+
+ );
+ fireEvent.click(screen.getByTestId('chat-files-download-art-1'));
+ await waitFor(() => {
+ expect(screen.getByTestId('chat-files-reveal-art-1')).toBeInTheDocument();
+ });
+ expect(downloadArtifact).toHaveBeenCalledWith('art-1', 'Climate Deck', 'pptx');
+
+ fireEvent.click(screen.getByTestId('chat-files-reveal-art-1'));
+ await waitFor(() => {
+ expect(revealArtifactInFileManager).toHaveBeenCalledWith(
+ '/Users/me/Downloads/Climate Deck.pptx'
+ );
+ });
+ });
+
+ it('Delete → Cancel keeps the artifact and does NOT call the RPC', async () => {
+ const a = readyArtifact('art-1', 'Climate Deck');
+ const store = mkStore([
+ {
+ threadId: THREAD,
+ artifactId: a.artifactId,
+ kind: a.kind,
+ title: a.title,
+ path: a.path!,
+ sizeBytes: a.sizeBytes!,
+ },
+ ]);
+ render(
+
+ {}} />
+
+ );
+ fireEvent.click(screen.getByTestId('chat-files-delete-art-1'));
+ expect(screen.getByText('Delete this file?')).toBeInTheDocument();
+ fireEvent.click(screen.getByText('Cancel'));
+ expect(deleteArtifact).not.toHaveBeenCalled();
+ expect(store.getState().chatRuntime.artifactsByThread[THREAD]).toHaveLength(1);
+ });
+
+ it('Delete → Confirm → RPC ok → row removed from slice', async () => {
+ const a = readyArtifact('art-1', 'Climate Deck');
+ vi.mocked(deleteArtifact).mockResolvedValueOnce({ ok: true });
+ const store = mkStore([
+ {
+ threadId: THREAD,
+ artifactId: a.artifactId,
+ kind: a.kind,
+ title: a.title,
+ path: a.path!,
+ sizeBytes: a.sizeBytes!,
+ },
+ ]);
+ render(
+
+ {}} />
+
+ );
+ fireEvent.click(screen.getByTestId('chat-files-delete-art-1'));
+ fireEvent.click(screen.getByTestId('chat-files-confirm-art-1'));
+ await waitFor(() => {
+ expect(deleteArtifact).toHaveBeenCalledWith('art-1');
+ });
+ // Bucket should be empty (last row removed → key deleted in reducer).
+ expect(store.getState().chatRuntime.artifactsByThread[THREAD]).toBeUndefined();
+ });
+
+ it('Delete → Confirm → RPC fails → row re-inserted + error surfaced', async () => {
+ const a = readyArtifact('art-1', 'Climate Deck');
+ vi.mocked(deleteArtifact).mockResolvedValueOnce({ ok: false, error: 'core dropped' });
+ const store = mkStore([
+ {
+ threadId: THREAD,
+ artifactId: a.artifactId,
+ kind: a.kind,
+ title: a.title,
+ path: a.path!,
+ sizeBytes: a.sizeBytes!,
+ },
+ ]);
+ render(
+
+ {}} />
+
+ );
+ fireEvent.click(screen.getByTestId('chat-files-delete-art-1'));
+ fireEvent.click(screen.getByTestId('chat-files-confirm-art-1'));
+ await waitFor(() => {
+ expect(screen.getByText('core dropped')).toBeInTheDocument();
+ });
+ // Re-inserted: bucket still has the entry.
+ expect(store.getState().chatRuntime.artifactsByThread[THREAD]).toHaveLength(1);
+ expect(store.getState().chatRuntime.artifactsByThread[THREAD][0].artifactId).toBe('art-1');
+ });
+
+ it('Delete → typed code surfaces the localized headline, not the raw detail', async () => {
+ const a = readyArtifact('art-1', 'Climate Deck');
+ // Service returns the new shape: structured code + raw RPC string.
+ // UI must prefer the localized headline mapped from `code`.
+ vi.mocked(deleteArtifact).mockResolvedValueOnce({
+ ok: false,
+ code: 'DELETE_FAILED',
+ error: 'rpc transport closed',
+ });
+ const store = mkStore([
+ {
+ threadId: THREAD,
+ artifactId: a.artifactId,
+ kind: a.kind,
+ title: a.title,
+ path: a.path!,
+ sizeBytes: a.sizeBytes!,
+ },
+ ]);
+ render(
+
+ {}} />
+
+ );
+ fireEvent.click(screen.getByTestId('chat-files-delete-art-1'));
+ fireEvent.click(screen.getByTestId('chat-files-confirm-art-1'));
+ await waitFor(() => {
+ // Localized headline from chat.files.error.delete_failed (en).
+ expect(screen.getByText('Couldn’t delete the file. Please try again.')).toBeInTheDocument();
+ });
+ // Raw detail MUST NOT leak into the user-facing surface.
+ expect(screen.queryByText('rpc transport closed')).toBeNull();
+ });
+
+ it('passes the title-derived extension to downloadArtifact when title carries one', async () => {
+ const a = readyArtifact('art-1', 'climate-deck.pptx');
+ vi.mocked(downloadArtifact).mockResolvedValueOnce({ ok: true, path: '/d/x.pptx' });
+ const store = mkStore([
+ {
+ threadId: THREAD,
+ artifactId: a.artifactId,
+ kind: a.kind,
+ title: a.title,
+ path: a.path!,
+ sizeBytes: a.sizeBytes!,
+ },
+ ]);
+ render(
+
+ {}} />
+
+ );
+ fireEvent.click(screen.getByTestId('chat-files-download-art-1'));
+ await waitFor(() => {
+ expect(downloadArtifact).toHaveBeenCalledWith('art-1', 'climate-deck.pptx', 'pptx');
+ });
+ });
+
+ it.each([
+ ['document' as const, 'pdf'],
+ ['image' as const, 'png'],
+ ['other' as const, 'bin'],
+ ])(
+ 'falls back to the per-kind extension default when the title has none (kind=%s → ext=%s)',
+ async (kind, expectedExt) => {
+ const a: ArtifactSnapshot = {
+ artifactId: 'art-1',
+ kind,
+ title: 'no-extension-title',
+ status: 'ready',
+ path: 'artifacts/x',
+ sizeBytes: 1024,
+ updatedAt: Date.now(),
+ };
+ vi.mocked(downloadArtifact).mockResolvedValueOnce({ ok: true, path: '/d/x' });
+ const store = mkStore([
+ {
+ threadId: THREAD,
+ artifactId: a.artifactId,
+ kind: a.kind,
+ title: a.title,
+ path: a.path!,
+ sizeBytes: a.sizeBytes!,
+ },
+ ]);
+ render(
+
+ {}} />
+
+ );
+ fireEvent.click(screen.getByTestId('chat-files-download-art-1'));
+ await waitFor(() => {
+ expect(downloadArtifact).toHaveBeenCalledWith('art-1', 'no-extension-title', expectedExt);
+ });
+ }
+ );
+
+ it('Esc closes the panel via the keydown handler', async () => {
+ const a = readyArtifact('art-1', 'Climate Deck');
+ const onClose = vi.fn();
+ const store = mkStore([
+ {
+ threadId: THREAD,
+ artifactId: a.artifactId,
+ kind: a.kind,
+ title: a.title,
+ path: a.path!,
+ sizeBytes: a.sizeBytes!,
+ },
+ ]);
+ render(
+
+
+
+ );
+ fireEvent.keyDown(window, { key: 'Escape' });
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+
+ it('Esc while a row is in confirm-delete mode dismisses the confirm, NOT the panel', async () => {
+ const a = readyArtifact('art-1', 'Climate Deck');
+ const onClose = vi.fn();
+ const store = mkStore([
+ {
+ threadId: THREAD,
+ artifactId: a.artifactId,
+ kind: a.kind,
+ title: a.title,
+ path: a.path!,
+ sizeBytes: a.sizeBytes!,
+ },
+ ]);
+ render(
+
+
+
+ );
+ fireEvent.click(screen.getByTestId('chat-files-delete-art-1'));
+ expect(screen.getByText('Delete this file?')).toBeInTheDocument();
+ fireEvent.keyDown(window, { key: 'Escape' });
+ expect(onClose).not.toHaveBeenCalled();
+ expect(screen.queryByText('Delete this file?')).toBeNull();
+ });
+
+ it('pointerdown outside the panel closes it (click-outside)', () => {
+ const a = readyArtifact('art-1', 'Climate Deck');
+ const onClose = vi.fn();
+ const store = mkStore([
+ {
+ threadId: THREAD,
+ artifactId: a.artifactId,
+ kind: a.kind,
+ title: a.title,
+ path: a.path!,
+ sizeBytes: a.sizeBytes!,
+ },
+ ]);
+ render(
+
+
+
+ );
+ // Fire a pointerdown event whose target is outside the panel.
+ const outside = document.createElement('div');
+ document.body.appendChild(outside);
+ const evt = new PointerEvent('pointerdown', { bubbles: true });
+ Object.defineProperty(evt, 'target', { value: outside });
+ document.dispatchEvent(evt);
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+
+ it('Download → typed code surfaces the localized headline in the row error', async () => {
+ const a = readyArtifact('art-1', 'Climate Deck');
+ // Use a deliberately distinct raw `error` so this test only passes when
+ // the UI renders the localized copy keyed off `code`, not the raw
+ // backend detail. If the row ever regressed to echoing `error` verbatim,
+ // the `queryByText(rawError)` assertion below would catch it.
+ const rawError = 'transport socket closed mid-resolve';
+ vi.mocked(downloadArtifact).mockResolvedValueOnce({
+ ok: false,
+ code: 'NOT_DESKTOP',
+ error: rawError,
+ });
+ const store = mkStore([
+ {
+ threadId: THREAD,
+ artifactId: a.artifactId,
+ kind: a.kind,
+ title: a.title,
+ path: a.path!,
+ sizeBytes: a.sizeBytes!,
+ },
+ ]);
+ render(
+
+ {}} />
+
+ );
+ fireEvent.click(screen.getByTestId('chat-files-download-art-1'));
+ await waitFor(() => {
+ // The download_failed copy is wrapped by the chat.artifact.download_failed
+ // template (`{reason}`), which renders the localized inner text.
+ expect(
+ screen.getByText(/Downloads are only available in the desktop app/)
+ ).toBeInTheDocument();
+ });
+ // Raw backend detail must NOT leak into the rendered row.
+ expect(screen.queryByText(rawError)).toBeNull();
+ });
+});
diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts
index 4cbac6c84..a94fe0f9f 100644
--- a/app/src/lib/i18n/ar.ts
+++ b/app/src/lib/i18n/ar.ts
@@ -4304,6 +4304,38 @@ const messages: TranslationMap = {
'settings.agents.editor.toolsDone': 'Done',
'settings.agents.editor.builtInReadonly':
'لا يمكن تعديل العوامل المدمجة. يمكنك تفعيلها أو تعطيلها أو إعادة ضبطها من قائمة العوامل.',
+ // Chat — agent-generated artifacts (#2779)
+ 'chat.artifact.aria': 'الملف: {title}',
+ 'chat.artifact.generating': 'جارٍ إنشاء {kind}…',
+ 'chat.artifact.ready': 'جاهز',
+ 'chat.artifact.failed': 'فشل الإنشاء',
+ 'chat.artifact.download': 'تنزيل',
+ 'chat.artifact.downloading': 'جارٍ التنزيل…',
+ 'chat.artifact.downloaded': 'تم الحفظ في {path}',
+ 'chat.artifact.download_failed': 'فشل التنزيل: {reason}',
+ 'chat.artifact.retry': 'إعادة المحاولة',
+ 'chat.artifact.reveal': 'عرض في المجلد',
+ 'chat.artifact.show_more': 'عرض المزيد',
+ 'chat.artifact.show_less': 'عرض أقل',
+
+ // Chat — files panel (#3024)
+ 'chat.files.chip.aria.one': 'ملف واحد في هذه المحادثة',
+ 'chat.files.chip.aria.other': '{count} ملفات في هذه المحادثة',
+ 'chat.files.panel.aria': 'ملفات في هذه المحادثة',
+ 'chat.files.panel.title': 'الملفات ({count})',
+ 'chat.files.panel.empty': 'لا توجد ملفات بعد. اطلب من الوكيل إنشاء واحد.',
+ 'chat.files.panel.close': 'إغلاق لوحة الملفات',
+ 'chat.files.delete.aria': 'حذف {title}',
+ 'chat.files.delete.confirm': 'هل تريد حذف هذا الملف؟',
+ 'chat.files.delete.cancel': 'إلغاء',
+ 'chat.files.delete.action': 'حذف',
+ 'chat.files.delete.failed': 'تعذّر حذف الملف. حاول مرة أخرى.',
+ 'chat.files.error.not_desktop': 'التنزيلات متاحة فقط في تطبيق سطح المكتب.',
+ 'chat.files.error.missing_artifact_id': 'معرّف الملف مفقود.',
+ 'chat.files.error.missing_artifact_path': 'مسار الملف مفقود من استجابة النظام.',
+ 'chat.files.error.resolve_failed': 'تعذّر تحديد الملف. حاول مرة أخرى.',
+ 'chat.files.error.download_failed': 'فشل التنزيل. حاول مرة أخرى.',
+ 'chat.files.error.delete_failed': 'تعذّر حذف الملف. حاول مرة أخرى.',
'autocomplete.debounceMs': 'مهلة الإدخال (مللي ثانية)',
'autocomplete.maxChars': 'أقصى عدد لأحرف السياق',
'autocomplete.overlayTtlMs': 'مهلة الطبقة (مللي ثانية)',
@@ -4416,18 +4448,6 @@ const messages: TranslationMap = {
'memory.health.remediation.unknown':
'واجهت معالجة الذاكرة مشكلة. تحقق من الإعدادات → الذكاء الاصطناعي للتكوين.',
// Chat — agent-generated artifacts (#2779)
- 'chat.artifact.aria': 'الملف: {title}',
- 'chat.artifact.generating': 'جارٍ إنشاء {kind}…',
- 'chat.artifact.ready': 'جاهز',
- 'chat.artifact.failed': 'فشل الإنشاء',
- 'chat.artifact.download': 'تنزيل',
- 'chat.artifact.downloading': 'جارٍ التنزيل…',
- 'chat.artifact.downloaded': 'تم الحفظ في {path}',
- 'chat.artifact.download_failed': 'فشل التنزيل: {reason}',
- 'chat.artifact.retry': 'إعادة المحاولة',
- 'chat.artifact.reveal': 'عرض في المجلد',
- 'chat.artifact.show_more': 'عرض المزيد',
- 'chat.artifact.show_less': 'عرض أقل',
// Chat composer toolbar
'composer.attachFile': 'إرفاق ملف',
diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts
index 1b47ffee8..c9de2e69b 100644
--- a/app/src/lib/i18n/bn.ts
+++ b/app/src/lib/i18n/bn.ts
@@ -4380,6 +4380,38 @@ const messages: TranslationMap = {
'settings.agents.editor.toolsDone': 'Done',
'settings.agents.editor.builtInReadonly':
'বিল্ট-ইন এজেন্ট সম্পাদনা করা যাবে না। আপনি এজেন্ট তালিকা থেকে সেগুলো সক্রিয়, নিষ্ক্রিয় বা পুনরায় সেট করতে পারেন।',
+ // Chat — agent-generated artifacts (#2779)
+ 'chat.artifact.aria': 'আর্টিফ্যাক্ট: {title}',
+ 'chat.artifact.generating': '{kind} তৈরি হচ্ছে…',
+ 'chat.artifact.ready': 'প্রস্তুত',
+ 'chat.artifact.failed': 'তৈরি ব্যর্থ হয়েছে',
+ 'chat.artifact.download': 'ডাউনলোড',
+ 'chat.artifact.downloading': 'ডাউনলোড হচ্ছে…',
+ 'chat.artifact.downloaded': '{path} এ সংরক্ষিত',
+ 'chat.artifact.download_failed': 'ডাউনলোড ব্যর্থ: {reason}',
+ 'chat.artifact.retry': 'পুনরায় চেষ্টা',
+ 'chat.artifact.reveal': 'ফোল্ডারে দেখান',
+ 'chat.artifact.show_more': 'আরও দেখুন',
+ 'chat.artifact.show_less': 'কম দেখুন',
+
+ // Chat — files panel (#3024)
+ 'chat.files.chip.aria.one': 'এই চ্যাটে {count}টি ফাইল',
+ 'chat.files.chip.aria.other': 'এই চ্যাটে {count}টি ফাইল',
+ 'chat.files.panel.aria': 'এই চ্যাটের ফাইল',
+ 'chat.files.panel.title': 'ফাইল ({count})',
+ 'chat.files.panel.empty': 'এখনো কোনো ফাইল নেই। এজেন্টকে একটি তৈরি করতে বলুন।',
+ 'chat.files.panel.close': 'ফাইল প্যানেল বন্ধ করুন',
+ 'chat.files.delete.aria': '{title} মুছুন',
+ 'chat.files.delete.confirm': 'এই ফাইলটি মুছবেন?',
+ 'chat.files.delete.cancel': 'বাতিল',
+ 'chat.files.delete.action': 'মুছুন',
+ 'chat.files.delete.failed': 'ফাইল মোছা যায়নি। আবার চেষ্টা করুন।',
+ 'chat.files.error.not_desktop': 'ডাউনলোড শুধুমাত্র ডেস্কটপ অ্যাপে উপলব্ধ।',
+ 'chat.files.error.missing_artifact_id': 'আর্টিফ্যাক্ট আইডি অনুপস্থিত।',
+ 'chat.files.error.missing_artifact_path': 'কোর প্রতিক্রিয়া থেকে আর্টিফ্যাক্ট পথ অনুপস্থিত।',
+ 'chat.files.error.resolve_failed': 'আর্টিফ্যাক্ট সমাধান করা যায়নি। আবার চেষ্টা করুন।',
+ 'chat.files.error.download_failed': 'ডাউনলোড ব্যর্থ হয়েছে। আবার চেষ্টা করুন।',
+ 'chat.files.error.delete_failed': 'ফাইল মোছা যায়নি। আবার চেষ্টা করুন।',
'autocomplete.debounceMs': 'ডিবাউন্স (মিলিসেকেন্ড)',
'autocomplete.maxChars': 'সর্বোচ্চ প্রসঙ্গ অক্ষর',
'autocomplete.overlayTtlMs': 'ওভারলে টাইমআউট (মিলিসেকেন্ড)',
@@ -4494,18 +4526,6 @@ const messages: TranslationMap = {
'memory.health.remediation.unknown':
'মেমরি প্রক্রিয়াকরণে একটি সমস্যা হয়েছে। কনফিগারেশনের জন্য সেটিংস → AI পরীক্ষা করুন।',
// Chat — agent-generated artifacts (#2779)
- 'chat.artifact.aria': 'আর্টিফ্যাক্ট: {title}',
- 'chat.artifact.generating': '{kind} তৈরি হচ্ছে…',
- 'chat.artifact.ready': 'প্রস্তুত',
- 'chat.artifact.failed': 'তৈরি ব্যর্থ হয়েছে',
- 'chat.artifact.download': 'ডাউনলোড',
- 'chat.artifact.downloading': 'ডাউনলোড হচ্ছে…',
- 'chat.artifact.downloaded': '{path} এ সংরক্ষিত',
- 'chat.artifact.download_failed': 'ডাউনলোড ব্যর্থ: {reason}',
- 'chat.artifact.retry': 'পুনরায় চেষ্টা',
- 'chat.artifact.reveal': 'ফোল্ডারে দেখান',
- 'chat.artifact.show_more': 'আরও দেখুন',
- 'chat.artifact.show_less': 'কম দেখুন',
// Chat composer toolbar
'composer.attachFile': 'ফাইল সংযুক্ত করুন',
diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts
index f80c69f58..bed2ab324 100644
--- a/app/src/lib/i18n/de.ts
+++ b/app/src/lib/i18n/de.ts
@@ -4496,6 +4496,39 @@ const messages: TranslationMap = {
'settings.agents.editor.toolsDone': 'Done',
'settings.agents.editor.builtInReadonly':
'Integrierte Agenten können nicht bearbeitet werden. Sie können sie in der Agentenliste aktivieren, deaktivieren oder zurücksetzen.',
+ // Chat — agent-generated artifacts (#2779)
+ 'chat.artifact.aria': 'Artefakt: {title}',
+ 'chat.artifact.generating': 'Erstelle {kind}…',
+ 'chat.artifact.ready': 'Bereit',
+ 'chat.artifact.failed': 'Erstellung fehlgeschlagen',
+ 'chat.artifact.download': 'Herunterladen',
+ 'chat.artifact.downloading': 'Wird heruntergeladen…',
+ 'chat.artifact.downloaded': 'Gespeichert unter {path}',
+ 'chat.artifact.download_failed': 'Download fehlgeschlagen: {reason}',
+ 'chat.artifact.retry': 'Erneut versuchen',
+ 'chat.artifact.reveal': 'Im Ordner anzeigen',
+ 'chat.artifact.show_more': 'Mehr anzeigen',
+ 'chat.artifact.show_less': 'Weniger anzeigen',
+
+ // Chat — files panel (#3024)
+ 'chat.files.chip.aria.one': '{count} Datei in diesem Chat',
+ 'chat.files.chip.aria.other': '{count} Dateien in diesem Chat',
+ 'chat.files.panel.aria': 'Dateien in diesem Chat',
+ 'chat.files.panel.title': 'Dateien ({count})',
+ 'chat.files.panel.empty': 'Noch keine Dateien. Bitten Sie den Agenten, eine zu erstellen.',
+ 'chat.files.panel.close': 'Dateibereich schließen',
+ 'chat.files.delete.aria': '{title} löschen',
+ 'chat.files.delete.confirm': 'Diese Datei löschen?',
+ 'chat.files.delete.cancel': 'Abbrechen',
+ 'chat.files.delete.action': 'Löschen',
+ 'chat.files.delete.failed': 'Datei konnte nicht gelöscht werden. Erneut versuchen.',
+ 'chat.files.error.not_desktop': 'Downloads sind nur in der Desktop-App verfügbar.',
+ 'chat.files.error.missing_artifact_id': 'Artefakt-ID fehlt.',
+ 'chat.files.error.missing_artifact_path': 'Artefaktpfad fehlt in der Core-Antwort.',
+ 'chat.files.error.resolve_failed':
+ 'Das Artefakt konnte nicht aufgelöst werden. Bitte erneut versuchen.',
+ 'chat.files.error.download_failed': 'Download fehlgeschlagen. Bitte erneut versuchen.',
+ 'chat.files.error.delete_failed': 'Datei konnte nicht gelöscht werden. Bitte erneut versuchen.',
'autocomplete.debounceMs': 'Entprellung (ms)',
'autocomplete.maxChars': 'Maximale Kontextzeichen',
'autocomplete.overlayTtlMs': 'Overlay-Timeout (ms)',
@@ -4612,18 +4645,6 @@ const messages: TranslationMap = {
'memory.health.remediation.unknown':
'Bei der Speicherverarbeitung ist ein Problem aufgetreten. Überprüfe Einstellungen → KI für die Konfiguration.',
// Chat — agent-generated artifacts (#2779)
- 'chat.artifact.aria': 'Artefakt: {title}',
- 'chat.artifact.generating': 'Erstelle {kind}…',
- 'chat.artifact.ready': 'Bereit',
- 'chat.artifact.failed': 'Erstellung fehlgeschlagen',
- 'chat.artifact.download': 'Herunterladen',
- 'chat.artifact.downloading': 'Wird heruntergeladen…',
- 'chat.artifact.downloaded': 'Gespeichert unter {path}',
- 'chat.artifact.download_failed': 'Download fehlgeschlagen: {reason}',
- 'chat.artifact.retry': 'Erneut versuchen',
- 'chat.artifact.reveal': 'Im Ordner anzeigen',
- 'chat.artifact.show_more': 'Mehr anzeigen',
- 'chat.artifact.show_less': 'Weniger anzeigen',
// Chat composer toolbar
'composer.attachFile': 'Datei anhängen',
diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts
index 8598d607f..371720059 100644
--- a/app/src/lib/i18n/en.ts
+++ b/app/src/lib/i18n/en.ts
@@ -4786,6 +4786,41 @@ const en: TranslationMap = {
'settings.agents.editor.builtInReadonly':
"Built-in agents can't be edited. You can enable, disable, or reset them from the agents list.",
+ // Chat — agent-generated artifacts (#2779)
+ 'chat.artifact.aria': 'Artifact: {title}',
+ 'chat.artifact.generating': 'Generating {kind}…',
+ 'chat.artifact.ready': 'Ready',
+ 'chat.artifact.failed': 'Generation failed',
+ 'chat.artifact.download': 'Download',
+ 'chat.artifact.downloading': 'Downloading…',
+ 'chat.artifact.downloaded': 'Saved to {path}',
+ 'chat.artifact.download_failed': 'Download failed: {reason}',
+ 'chat.artifact.retry': 'Retry',
+ 'chat.artifact.reveal': 'Show in folder',
+ 'chat.artifact.show_more': 'Show more',
+ 'chat.artifact.show_less': 'Show less',
+
+ // Chat — files panel (#3024)
+ 'chat.files.chip.aria.one': '{count} file in this chat',
+ 'chat.files.chip.aria.other': '{count} files in this chat',
+ 'chat.files.panel.aria': 'Files in this chat',
+ 'chat.files.panel.title': 'Files ({count})',
+ 'chat.files.panel.empty': 'No files yet. Ask the agent to generate one.',
+ 'chat.files.panel.close': 'Close files panel',
+ 'chat.files.delete.aria': 'Delete {title}',
+ 'chat.files.delete.confirm': 'Delete this file?',
+ 'chat.files.delete.cancel': 'Cancel',
+ 'chat.files.delete.action': 'Delete',
+ 'chat.files.delete.failed': 'Couldn’t delete the file. Try again.',
+ // Error labels for download/delete outcomes (#3024). Keyed off
+ // `ArtifactErrorCode` returned by artifactDownloadService.
+ 'chat.files.error.not_desktop': 'Downloads are only available in the desktop app.',
+ 'chat.files.error.missing_artifact_id': 'Missing artifact id.',
+ 'chat.files.error.missing_artifact_path': 'The artifact path is missing from the core response.',
+ 'chat.files.error.resolve_failed': 'Couldn’t resolve the artifact. Please try again.',
+ 'chat.files.error.download_failed': 'Download failed. Please try again.',
+ 'chat.files.error.delete_failed': 'Couldn’t delete the file. Please try again.',
+
// Keyring consent & security
'keyring.consent.title': 'Secure Storage Unavailable',
'keyring.consent.description':
@@ -4823,18 +4858,6 @@ const en: TranslationMap = {
'pages.settings.account.securityDesc': 'Secret storage mode and keychain status',
// Chat — agent-generated artifacts (#2779)
- 'chat.artifact.aria': 'Artifact: {title}',
- 'chat.artifact.generating': 'Generating {kind}…',
- 'chat.artifact.ready': 'Ready',
- 'chat.artifact.failed': 'Generation failed',
- 'chat.artifact.download': 'Download',
- 'chat.artifact.downloading': 'Downloading…',
- 'chat.artifact.downloaded': 'Saved to {path}',
- 'chat.artifact.download_failed': 'Download failed: {reason}',
- 'chat.artifact.retry': 'Retry',
- 'chat.artifact.reveal': 'Show in folder',
- 'chat.artifact.show_more': 'Show more',
- 'chat.artifact.show_less': 'Show less',
// Chat composer toolbar
'composer.attachFile': 'Attach file',
'composer.modelSelector': 'Model',
diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts
index bdea137c8..728727d5e 100644
--- a/app/src/lib/i18n/es.ts
+++ b/app/src/lib/i18n/es.ts
@@ -4464,6 +4464,39 @@ const messages: TranslationMap = {
'settings.agents.editor.toolsDone': 'Done',
'settings.agents.editor.builtInReadonly':
'Los agentes integrados no se pueden editar. Puedes activarlos, desactivarlos o restablecerlos desde la lista de agentes.',
+ // Chat — agent-generated artifacts (#2779)
+ 'chat.artifact.aria': 'Artefacto: {title}',
+ 'chat.artifact.generating': 'Generando {kind}…',
+ 'chat.artifact.ready': 'Listo',
+ 'chat.artifact.failed': 'Error al generar',
+ 'chat.artifact.download': 'Descargar',
+ 'chat.artifact.downloading': 'Descargando…',
+ 'chat.artifact.downloaded': 'Guardado en {path}',
+ 'chat.artifact.download_failed': 'Error al descargar: {reason}',
+ 'chat.artifact.retry': 'Reintentar',
+ 'chat.artifact.reveal': 'Mostrar en la carpeta',
+ 'chat.artifact.show_more': 'Ver más',
+ 'chat.artifact.show_less': 'Ver menos',
+
+ // Chat — files panel (#3024)
+ 'chat.files.chip.aria.one': '{count} archivo en este chat',
+ 'chat.files.chip.aria.other': '{count} archivos en este chat',
+ 'chat.files.panel.aria': 'Archivos en este chat',
+ 'chat.files.panel.title': 'Archivos ({count})',
+ 'chat.files.panel.empty': 'Aún no hay archivos. Pídele al agente que genere uno.',
+ 'chat.files.panel.close': 'Cerrar panel de archivos',
+ 'chat.files.delete.aria': 'Eliminar {title}',
+ 'chat.files.delete.confirm': '¿Eliminar este archivo?',
+ 'chat.files.delete.cancel': 'Cancelar',
+ 'chat.files.delete.action': 'Eliminar',
+ 'chat.files.delete.failed': 'No se pudo eliminar el archivo. Inténtalo de nuevo.',
+ 'chat.files.error.not_desktop': 'Las descargas solo están disponibles en la app de escritorio.',
+ 'chat.files.error.missing_artifact_id': 'Falta el id del artefacto.',
+ 'chat.files.error.missing_artifact_path':
+ 'Falta la ruta del artefacto en la respuesta del núcleo.',
+ 'chat.files.error.resolve_failed': 'No se pudo resolver el artefacto. Inténtalo de nuevo.',
+ 'chat.files.error.download_failed': 'La descarga falló. Inténtalo de nuevo.',
+ 'chat.files.error.delete_failed': 'No se pudo eliminar el archivo. Inténtalo de nuevo.',
'autocomplete.debounceMs': 'Retardo (ms)',
'autocomplete.maxChars': 'Máximo de caracteres de contexto',
'autocomplete.overlayTtlMs': 'Tiempo de espera de superposición (ms)',
@@ -4578,18 +4611,6 @@ const messages: TranslationMap = {
'memory.health.remediation.unknown':
'El procesamiento de la memoria encontró un problema. Comprueba Configuración → IA para la configuración.',
// Chat — agent-generated artifacts (#2779)
- 'chat.artifact.aria': 'Artefacto: {title}',
- 'chat.artifact.generating': 'Generando {kind}…',
- 'chat.artifact.ready': 'Listo',
- 'chat.artifact.failed': 'Error al generar',
- 'chat.artifact.download': 'Descargar',
- 'chat.artifact.downloading': 'Descargando…',
- 'chat.artifact.downloaded': 'Guardado en {path}',
- 'chat.artifact.download_failed': 'Error al descargar: {reason}',
- 'chat.artifact.retry': 'Reintentar',
- 'chat.artifact.reveal': 'Mostrar en la carpeta',
- 'chat.artifact.show_more': 'Ver más',
- 'chat.artifact.show_less': 'Ver menos',
// Chat composer toolbar
'composer.attachFile': 'Adjuntar archivo',
diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts
index b590c16df..31b97f082 100644
--- a/app/src/lib/i18n/fr.ts
+++ b/app/src/lib/i18n/fr.ts
@@ -4480,6 +4480,40 @@ const messages: TranslationMap = {
'settings.agents.editor.toolsDone': 'Done',
'settings.agents.editor.builtInReadonly':
'Les agents intégrés ne peuvent pas être modifiés. Vous pouvez les activer, les désactiver ou les réinitialiser depuis la liste des agents.',
+ // Chat — agent-generated artifacts (#2779)
+ 'chat.artifact.aria': 'Artefact : {title}',
+ 'chat.artifact.generating': 'Génération de {kind}…',
+ 'chat.artifact.ready': 'Prêt',
+ 'chat.artifact.failed': 'Échec de la génération',
+ 'chat.artifact.download': 'Télécharger',
+ 'chat.artifact.downloading': 'Téléchargement…',
+ 'chat.artifact.downloaded': 'Enregistré dans {path}',
+ 'chat.artifact.download_failed': 'Échec du téléchargement : {reason}',
+ 'chat.artifact.retry': 'Réessayer',
+ 'chat.artifact.reveal': 'Afficher dans le dossier',
+ 'chat.artifact.show_more': 'Voir plus',
+ 'chat.artifact.show_less': 'Voir moins',
+
+ // Chat — files panel (#3024)
+ 'chat.files.chip.aria.one': '{count} fichier dans cette discussion',
+ 'chat.files.chip.aria.other': '{count} fichiers dans cette discussion',
+ 'chat.files.panel.aria': 'Fichiers dans cette discussion',
+ 'chat.files.panel.title': 'Fichiers ({count})',
+ 'chat.files.panel.empty': 'Aucun fichier pour l’instant. Demandez à l’agent d’en générer un.',
+ 'chat.files.panel.close': 'Fermer le panneau de fichiers',
+ 'chat.files.delete.aria': 'Supprimer {title}',
+ 'chat.files.delete.confirm': 'Supprimer ce fichier ?',
+ 'chat.files.delete.cancel': 'Annuler',
+ 'chat.files.delete.action': 'Supprimer',
+ 'chat.files.delete.failed': 'Impossible de supprimer le fichier. Réessayez.',
+ 'chat.files.error.not_desktop':
+ 'Les téléchargements sont uniquement disponibles dans l’application bureau.',
+ 'chat.files.error.missing_artifact_id': 'Identifiant d’artefact manquant.',
+ 'chat.files.error.missing_artifact_path':
+ 'Le chemin de l’artefact est absent de la réponse du cœur.',
+ 'chat.files.error.resolve_failed': 'Impossible de résoudre l’artefact. Réessayez.',
+ 'chat.files.error.download_failed': 'Échec du téléchargement. Réessayez.',
+ 'chat.files.error.delete_failed': 'Impossible de supprimer le fichier. Réessayez.',
'autocomplete.debounceMs': 'Anti-rebond (ms)',
'autocomplete.maxChars': 'Caractères de contexte maximum',
'autocomplete.overlayTtlMs': "Délai d'affichage (ms)",
@@ -4594,18 +4628,6 @@ const messages: TranslationMap = {
'memory.health.remediation.unknown':
'Le traitement de la mémoire a rencontré un problème. Vérifiez Paramètres → IA pour la configuration.',
// Chat — agent-generated artifacts (#2779)
- 'chat.artifact.aria': 'Artefact : {title}',
- 'chat.artifact.generating': 'Génération de {kind}…',
- 'chat.artifact.ready': 'Prêt',
- 'chat.artifact.failed': 'Échec de la génération',
- 'chat.artifact.download': 'Télécharger',
- 'chat.artifact.downloading': 'Téléchargement…',
- 'chat.artifact.downloaded': 'Enregistré dans {path}',
- 'chat.artifact.download_failed': 'Échec du téléchargement : {reason}',
- 'chat.artifact.retry': 'Réessayer',
- 'chat.artifact.reveal': 'Afficher dans le dossier',
- 'chat.artifact.show_more': 'Voir plus',
- 'chat.artifact.show_less': 'Voir moins',
// Chat composer toolbar
'composer.attachFile': 'Joindre un fichier',
diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts
index fd4e4fc1e..bc07953c3 100644
--- a/app/src/lib/i18n/hi.ts
+++ b/app/src/lib/i18n/hi.ts
@@ -4388,6 +4388,38 @@ const messages: TranslationMap = {
'settings.agents.editor.toolsDone': 'Done',
'settings.agents.editor.builtInReadonly':
'बिल्ट-इन एजेंट संपादित नहीं किए जा सकते। आप एजेंट सूची से उन्हें सक्षम, अक्षम या रीसेट कर सकते हैं।',
+ // Chat — agent-generated artifacts (#2779)
+ 'chat.artifact.aria': 'आर्टिफैक्ट: {title}',
+ 'chat.artifact.generating': '{kind} बना रहा है…',
+ 'chat.artifact.ready': 'तैयार',
+ 'chat.artifact.failed': 'निर्माण विफल',
+ 'chat.artifact.download': 'डाउनलोड',
+ 'chat.artifact.downloading': 'डाउनलोड हो रहा है…',
+ 'chat.artifact.downloaded': '{path} में सहेजा गया',
+ 'chat.artifact.download_failed': 'डाउनलोड विफल: {reason}',
+ 'chat.artifact.retry': 'पुनः प्रयास',
+ 'chat.artifact.reveal': 'फ़ोल्डर में दिखाएं',
+ 'chat.artifact.show_more': 'और दिखाएं',
+ 'chat.artifact.show_less': 'कम दिखाएं',
+
+ // Chat — files panel (#3024)
+ 'chat.files.chip.aria.one': 'इस चैट में {count} फ़ाइल',
+ 'chat.files.chip.aria.other': 'इस चैट में {count} फ़ाइलें',
+ 'chat.files.panel.aria': 'इस चैट की फ़ाइलें',
+ 'chat.files.panel.title': 'फ़ाइलें ({count})',
+ 'chat.files.panel.empty': 'अभी कोई फ़ाइल नहीं। एजेंट से एक बनाने को कहें।',
+ 'chat.files.panel.close': 'फ़ाइल पैनल बंद करें',
+ 'chat.files.delete.aria': '{title} हटाएं',
+ 'chat.files.delete.confirm': 'यह फ़ाइल हटाएं?',
+ 'chat.files.delete.cancel': 'रद्द करें',
+ 'chat.files.delete.action': 'हटाएं',
+ 'chat.files.delete.failed': 'फ़ाइल हटाई नहीं जा सकी। पुनः प्रयास करें।',
+ 'chat.files.error.not_desktop': 'डाउनलोड केवल डेस्कटॉप ऐप में उपलब्ध हैं।',
+ 'chat.files.error.missing_artifact_id': 'आर्टिफैक्ट आईडी अनुपस्थित है।',
+ 'chat.files.error.missing_artifact_path': 'कोर प्रतिक्रिया से आर्टिफैक्ट पथ गायब है।',
+ 'chat.files.error.resolve_failed': 'आर्टिफैक्ट को हल नहीं किया जा सका। पुनः प्रयास करें।',
+ 'chat.files.error.download_failed': 'डाउनलोड विफल रहा। पुनः प्रयास करें।',
+ 'chat.files.error.delete_failed': 'फ़ाइल हटाई नहीं जा सकी। पुनः प्रयास करें।',
'autocomplete.debounceMs': 'डिबाउंस (ms)',
'autocomplete.maxChars': 'अधिकतम संदर्भ वर्ण',
'autocomplete.overlayTtlMs': 'ओवरले समय-समाप्ति (ms)',
@@ -4501,18 +4533,6 @@ const messages: TranslationMap = {
'memory.health.remediation.unknown':
'मेमोरी प्रोसेसिंग में एक समस्या आई। कॉन्फ़िगरेशन के लिए सेटिंग्स → AI जाँचें।',
// Chat — agent-generated artifacts (#2779)
- 'chat.artifact.aria': 'आर्टिफैक्ट: {title}',
- 'chat.artifact.generating': '{kind} बना रहा है…',
- 'chat.artifact.ready': 'तैयार',
- 'chat.artifact.failed': 'निर्माण विफल',
- 'chat.artifact.download': 'डाउनलोड',
- 'chat.artifact.downloading': 'डाउनलोड हो रहा है…',
- 'chat.artifact.downloaded': '{path} में सहेजा गया',
- 'chat.artifact.download_failed': 'डाउनलोड विफल: {reason}',
- 'chat.artifact.retry': 'पुनः प्रयास',
- 'chat.artifact.reveal': 'फ़ोल्डर में दिखाएं',
- 'chat.artifact.show_more': 'और दिखाएं',
- 'chat.artifact.show_less': 'कम दिखाएं',
// Chat composer toolbar
'composer.attachFile': 'फ़ाइल संलग्न करें',
diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts
index 3448a3a27..5b2359f4b 100644
--- a/app/src/lib/i18n/id.ts
+++ b/app/src/lib/i18n/id.ts
@@ -4398,6 +4398,38 @@ const messages: TranslationMap = {
'settings.agents.editor.toolsDone': 'Done',
'settings.agents.editor.builtInReadonly':
'Agen bawaan tidak dapat diedit. Anda dapat mengaktifkan, menonaktifkan, atau meresetnya dari daftar agen.',
+ // Chat — agent-generated artifacts (#2779)
+ 'chat.artifact.aria': 'Artefak: {title}',
+ 'chat.artifact.generating': 'Membuat {kind}…',
+ 'chat.artifact.ready': 'Siap',
+ 'chat.artifact.failed': 'Gagal dibuat',
+ 'chat.artifact.download': 'Unduh',
+ 'chat.artifact.downloading': 'Mengunduh…',
+ 'chat.artifact.downloaded': 'Disimpan ke {path}',
+ 'chat.artifact.download_failed': 'Unduhan gagal: {reason}',
+ 'chat.artifact.retry': 'Coba lagi',
+ 'chat.artifact.reveal': 'Tampilkan di folder',
+ 'chat.artifact.show_more': 'Tampilkan selengkapnya',
+ 'chat.artifact.show_less': 'Tampilkan lebih sedikit',
+
+ // Chat — files panel (#3024)
+ 'chat.files.chip.aria.one': '{count} file di chat ini',
+ 'chat.files.chip.aria.other': '{count} file di chat ini',
+ 'chat.files.panel.aria': 'File di chat ini',
+ 'chat.files.panel.title': 'File ({count})',
+ 'chat.files.panel.empty': 'Belum ada file. Minta agen membuatnya.',
+ 'chat.files.panel.close': 'Tutup panel file',
+ 'chat.files.delete.aria': 'Hapus {title}',
+ 'chat.files.delete.confirm': 'Hapus file ini?',
+ 'chat.files.delete.cancel': 'Batal',
+ 'chat.files.delete.action': 'Hapus',
+ 'chat.files.delete.failed': 'Tidak bisa menghapus file. Coba lagi.',
+ 'chat.files.error.not_desktop': 'Unduhan hanya tersedia di aplikasi desktop.',
+ 'chat.files.error.missing_artifact_id': 'ID artefak tidak ada.',
+ 'chat.files.error.missing_artifact_path': 'Jalur artefak tidak ada dalam respons core.',
+ 'chat.files.error.resolve_failed': 'Tidak dapat memuat artefak. Coba lagi.',
+ 'chat.files.error.download_failed': 'Unduhan gagal. Coba lagi.',
+ 'chat.files.error.delete_failed': 'Tidak bisa menghapus file. Coba lagi.',
'autocomplete.debounceMs': 'Debounce (md)',
'autocomplete.maxChars': 'Karakter konteks maks',
'autocomplete.overlayTtlMs': 'Batas waktu overlay (md)',
@@ -4511,18 +4543,6 @@ const messages: TranslationMap = {
'memory.health.remediation.unknown':
'Pemrosesan memori mengalami masalah. Periksa Pengaturan → AI untuk konfigurasi.',
// Chat — agent-generated artifacts (#2779)
- 'chat.artifact.aria': 'Artefak: {title}',
- 'chat.artifact.generating': 'Membuat {kind}…',
- 'chat.artifact.ready': 'Siap',
- 'chat.artifact.failed': 'Gagal dibuat',
- 'chat.artifact.download': 'Unduh',
- 'chat.artifact.downloading': 'Mengunduh…',
- 'chat.artifact.downloaded': 'Disimpan ke {path}',
- 'chat.artifact.download_failed': 'Unduhan gagal: {reason}',
- 'chat.artifact.retry': 'Coba lagi',
- 'chat.artifact.reveal': 'Tampilkan di folder',
- 'chat.artifact.show_more': 'Tampilkan selengkapnya',
- 'chat.artifact.show_less': 'Tampilkan lebih sedikit',
// Chat composer toolbar
'composer.attachFile': 'Lampirkan file',
diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts
index f440ee059..8e7af0cc5 100644
--- a/app/src/lib/i18n/it.ts
+++ b/app/src/lib/i18n/it.ts
@@ -4456,6 +4456,39 @@ const messages: TranslationMap = {
'settings.agents.editor.toolsDone': 'Done',
'settings.agents.editor.builtInReadonly':
"Gli agenti integrati non possono essere modificati. Puoi abilitarli, disabilitarli o reimpostarli dall'elenco degli agenti.",
+ // Chat — agent-generated artifacts (#2779)
+ 'chat.artifact.aria': 'Artefatto: {title}',
+ 'chat.artifact.generating': 'Generazione {kind}…',
+ 'chat.artifact.ready': 'Pronto',
+ 'chat.artifact.failed': 'Generazione fallita',
+ 'chat.artifact.download': 'Scarica',
+ 'chat.artifact.downloading': 'Scaricamento…',
+ 'chat.artifact.downloaded': 'Salvato in {path}',
+ 'chat.artifact.download_failed': 'Download fallito: {reason}',
+ 'chat.artifact.retry': 'Riprova',
+ 'chat.artifact.reveal': 'Mostra nella cartella',
+ 'chat.artifact.show_more': 'Mostra altro',
+ 'chat.artifact.show_less': 'Mostra meno',
+
+ // Chat — files panel (#3024)
+ 'chat.files.chip.aria.one': '{count} file in questa chat',
+ 'chat.files.chip.aria.other': '{count} file in questa chat',
+ 'chat.files.panel.aria': 'File in questa chat',
+ 'chat.files.panel.title': 'File ({count})',
+ 'chat.files.panel.empty': 'Ancora nessun file. Chiedi all’agente di generarne uno.',
+ 'chat.files.panel.close': 'Chiudi pannello file',
+ 'chat.files.delete.aria': 'Elimina {title}',
+ 'chat.files.delete.confirm': 'Eliminare questo file?',
+ 'chat.files.delete.cancel': 'Annulla',
+ 'chat.files.delete.action': 'Elimina',
+ 'chat.files.delete.failed': 'Impossibile eliminare il file. Riprova.',
+ 'chat.files.error.not_desktop': 'I download sono disponibili solo nell’app desktop.',
+ 'chat.files.error.missing_artifact_id': 'ID dell’artefatto mancante.',
+ 'chat.files.error.missing_artifact_path':
+ 'Percorso dell’artefatto mancante nella risposta del core.',
+ 'chat.files.error.resolve_failed': 'Impossibile risolvere l’artefatto. Riprova.',
+ 'chat.files.error.download_failed': 'Download non riuscito. Riprova.',
+ 'chat.files.error.delete_failed': 'Impossibile eliminare il file. Riprova.',
'autocomplete.debounceMs': 'Debounce (ms)',
'autocomplete.maxChars': 'Caratteri massimi di contesto',
'autocomplete.overlayTtlMs': 'Timeout overlay (ms)',
@@ -4570,18 +4603,6 @@ const messages: TranslationMap = {
'memory.health.remediation.unknown':
"L'elaborazione della memoria ha riscontrato un problema. Controlla Impostazioni → IA per la configurazione.",
// Chat — agent-generated artifacts (#2779)
- 'chat.artifact.aria': 'Artefatto: {title}',
- 'chat.artifact.generating': 'Generazione {kind}…',
- 'chat.artifact.ready': 'Pronto',
- 'chat.artifact.failed': 'Generazione fallita',
- 'chat.artifact.download': 'Scarica',
- 'chat.artifact.downloading': 'Scaricamento…',
- 'chat.artifact.downloaded': 'Salvato in {path}',
- 'chat.artifact.download_failed': 'Download fallito: {reason}',
- 'chat.artifact.retry': 'Riprova',
- 'chat.artifact.reveal': 'Mostra nella cartella',
- 'chat.artifact.show_more': 'Mostra altro',
- 'chat.artifact.show_less': 'Mostra meno',
// Chat composer toolbar
'composer.attachFile': 'Allega file',
diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts
index a69915d85..72fa35107 100644
--- a/app/src/lib/i18n/ko.ts
+++ b/app/src/lib/i18n/ko.ts
@@ -4345,6 +4345,38 @@ const messages: TranslationMap = {
'settings.agents.editor.toolsDone': 'Done',
'settings.agents.editor.builtInReadonly':
'기본 제공 에이전트는 편집할 수 없습니다. 에이전트 목록에서 활성화, 비활성화 또는 초기화할 수 있습니다.',
+ // Chat — agent-generated artifacts (#2779)
+ 'chat.artifact.aria': '아티팩트: {title}',
+ 'chat.artifact.generating': '{kind} 생성 중…',
+ 'chat.artifact.ready': '준비됨',
+ 'chat.artifact.failed': '생성 실패',
+ 'chat.artifact.download': '다운로드',
+ 'chat.artifact.downloading': '다운로드 중…',
+ 'chat.artifact.downloaded': '{path}에 저장됨',
+ 'chat.artifact.download_failed': '다운로드 실패: {reason}',
+ 'chat.artifact.retry': '다시 시도',
+ 'chat.artifact.reveal': '폴더에서 보기',
+ 'chat.artifact.show_more': '더 보기',
+ 'chat.artifact.show_less': '간단히 보기',
+
+ // Chat — files panel (#3024)
+ 'chat.files.chip.aria.one': '이 채팅의 파일 {count}개',
+ 'chat.files.chip.aria.other': '이 채팅의 파일 {count}개',
+ 'chat.files.panel.aria': '이 채팅의 파일',
+ 'chat.files.panel.title': '파일 ({count})',
+ 'chat.files.panel.empty': '아직 파일이 없습니다. 에이전트에 생성을 요청하세요.',
+ 'chat.files.panel.close': '파일 패널 닫기',
+ 'chat.files.delete.aria': '{title} 삭제',
+ 'chat.files.delete.confirm': '이 파일을 삭제할까요?',
+ 'chat.files.delete.cancel': '취소',
+ 'chat.files.delete.action': '삭제',
+ 'chat.files.delete.failed': '파일을 삭제하지 못했습니다. 다시 시도해주세요.',
+ 'chat.files.error.not_desktop': '다운로드는 데스크톱 앱에서만 사용할 수 있습니다.',
+ 'chat.files.error.missing_artifact_id': '아티팩트 ID가 없습니다.',
+ 'chat.files.error.missing_artifact_path': '코어 응답에 아티팩트 경로가 없습니다.',
+ 'chat.files.error.resolve_failed': '아티팩트를 해석할 수 없습니다. 다시 시도해주세요.',
+ 'chat.files.error.download_failed': '다운로드에 실패했습니다. 다시 시도해주세요.',
+ 'chat.files.error.delete_failed': '파일을 삭제하지 못했습니다. 다시 시도해주세요.',
'autocomplete.debounceMs': '디바운스 (ms)',
'autocomplete.maxChars': '최대 컨텍스트 문자 수',
'autocomplete.overlayTtlMs': '오버레이 시간 초과 (ms)',
@@ -4458,18 +4490,6 @@ const messages: TranslationMap = {
'memory.health.remediation.unknown':
'메모리 처리 중 문제가 발생했습니다. 설정 → AI에서 구성을 확인하세요.',
// Chat — agent-generated artifacts (#2779)
- 'chat.artifact.aria': '아티팩트: {title}',
- 'chat.artifact.generating': '{kind} 생성 중…',
- 'chat.artifact.ready': '준비됨',
- 'chat.artifact.failed': '생성 실패',
- 'chat.artifact.download': '다운로드',
- 'chat.artifact.downloading': '다운로드 중…',
- 'chat.artifact.downloaded': '{path}에 저장됨',
- 'chat.artifact.download_failed': '다운로드 실패: {reason}',
- 'chat.artifact.retry': '다시 시도',
- 'chat.artifact.reveal': '폴더에서 보기',
- 'chat.artifact.show_more': '더 보기',
- 'chat.artifact.show_less': '간단히 보기',
// Chat composer toolbar
'composer.attachFile': '파일 첨부',
diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts
index 558594854..93c86280e 100644
--- a/app/src/lib/i18n/pl.ts
+++ b/app/src/lib/i18n/pl.ts
@@ -4507,6 +4507,42 @@ const messages: TranslationMap = {
'graphCohesion.title': 'Spójność grafu',
'memory.tab.cohesion': 'Cohesion',
+ // Chat — agent-generated artifacts (#2779)
+ 'chat.artifact.aria': 'Artefakt: {title}',
+ 'chat.artifact.generating': 'Generowanie: {kind}…',
+ 'chat.artifact.ready': 'Gotowe',
+ 'chat.artifact.failed': 'Generowanie nie powiodło się',
+ 'chat.artifact.download': 'Pobierz',
+ 'chat.artifact.downloading': 'Pobieranie…',
+ 'chat.artifact.downloaded': 'Zapisano w {path}',
+ 'chat.artifact.download_failed': 'Pobieranie nie powiodło się: {reason}',
+ 'chat.artifact.retry': 'Spróbuj ponownie',
+ 'chat.artifact.reveal': 'Pokaż w folderze',
+ 'chat.artifact.show_more': 'Pokaż więcej',
+ 'chat.artifact.show_less': 'Pokaż mniej',
+
+ // Chat — files panel (#3024)
+ 'chat.files.chip.aria.one': '{count} plik w tej rozmowie',
+ 'chat.files.chip.aria.other': '{count} plików w tej rozmowie',
+ 'chat.files.panel.aria': 'Pliki w tej rozmowie',
+ 'chat.files.panel.title': 'Pliki ({count})',
+ 'chat.files.panel.empty': 'Brak plików. Poproś agenta o wygenerowanie pliku.',
+ 'chat.files.panel.close': 'Zamknij panel plików',
+ 'chat.files.delete.aria': 'Usuń: {title}',
+ 'chat.files.delete.confirm': 'Usunąć ten plik?',
+ 'chat.files.delete.cancel': 'Anuluj',
+ 'chat.files.delete.action': 'Usuń',
+ 'chat.files.delete.failed': 'Nie udało się usunąć pliku. Spróbuj ponownie.',
+ // Etykiety błędów dla pobrania/usunięcia (#3024). Powiązane z
+ // `ArtifactErrorCode` zwracanym przez artifactDownloadService.
+ 'chat.files.error.not_desktop': 'Pobieranie jest dostępne tylko w aplikacji desktopowej.',
+ 'chat.files.error.missing_artifact_id': 'Brak identyfikatora artefaktu.',
+ 'chat.files.error.missing_artifact_path': 'W odpowiedzi rdzenia brakuje ścieżki artefaktu.',
+ 'chat.files.error.resolve_failed':
+ 'Nie udało się odnaleźć artefaktu. Prosimy spróbować ponownie.',
+ 'chat.files.error.download_failed': 'Pobieranie nie powiodło się. Prosimy spróbować ponownie.',
+ 'chat.files.error.delete_failed': 'Nie udało się usunąć pliku. Prosimy spróbować ponownie.',
+
'keyring.consent.title': 'Bezpieczne przechowywanie niedostępne',
'keyring.consent.description':
'Pęk kluczy systemu operacyjnego jest niedostępny. OpenHuman potrzebuje Twojej zgody na przechowywanie sekretów w lokalnym zaszyfrowanym magazynie.',
@@ -4568,18 +4604,6 @@ const messages: TranslationMap = {
'memory.health.remediation.unknown':
'Przetwarzanie pamięci napotkało problem. Sprawdź Ustawienia → AI w celu konfiguracji.',
// Chat — agent-generated artifacts (#2779)
- 'chat.artifact.aria': 'Artefakt: {title}',
- 'chat.artifact.generating': 'Tworzenie {kind}…',
- 'chat.artifact.ready': 'Gotowe',
- 'chat.artifact.failed': 'Tworzenie nie powiodło się',
- 'chat.artifact.download': 'Pobierz',
- 'chat.artifact.downloading': 'Pobieranie…',
- 'chat.artifact.downloaded': 'Zapisano w {path}',
- 'chat.artifact.download_failed': 'Pobieranie nie powiodło się: {reason}',
- 'chat.artifact.retry': 'Spróbuj ponownie',
- 'chat.artifact.reveal': 'Pokaż w folderze',
- 'chat.artifact.show_more': 'Pokaż więcej',
- 'chat.artifact.show_less': 'Pokaż mniej',
// Chat composer toolbar
'composer.attachFile': 'Dołącz plik',
diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts
index 9c8a33525..19d77b4a7 100644
--- a/app/src/lib/i18n/pt.ts
+++ b/app/src/lib/i18n/pt.ts
@@ -4454,6 +4454,38 @@ const messages: TranslationMap = {
'settings.agents.editor.toolsDone': 'Done',
'settings.agents.editor.builtInReadonly':
'Agentes integrados não podem ser editados. Você pode ativá-los, desativá-los ou redefini-los na lista de agentes.',
+ // Chat — agent-generated artifacts (#2779)
+ 'chat.artifact.aria': 'Artefato: {title}',
+ 'chat.artifact.generating': 'Gerando {kind}…',
+ 'chat.artifact.ready': 'Pronto',
+ 'chat.artifact.failed': 'Falha ao gerar',
+ 'chat.artifact.download': 'Baixar',
+ 'chat.artifact.downloading': 'Baixando…',
+ 'chat.artifact.downloaded': 'Salvo em {path}',
+ 'chat.artifact.download_failed': 'Falha no download: {reason}',
+ 'chat.artifact.retry': 'Tentar novamente',
+ 'chat.artifact.reveal': 'Mostrar na pasta',
+ 'chat.artifact.show_more': 'Mostrar mais',
+ 'chat.artifact.show_less': 'Mostrar menos',
+
+ // Chat — files panel (#3024)
+ 'chat.files.chip.aria.one': '{count} arquivo neste chat',
+ 'chat.files.chip.aria.other': '{count} arquivos neste chat',
+ 'chat.files.panel.aria': 'Arquivos neste chat',
+ 'chat.files.panel.title': 'Arquivos ({count})',
+ 'chat.files.panel.empty': 'Ainda não há arquivos. Peça ao agente para gerar um.',
+ 'chat.files.panel.close': 'Fechar painel de arquivos',
+ 'chat.files.delete.aria': 'Excluir {title}',
+ 'chat.files.delete.confirm': 'Excluir este arquivo?',
+ 'chat.files.delete.cancel': 'Cancelar',
+ 'chat.files.delete.action': 'Excluir',
+ 'chat.files.delete.failed': 'Não foi possível excluir o arquivo. Tente novamente.',
+ 'chat.files.error.not_desktop': 'Os downloads estão disponíveis apenas no app de desktop.',
+ 'chat.files.error.missing_artifact_id': 'ID do artefato ausente.',
+ 'chat.files.error.missing_artifact_path': 'Caminho do artefato ausente na resposta do core.',
+ 'chat.files.error.resolve_failed': 'Não foi possível resolver o artefato. Tente novamente.',
+ 'chat.files.error.download_failed': 'O download falhou. Tente novamente.',
+ 'chat.files.error.delete_failed': 'Não foi possível excluir o arquivo. Tente novamente.',
'autocomplete.debounceMs': 'Atraso (ms)',
'autocomplete.maxChars': 'Máximo de caracteres de contexto',
'autocomplete.overlayTtlMs': 'Tempo limite da sobreposição (ms)',
@@ -4568,18 +4600,6 @@ const messages: TranslationMap = {
'memory.health.remediation.unknown':
'O processamento da memória encontrou um problema. Verifique Configurações → IA para a configuração.',
// Chat — agent-generated artifacts (#2779)
- 'chat.artifact.aria': 'Artefato: {title}',
- 'chat.artifact.generating': 'Gerando {kind}…',
- 'chat.artifact.ready': 'Pronto',
- 'chat.artifact.failed': 'Falha ao gerar',
- 'chat.artifact.download': 'Baixar',
- 'chat.artifact.downloading': 'Baixando…',
- 'chat.artifact.downloaded': 'Salvo em {path}',
- 'chat.artifact.download_failed': 'Falha no download: {reason}',
- 'chat.artifact.retry': 'Tentar novamente',
- 'chat.artifact.reveal': 'Mostrar na pasta',
- 'chat.artifact.show_more': 'Mostrar mais',
- 'chat.artifact.show_less': 'Mostrar menos',
// Chat composer toolbar
'composer.attachFile': 'Anexar arquivo',
diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts
index ae550fdba..dde525f88 100644
--- a/app/src/lib/i18n/ru.ts
+++ b/app/src/lib/i18n/ru.ts
@@ -4423,6 +4423,38 @@ const messages: TranslationMap = {
'settings.agents.editor.toolsDone': 'Done',
'settings.agents.editor.builtInReadonly':
'Встроенные агенты нельзя редактировать. Вы можете включить, отключить или сбросить их в списке агентов.',
+ // Chat — agent-generated artifacts (#2779)
+ 'chat.artifact.aria': 'Артефакт: {title}',
+ 'chat.artifact.generating': 'Создание {kind}…',
+ 'chat.artifact.ready': 'Готово',
+ 'chat.artifact.failed': 'Сбой генерации',
+ 'chat.artifact.download': 'Скачать',
+ 'chat.artifact.downloading': 'Скачивание…',
+ 'chat.artifact.downloaded': 'Сохранено в {path}',
+ 'chat.artifact.download_failed': 'Сбой скачивания: {reason}',
+ 'chat.artifact.retry': 'Повторить',
+ 'chat.artifact.reveal': 'Показать в папке',
+ 'chat.artifact.show_more': 'Показать больше',
+ 'chat.artifact.show_less': 'Свернуть',
+
+ // Chat — files panel (#3024)
+ 'chat.files.chip.aria.one': '{count} файл в этом чате',
+ 'chat.files.chip.aria.other': '{count} файлов в этом чате',
+ 'chat.files.panel.aria': 'Файлы в этом чате',
+ 'chat.files.panel.title': 'Файлы ({count})',
+ 'chat.files.panel.empty': 'Файлов пока нет. Попросите агента создать один.',
+ 'chat.files.panel.close': 'Закрыть панель файлов',
+ 'chat.files.delete.aria': 'Удалить {title}',
+ 'chat.files.delete.confirm': 'Удалить этот файл?',
+ 'chat.files.delete.cancel': 'Отмена',
+ 'chat.files.delete.action': 'Удалить',
+ 'chat.files.delete.failed': 'Не удалось удалить файл. Попробуйте ещё раз.',
+ 'chat.files.error.not_desktop': 'Загрузки доступны только в настольном приложении.',
+ 'chat.files.error.missing_artifact_id': 'Отсутствует идентификатор артефакта.',
+ 'chat.files.error.missing_artifact_path': 'В ответе ядра отсутствует путь к артефакту.',
+ 'chat.files.error.resolve_failed': 'Не удалось получить артефакт. Попробуйте ещё раз.',
+ 'chat.files.error.download_failed': 'Не удалось загрузить файл. Попробуйте ещё раз.',
+ 'chat.files.error.delete_failed': 'Не удалось удалить файл. Попробуйте ещё раз.',
'autocomplete.debounceMs': 'Задержка (мс)',
'autocomplete.maxChars': 'Макс. символов контекста',
'autocomplete.overlayTtlMs': 'Тайм-аут наложения (мс)',
@@ -4537,18 +4569,6 @@ const messages: TranslationMap = {
'memory.health.remediation.unknown':
'При обработке памяти возникла проблема. Проверьте конфигурацию в разделе Настройки → ИИ.',
// Chat — agent-generated artifacts (#2779)
- 'chat.artifact.aria': 'Артефакт: {title}',
- 'chat.artifact.generating': 'Создание {kind}…',
- 'chat.artifact.ready': 'Готово',
- 'chat.artifact.failed': 'Сбой генерации',
- 'chat.artifact.download': 'Скачать',
- 'chat.artifact.downloading': 'Скачивание…',
- 'chat.artifact.downloaded': 'Сохранено в {path}',
- 'chat.artifact.download_failed': 'Сбой скачивания: {reason}',
- 'chat.artifact.retry': 'Повторить',
- 'chat.artifact.reveal': 'Показать в папке',
- 'chat.artifact.show_more': 'Показать больше',
- 'chat.artifact.show_less': 'Свернуть',
// Chat composer toolbar
'composer.attachFile': 'Прикрепить файл',
diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts
index 802395e95..7363adde3 100644
--- a/app/src/lib/i18n/zh-CN.ts
+++ b/app/src/lib/i18n/zh-CN.ts
@@ -4169,6 +4169,38 @@ const messages: TranslationMap = {
'settings.agents.editor.toolsDone': 'Done',
'settings.agents.editor.builtInReadonly':
'内置智能体不可编辑。您可以在智能体列表中启用、禁用或重置它们。',
+ // Chat — agent-generated artifacts (#2779)
+ 'chat.artifact.aria': '工件:{title}',
+ 'chat.artifact.generating': '正在生成{kind}…',
+ 'chat.artifact.ready': '已就绪',
+ 'chat.artifact.failed': '生成失败',
+ 'chat.artifact.download': '下载',
+ 'chat.artifact.downloading': '下载中…',
+ 'chat.artifact.downloaded': '已保存到 {path}',
+ 'chat.artifact.download_failed': '下载失败:{reason}',
+ 'chat.artifact.retry': '重试',
+ 'chat.artifact.reveal': '在文件夹中显示',
+ 'chat.artifact.show_more': '显示更多',
+ 'chat.artifact.show_less': '收起',
+
+ // Chat — files panel (#3024)
+ 'chat.files.chip.aria.one': '本聊天中有 {count} 个文件',
+ 'chat.files.chip.aria.other': '本聊天中有 {count} 个文件',
+ 'chat.files.panel.aria': '本聊天中的文件',
+ 'chat.files.panel.title': '文件 ({count})',
+ 'chat.files.panel.empty': '暂无文件。请让智能体生成一个。',
+ 'chat.files.panel.close': '关闭文件面板',
+ 'chat.files.delete.aria': '删除 {title}',
+ 'chat.files.delete.confirm': '要删除此文件吗?',
+ 'chat.files.delete.cancel': '取消',
+ 'chat.files.delete.action': '删除',
+ 'chat.files.delete.failed': '无法删除文件。请重试。',
+ 'chat.files.error.not_desktop': '下载仅在桌面应用中可用。',
+ 'chat.files.error.missing_artifact_id': '缺少 artifact id。',
+ 'chat.files.error.missing_artifact_path': '核心响应中缺少 artifact 路径。',
+ 'chat.files.error.resolve_failed': '无法解析 artifact。请重试。',
+ 'chat.files.error.download_failed': '下载失败。请重试。',
+ 'chat.files.error.delete_failed': '无法删除文件。请重试。',
'autocomplete.debounceMs': '防抖 (毫秒)',
'autocomplete.maxChars': '最大上下文字符数',
'autocomplete.overlayTtlMs': '覆盖层超时 (ms)',
@@ -4277,18 +4309,6 @@ const messages: TranslationMap = {
'memory.health.remediation.transient': '临时错误中断了记忆处理。将自动重试。',
'memory.health.remediation.unknown': '记忆处理遇到问题。请在设置 → AI 中检查配置。',
// Chat — agent-generated artifacts (#2779)
- 'chat.artifact.aria': '工件:{title}',
- 'chat.artifact.generating': '正在生成{kind}…',
- 'chat.artifact.ready': '已就绪',
- 'chat.artifact.failed': '生成失败',
- 'chat.artifact.download': '下载',
- 'chat.artifact.downloading': '下载中…',
- 'chat.artifact.downloaded': '已保存到 {path}',
- 'chat.artifact.download_failed': '下载失败:{reason}',
- 'chat.artifact.retry': '重试',
- 'chat.artifact.reveal': '在文件夹中显示',
- 'chat.artifact.show_more': '显示更多',
- 'chat.artifact.show_less': '收起',
// Chat composer toolbar
'composer.attachFile': '附加文件',
diff --git a/app/src/pages/Conversations.tsx b/app/src/pages/Conversations.tsx
index 50efb4f29..abf085359 100644
--- a/app/src/pages/Conversations.tsx
+++ b/app/src/pages/Conversations.tsx
@@ -8,6 +8,8 @@ import { checkPromptInjection, promptGuardMessage } from '../chat/promptInjectio
import ApprovalRequestCard from '../components/chat/ApprovalRequestCard';
import ArtifactCard from '../components/chat/ArtifactCard';
import ChatComposer from '../components/chat/ChatComposer';
+import ChatFilesChip from '../components/chat/ChatFilesChip';
+import TokenUsagePill from '../components/chat/TokenUsagePill';
import { ConfirmationModal } from '../components/intelligence/ConfirmationModal';
import PillTabBar from '../components/PillTabBar';
import UpsellBanner from '../components/upsell/UpsellBanner';
@@ -1528,6 +1530,10 @@ const Conversations = ({
+
+ {(selectedThreadId ?? activeThreadId) && (
+
+ )}
+