Files
openhuman/app/src/components/chat/ChatFilesPanel.tsx
T

387 lines
15 KiB
TypeScript

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}
data-analytics-id="chat-files-panel-close"
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)}
data-analytics-id="chat-files-delete-cancel"
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-analytics-id={`chat-files-delete-confirm-${artifact.kind}`}
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-analytics-id={`chat-files-download-${artifact.kind}`}
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-analytics-id={`chat-files-reveal-${artifact.kind}`}
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-analytics-id={`chat-files-delete-${artifact.kind}`}
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>
);
}