mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai>
This commit is contained in:
co-authored by
Steven Enamakel
parent
00b8a669c6
commit
71e04eac45
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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('Couldn’t delete the file. Please try again.')).toBeInTheDocument();
|
||||
});
|
||||
// Raw detail MUST NOT leak into the user-facing surface.
|
||||
expect(screen.queryByText('rpc transport closed')).toBeNull();
|
||||
});
|
||||
|
||||
it('passes the title-derived extension to downloadArtifact when title carries one', async () => {
|
||||
const a = readyArtifact('art-1', 'climate-deck.pptx');
|
||||
vi.mocked(downloadArtifact).mockResolvedValueOnce({ ok: true, path: '/d/x.pptx' });
|
||||
const store = mkStore([
|
||||
{
|
||||
threadId: THREAD,
|
||||
artifactId: a.artifactId,
|
||||
kind: a.kind,
|
||||
title: a.title,
|
||||
path: a.path!,
|
||||
sizeBytes: a.sizeBytes!,
|
||||
},
|
||||
]);
|
||||
render(
|
||||
<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
@@ -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
@@ -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
@@ -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
@@ -4786,6 +4786,41 @@ const en: TranslationMap = {
|
||||
'settings.agents.editor.builtInReadonly':
|
||||
"Built-in agents can't be edited. You can enable, disable, or reset them from the agents list.",
|
||||
|
||||
// Chat — agent-generated artifacts (#2779)
|
||||
'chat.artifact.aria': 'Artifact: {title}',
|
||||
'chat.artifact.generating': 'Generating {kind}…',
|
||||
'chat.artifact.ready': 'Ready',
|
||||
'chat.artifact.failed': 'Generation failed',
|
||||
'chat.artifact.download': 'Download',
|
||||
'chat.artifact.downloading': 'Downloading…',
|
||||
'chat.artifact.downloaded': 'Saved to {path}',
|
||||
'chat.artifact.download_failed': 'Download failed: {reason}',
|
||||
'chat.artifact.retry': 'Retry',
|
||||
'chat.artifact.reveal': 'Show in folder',
|
||||
'chat.artifact.show_more': 'Show more',
|
||||
'chat.artifact.show_less': 'Show less',
|
||||
|
||||
// Chat — files panel (#3024)
|
||||
'chat.files.chip.aria.one': '{count} file in this chat',
|
||||
'chat.files.chip.aria.other': '{count} files in this chat',
|
||||
'chat.files.panel.aria': 'Files in this chat',
|
||||
'chat.files.panel.title': 'Files ({count})',
|
||||
'chat.files.panel.empty': 'No files yet. Ask the agent to generate one.',
|
||||
'chat.files.panel.close': 'Close files panel',
|
||||
'chat.files.delete.aria': 'Delete {title}',
|
||||
'chat.files.delete.confirm': 'Delete this file?',
|
||||
'chat.files.delete.cancel': 'Cancel',
|
||||
'chat.files.delete.action': 'Delete',
|
||||
'chat.files.delete.failed': 'Couldn’t delete the file. Try again.',
|
||||
// Error labels for download/delete outcomes (#3024). Keyed off
|
||||
// `ArtifactErrorCode` returned by artifactDownloadService.
|
||||
'chat.files.error.not_desktop': 'Downloads are only available in the desktop app.',
|
||||
'chat.files.error.missing_artifact_id': 'Missing artifact id.',
|
||||
'chat.files.error.missing_artifact_path': 'The artifact path is missing from the core response.',
|
||||
'chat.files.error.resolve_failed': 'Couldn’t resolve the artifact. Please try again.',
|
||||
'chat.files.error.download_failed': 'Download failed. Please try again.',
|
||||
'chat.files.error.delete_failed': 'Couldn’t delete the file. Please try again.',
|
||||
|
||||
// Keyring consent & security
|
||||
'keyring.consent.title': 'Secure Storage Unavailable',
|
||||
'keyring.consent.description':
|
||||
@@ -4823,18 +4858,6 @@ const en: TranslationMap = {
|
||||
'pages.settings.account.securityDesc': 'Secret storage mode and keychain status',
|
||||
|
||||
// Chat — agent-generated artifacts (#2779)
|
||||
'chat.artifact.aria': 'Artifact: {title}',
|
||||
'chat.artifact.generating': 'Generating {kind}…',
|
||||
'chat.artifact.ready': 'Ready',
|
||||
'chat.artifact.failed': 'Generation failed',
|
||||
'chat.artifact.download': 'Download',
|
||||
'chat.artifact.downloading': 'Downloading…',
|
||||
'chat.artifact.downloaded': 'Saved to {path}',
|
||||
'chat.artifact.download_failed': 'Download failed: {reason}',
|
||||
'chat.artifact.retry': 'Retry',
|
||||
'chat.artifact.reveal': 'Show in folder',
|
||||
'chat.artifact.show_more': 'Show more',
|
||||
'chat.artifact.show_less': 'Show less',
|
||||
// Chat composer toolbar
|
||||
'composer.attachFile': 'Attach file',
|
||||
'composer.modelSelector': 'Model',
|
||||
|
||||
+33
-12
@@ -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
@@ -4480,6 +4480,40 @@ const messages: TranslationMap = {
|
||||
'settings.agents.editor.toolsDone': 'Done',
|
||||
'settings.agents.editor.builtInReadonly':
|
||||
'Les agents intégrés ne peuvent pas être modifiés. Vous pouvez les activer, les désactiver ou les réinitialiser depuis la liste des agents.',
|
||||
// Chat — agent-generated artifacts (#2779)
|
||||
'chat.artifact.aria': 'Artefact : {title}',
|
||||
'chat.artifact.generating': 'Génération de {kind}…',
|
||||
'chat.artifact.ready': 'Prêt',
|
||||
'chat.artifact.failed': 'Échec de la génération',
|
||||
'chat.artifact.download': 'Télécharger',
|
||||
'chat.artifact.downloading': 'Téléchargement…',
|
||||
'chat.artifact.downloaded': 'Enregistré dans {path}',
|
||||
'chat.artifact.download_failed': 'Échec du téléchargement : {reason}',
|
||||
'chat.artifact.retry': 'Réessayer',
|
||||
'chat.artifact.reveal': 'Afficher dans le dossier',
|
||||
'chat.artifact.show_more': 'Voir plus',
|
||||
'chat.artifact.show_less': 'Voir moins',
|
||||
|
||||
// Chat — files panel (#3024)
|
||||
'chat.files.chip.aria.one': '{count} fichier dans cette discussion',
|
||||
'chat.files.chip.aria.other': '{count} fichiers dans cette discussion',
|
||||
'chat.files.panel.aria': 'Fichiers dans cette discussion',
|
||||
'chat.files.panel.title': 'Fichiers ({count})',
|
||||
'chat.files.panel.empty': 'Aucun fichier pour l’instant. Demandez à l’agent d’en générer un.',
|
||||
'chat.files.panel.close': 'Fermer le panneau de fichiers',
|
||||
'chat.files.delete.aria': 'Supprimer {title}',
|
||||
'chat.files.delete.confirm': 'Supprimer ce fichier ?',
|
||||
'chat.files.delete.cancel': 'Annuler',
|
||||
'chat.files.delete.action': 'Supprimer',
|
||||
'chat.files.delete.failed': 'Impossible de supprimer le fichier. Réessayez.',
|
||||
'chat.files.error.not_desktop':
|
||||
'Les téléchargements sont uniquement disponibles dans l’application bureau.',
|
||||
'chat.files.error.missing_artifact_id': 'Identifiant d’artefact manquant.',
|
||||
'chat.files.error.missing_artifact_path':
|
||||
'Le chemin de l’artefact est absent de la réponse du cœur.',
|
||||
'chat.files.error.resolve_failed': 'Impossible de résoudre l’artefact. Réessayez.',
|
||||
'chat.files.error.download_failed': 'Échec du téléchargement. Réessayez.',
|
||||
'chat.files.error.delete_failed': 'Impossible de supprimer le fichier. Réessayez.',
|
||||
'autocomplete.debounceMs': 'Anti-rebond (ms)',
|
||||
'autocomplete.maxChars': 'Caractères de contexte maximum',
|
||||
'autocomplete.overlayTtlMs': "Délai d'affichage (ms)",
|
||||
@@ -4594,18 +4628,6 @@ const messages: TranslationMap = {
|
||||
'memory.health.remediation.unknown':
|
||||
'Le traitement de la mémoire a rencontré un problème. Vérifiez Paramètres → IA pour la configuration.',
|
||||
// Chat — agent-generated artifacts (#2779)
|
||||
'chat.artifact.aria': 'Artefact : {title}',
|
||||
'chat.artifact.generating': 'Génération de {kind}…',
|
||||
'chat.artifact.ready': 'Prêt',
|
||||
'chat.artifact.failed': 'Échec de la génération',
|
||||
'chat.artifact.download': 'Télécharger',
|
||||
'chat.artifact.downloading': 'Téléchargement…',
|
||||
'chat.artifact.downloaded': 'Enregistré dans {path}',
|
||||
'chat.artifact.download_failed': 'Échec du téléchargement : {reason}',
|
||||
'chat.artifact.retry': 'Réessayer',
|
||||
'chat.artifact.reveal': 'Afficher dans le dossier',
|
||||
'chat.artifact.show_more': 'Voir plus',
|
||||
'chat.artifact.show_less': 'Voir moins',
|
||||
|
||||
// Chat composer toolbar
|
||||
'composer.attachFile': 'Joindre un fichier',
|
||||
|
||||
+32
-12
@@ -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
@@ -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
@@ -4456,6 +4456,39 @@ const messages: TranslationMap = {
|
||||
'settings.agents.editor.toolsDone': 'Done',
|
||||
'settings.agents.editor.builtInReadonly':
|
||||
"Gli agenti integrati non possono essere modificati. Puoi abilitarli, disabilitarli o reimpostarli dall'elenco degli agenti.",
|
||||
// Chat — agent-generated artifacts (#2779)
|
||||
'chat.artifact.aria': 'Artefatto: {title}',
|
||||
'chat.artifact.generating': 'Generazione {kind}…',
|
||||
'chat.artifact.ready': 'Pronto',
|
||||
'chat.artifact.failed': 'Generazione fallita',
|
||||
'chat.artifact.download': 'Scarica',
|
||||
'chat.artifact.downloading': 'Scaricamento…',
|
||||
'chat.artifact.downloaded': 'Salvato in {path}',
|
||||
'chat.artifact.download_failed': 'Download fallito: {reason}',
|
||||
'chat.artifact.retry': 'Riprova',
|
||||
'chat.artifact.reveal': 'Mostra nella cartella',
|
||||
'chat.artifact.show_more': 'Mostra altro',
|
||||
'chat.artifact.show_less': 'Mostra meno',
|
||||
|
||||
// Chat — files panel (#3024)
|
||||
'chat.files.chip.aria.one': '{count} file in questa chat',
|
||||
'chat.files.chip.aria.other': '{count} file in questa chat',
|
||||
'chat.files.panel.aria': 'File in questa chat',
|
||||
'chat.files.panel.title': 'File ({count})',
|
||||
'chat.files.panel.empty': 'Ancora nessun file. Chiedi all’agente di generarne uno.',
|
||||
'chat.files.panel.close': 'Chiudi pannello file',
|
||||
'chat.files.delete.aria': 'Elimina {title}',
|
||||
'chat.files.delete.confirm': 'Eliminare questo file?',
|
||||
'chat.files.delete.cancel': 'Annulla',
|
||||
'chat.files.delete.action': 'Elimina',
|
||||
'chat.files.delete.failed': 'Impossibile eliminare il file. Riprova.',
|
||||
'chat.files.error.not_desktop': 'I download sono disponibili solo nell’app desktop.',
|
||||
'chat.files.error.missing_artifact_id': 'ID dell’artefatto mancante.',
|
||||
'chat.files.error.missing_artifact_path':
|
||||
'Percorso dell’artefatto mancante nella risposta del core.',
|
||||
'chat.files.error.resolve_failed': 'Impossibile risolvere l’artefatto. Riprova.',
|
||||
'chat.files.error.download_failed': 'Download non riuscito. Riprova.',
|
||||
'chat.files.error.delete_failed': 'Impossibile eliminare il file. Riprova.',
|
||||
'autocomplete.debounceMs': 'Debounce (ms)',
|
||||
'autocomplete.maxChars': 'Caratteri massimi di contesto',
|
||||
'autocomplete.overlayTtlMs': 'Timeout overlay (ms)',
|
||||
@@ -4570,18 +4603,6 @@ const messages: TranslationMap = {
|
||||
'memory.health.remediation.unknown':
|
||||
"L'elaborazione della memoria ha riscontrato un problema. Controlla Impostazioni → IA per la configurazione.",
|
||||
// Chat — agent-generated artifacts (#2779)
|
||||
'chat.artifact.aria': 'Artefatto: {title}',
|
||||
'chat.artifact.generating': 'Generazione {kind}…',
|
||||
'chat.artifact.ready': 'Pronto',
|
||||
'chat.artifact.failed': 'Generazione fallita',
|
||||
'chat.artifact.download': 'Scarica',
|
||||
'chat.artifact.downloading': 'Scaricamento…',
|
||||
'chat.artifact.downloaded': 'Salvato in {path}',
|
||||
'chat.artifact.download_failed': 'Download fallito: {reason}',
|
||||
'chat.artifact.retry': 'Riprova',
|
||||
'chat.artifact.reveal': 'Mostra nella cartella',
|
||||
'chat.artifact.show_more': 'Mostra altro',
|
||||
'chat.artifact.show_less': 'Mostra meno',
|
||||
|
||||
// Chat composer toolbar
|
||||
'composer.attachFile': 'Allega file',
|
||||
|
||||
+32
-12
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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': '附加文件',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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({});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 ?? {};
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) — \
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user