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) && ( + + )} +