feat(chat): Files in this chat — persistent per-chat artifact panel (#3024) (#3026)

Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai>
This commit is contained in:
oxoxDev
2026-06-02 17:22:08 -07:00
committed by GitHub
co-authored by Steven Enamakel
parent 00b8a669c6
commit 71e04eac45
36 changed files with 2982 additions and 389 deletions
+76
View File
@@ -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 (
<div className="relative inline-block">
<button
type="button"
onClick={() => setOpen(prev => !prev)}
aria-haspopup="dialog"
aria-expanded={open}
aria-label={t(
count === 1 ? 'chat.files.chip.aria.one' : 'chat.files.chip.aria.other'
).replace('{count}', String(count))}
data-testid="chat-files-chip"
className="h-7 inline-flex items-center gap-1.5 rounded-lg border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 hover:bg-stone-50 dark:hover:bg-neutral-800/60 text-xs font-medium text-stone-600 dark:text-neutral-300 transition-colors px-2">
<svg
aria-hidden="true"
className="w-3.5 h-3.5"
fill="none"
stroke="currentColor"
strokeWidth={1.8}
viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M21.44 11.05l-9.19 9.19a6 6 0 01-8.49-8.49l9.19-9.19a4 4 0 015.66 5.66l-9.2 9.19a2 2 0 01-2.83-2.83l8.49-8.49"
/>
</svg>
<span data-testid="chat-files-chip-count" className="font-mono leading-none">
{count}
</span>
</button>
{open && (
<ChatFilesPanel
threadId={threadId}
artifacts={readyArtifacts}
onClose={() => setOpen(false)}
/>
)}
</div>
);
}
+380
View File
@@ -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 (
<svg
aria-hidden="true"
className="w-4 h-4 flex-shrink-0"
fill="none"
stroke={stroke}
strokeWidth={1.8}
viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M3 5h18v12H3z" />
<path strokeLinecap="round" d="M8 21h8M12 17v4" />
<path strokeLinecap="round" strokeLinejoin="round" d="M7 11l3 3 4-5 3 4" />
</svg>
);
case 'document':
return (
<svg
aria-hidden="true"
className="w-4 h-4 flex-shrink-0"
fill="none"
stroke={stroke}
strokeWidth={1.8}
viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M14 3H7a2 2 0 00-2 2v14a2 2 0 002 2h10a2 2 0 002-2V8z"
/>
<path strokeLinecap="round" d="M14 3v5h5M9 13h6M9 17h6" />
</svg>
);
case 'image':
return (
<svg
aria-hidden="true"
className="w-4 h-4 flex-shrink-0"
fill="none"
stroke={stroke}
strokeWidth={1.8}
viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M3 5h18v14H3z" />
<circle cx="9" cy="10" r="1.5" />
<path strokeLinecap="round" strokeLinejoin="round" d="M3 17l5-5 4 4 3-3 6 6" />
</svg>
);
default:
return (
<svg
aria-hidden="true"
className="w-4 h-4 flex-shrink-0"
fill="none"
stroke={stroke}
strokeWidth={1.8}
viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M4 4h16v16H4z" />
</svg>
);
}
}
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<HTMLDivElement | null>(null);
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
const [downloadState, setDownloadState] = useState<Record<string, RowDownloadState>>({});
const [deleteError, setDeleteError] = useState<string | null>(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 (
<div
ref={containerRef}
role="dialog"
aria-label={t('chat.files.panel.aria')}
data-testid="chat-files-panel"
className="absolute right-0 top-9 z-30 w-[360px] max-h-[420px] overflow-y-auto rounded-xl border border-stone-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 shadow-lg">
<header className="sticky top-0 z-10 bg-white dark:bg-neutral-900 border-b border-stone-100 dark:border-neutral-800 px-3 py-2 flex items-center justify-between">
<span className="text-xs font-semibold uppercase tracking-wide text-stone-500 dark:text-neutral-400">
{t('chat.files.panel.title').replace('{count}', String(artifacts.length))}
</span>
<button
type="button"
onClick={onClose}
aria-label={t('chat.files.panel.close')}
className="w-6 h-6 flex items-center justify-center rounded hover:bg-stone-100 dark:hover:bg-neutral-800 text-stone-500 dark:text-neutral-400">
<svg
className="w-3.5 h-3.5"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</header>
{artifacts.length === 0 ? (
<div className="px-3 py-6 text-xs text-stone-500 dark:text-neutral-400 text-center">
{t('chat.files.panel.empty')}
</div>
) : (
<ul className="divide-y divide-stone-100 dark:divide-neutral-800">
{artifacts.map(artifact => {
const row = downloadState[artifact.artifactId] ?? { state: 'idle' as const };
const isConfirming = confirmDeleteId === artifact.artifactId;
return (
<li
key={artifact.artifactId}
className="px-3 py-2.5 flex flex-col gap-1"
data-testid={`chat-files-row-${artifact.artifactId}`}>
<div className="flex items-center gap-2.5 text-sm text-stone-700 dark:text-neutral-200">
<KindIcon kind={artifact.kind} />
<div className="flex flex-col min-w-0 flex-1">
<span className="truncate font-medium leading-tight">{artifact.title}</span>
<span className="text-[11px] font-mono text-stone-500 dark:text-neutral-400 leading-tight">
{artifact.sizeBytes != null ? formatFileSize(artifact.sizeBytes) : ''}
</span>
</div>
</div>
{isConfirming ? (
<div className="flex items-center gap-2 mt-1">
<span className="text-[11px] text-stone-600 dark:text-neutral-300 flex-1">
{t('chat.files.delete.confirm')}
</span>
<button
type="button"
onClick={() => setConfirmDeleteId(null)}
className="rounded-md bg-stone-100 dark:bg-neutral-800 hover:bg-stone-200 dark:hover:bg-neutral-700 text-stone-700 dark:text-neutral-200 text-[11px] font-medium px-2 py-1 transition-colors">
{t('chat.files.delete.cancel')}
</button>
<button
type="button"
onClick={() => void handleDeleteConfirm(artifact)}
data-testid={`chat-files-confirm-${artifact.artifactId}`}
className="rounded-md bg-coral-500 hover:bg-coral-600 text-white text-[11px] font-medium px-2 py-1 transition-colors">
{t('chat.files.delete.action')}
</button>
</div>
) : (
<div className="flex items-center gap-2 mt-0.5">
<button
type="button"
onClick={() => void handleDownload(artifact)}
disabled={row.state === 'downloading'}
data-testid={`chat-files-download-${artifact.artifactId}`}
className="rounded-md bg-ocean-500 hover:bg-ocean-600 disabled:bg-stone-300 dark:disabled:bg-neutral-700 text-white text-[11px] font-medium px-2 py-1 transition-colors">
{row.state === 'downloading'
? t('chat.artifact.downloading')
: t('chat.artifact.download')}
</button>
{row.state === 'done' && row.path && (
<button
type="button"
onClick={() => void handleReveal(row.path!)}
data-testid={`chat-files-reveal-${artifact.artifactId}`}
className="text-[11px] underline text-sage-700 dark:text-sage-300 hover:text-sage-900 dark:hover:text-sage-100">
{t('chat.artifact.reveal')}
</button>
)}
<button
type="button"
onClick={() => setConfirmDeleteId(artifact.artifactId)}
data-testid={`chat-files-delete-${artifact.artifactId}`}
aria-label={t('chat.files.delete.aria').replace('{title}', artifact.title)}
className="ml-auto rounded-md bg-transparent text-stone-500 dark:text-neutral-400 hover:bg-coral-50 dark:hover:bg-coral-900/20 hover:text-coral-700 dark:hover:text-coral-300 text-[11px] font-medium px-2 py-1 transition-colors">
<svg
className="w-3.5 h-3.5"
fill="none"
stroke="currentColor"
strokeWidth={1.8}
viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M19 7l-1 12a2 2 0 01-2 2H8a2 2 0 01-2-2L5 7m5 4v6m4-6v6M4 7h16M9 7V4a1 1 0 011-1h4a1 1 0 011 1v3"
/>
</svg>
</button>
</div>
)}
{row.state === 'error' && row.error && (
<p className="text-[11px] text-coral-600 dark:text-coral-400 mt-0.5 break-words">
{t('chat.artifact.download_failed').replace('{reason}', row.error)}
</p>
)}
</li>
);
})}
</ul>
)}
{deleteError && (
<div className="px-3 py-2 border-t border-stone-100 dark:border-neutral-800 text-[11px] text-coral-600 dark:text-coral-400 break-words">
{deleteError}
</div>
)}
</div>
);
}
@@ -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> = {}): ArtifactSnapshot {
return {
artifactId: 'art-1',
kind: 'presentation',
title: 'Climate Deck',
status: 'in_progress',
updatedAt: Date.now(),
...overrides,
};
}
function ready(overrides: Partial<ArtifactSnapshot> = {}): 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> = {}): 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(<ArtifactCard artifact={inProgress()} />);
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(<ArtifactCard artifact={ready({ sizeBytes: 4096 })} />);
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(<ArtifactCard artifact={ready({ title: 'climate-deck.pptx' })} />);
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(<ArtifactCard artifact={ready()} />);
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(<ArtifactCard artifact={ready()} />);
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(<ArtifactCard artifact={ready({ kind, title: 'no-extension' })} />);
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(<ArtifactCard artifact={ready({ kind: 'presentation', title: 'trailing.' })} />);
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(<ArtifactCard artifact={ready()} />);
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(<ArtifactCard artifact={failed({ error: 'pip install crashed' })} />);
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(<ArtifactCard artifact={a} />);
// No Retry when onRetry is absent.
expect(screen.queryByRole('button', { name: 'Retry' })).toBeNull();
rerender(<ArtifactCard artifact={a} onRetry={onRetry} />);
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(<ArtifactCard artifact={failed({ error: longError })} />);
// 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(<ArtifactCard artifact={failed({ error: 'short oops' })} />);
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(<ArtifactCard artifact={failed()} />);
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(<ArtifactCard artifact={inProgress({ kind })} />);
expect(screen.getByText(/Generating /)).toBeInTheDocument();
}
);
it.each(['presentation', 'document', 'image', 'other'] as const)(
'renders the kind icon when ready for kind=%s',
kind => {
render(<ArtifactCard artifact={ready({ kind })} />);
// Ready label is present regardless of kind.
expect(screen.getByText(/Ready/)).toBeInTheDocument();
}
);
});
@@ -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<ArtifactSnapshot, 'updatedAt' | 'status'> & { 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(
<Provider store={store}>
<ChatFilesChip threadId={THREAD} />
</Provider>
);
// 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(
<Provider store={store}>
<ChatFilesChip threadId={THREAD} />
</Provider>
);
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(
<Provider store={store}>
<ChatFilesChip threadId={THREAD} />
</Provider>
);
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(
<Provider store={store}>
<ChatFilesChip threadId={THREAD} />
</Provider>
);
// 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(
<Provider store={store}>
<ChatFilesChip threadId={THREAD} />
</Provider>
);
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(
<Provider store={store}>
<ChatFilesChip threadId={THREAD} />
</Provider>
);
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();
});
});
@@ -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<Parameters<typeof upsertArtifactReadyForThread>[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(
<Provider store={store}>
<ChatFilesPanel threadId={THREAD} artifacts={[]} onClose={() => {}} />
</Provider>
);
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(
<Provider store={store}>
<ChatFilesPanel threadId={THREAD} artifacts={[a, b]} onClose={() => {}} />
</Provider>
);
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(
<Provider store={store}>
<ChatFilesPanel threadId={THREAD} artifacts={[a]} onClose={() => {}} />
</Provider>
);
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(
<Provider store={store}>
<ChatFilesPanel threadId={THREAD} artifacts={[a]} onClose={() => {}} />
</Provider>
);
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(
<Provider store={store}>
<ChatFilesPanel threadId={THREAD} artifacts={[a]} onClose={() => {}} />
</Provider>
);
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(
<Provider store={store}>
<ChatFilesPanel threadId={THREAD} artifacts={[a]} onClose={() => {}} />
</Provider>
);
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(
<Provider store={store}>
<ChatFilesPanel threadId={THREAD} artifacts={[a]} onClose={() => {}} />
</Provider>
);
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('Couldnt 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(
<Provider store={store}>
<ChatFilesPanel threadId={THREAD} artifacts={[a]} onClose={() => {}} />
</Provider>
);
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(
<Provider store={store}>
<ChatFilesPanel threadId={THREAD} artifacts={[a]} onClose={() => {}} />
</Provider>
);
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(
<Provider store={store}>
<ChatFilesPanel threadId={THREAD} artifacts={[a]} onClose={onClose} />
</Provider>
);
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(
<Provider store={store}>
<ChatFilesPanel threadId={THREAD} artifacts={[a]} onClose={onClose} />
</Provider>
);
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(
<Provider store={store}>
<ChatFilesPanel threadId={THREAD} artifacts={[a]} onClose={onClose} />
</Provider>
);
// 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(
<Provider store={store}>
<ChatFilesPanel threadId={THREAD} artifacts={[a]} onClose={() => {}} />
</Provider>
);
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();
});
});
+32 -12
View File
@@ -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': 'إرفاق ملف',
+32 -12
View File
@@ -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': 'ফাইল সংযুক্ত করুন',
+33 -12
View File
@@ -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',
+35 -12
View File
@@ -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': 'Couldnt 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': 'Couldnt resolve the artifact. Please try again.',
'chat.files.error.download_failed': 'Download failed. Please try again.',
'chat.files.error.delete_failed': 'Couldnt 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',
+33 -12
View File
@@ -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',
+34 -12
View File
@@ -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 linstant. Demandez à lagent den 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 lapplication bureau.',
'chat.files.error.missing_artifact_id': 'Identifiant dartefact manquant.',
'chat.files.error.missing_artifact_path':
'Le chemin de lartefact est absent de la réponse du cœur.',
'chat.files.error.resolve_failed': 'Impossible de résoudre lartefact. 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',
+32 -12
View File
@@ -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': 'फ़ाइल संलग्न करें',
+32 -12
View File
@@ -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',
+33 -12
View File
@@ -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 allagente 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 nellapp desktop.',
'chat.files.error.missing_artifact_id': 'ID dellartefatto mancante.',
'chat.files.error.missing_artifact_path':
'Percorso dellartefatto mancante nella risposta del core.',
'chat.files.error.resolve_failed': 'Impossibile risolvere lartefatto. 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',
+32 -12
View File
@@ -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': '파일 첨부',
+36 -12
View File
@@ -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',
+32 -12
View File
@@ -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',
+32 -12
View File
@@ -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': 'Прикрепить файл',
+32 -12
View File
@@ -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': '附加文件',
+22 -26
View File
@@ -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 = ({
+
</button>
</div>
{(selectedThreadId ?? activeThreadId) && (
<ChatFilesChip threadId={(selectedThreadId ?? activeThreadId) as string} />
)}
<TokenUsagePill />
<button
data-testid="new-thread-button"
onClick={() => void handleCreateNewThread()}
@@ -2108,36 +2114,26 @@ const Conversations = ({
})()}
{(() => {
// Surface artifact cards for the shown thread above the composer
// Surface in-flight + failed artifact cards above the composer
// (#2779). Mirrors the approval-card placement so the user sees
// the just-generated deck without scrolling. Cards stay visible
// across turns until the thread is cleared. ArtifactCard handles
// its own download lifecycle (dialog → copy → "Saved to …").
// the spinner / error without scrolling. `ready` cards are
// delegated to the header ChatFilesChip panel (#3024) so the
// chat scroll area isn't permanently occupied — restored decks
// are listable from the chip on demand.
//
// NOTE: `onRetry` is intentionally omitted on `ArtifactCard`
// below — real retry (either `removeArtifact(thread, id)` to
// let the user re-prompt, or full re-dispatch of the producing
// tool call) is tracked in follow-up issue #3162. The
// failed-card UI still surfaces the truncated error reason;
// the button just stays hidden until #3162 lands.
const artifactThreadId = selectedThreadId ?? activeThreadId;
const artifacts = artifactThreadId ? (artifactsByThread[artifactThreadId] ?? []) : [];
if (artifacts.length === 0) return null;
const all = artifactThreadId ? (artifactsByThread[artifactThreadId] ?? []) : [];
const live = all.filter(a => a.status !== 'ready');
if (live.length === 0) return null;
return (
<div className="mb-2 flex flex-col gap-2">
{artifacts.map(artifact => (
// NOTE: two intentionally-deferred surface gaps live here,
// both tracked in follow-up issue #3162:
//
// 1. `onRetry` is intentionally omitted — `ArtifactCard`
// declares the prop as optional and renders a Retry
// button only when it's wired. Real retry (either
// `removeArtifact(thread, id)` to let the user
// re-prompt, or full re-dispatch of the producing
// tool call) is out of scope for #2779. The
// failed-card UI still surfaces the truncated error
// reason; the button just stays hidden until #3162.
//
// 2. The card's in-progress / "generating…" state is
// unreachable from this call site today — we only
// push an `ArtifactSnapshot` into `artifactsByThread`
// on `ArtifactReady` / `ArtifactFailed`, not on the
// earlier `ChatToolCallEvent` that fires when the
// agent dispatches `generate_presentation`. Wiring
// that event through is the other half of #3162.
{live.map(artifact => (
<ArtifactCard key={artifact.artifactId} artifact={artifact} />
))}
</div>
@@ -0,0 +1,154 @@
import { render } from '@testing-library/react';
import { act } from 'react';
import { Provider } from 'react-redux';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import * as chatService from '../../services/chatService';
import { threadApi } from '../../services/api/threadApi';
import { store } from '../../store';
import { clearAllChatRuntime } from '../../store/chatRuntimeSlice';
import { setStatusForUser } from '../../store/socketSlice';
import { clearAllThreads } from '../../store/threadSlice';
import ChatRuntimeProvider from '../ChatRuntimeProvider';
// Mirrors the harness in ChatRuntimeProvider.test.tsx but scoped to the
// artifact handlers (#3024 workspace_dir binding). We need the listeners
// captured by the real `subscribeChatEvents` call to fire `onArtifactReady`
// / `onArtifactFailed` end-to-end through the provider — otherwise the
// handler bodies (797-825) stay at 0% diff-cover.
vi.mock('../../services/chatService', async () => {
const actual = await vi.importActual<typeof chatService>('../../services/chatService');
return { ...actual, subscribeChatEvents: vi.fn() };
});
vi.mock('../../services/api/threadApi', () => ({
threadApi: {
createNewThread: vi.fn(),
getThreads: vi.fn(),
getThreadMessages: vi.fn(),
appendMessage: vi.fn(),
generateTitleIfNeeded: vi.fn(),
updateMessage: vi.fn(),
deleteThread: vi.fn(),
purge: vi.fn(),
getTaskBoard: vi.fn(),
putTaskBoard: vi.fn(),
},
}));
vi.mock('../../hooks/usageRefresh', () => ({ requestUsageRefresh: vi.fn() }));
const mockRefetchSnapshot = vi.fn();
vi.mock('../../hooks/useRefetchSnapshotOnTurnEnd', () => ({
useRefetchSnapshotOnTurnEnd: () => ({ refetch: mockRefetchSnapshot }),
}));
function renderProvider(): chatService.ChatEventListeners {
let captured: chatService.ChatEventListeners = {};
vi.mocked(chatService.subscribeChatEvents).mockImplementation(listeners => {
captured = listeners;
return () => {};
});
store.dispatch(setStatusForUser({ userId: '__pending__', status: 'connected' }));
render(
<Provider store={store}>
<ChatRuntimeProvider>
<div />
</ChatRuntimeProvider>
</Provider>
);
return captured;
}
function resetRuntimeState() {
store.dispatch(clearAllThreads());
store.dispatch(clearAllChatRuntime());
store.dispatch(setStatusForUser({ userId: '__pending__', status: 'disconnected' }));
}
describe('ChatRuntimeProvider — artifact lifecycle (#3024)', () => {
beforeEach(() => {
vi.clearAllMocks();
resetRuntimeState();
vi.mocked(threadApi.getThreads).mockResolvedValue({ threads: [], count: 0 });
});
it('onArtifactReady upserts a ready snapshot into the thread bucket', () => {
const listeners = renderProvider();
act(() => {
listeners.onArtifactReady?.({
thread_id: 'thread-artifact',
client_id: 'socket-1',
artifact_id: 'art-1',
kind: 'presentation',
title: 'Climate Deck',
workspace_dir: '/workspace',
path: 'artifacts/art-1.pptx',
size_bytes: 4096,
});
});
const bucket = store.getState().chatRuntime.artifactsByThread['thread-artifact'] ?? [];
expect(bucket).toHaveLength(1);
expect(bucket[0]).toMatchObject({
artifactId: 'art-1',
kind: 'presentation',
title: 'Climate Deck',
status: 'ready',
path: 'artifacts/art-1.pptx',
sizeBytes: 4096,
});
});
it('onArtifactFailed upserts a failed snapshot with the producer error', () => {
const listeners = renderProvider();
act(() => {
listeners.onArtifactFailed?.({
thread_id: 'thread-fail',
client_id: 'socket-1',
artifact_id: 'art-2',
kind: 'document',
title: 'Quarterly Report',
workspace_dir: '/workspace',
error: 'pip install crashed',
});
});
const bucket = store.getState().chatRuntime.artifactsByThread['thread-fail'] ?? [];
expect(bucket).toHaveLength(1);
expect(bucket[0]).toMatchObject({
artifactId: 'art-2',
kind: 'document',
title: 'Quarterly Report',
status: 'failed',
error: 'pip install crashed',
});
});
// The defence-in-depth slice(0, 80) in the provider's onArtifactFailed
// body protects the dispatch logging, not the redux payload — assert
// the full producer error still lands in the store for the UI to render.
it('onArtifactFailed preserves the FULL producer error in redux (logging is capped, not the payload)', () => {
const listeners = renderProvider();
const longError = 'x'.repeat(500);
act(() => {
listeners.onArtifactFailed?.({
thread_id: 'thread-long',
client_id: 'socket-1',
artifact_id: 'art-3',
kind: 'image',
title: 'Render',
workspace_dir: '/workspace',
error: longError,
});
});
const bucket = store.getState().chatRuntime.artifactsByThread['thread-long'] ?? [];
expect(bucket).toHaveLength(1);
expect(bucket[0]?.error).toBe(longError);
});
});
@@ -80,6 +80,7 @@ describe('ChatRuntimeProvider — artifact event dispatch (#2779)', () => {
artifact_id: 'a-1',
kind: 'presentation',
title: 'Deck',
workspace_dir: '/workspace',
path: 'a-1/deck.pptx',
size_bytes: 4096,
});
@@ -106,6 +107,7 @@ describe('ChatRuntimeProvider — artifact event dispatch (#2779)', () => {
artifact_id: 'a-2',
kind: 'document',
title: 'Notes',
workspace_dir: '/workspace',
error: 'producer crashed',
});
});
@@ -134,6 +136,7 @@ describe('ChatRuntimeProvider — artifact event dispatch (#2779)', () => {
artifact_id: 'a-3',
kind: 'presentation',
title: 'Deck',
workspace_dir: '/workspace',
error: huge,
});
});
@@ -152,6 +155,7 @@ describe('ChatRuntimeProvider — artifact event dispatch (#2779)', () => {
artifact_id: 'a-4',
kind: 'presentation',
title: 'Deck',
workspace_dir: '/workspace',
error: 'boom',
});
});
@@ -161,6 +165,7 @@ describe('ChatRuntimeProvider — artifact event dispatch (#2779)', () => {
artifact_id: 'a-4',
kind: 'presentation',
title: 'Deck',
workspace_dir: '/workspace',
path: 'a-4/deck.pptx',
size_bytes: 1024,
});
@@ -181,6 +186,7 @@ describe('ChatRuntimeProvider — artifact event dispatch (#2779)', () => {
artifact_id: 'a-1',
kind: 'image',
title: 'Pic',
workspace_dir: '/workspace',
path: 'a-1/pic.png',
size_bytes: 1,
});
@@ -189,6 +195,7 @@ describe('ChatRuntimeProvider — artifact event dispatch (#2779)', () => {
artifact_id: 'a-2',
kind: 'document',
title: 'Doc',
workspace_dir: '/workspace',
error: 'denied',
});
});
@@ -1,203 +1,299 @@
/**
* artifactDownloadService coverage (#3024).
*
* Exercises every branch of `downloadArtifact`, `deleteArtifact`, and
* `revealArtifactInFileManager` — including the typed error-code
* payloads, the title double-extension guard, and the non-Tauri /
* empty-id / RPC-error / invoke-error paths.
*
* Mocks: `safeInvoke` + `isTauri` from `utils/tauriCommands/common`,
* `callCoreRpc` from `services/coreRpcClient`, and `revealItemInDir`
* from `@tauri-apps/plugin-opener`. The service has no other I/O.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
// Importing AFTER mocks are registered so the service binds to the stubs.
import { downloadArtifact, revealArtifactInFileManager } from '../artifactDownloadService';
import {
deleteArtifact,
downloadArtifact,
revealArtifactInFileManager,
} from '../artifactDownloadService';
import { callCoreRpc } from '../coreRpcClient';
const hoisted = vi.hoisted(() => ({
invoke: vi.fn(),
isTauri: vi.fn(() => true),
revealItemInDir: vi.fn(),
}));
// Mock the Tauri common shim (safeInvoke + isTauri) so the service
// runs entirely in JS — no real Tauri IPC reach.
const invokeMock = vi.fn();
const isTauriMock = vi.fn(() => true);
vi.mock('../../utils/tauriCommands/common', () => ({
safeInvoke: (...args: unknown[]) => invokeMock(...args),
isTauri: () => isTauriMock(),
isTauri: hoisted.isTauri,
safeInvoke: (...args: unknown[]) => hoisted.invoke(...args),
}));
// Mock the core RPC client — the service only uses one method.
const callCoreRpcMock = vi.fn();
vi.mock('../coreRpcClient', () => ({
callCoreRpc: (...args: unknown[]) => callCoreRpcMock(...args),
}));
vi.mock('../coreRpcClient', () => ({ callCoreRpc: vi.fn() }));
// Mock the opener plugin's reveal binding so we can assert it is
// invoked with the canonical absolute-path argument.
const revealMock = vi.fn();
vi.mock('@tauri-apps/plugin-opener', () => ({
revealItemInDir: (...args: unknown[]) => revealMock(...args),
revealItemInDir: (...args: unknown[]) => hoisted.revealItemInDir(...args),
}));
beforeEach(() => {
invokeMock.mockReset();
callCoreRpcMock.mockReset();
revealMock.mockReset();
isTauriMock.mockReset();
isTauriMock.mockReturnValue(true);
});
describe('downloadArtifact', () => {
it('refuses outside Tauri with a user-facing message', async () => {
isTauriMock.mockReturnValueOnce(false);
const out = await downloadArtifact('a-1', 'Deck', 'pptx');
expect(out).toEqual({ ok: false, error: 'Downloads are only available in the desktop app' });
expect(callCoreRpcMock).not.toHaveBeenCalled();
expect(invokeMock).not.toHaveBeenCalled();
beforeEach(() => {
vi.clearAllMocks();
hoisted.isTauri.mockReturnValue(true);
});
it('refuses an empty artifact id', async () => {
const out = await downloadArtifact(' ', 'Deck', 'pptx');
expect(out).toEqual({ ok: false, error: 'artifact id missing' });
expect(callCoreRpcMock).not.toHaveBeenCalled();
});
it('surfaces a friendly error when ai_get_artifact rejects (Error)', async () => {
callCoreRpcMock.mockRejectedValueOnce(new Error('rpc dropped'));
const out = await downloadArtifact('a-1', 'Deck', 'pptx');
expect(out.ok).toBe(false);
expect(out.error).toBe('failed to resolve artifact: rpc dropped');
});
it('stringifies non-Error rejections from the core RPC', async () => {
callCoreRpcMock.mockRejectedValueOnce('boom');
const out = await downloadArtifact('a-1', 'Deck', 'pptx');
expect(out.ok).toBe(false);
expect(out.error).toBe('failed to resolve artifact: boom');
});
it('returns a clear error when the core response lacks absolute_path', async () => {
callCoreRpcMock.mockResolvedValueOnce({ meta: { title: 'Deck' } });
const out = await downloadArtifact('a-1', 'Deck', 'pptx');
expect(out).toEqual({ ok: false, error: 'artifact path missing from core response' });
expect(invokeMock).not.toHaveBeenCalled();
});
it('treats a null core response as missing path', async () => {
callCoreRpcMock.mockResolvedValueOnce(null);
const out = await downloadArtifact('a-1', 'Deck', 'pptx');
expect(out).toEqual({ ok: false, error: 'artifact path missing from core response' });
});
it('prefers the persisted title over the caller fallback', async () => {
callCoreRpcMock.mockResolvedValueOnce({
absolute_path: '/workspace/artifacts/a-1/deck.pptx',
meta: { title: 'Real Title' },
it('returns NOT_DESKTOP code when called outside Tauri', async () => {
hoisted.isTauri.mockReturnValueOnce(false);
const outcome = await downloadArtifact('art-1', 'Deck', 'pptx');
expect(outcome).toEqual({
ok: false,
code: 'NOT_DESKTOP',
error: expect.stringContaining('desktop'),
});
invokeMock.mockResolvedValueOnce('/Users/me/Downloads/Real Title.pptx');
expect(callCoreRpc).not.toHaveBeenCalled();
expect(hoisted.invoke).not.toHaveBeenCalled();
});
const out = await downloadArtifact('a-1', 'Caller Fallback', 'pptx');
it('returns MISSING_ARTIFACT_ID for empty / whitespace ids', async () => {
const outcome = await downloadArtifact(' ', 'Deck', 'pptx');
expect(outcome.ok).toBe(false);
expect(outcome.code).toBe('MISSING_ARTIFACT_ID');
expect(callCoreRpc).not.toHaveBeenCalled();
});
expect(out).toEqual({ ok: true, path: '/Users/me/Downloads/Real Title.pptx' });
expect(callCoreRpcMock).toHaveBeenCalledWith({
method: 'openhuman.ai_get_artifact',
params: { artifact_id: 'a-1' },
it('returns RESOLVE_FAILED when the core RPC throws (Error instance)', async () => {
vi.mocked(callCoreRpc).mockRejectedValueOnce(new Error('boom'));
const outcome = await downloadArtifact('art-1', 'Deck', 'pptx');
expect(outcome).toEqual({ ok: false, code: 'RESOLVE_FAILED', error: 'boom' });
});
it('returns RESOLVE_FAILED with String coercion when RPC throws a non-Error', async () => {
vi.mocked(callCoreRpc).mockRejectedValueOnce('plain-string-rejection');
const outcome = await downloadArtifact('art-1', 'Deck', 'pptx');
expect(outcome.code).toBe('RESOLVE_FAILED');
expect(outcome.error).toBe('plain-string-rejection');
});
it('returns MISSING_ARTIFACT_PATH when core resolves without absolute_path', async () => {
vi.mocked(callCoreRpc).mockResolvedValueOnce({ meta: { id: 'art-1', title: 'Deck' } });
const outcome = await downloadArtifact('art-1', 'Deck', 'pptx');
expect(outcome.code).toBe('MISSING_ARTIFACT_PATH');
});
it('returns MISSING_ARTIFACT_PATH when core returns nullish payload', async () => {
vi.mocked(callCoreRpc).mockResolvedValueOnce(null);
const outcome = await downloadArtifact('art-1', 'Deck', 'pptx');
expect(outcome.code).toBe('MISSING_ARTIFACT_PATH');
});
it('happy path: resolves → invokes download → returns ok + dest path', async () => {
vi.mocked(callCoreRpc).mockResolvedValueOnce({
absolute_path: '/workspace/artifacts/art-1/deck.pptx',
meta: { id: 'art-1', title: 'climate-deck' },
});
expect(invokeMock).toHaveBeenCalledWith('download_artifact_to_downloads', {
sourcePath: '/workspace/artifacts/a-1/deck.pptx',
filename: 'Real Title.pptx',
hoisted.invoke.mockResolvedValueOnce('/Users/me/Downloads/climate-deck.pptx');
const outcome = await downloadArtifact('art-1', 'fallback-title', 'pptx');
expect(outcome).toEqual({ ok: true, path: '/Users/me/Downloads/climate-deck.pptx' });
expect(hoisted.invoke).toHaveBeenCalledWith('download_artifact_to_downloads', {
sourcePath: '/workspace/artifacts/art-1/deck.pptx',
filename: 'climate-deck.pptx',
});
});
it('falls back to the caller-supplied title when meta.title is blank', async () => {
callCoreRpcMock.mockResolvedValueOnce({
absolute_path: '/workspace/artifacts/a-1/deck.pptx',
meta: { title: ' ' },
vi.mocked(callCoreRpc).mockResolvedValueOnce({
absolute_path: '/p/file',
meta: { id: 'art-1', title: ' ' },
});
invokeMock.mockResolvedValueOnce('/Users/me/Downloads/Deck.pptx');
await downloadArtifact('a-1', 'Deck', 'pptx');
expect(invokeMock).toHaveBeenCalledWith('download_artifact_to_downloads', {
sourcePath: '/workspace/artifacts/a-1/deck.pptx',
filename: 'Deck.pptx',
hoisted.invoke.mockResolvedValueOnce('/dest/file.pdf');
await downloadArtifact('art-1', 'caller-fallback', 'pdf');
expect(hoisted.invoke).toHaveBeenCalledWith('download_artifact_to_downloads', {
sourcePath: '/p/file',
filename: 'caller-fallback.pdf',
});
});
it('uses the "artifact" placeholder when both title sources are empty', async () => {
callCoreRpcMock.mockResolvedValueOnce({
absolute_path: '/workspace/artifacts/a-1/x.bin',
meta: {},
});
invokeMock.mockResolvedValueOnce('/Users/me/Downloads/artifact.bin');
await downloadArtifact('a-1', ' ', 'bin');
expect(invokeMock).toHaveBeenCalledWith('download_artifact_to_downloads', {
sourcePath: '/workspace/artifacts/a-1/x.bin',
it('falls back to "artifact" when both meta.title and fallbackTitle are blank', async () => {
vi.mocked(callCoreRpc).mockResolvedValueOnce({ absolute_path: '/p/file' });
hoisted.invoke.mockResolvedValueOnce('/dest/x');
await downloadArtifact('art-1', ' ', 'bin');
expect(hoisted.invoke).toHaveBeenCalledWith('download_artifact_to_downloads', {
sourcePath: '/p/file',
filename: 'artifact.bin',
});
});
it('strips leading dots from the extension before appending', async () => {
callCoreRpcMock.mockResolvedValueOnce({
absolute_path: '/workspace/artifacts/a-1/deck.pptx',
meta: { title: 'Deck' },
it('strips leading dots from the extension hint', async () => {
vi.mocked(callCoreRpc).mockResolvedValueOnce({
absolute_path: '/p/file',
meta: { title: 'deck' },
});
invokeMock.mockResolvedValueOnce('/Users/me/Downloads/Deck.pptx');
await downloadArtifact('a-1', 'Deck', '..pptx');
expect(invokeMock).toHaveBeenCalledWith('download_artifact_to_downloads', {
sourcePath: '/workspace/artifacts/a-1/deck.pptx',
filename: 'Deck.pptx',
hoisted.invoke.mockResolvedValueOnce('/dest/deck.pptx');
await downloadArtifact('art-1', 'deck', '...pptx');
expect(hoisted.invoke).toHaveBeenCalledWith('download_artifact_to_downloads', {
sourcePath: '/p/file',
filename: 'deck.pptx',
});
});
it('omits the dot when extension is empty', async () => {
callCoreRpcMock.mockResolvedValueOnce({
absolute_path: '/workspace/artifacts/a-1/raw',
meta: { title: 'Raw' },
it('does NOT double-append the extension when the title already carries it (same ext)', async () => {
vi.mocked(callCoreRpc).mockResolvedValueOnce({
absolute_path: '/p/file',
meta: { title: 'deck.pptx' },
});
invokeMock.mockResolvedValueOnce('/Users/me/Downloads/Raw');
await downloadArtifact('a-1', 'Raw', ' ');
expect(invokeMock).toHaveBeenCalledWith('download_artifact_to_downloads', {
sourcePath: '/workspace/artifacts/a-1/raw',
filename: 'Raw',
hoisted.invoke.mockResolvedValueOnce('/dest/deck.pptx');
await downloadArtifact('art-1', 'deck.pptx', 'pptx');
expect(hoisted.invoke).toHaveBeenCalledWith('download_artifact_to_downloads', {
sourcePath: '/p/file',
filename: 'deck.pptx',
});
});
it('returns the Tauri error when the copy command rejects', async () => {
callCoreRpcMock.mockResolvedValueOnce({
absolute_path: '/workspace/artifacts/a-1/deck.pptx',
meta: { title: 'Deck' },
it('does NOT double-append the extension (case-insensitive match on existing ext)', async () => {
vi.mocked(callCoreRpc).mockResolvedValueOnce({
absolute_path: '/p/file',
meta: { title: 'DECK.PPTX' },
});
hoisted.invoke.mockResolvedValueOnce('/dest/x');
await downloadArtifact('art-1', 'DECK.PPTX', 'pptx');
expect(hoisted.invoke).toHaveBeenCalledWith('download_artifact_to_downloads', {
sourcePath: '/p/file',
filename: 'DECK.PPTX',
});
invokeMock.mockRejectedValueOnce(new Error('disk full'));
const out = await downloadArtifact('a-1', 'Deck', 'pptx');
expect(out).toEqual({ ok: false, error: 'disk full' });
});
it('stringifies a non-Error Tauri rejection', async () => {
callCoreRpcMock.mockResolvedValueOnce({
absolute_path: '/workspace/artifacts/a-1/deck.pptx',
meta: { title: 'Deck' },
it('does NOT double-append when the title already has any trailing extension', async () => {
// Title says .pdf but caller asked for pptx — defensive: keep the
// user-visible title untouched rather than synthesising deck.pdf.pptx.
vi.mocked(callCoreRpc).mockResolvedValueOnce({
absolute_path: '/p/file',
meta: { title: 'deck.pdf' },
});
invokeMock.mockRejectedValueOnce('nope');
hoisted.invoke.mockResolvedValueOnce('/dest/x');
await downloadArtifact('art-1', 'deck.pdf', 'pptx');
expect(hoisted.invoke).toHaveBeenCalledWith('download_artifact_to_downloads', {
sourcePath: '/p/file',
filename: 'deck.pdf',
});
});
const out = await downloadArtifact('a-1', 'Deck', 'pptx');
expect(out).toEqual({ ok: false, error: 'nope' });
it('appends the extension when the title has no extension', async () => {
vi.mocked(callCoreRpc).mockResolvedValueOnce({
absolute_path: '/p/file',
meta: { title: 'climate-overview' },
});
hoisted.invoke.mockResolvedValueOnce('/dest/climate-overview.pptx');
await downloadArtifact('art-1', 'climate-overview', 'pptx');
expect(hoisted.invoke).toHaveBeenCalledWith('download_artifact_to_downloads', {
sourcePath: '/p/file',
filename: 'climate-overview.pptx',
});
});
it('keeps the title bare when the extension hint is empty', async () => {
vi.mocked(callCoreRpc).mockResolvedValueOnce({
absolute_path: '/p/file',
meta: { title: 'just-a-title' },
});
hoisted.invoke.mockResolvedValueOnce('/dest/just-a-title');
await downloadArtifact('art-1', 'just-a-title', '');
expect(hoisted.invoke).toHaveBeenCalledWith('download_artifact_to_downloads', {
sourcePath: '/p/file',
filename: 'just-a-title',
});
});
it('returns DOWNLOAD_FAILED when the Tauri invoke throws', async () => {
vi.mocked(callCoreRpc).mockResolvedValueOnce({
absolute_path: '/p/file',
meta: { title: 'deck' },
});
hoisted.invoke.mockRejectedValueOnce(new Error('disk full'));
const outcome = await downloadArtifact('art-1', 'deck', 'pptx');
expect(outcome).toEqual({ ok: false, code: 'DOWNLOAD_FAILED', error: 'disk full' });
});
it('returns DOWNLOAD_FAILED with String coercion when invoke rejects with a non-Error', async () => {
vi.mocked(callCoreRpc).mockResolvedValueOnce({
absolute_path: '/p/file',
meta: { title: 'deck' },
});
hoisted.invoke.mockRejectedValueOnce('not-an-Error');
const outcome = await downloadArtifact('art-1', 'deck', 'pptx');
expect(outcome).toEqual({ ok: false, code: 'DOWNLOAD_FAILED', error: 'not-an-Error' });
});
});
describe('deleteArtifact', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('returns MISSING_ARTIFACT_ID for empty / whitespace ids without calling the RPC', async () => {
const outcome = await deleteArtifact(' ');
expect(outcome).toEqual({
ok: false,
code: 'MISSING_ARTIFACT_ID',
error: 'artifact id missing',
});
expect(callCoreRpc).not.toHaveBeenCalled();
});
it('returns ok when the core RPC resolves', async () => {
vi.mocked(callCoreRpc).mockResolvedValueOnce(null);
const outcome = await deleteArtifact('art-1');
expect(outcome).toEqual({ ok: true });
expect(callCoreRpc).toHaveBeenCalledWith({
method: 'openhuman.ai_delete_artifact',
params: { artifact_id: 'art-1' },
});
});
it('returns DELETE_FAILED when the core RPC throws', async () => {
vi.mocked(callCoreRpc).mockRejectedValueOnce(new Error('rpc down'));
const outcome = await deleteArtifact('art-1');
expect(outcome).toEqual({ ok: false, code: 'DELETE_FAILED', error: 'rpc down' });
});
it('returns DELETE_FAILED with String coercion on non-Error rejection', async () => {
vi.mocked(callCoreRpc).mockRejectedValueOnce({ code: 500 });
const outcome = await deleteArtifact('art-1');
expect(outcome.code).toBe('DELETE_FAILED');
expect(outcome.error).toBe('[object Object]');
});
});
describe('revealArtifactInFileManager', () => {
it('no-ops outside Tauri', async () => {
isTauriMock.mockReturnValueOnce(false);
const ok = await revealArtifactInFileManager('/Users/me/Downloads/Deck.pptx');
expect(ok).toBe(false);
expect(revealMock).not.toHaveBeenCalled();
beforeEach(() => {
vi.clearAllMocks();
hoisted.isTauri.mockReturnValue(true);
});
it('no-ops on an empty absolute path', async () => {
it('returns false when outside Tauri (no plugin call)', async () => {
hoisted.isTauri.mockReturnValueOnce(false);
const ok = await revealArtifactInFileManager('/some/path');
expect(ok).toBe(false);
expect(hoisted.revealItemInDir).not.toHaveBeenCalled();
});
it('returns false for an empty / whitespace path', async () => {
const ok = await revealArtifactInFileManager(' ');
expect(ok).toBe(false);
expect(revealMock).not.toHaveBeenCalled();
expect(hoisted.revealItemInDir).not.toHaveBeenCalled();
});
it('routes through the typed plugin binding', async () => {
revealMock.mockResolvedValueOnce(undefined);
const ok = await revealArtifactInFileManager('/Users/me/Downloads/Deck.pptx');
it('delegates to revealItemInDir on success', async () => {
hoisted.revealItemInDir.mockResolvedValueOnce(undefined);
const ok = await revealArtifactInFileManager('/Users/me/Downloads/deck.pptx');
expect(ok).toBe(true);
expect(revealMock).toHaveBeenCalledWith('/Users/me/Downloads/Deck.pptx');
expect(hoisted.revealItemInDir).toHaveBeenCalledWith('/Users/me/Downloads/deck.pptx');
});
it('swallows reveal failures and returns false', async () => {
revealMock.mockRejectedValueOnce(new Error('opener missing'));
const ok = await revealArtifactInFileManager('/Users/me/Downloads/Deck.pptx');
it('swallows plugin errors and returns false (best-effort reveal)', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
hoisted.revealItemInDir.mockRejectedValueOnce(new Error('plugin failed'));
const ok = await revealArtifactInFileManager('/Users/me/Downloads/deck.pptx');
expect(ok).toBe(false);
warn.mockRestore();
});
});
@@ -59,6 +59,7 @@ describe('chatService — artifact_ready / artifact_failed handlers (#2779)', ()
artifact_id: 'a-1',
kind: 'presentation',
title: 'Deck',
workspace_dir: '/workspace',
path: 'a-1/deck.pptx',
size_bytes: 4096,
},
@@ -71,6 +72,7 @@ describe('chatService — artifact_ready / artifact_failed handlers (#2779)', ()
artifact_id: 'a-1',
kind: 'presentation',
title: 'Deck',
workspace_dir: '/workspace',
path: 'a-1/deck.pptx',
size_bytes: 4096,
});
@@ -136,7 +138,14 @@ describe('chatService — artifact_ready / artifact_failed handlers (#2779)', ()
for (const kind of ['presentation', 'document', 'image', 'other'] as const) {
socket.emit('artifact_ready', {
thread_id: 'thread-1',
args: { artifact_id: `a-${kind}`, kind, title: 'X', path: `a-${kind}/file`, size_bytes: 1 },
args: {
artifact_id: `a-${kind}`,
kind,
title: 'X',
workspace_dir: '/workspace',
path: `a-${kind}/file`,
size_bytes: 1,
},
});
}
@@ -163,7 +172,8 @@ describe('chatService — artifact_ready / artifact_failed handlers (#2779)', ()
artifact_id: 'a-1',
kind: 'presentation',
title: 'Deck',
error: 'pip install failed',
workspace_dir: '/workspace',
error: 'engine failed: validation rejected slides[0]',
},
});
@@ -174,7 +184,8 @@ describe('chatService — artifact_ready / artifact_failed handlers (#2779)', ()
artifact_id: 'a-1',
kind: 'presentation',
title: 'Deck',
error: 'pip install failed',
workspace_dir: '/workspace',
error: 'engine failed: validation rejected slides[0]',
});
});
@@ -230,7 +241,13 @@ describe('chatService — artifact_ready / artifact_failed handlers (#2779)', ()
const huge = 'x'.repeat(500);
socket.emit('artifact_failed', {
thread_id: 'thread-1',
args: { artifact_id: 'a-1', kind: 'presentation', title: 'Deck', error: huge },
args: {
artifact_id: 'a-1',
kind: 'presentation',
title: 'Deck',
workspace_dir: '/workspace',
error: huge,
},
});
expect(onArtifactFailed).toHaveBeenCalledTimes(1);
@@ -206,6 +206,174 @@ describe('chatService.subscribeChatEvents', () => {
expect(onTaskBoardUpdated).toHaveBeenCalledWith(payload);
});
it('drops malformed artifact_ready payloads without crashing', () => {
const socket = createMockSocket();
vi.mocked(socketService.getSocket).mockReturnValue(socket as never);
const onArtifactReady = vi.fn();
const onArtifactFailed = vi.fn();
subscribeChatEvents({ onArtifactReady, onArtifactFailed });
// 1. Non-string title — previously passed truthiness check, would
// have downstream consumers crash on `.slice()` / `.length`.
socket.emit('artifact_ready', {
thread_id: 't1',
args: {
artifact_id: 'a1',
kind: 'presentation',
title: 42, // ← non-string
workspace_dir: '/workspace',
path: '/some/path.pptx',
size_bytes: 1024,
},
});
expect(onArtifactReady).not.toHaveBeenCalled();
// 2. Non-number size_bytes
socket.emit('artifact_ready', {
thread_id: 't1',
args: {
artifact_id: 'a1',
kind: 'presentation',
title: 'Deck',
workspace_dir: '/workspace',
path: '/some/path.pptx',
size_bytes: 'lots', // ← non-number
},
});
expect(onArtifactReady).not.toHaveBeenCalled();
// 3. Non-string error on artifact_failed — used to crash at
// `.slice(0, 80)` because the truthiness check let it pass.
socket.emit('artifact_failed', {
thread_id: 't1',
args: {
artifact_id: 'a1',
kind: 'presentation',
title: 'Deck',
workspace_dir: '/workspace',
error: { reason: 'object instead of string' }, // ← non-string
},
});
expect(onArtifactFailed).not.toHaveBeenCalled();
// 4. Missing thread_id on the envelope
socket.emit('artifact_ready', {
args: {
artifact_id: 'a1',
kind: 'presentation',
title: 'Deck',
workspace_dir: '/workspace',
path: '/some/path.pptx',
size_bytes: 1024,
},
});
expect(onArtifactReady).not.toHaveBeenCalled();
// 5. Missing workspace_dir — without it, a subscriber can't detect a
// cross-workspace event after a workspace switch (V5 binding).
socket.emit('artifact_ready', {
thread_id: 't1',
args: {
artifact_id: 'a1',
kind: 'presentation',
title: 'Deck',
// workspace_dir omitted
path: '/some/path.pptx',
size_bytes: 1024,
},
});
expect(onArtifactReady).not.toHaveBeenCalled();
// 6. Sanity — a well-formed payload (incl. workspace_dir) flows through.
socket.emit('artifact_ready', {
thread_id: 't1',
args: {
artifact_id: 'a1',
kind: 'presentation',
title: 'Deck',
workspace_dir: '/workspace',
path: '/some/path.pptx',
size_bytes: 1024,
},
});
expect(onArtifactReady).toHaveBeenCalledWith({
thread_id: 't1',
client_id: undefined,
artifact_id: 'a1',
kind: 'presentation',
title: 'Deck',
workspace_dir: '/workspace',
path: '/some/path.pptx',
size_bytes: 1024,
});
});
// Forces the well-formed `artifact_failed` branch (chatService.ts:869,
// 882-890) to execute end-to-end — event-object construction +
// capped chatLog preview + listener dispatch. Without this, the
// `onArtifactFailed` listener is wired but never fired.
it('forwards a well-formed artifact_failed payload through onArtifactFailed', () => {
const socket = createMockSocket();
vi.mocked(socketService.getSocket).mockReturnValue(socket as never);
const onArtifactFailed = vi.fn();
subscribeChatEvents({ onArtifactFailed });
socket.emit('artifact_failed', {
thread_id: 't1',
client_id: 'socket-1',
args: {
artifact_id: 'a1',
kind: 'document',
title: 'Quarterly Report',
workspace_dir: '/workspace',
// Long error to exercise the .slice(0, 80) chatLog cap defence.
error: 'x'.repeat(200),
},
});
expect(onArtifactFailed).toHaveBeenCalledTimes(1);
expect(onArtifactFailed).toHaveBeenCalledWith({
thread_id: 't1',
client_id: 'socket-1',
artifact_id: 'a1',
kind: 'document',
title: 'Quarterly Report',
workspace_dir: '/workspace',
error: 'x'.repeat(200),
});
});
// Drives the bad-envelope skip in both artifact handlers
// (chatService.ts:851-852 for artifact_failed, plus the matching
// artifact_ready branch). Without this, the `if (!env)` arm never
// fires for the failed path.
it('drops artifact_ready / artifact_failed envelopes with non-string thread_id', () => {
const socket = createMockSocket();
vi.mocked(socketService.getSocket).mockReturnValue(socket as never);
const onArtifactReady = vi.fn();
const onArtifactFailed = vi.fn();
subscribeChatEvents({ onArtifactReady, onArtifactFailed });
// Envelope with a non-string thread_id → readEnvelope returns null.
socket.emit('artifact_failed', {
thread_id: 42,
args: {
artifact_id: 'a1',
kind: 'presentation',
title: 'Deck',
workspace_dir: '/workspace',
error: 'boom',
},
});
expect(onArtifactFailed).not.toHaveBeenCalled();
socket.emit('artifact_ready', null);
expect(onArtifactReady).not.toHaveBeenCalled();
});
it('sends chat payload with consistent optional RPC params', async () => {
const socket = createMockSocket();
vi.mocked(socketService.getSocket).mockReturnValue(socket as never);
+87 -8
View File
@@ -21,12 +21,51 @@ import { revealItemInDir } from '@tauri-apps/plugin-opener';
import { safeInvoke as invoke, isTauri } from '../utils/tauriCommands/common';
import { callCoreRpc } from './coreRpcClient';
/**
* Stable, machine-readable failure reasons surfaced by the artifact
* download/delete flows. UI layers should branch on `code` and route to
* their own `t(...)` strings — `error` is kept as a diagnostic detail
* (RPC text, transport error) and MUST NOT be the sole label shown to a
* non-English locale. Codes are intentionally narrow so adding a new
* arm requires a deliberate change here, not a free-form string.
*/
export type ArtifactErrorCode =
| 'NOT_DESKTOP'
| 'MISSING_ARTIFACT_ID'
| 'MISSING_ARTIFACT_PATH'
| 'RESOLVE_FAILED'
| 'DOWNLOAD_FAILED'
| 'DELETE_FAILED';
/** Outcome surfaced to the UI for a single download attempt. */
export interface DownloadArtifactOutcome {
ok: boolean;
/** Absolute destination path when `ok === true`. */
path?: string;
/** Short, user-facing error string when `ok === false`. */
/**
* Stable failure code when `ok === false`. Pair with `error` (raw
* detail) — UI maps `code` to a localized string via `t(...)`.
*/
code?: ArtifactErrorCode;
/**
* Diagnostic detail (RPC text, transport error). Not localized; the
* UI should treat this as a developer-facing hint, not the headline.
*/
error?: string;
}
/** Outcome surfaced to the UI for a single delete attempt (#3024). */
export interface DeleteArtifactOutcome {
ok: boolean;
/**
* Stable failure code when `ok === false`. Pair with `error` (raw
* detail) — UI maps `code` to a localized string via `t(...)`.
*/
code?: ArtifactErrorCode;
/**
* Diagnostic detail (RPC text, transport error). Not localized; the
* UI should treat this as a developer-facing hint, not the headline.
*/
error?: string;
}
@@ -54,10 +93,14 @@ export async function downloadArtifact(
extension: string
): Promise<DownloadArtifactOutcome> {
if (!isTauri()) {
return { ok: false, error: 'Downloads are only available in the desktop app' };
return {
ok: false,
code: 'NOT_DESKTOP',
error: 'Downloads are only available in the desktop app',
};
}
if (!artifactId.trim()) {
return { ok: false, error: 'artifact id missing' };
return { ok: false, code: 'MISSING_ARTIFACT_ID', error: 'artifact id missing' };
}
let resolved: AiGetArtifactData;
@@ -69,26 +112,36 @@ export async function downloadArtifact(
resolved = raw ?? {};
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);
return { ok: false, error: `failed to resolve artifact: ${reason}` };
return { ok: false, code: 'RESOLVE_FAILED', error: reason };
}
const sourcePath = resolved.absolute_path;
if (!sourcePath) {
return { ok: false, error: 'artifact path missing from core response' };
return {
ok: false,
code: 'MISSING_ARTIFACT_PATH',
error: 'artifact path missing from core response',
};
}
// Prefer the persisted title (came from create_artifact's
// sanitized stem) but fall back to the caller-supplied hint.
const title = resolved.meta?.title?.trim() || fallbackTitle.trim() || 'artifact';
const ext = extension.trim().replace(/^\.+/, '');
const filename = ext ? `${title}.${ext}` : title;
// Guard against double extensions: if `title` already ends in the
// requested extension (case-insensitive, with any other extension also
// tolerated), don't append again. Prevents `deck.pptx.pptx` when the
// persisted title is `deck.pptx` and the caller passes `'pptx'`.
const titleHasExtension = /\.[^./\\]+$/.test(title);
const titleHasSameExt = ext.length > 0 && title.toLowerCase().endsWith(`.${ext.toLowerCase()}`);
const filename = ext && !titleHasExtension && !titleHasSameExt ? `${title}.${ext}` : title;
try {
const dest = await invoke<string>('download_artifact_to_downloads', { sourcePath, filename });
return { ok: true, path: dest };
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);
return { ok: false, error: reason };
return { ok: false, code: 'DOWNLOAD_FAILED', error: reason };
}
}
@@ -98,6 +151,33 @@ export async function downloadArtifact(
* no new permission needed. Returns `false` when not in Tauri or the
* invoke fails (caller usually ignores the result).
*/
/**
* Delete the artifact and its on-disk blob via the core RPC (#3024).
* Caller is expected to optimistically remove the slice row first and
* re-insert on `{ ok: false }`. Distinct from the runtime in-memory
* slice ledger — this drops the file on disk and the persistent
* `ArtifactMeta` row in the workspace registry.
*
* Returns `{ ok: false, error }` on any transport or RPC error
* (network drop, core gone, unknown id, file vanished). The core
* treats "missing meta" / "file already gone" as success.
*/
export async function deleteArtifact(artifactId: string): Promise<DeleteArtifactOutcome> {
if (!artifactId.trim()) {
return { ok: false, code: 'MISSING_ARTIFACT_ID', error: 'artifact id missing' };
}
try {
await callCoreRpc<unknown>({
method: 'openhuman.ai_delete_artifact',
params: { artifact_id: artifactId },
});
return { ok: true };
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);
return { ok: false, code: 'DELETE_FAILED', error: reason };
}
}
export async function revealArtifactInFileManager(absolutePath: string): Promise<boolean> {
if (!isTauri()) return false;
if (!absolutePath.trim()) return false;
@@ -110,7 +190,6 @@ export async function revealArtifactInFileManager(absolutePath: string): Promise
return true;
} catch (err) {
// Swallow — reveal is best-effort, the file is already saved.
// eslint-disable-next-line no-console
console.warn('[artifact] revealItemInDir failed:', err);
return false;
}
+66 -32
View File
@@ -157,6 +157,13 @@ export interface ArtifactReadyEvent {
kind: ArtifactKind;
/** Human-readable title; also the on-disk filename stem. */
title: string;
/**
* Absolute workspace root the artifact belongs to. Subscribers must compare
* this to their own workspace binding and silently drop events that don't
* match — `path` is workspace-relative and would otherwise resolve into the
* wrong `<workspace>/artifacts/` tree after a workspace switch.
*/
workspace_dir: string;
/** Relative path under `<workspace>/artifacts/`, e.g. `<uuid>/deck.pptx`. */
path: string;
/** Final on-disk size in bytes. */
@@ -174,6 +181,8 @@ export interface ArtifactFailedEvent {
artifact_id: string;
kind: ArtifactKind;
title: string;
/** Absolute workspace root — see {@link ArtifactReadyEvent.workspace_dir}. */
workspace_dir: string;
/** Producer-supplied failure reason, already truncated. */
error: string;
}
@@ -767,40 +776,57 @@ export function subscribeChatEvents(listeners: ChatEventListeners): () => void {
]);
const isValidArtifactKind = (k: unknown): k is ArtifactKind =>
typeof k === 'string' && validArtifactKinds.has(k as ArtifactKind);
// Type-narrowing guards: previously `!args.title` etc. only checked
// truthiness, so a non-string `title` (number, object, true) would
// pass — and then `.slice(0, 80)` on a non-string `error` crashed
// at L833. Type the payload as `unknown` and narrow each field with
// `typeof` so the runtime contract matches the TS contract.
const isNonEmptyString = (v: unknown): v is string => typeof v === 'string' && v.length > 0;
const isFiniteNumber = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v);
const readEnvelope = (
payload: unknown
): { thread_id: string; client_id?: string; args: Record<string, unknown> } | null => {
if (!payload || typeof payload !== 'object') return null;
const env = payload as { thread_id?: unknown; client_id?: unknown; args?: unknown };
if (!isNonEmptyString(env.thread_id)) return null;
const client_id = typeof env.client_id === 'string' ? env.client_id : undefined;
const args =
env.args && typeof env.args === 'object' && !Array.isArray(env.args)
? (env.args as Record<string, unknown>)
: {};
return { thread_id: env.thread_id, client_id, args };
};
if (listeners.onArtifactReady) {
const cb = (payload: unknown) => {
const raw = payload as {
thread_id: string;
client_id?: string;
args?: {
artifact_id?: string;
kind?: ArtifactKind;
title?: string;
path?: string;
size_bytes?: number;
};
};
const args = raw.args ?? {};
const env = readEnvelope(payload);
if (!env) {
chatLog('%s — skipping malformed payload (bad envelope)', EVENTS.artifactReady);
return;
}
const { args } = env;
if (
!args.artifact_id ||
!isNonEmptyString(args.artifact_id) ||
!isValidArtifactKind(args.kind) ||
!args.title ||
!args.path ||
args.size_bytes == null
!isNonEmptyString(args.title) ||
!isNonEmptyString(args.workspace_dir) ||
!isNonEmptyString(args.path) ||
!isFiniteNumber(args.size_bytes)
) {
chatLog(
'%s thread_id=%s — skipping malformed payload (missing args)',
'%s thread_id=%s — skipping malformed payload (bad args)',
EVENTS.artifactReady,
raw.thread_id
env.thread_id
);
return;
}
const event: ArtifactReadyEvent = {
thread_id: raw.thread_id,
client_id: raw.client_id,
thread_id: env.thread_id,
client_id: env.client_id,
artifact_id: args.artifact_id,
kind: args.kind,
title: args.title,
workspace_dir: args.workspace_dir,
path: args.path,
size_bytes: args.size_bytes,
};
@@ -820,31 +846,39 @@ export function subscribeChatEvents(listeners: ChatEventListeners): () => void {
if (listeners.onArtifactFailed) {
const cb = (payload: unknown) => {
const raw = payload as {
thread_id: string;
client_id?: string;
args?: { artifact_id?: string; kind?: ArtifactKind; title?: string; error?: string };
};
const args = raw.args ?? {};
if (!args.artifact_id || !isValidArtifactKind(args.kind) || !args.title || !args.error) {
const env = readEnvelope(payload);
if (!env) {
chatLog('%s — skipping malformed payload (bad envelope)', EVENTS.artifactFailed);
return;
}
const { args } = env;
if (
!isNonEmptyString(args.artifact_id) ||
!isValidArtifactKind(args.kind) ||
!isNonEmptyString(args.title) ||
!isNonEmptyString(args.workspace_dir) ||
!isNonEmptyString(args.error)
) {
chatLog(
'%s thread_id=%s — skipping malformed payload (missing args)',
'%s thread_id=%s — skipping malformed payload (bad args)',
EVENTS.artifactFailed,
raw.thread_id
env.thread_id
);
return;
}
const event: ArtifactFailedEvent = {
thread_id: raw.thread_id,
client_id: raw.client_id,
thread_id: env.thread_id,
client_id: env.client_id,
artifact_id: args.artifact_id,
kind: args.kind,
title: args.title,
workspace_dir: args.workspace_dir,
error: args.error,
};
// Defence-in-depth: producer is expected to pre-truncate, but
// cap the log preview again so a leaky producer cannot blast
// unbounded provider stderr into client telemetry.
// unbounded provider stderr into client telemetry. (`event.error`
// is now guaranteed a string by the guard above — no .slice crash.)
chatLog(
'%s thread_id=%s artifact_id=%s kind=%s err=%s',
EVENTS.artifactFailed,
@@ -0,0 +1,108 @@
/**
* Coverage for the persist filter that gates `chatRuntime.artifactsByThread`
* on its way to storage (#3024). Confirms the slice's contract:
* `ready` survives a cold boot, `in_progress` / `failed` do not.
*/
import { describe, expect, it } from 'vitest';
import {
filterArtifactsForPersist,
rehydrateArtifactsFromPersist,
} from '../artifactsPersistFilter';
import type { ArtifactSnapshot } from '../chatRuntimeSlice';
function ready(id: string, threadSuffix = ''): ArtifactSnapshot {
return {
artifactId: id,
kind: 'presentation',
title: `Deck ${id}${threadSuffix}`,
status: 'ready',
path: `artifacts/${id}.pptx`,
sizeBytes: 4096,
updatedAt: 1700000000000,
};
}
function inProgress(id: string): ArtifactSnapshot {
return {
artifactId: id,
kind: 'presentation',
title: `Live ${id}`,
status: 'in_progress',
updatedAt: 1700000000000,
};
}
function failed(id: string, error: string): ArtifactSnapshot {
return {
artifactId: id,
kind: 'presentation',
title: `Failed ${id}`,
status: 'failed',
error,
updatedAt: 1700000000000,
};
}
describe('filterArtifactsForPersist (inbound — write side)', () => {
it('returns an empty map when input is undefined', () => {
expect(filterArtifactsForPersist(undefined)).toEqual({});
});
it('returns an empty map when input has no buckets', () => {
expect(filterArtifactsForPersist({})).toEqual({});
});
it('strips in_progress and failed snapshots, keeps ready ones', () => {
const inbound = { t1: [ready('a'), inProgress('b'), failed('c', 'timeout')] };
const out = filterArtifactsForPersist(inbound);
expect(out.t1).toHaveLength(1);
expect(out.t1[0].artifactId).toBe('a');
expect(out.t1[0].status).toBe('ready');
});
it('drops bucket keys whose every snapshot was non-ready', () => {
const inbound = {
'thread-mixed': [ready('a'), ready('b')],
'thread-all-in-flight': [inProgress('x'), inProgress('y')],
'thread-all-failed': [failed('z', 'oops')],
};
const out = filterArtifactsForPersist(inbound);
expect(Object.keys(out)).toEqual(['thread-mixed']);
expect(out['thread-mixed']).toHaveLength(2);
});
it('drops a bucket whose only entry has been deleted (empty list left behind)', () => {
const inbound = { t1: [] as ArtifactSnapshot[] };
expect(filterArtifactsForPersist(inbound)).toEqual({});
});
it('does not mutate the input map or list', () => {
const a = ready('a');
const b = inProgress('b');
const inbound = { t1: [a, b] };
const before = JSON.stringify(inbound);
filterArtifactsForPersist(inbound);
expect(JSON.stringify(inbound)).toBe(before);
});
it('preserves multiple ready snapshots across multiple threads', () => {
const inbound = { t1: [ready('a'), ready('b')], t2: [ready('c')] };
const out = filterArtifactsForPersist(inbound);
expect(out.t1).toHaveLength(2);
expect(out.t2).toHaveLength(1);
expect(out.t2[0].artifactId).toBe('c');
});
});
describe('rehydrateArtifactsFromPersist (outbound — read side)', () => {
it('passes a populated map through untouched', () => {
const stored = { t1: [ready('a')] };
const out = rehydrateArtifactsFromPersist(stored);
expect(out).toEqual(stored);
});
it('substitutes an empty map when storage returns undefined', () => {
expect(rehydrateArtifactsFromPersist(undefined)).toEqual({});
});
});
@@ -4,6 +4,7 @@ import type { PersistedTurnState } from '../../types/turnState';
import reducer, {
beginInferenceTurn,
clearAllChatRuntime,
clearArtifactsForThread,
clearInferenceStatusForThread,
clearPendingApprovalForThread,
clearRuntimeForThread,
@@ -13,11 +14,15 @@ import reducer, {
endInferenceTurn,
hydrateRuntimeFromSnapshot,
markInferenceTurnStreaming,
removeArtifactForThread,
setInferenceStatusForThread,
setPendingApprovalForThread,
setStreamingAssistantForThread,
setTaskBoardForThread,
setToolTimelineForThread,
upsertArtifactFailedForThread,
upsertArtifactInProgressForThread,
upsertArtifactReadyForThread,
} from '../chatRuntimeSlice';
describe('chatRuntimeSlice', () => {
@@ -319,4 +324,273 @@ describe('chatRuntimeSlice', () => {
expect(cleared.pendingApprovalByThread).toEqual({});
});
});
describe('removeArtifactForThread (#3024)', () => {
it('removes a single artifact from a bucket while leaving siblings intact', () => {
let state = reducer(
undefined,
upsertArtifactReadyForThread({
threadId: 't1',
artifactId: 'a',
kind: 'presentation',
title: 'A',
path: 'artifacts/a.pptx',
sizeBytes: 100,
})
);
state = reducer(
state,
upsertArtifactReadyForThread({
threadId: 't1',
artifactId: 'b',
kind: 'document',
title: 'B',
path: 'artifacts/b.pdf',
sizeBytes: 200,
})
);
const next = reducer(state, removeArtifactForThread({ threadId: 't1', artifactId: 'a' }));
expect(next.artifactsByThread['t1']).toHaveLength(1);
expect(next.artifactsByThread['t1'][0].artifactId).toBe('b');
});
it('drops the thread key entirely when the last artifact is removed', () => {
const seeded = reducer(
undefined,
upsertArtifactReadyForThread({
threadId: 't1',
artifactId: 'a',
kind: 'presentation',
title: 'A',
path: 'artifacts/a.pptx',
sizeBytes: 100,
})
);
const next = reducer(seeded, removeArtifactForThread({ threadId: 't1', artifactId: 'a' }));
expect(next.artifactsByThread['t1']).toBeUndefined();
});
it('is a no-op for an unknown thread or unknown id', () => {
const seeded = reducer(
undefined,
upsertArtifactReadyForThread({
threadId: 't1',
artifactId: 'a',
kind: 'presentation',
title: 'A',
path: 'artifacts/a.pptx',
sizeBytes: 100,
})
);
const noThread = reducer(
seeded,
removeArtifactForThread({ threadId: 'nope', artifactId: 'a' })
);
expect(noThread.artifactsByThread['t1']).toHaveLength(1);
const noId = reducer(
seeded,
removeArtifactForThread({ threadId: 't1', artifactId: 'missing' })
);
expect(noId.artifactsByThread['t1']).toHaveLength(1);
});
it('replaces an existing snapshot in place (status promotion in_progress → ready)', () => {
// Covers the upsertArtifact "found at idx" branch — the snapshot
// must update in place so the inline card flips status without
// remounting.
let state = reducer(
undefined,
upsertArtifactInProgressForThread({
threadId: 't1',
artifactId: 'a',
kind: 'presentation',
title: 'Live',
})
);
expect(state.artifactsByThread['t1']).toHaveLength(1);
expect(state.artifactsByThread['t1'][0].status).toBe('in_progress');
state = reducer(
state,
upsertArtifactReadyForThread({
threadId: 't1',
artifactId: 'a',
kind: 'presentation',
title: 'Live',
path: 'artifacts/a.pptx',
sizeBytes: 4096,
})
);
// Same artifactId — count must NOT grow; status flips in place.
expect(state.artifactsByThread['t1']).toHaveLength(1);
expect(state.artifactsByThread['t1'][0].status).toBe('ready');
expect(state.artifactsByThread['t1'][0].path).toBe('artifacts/a.pptx');
expect(state.artifactsByThread['t1'][0].sizeBytes).toBe(4096);
});
it('coexists with in_progress siblings without disturbing them', () => {
let state = reducer(
undefined,
upsertArtifactInProgressForThread({
threadId: 't1',
artifactId: 'in-flight',
kind: 'presentation',
title: 'Live',
})
);
state = reducer(
state,
upsertArtifactReadyForThread({
threadId: 't1',
artifactId: 'done',
kind: 'presentation',
title: 'Done',
path: 'artifacts/done.pptx',
sizeBytes: 1,
})
);
const next = reducer(state, removeArtifactForThread({ threadId: 't1', artifactId: 'done' }));
expect(next.artifactsByThread['t1']).toHaveLength(1);
expect(next.artifactsByThread['t1'][0].artifactId).toBe('in-flight');
expect(next.artifactsByThread['t1'][0].status).toBe('in_progress');
});
});
describe('upsertArtifactFailedForThread (#3024)', () => {
it('appends a new failed snapshot with the producer-supplied error', () => {
const next = reducer(
undefined,
upsertArtifactFailedForThread({
threadId: 't1',
artifactId: 'a',
kind: 'presentation',
title: 'Bad Deck',
error: 'engine failed: validation rejected slides[0]',
})
);
expect(next.artifactsByThread['t1']).toHaveLength(1);
const entry = next.artifactsByThread['t1'][0];
expect(entry.status).toBe('failed');
expect(entry.error).toBe('engine failed: validation rejected slides[0]');
expect(entry.title).toBe('Bad Deck');
expect(entry.kind).toBe('presentation');
});
it('promotes an in-flight snapshot to failed in place (same artifactId)', () => {
const seeded = reducer(
undefined,
upsertArtifactInProgressForThread({
threadId: 't1',
artifactId: 'a',
kind: 'presentation',
title: 'Live',
})
);
const next = reducer(
seeded,
upsertArtifactFailedForThread({
threadId: 't1',
artifactId: 'a',
kind: 'presentation',
title: 'Live',
error: 'timeout',
})
);
expect(next.artifactsByThread['t1']).toHaveLength(1);
expect(next.artifactsByThread['t1'][0].status).toBe('failed');
expect(next.artifactsByThread['t1'][0].error).toBe('timeout');
});
});
describe('clearArtifactsForThread (#3024)', () => {
it('drops the entire bucket for the named thread', () => {
let state = reducer(
undefined,
upsertArtifactReadyForThread({
threadId: 't1',
artifactId: 'a',
kind: 'presentation',
title: 'A',
path: 'artifacts/a.pptx',
sizeBytes: 100,
})
);
state = reducer(
state,
upsertArtifactReadyForThread({
threadId: 't2',
artifactId: 'b',
kind: 'document',
title: 'B',
path: 'artifacts/b.pdf',
sizeBytes: 200,
})
);
const next = reducer(state, clearArtifactsForThread({ threadId: 't1' }));
expect(next.artifactsByThread['t1']).toBeUndefined();
// Sibling thread is untouched.
expect(next.artifactsByThread['t2']).toHaveLength(1);
});
it('is safe to call against an unknown thread (no-op)', () => {
const next = reducer(undefined, clearArtifactsForThread({ threadId: 'never-seen' }));
expect(next.artifactsByThread).toEqual({});
});
});
// Pins the cross-reducer contract: clearRuntimeForThread is a soft reset
// (drops in-flight turn state, pending approvals, tool timelines, task
// board) but *preserves* artifact ledgers so the Files panel + chat
// ArtifactCard surfaces don't lose ready deck rows on a routine
// turn-clear. clearAllChatRuntime is a hard reset (signout / workspace
// switch) and *does* drop artifacts. Per graycyrus on PR #3026: the
// kind of contract that silently regresses on a refactor without a
// pinning test — also a CodeRabbit nit. (#3024)
describe('clear-semantics: artifacts preserved vs cleared (#3024)', () => {
it('clearRuntimeForThread preserves ready artifacts on the same thread', () => {
const seeded = reducer(
undefined,
upsertArtifactReadyForThread({
threadId: 't1',
artifactId: 'a',
kind: 'presentation',
title: 'A',
path: 'artifacts/a.pptx',
sizeBytes: 100,
})
);
const cleared = reducer(seeded, clearRuntimeForThread({ threadId: 't1' }));
expect(cleared.artifactsByThread['t1']).toHaveLength(1);
expect(cleared.artifactsByThread['t1'][0].artifactId).toBe('a');
expect(cleared.artifactsByThread['t1'][0].status).toBe('ready');
});
it('clearAllChatRuntime drops every thread bucket', () => {
let state = reducer(
undefined,
upsertArtifactReadyForThread({
threadId: 't1',
artifactId: 'a',
kind: 'presentation',
title: 'A',
path: 'artifacts/a.pptx',
sizeBytes: 100,
})
);
state = reducer(
state,
upsertArtifactReadyForThread({
threadId: 't2',
artifactId: 'b',
kind: 'document',
title: 'B',
path: 'artifacts/b.pdf',
sizeBytes: 200,
})
);
const cleared = reducer(state, clearAllChatRuntime());
expect(cleared.artifactsByThread).toEqual({});
});
});
});
+60
View File
@@ -0,0 +1,60 @@
/**
* Persist-layer filter for `chatRuntime.artifactsByThread` (#3024).
*
* Only `status === 'ready'` snapshots survive a write to storage:
*
* - `in_progress` would resurrect "Generating…" placeholders on cold
* boot even though no producer task is alive — a misleading
* forever-spinner state.
* - `failed` carries a producer-supplied error message that may
* reference a session-bound run (timeout, engine internal error),
* which is irrelevant after a restart. Letting the failed card
* persist would suggest a permanent failure rather than a
* transient one.
*
* Threads with zero ready snapshots are dropped from the persisted
* map so the storage layer stays compact across cold reboots.
*
* Extracted from `store/index.ts`'s redux-persist `createTransform`
* so the pure data behaviour is unit-testable without instantiating
* the persist machinery.
*/
import type { ArtifactSnapshot } from './chatRuntimeSlice';
export type ArtifactsByThread = Record<string, ArtifactSnapshot[]>;
/**
* Filter the in-memory `artifactsByThread` map to the subset that
* should be written to storage. Pure — input is not mutated.
*
* @param inbound - the live slice's `artifactsByThread` map, or
* `undefined` when the slice has never been written.
* @returns a fresh map containing only `status === 'ready'` entries,
* with empty buckets removed.
*/
export function filterArtifactsForPersist(
inbound: ArtifactsByThread | undefined
): ArtifactsByThread {
if (!inbound) return {};
const filtered: ArtifactsByThread = {};
for (const [threadId, list] of Object.entries(inbound)) {
const readyOnly = list.filter(entry => entry.status === 'ready');
if (readyOnly.length > 0) {
filtered[threadId] = readyOnly;
}
}
return filtered;
}
/**
* Rehydrate the persisted map back into the live slice shape.
*
* Storage was already filtered on write, so this is a trust-but-defend
* step: missing/nullish inputs collapse to an empty map rather than
* propagating `undefined` through the rehydration pipeline.
*/
export function rehydrateArtifactsFromPersist(
outbound: ArtifactsByThread | undefined
): ArtifactsByThread {
return outbound ?? {};
}
+21
View File
@@ -562,6 +562,26 @@ const chatRuntimeSlice = createSlice({
clearArtifactsForThread: (state, action: PayloadAction<{ threadId: string }>) => {
delete state.artifactsByThread[action.payload.threadId];
},
/**
* Remove a single artifact entry from a thread's ledger (#3024). Used
* by the Files panel's per-row Delete affordance: caller dispatches
* this optimistically, then fires `openhuman.ai_delete_artifact` and
* re-upserts the snapshot on RPC failure. No-op if either the thread
* or the artifactId is unknown.
*/
removeArtifactForThread: (
state,
action: PayloadAction<{ threadId: string; artifactId: string }>
) => {
const bucket = state.artifactsByThread[action.payload.threadId];
if (!bucket) return;
const next = bucket.filter(entry => entry.artifactId !== action.payload.artifactId);
if (next.length === 0) {
delete state.artifactsByThread[action.payload.threadId];
} else {
state.artifactsByThread[action.payload.threadId] = next;
}
},
beginInferenceTurn: (state, action: PayloadAction<{ threadId: string }>) => {
state.inferenceTurnLifecycleByThread[action.payload.threadId] = 'started';
},
@@ -687,6 +707,7 @@ export const {
upsertArtifactReadyForThread,
upsertArtifactFailedForThread,
clearArtifactsForThread,
removeArtifactForThread,
beginInferenceTurn,
markInferenceTurnStreaming,
endInferenceTurn,
+33 -1
View File
@@ -1,6 +1,7 @@
import { configureStore } from '@reduxjs/toolkit';
import { createLogger } from 'redux-logger';
import {
createTransform,
FLUSH,
PAUSE,
PERSIST,
@@ -14,6 +15,11 @@ import {
import { E2E_RESTART_APP_AS_RELOAD, IS_DEV } from '../utils/config';
import accountsReducer from './accountsSlice';
import agentProfileReducer from './agentProfileSlice';
import {
type ArtifactsByThread,
filterArtifactsForPersist,
rehydrateArtifactsFromPersist,
} from './artifactsPersistFilter';
import channelConnectionsReducer from './channelConnectionsSlice';
import chatRuntimeReducer from './chatRuntimeSlice';
import companionReducer from './companionSlice';
@@ -150,12 +156,38 @@ const persistedMascotReducer = persistReducer(mascotPersistConfig, mascotReducer
const personaPersistConfig = { key: 'persona', storage, whitelist: ['displayName', 'description'] };
const persistedPersonaReducer = persistReducer(personaPersistConfig, personaReducer);
// chatRuntime is mostly ephemeral (streaming buffers, tool timelines,
// inference status) — those MUST NOT survive a restart or the UI tries
// to resume a turn whose live driver has gone. The single exception is
// `artifactsByThread`: agent-generated files (#3024) survive across
// restarts so the user can return to a thread and still find a deck
// they made earlier. Only `status === 'ready'` snapshots are written;
// in_progress / failed states stay session-scoped via the transform
// below (a half-written PPT shouldn't reappear as "Generating…" on
// cold boot).
// Pure filter/rehydrate logic lives in `artifactsPersistFilter.ts` so it
// can be exercised by unit tests without instantiating redux-persist's
// transform machinery (which expects a running store).
const artifactsReadyOnlyTransform = createTransform<ArtifactsByThread, ArtifactsByThread>(
filterArtifactsForPersist,
rehydrateArtifactsFromPersist,
{ whitelist: ['artifactsByThread'] }
);
const chatRuntimePersistConfig = {
key: 'chatRuntime',
storage,
whitelist: ['artifactsByThread'],
transforms: [artifactsReadyOnlyTransform],
};
const persistedChatRuntimeReducer = persistReducer(chatRuntimePersistConfig, chatRuntimeReducer);
export const store = configureStore({
reducer: {
socket: socketReducer,
connectivity: connectivityReducer,
thread: persistedThreadReducer,
chatRuntime: chatRuntimeReducer,
chatRuntime: persistedChatRuntimeReducer,
companion: companionReducer,
agentProfiles: agentProfileReducer,
channelConnections: persistedChannelConnectionsReducer,
+11
View File
@@ -368,6 +368,14 @@ pub enum DomainEvent {
kind: String,
/// Human-readable title (also the on-disk filename stem).
title: String,
/// Absolute workspace root the artifact belongs to (matches
/// the `workspace_dir` parameter passed to
/// `finalize_artifact`). Bound to the event so a subscriber
/// firing AFTER the user switched workspaces can detect the
/// mismatch and drop the surface — `path` is workspace-
/// relative and would otherwise resolve into the wrong
/// `<workspace>/artifacts/` tree.
workspace_dir: String,
/// Relative path under `<workspace>/artifacts/`, e.g.
/// `"<uuid>/deck.pptx"`. The absolute path is reachable via
/// `ai_get_artifact` so the renderer never needs the
@@ -391,6 +399,9 @@ pub enum DomainEvent {
artifact_id: String,
kind: String,
title: String,
/// Absolute workspace root the artifact belongs to — see
/// [`Self::ArtifactReady::workspace_dir`] for rationale.
workspace_dir: String,
/// Producer-supplied failure reason. Already truncated by the
/// producer (e.g. `PresentationError::truncate_stderr`).
error: String,
+1
View File
@@ -2075,6 +2075,7 @@ pub async fn bootstrap_core_runtime(embedded_core: bool) {
// case. Without this, the gate parks and publishes but nothing reaches the
// frontend → every prompt dies at the TTL. Idempotent (Once-guarded).
crate::openhuman::channels::providers::web::register_approval_surface_subscriber();
crate::openhuman::channels::providers::web::register_artifact_surface_subscriber();
} else {
log::info!(
"[runtime] approval gate disabled (OPENHUMAN_APPROVAL_GATE=0) — \
+3 -5
View File
@@ -364,11 +364,7 @@ pub async fn finalize_artifact(
size_bytes: u64,
) -> Result<ArtifactMeta, String> {
let mut meta = get_artifact(workspace_dir, artifact_id).await?;
if matches!(meta.status, ArtifactStatus::Ready) {
// Idempotent on status alone: a second finalize with a different
// size_bytes shouldn't re-emit ArtifactReady and flap the UI card
// — once an artifact is Ready, callers should not be redefining
// its size. Per graycyrus on PR #3017.
if matches!(meta.status, ArtifactStatus::Ready) && meta.size_bytes == size_bytes {
log::debug!("[artifacts] finalize_artifact: id={artifact_id} already Ready, no-op");
return Ok(meta);
}
@@ -383,6 +379,7 @@ pub async fn finalize_artifact(
artifact_id: meta.id.clone(),
kind: meta.kind.as_str().to_string(),
title: meta.title.clone(),
workspace_dir: workspace_dir.to_string_lossy().into_owned(),
path: meta.path.clone(),
size_bytes: meta.size_bytes,
thread_id,
@@ -422,6 +419,7 @@ pub async fn fail_artifact(
artifact_id: meta.id.clone(),
kind: meta.kind.as_str().to_string(),
title: meta.title.clone(),
workspace_dir: workspace_dir.to_string_lossy().into_owned(),
error: reason.to_string(),
thread_id,
client_id,
+4
View File
@@ -112,6 +112,7 @@ impl EventHandler for ArtifactSurfaceSubscriber {
artifact_id,
kind,
title,
workspace_dir,
path,
size_bytes,
thread_id,
@@ -134,6 +135,7 @@ impl EventHandler for ArtifactSurfaceSubscriber {
"artifact_id": artifact_id,
"kind": kind,
"title": title,
"workspace_dir": workspace_dir,
"path": path,
"size_bytes": size_bytes,
})),
@@ -144,6 +146,7 @@ impl EventHandler for ArtifactSurfaceSubscriber {
artifact_id,
kind,
title,
workspace_dir,
error,
thread_id,
client_id,
@@ -166,6 +169,7 @@ impl EventHandler for ArtifactSurfaceSubscriber {
"artifact_id": artifact_id,
"kind": kind,
"title": title,
"workspace_dir": workspace_dir,
"error": error,
})),
..Default::default()