diff --git a/app/src/components/chat/ChatFilesChip.tsx b/app/src/components/chat/ChatFilesChip.tsx index b5544b3ac..f3a226daf 100644 --- a/app/src/components/chat/ChatFilesChip.tsx +++ b/app/src/components/chat/ChatFilesChip.tsx @@ -1,7 +1,9 @@ -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { useT } from '../../lib/i18n/I18nContext'; -import { useAppSelector } from '../../store/hooks'; +import { listArtifactsForThread } from '../../services/artifactDownloadService'; +import { upsertArtifactReadyForThread } from '../../store/chatRuntimeSlice'; +import { useAppDispatch, useAppSelector } from '../../store/hooks'; import ChatFilesPanel from './ChatFilesPanel'; /** @@ -22,17 +24,53 @@ export interface ChatFilesChipProps { export default function ChatFilesChip({ threadId }: ChatFilesChipProps) { const { t } = useT(); + const dispatch = useAppDispatch(); const [open, setOpen] = useState(false); + const normalizedThreadId = threadId.trim(); 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] + () => (artifactsByThread[normalizedThreadId] ?? []).filter(a => a.status === 'ready'), + [artifactsByThread, normalizedThreadId] ); const count = readyArtifacts.length; + useEffect(() => { + if (!normalizedThreadId) return; + let cancelled = false; + void listArtifactsForThread(normalizedThreadId).then(outcome => { + if (cancelled) return; + if (!outcome.ok) { + console.warn('[artifact] ChatFilesChip failed to hydrate thread artifacts', { + threadId: normalizedThreadId, + error: outcome.error, + }); + return; + } + if (outcome.artifacts.length === 0) return; + console.debug('[artifact] ChatFilesChip hydrating thread artifacts', { + threadId: normalizedThreadId, + count: outcome.artifacts.length, + }); + for (const artifact of outcome.artifacts) { + dispatch( + upsertArtifactReadyForThread({ + threadId: normalizedThreadId, + artifactId: artifact.artifactId, + kind: artifact.kind, + title: artifact.title, + path: artifact.path, + sizeBytes: artifact.sizeBytes, + }) + ); + } + }); + return () => { + cancelled = true; + }; + }, [dispatch, normalizedThreadId]); + if (count === 0) return null; return ( @@ -67,7 +105,7 @@ export default function ChatFilesChip({ threadId }: ChatFilesChipProps) { {open && ( setOpen(false)} /> diff --git a/app/src/components/chat/__tests__/ChatFilesChip.test.tsx b/app/src/components/chat/__tests__/ChatFilesChip.test.tsx index b2fdcff40..bdb4c8425 100644 --- a/app/src/components/chat/__tests__/ChatFilesChip.test.tsx +++ b/app/src/components/chat/__tests__/ChatFilesChip.test.tsx @@ -1,8 +1,9 @@ import { configureStore } from '@reduxjs/toolkit'; -import { fireEvent, render, screen } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { Provider } from 'react-redux'; -import { describe, expect, it } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { listArtifactsForThread } from '../../../services/artifactDownloadService'; import chatRuntimeReducer, { type ArtifactSnapshot, upsertArtifactInProgressForThread, @@ -10,6 +11,13 @@ import chatRuntimeReducer, { } from '../../../store/chatRuntimeSlice'; import ChatFilesChip from '../ChatFilesChip'; +vi.mock('../../../services/artifactDownloadService', () => ({ + listArtifactsForThread: vi.fn(), + downloadArtifact: vi.fn(), + deleteArtifact: vi.fn(), + revealArtifactInFileManager: vi.fn(), +})); + const THREAD = 't-chip-1'; function mkStore() { @@ -29,6 +37,11 @@ function readyArtifact( } describe('ChatFilesChip', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(listArtifactsForThread).mockResolvedValue({ ok: true, artifacts: [] }); + }); + it('renders nothing when the thread has zero ready artifacts', () => { const store = mkStore(); const { container } = render( @@ -41,6 +54,94 @@ describe('ChatFilesChip', () => { expect(screen.queryByTestId('chat-files-chip')).toBeNull(); }); + it('hydrates thread artifacts from disk when redux starts cold', async () => { + vi.mocked(listArtifactsForThread).mockResolvedValueOnce({ + ok: true, + artifacts: [ + { + artifactId: 'art-cold', + kind: 'document', + title: 'Cold Redux Report', + path: 'artifacts/art-cold/report.pdf', + sizeBytes: 2048, + }, + ], + }); + const store = mkStore(); + render( + + + + ); + + await waitFor(() => { + expect(screen.getByTestId('chat-files-chip-count')).toHaveTextContent('1'); + }); + expect(listArtifactsForThread).toHaveBeenCalledWith(THREAD); + expect(store.getState().chatRuntime.artifactsByThread[THREAD]).toMatchObject([ + { + artifactId: 'art-cold', + kind: 'document', + title: 'Cold Redux Report', + status: 'ready', + path: 'artifacts/art-cold/report.pdf', + sizeBytes: 2048, + }, + ]); + }); + + it('uses the normalized thread id for hydration and redux lookup', async () => { + vi.mocked(listArtifactsForThread).mockResolvedValueOnce({ + ok: true, + artifacts: [ + { + artifactId: 'art-trimmed', + kind: 'document', + title: 'Trimmed Thread Report', + path: 'artifacts/art-trimmed/report.pdf', + sizeBytes: 4096, + }, + ], + }); + const store = mkStore(); + render( + + + + ); + + await waitFor(() => { + expect(screen.getByTestId('chat-files-chip-count')).toHaveTextContent('1'); + }); + expect(listArtifactsForThread).toHaveBeenCalledWith(THREAD); + expect(store.getState().chatRuntime.artifactsByThread[THREAD]).toMatchObject([ + { artifactId: 'art-trimmed', status: 'ready', path: 'artifacts/art-trimmed/report.pdf' }, + ]); + expect(store.getState().chatRuntime.artifactsByThread[` ${THREAD} `]).toBeUndefined(); + }); + + it('keeps the chip hidden when disk hydration fails', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + vi.mocked(listArtifactsForThread).mockResolvedValueOnce({ + ok: false, + artifacts: [], + error: 'core offline', + }); + const store = mkStore(); + const { container } = render( + + + + ); + + await waitFor(() => { + expect(listArtifactsForThread).toHaveBeenCalledWith(THREAD); + }); + expect(container.firstChild).toBeNull(); + expect(store.getState().chatRuntime.artifactsByThread[THREAD]).toBeUndefined(); + warn.mockRestore(); + }); + it('hides itself when only in_progress artifacts exist (those live above composer)', () => { const store = mkStore(); store.dispatch( diff --git a/app/src/services/__tests__/artifactDownloadService.test.ts b/app/src/services/__tests__/artifactDownloadService.test.ts index 25143e7ac..1836ab965 100644 --- a/app/src/services/__tests__/artifactDownloadService.test.ts +++ b/app/src/services/__tests__/artifactDownloadService.test.ts @@ -15,6 +15,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { deleteArtifact, downloadArtifact, + listArtifactsForThread, revealArtifactInFileManager, saveArtifactViaDialog, } from '../artifactDownloadService'; @@ -37,6 +38,143 @@ vi.mock('@tauri-apps/plugin-opener', () => ({ revealItemInDir: (...args: unknown[]) => hoisted.revealItemInDir(...args), })); +describe('listArtifactsForThread', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns an error for empty / whitespace thread ids without calling the RPC', async () => { + const outcome = await listArtifactsForThread(' '); + expect(outcome).toEqual({ ok: false, artifacts: [], error: 'thread id missing' }); + expect(callCoreRpc).not.toHaveBeenCalled(); + }); + + it('calls ai_list_artifacts with a thread filter and maps ready artifacts', async () => { + vi.mocked(callCoreRpc).mockResolvedValueOnce({ + artifacts: [ + { + id: 'art-1', + kind: 'document', + title: 'Report', + path: 'artifacts/art-1/report.pdf', + size_bytes: 1234, + status: 'ready', + }, + { + id: 'art-pending', + kind: 'presentation', + title: 'Pending Deck', + path: 'artifacts/art-pending/deck.pptx', + size_bytes: 55, + status: 'pending', + }, + { + id: 'art-bad-kind', + kind: 'video', + title: 'Unsupported', + path: 'artifacts/art-bad-kind/video.mp4', + size_bytes: 99, + status: 'ready', + }, + ], + }); + + const outcome = await listArtifactsForThread(' thread-1 '); + + expect(callCoreRpc).toHaveBeenCalledWith({ + method: 'openhuman.ai_list_artifacts', + params: { thread_id: 'thread-1', offset: 0, limit: 200 }, + }); + expect(outcome).toEqual({ + ok: true, + artifacts: [ + { + artifactId: 'art-1', + kind: 'document', + title: 'Report', + path: 'artifacts/art-1/report.pdf', + sizeBytes: 1234, + }, + ], + }); + }); + + it('fetches subsequent artifact pages before returning mapped artifacts', async () => { + const firstPage = Array.from({ length: 200 }, (_, idx) => ({ + id: `pending-${idx}`, + kind: 'document', + title: `Pending ${idx}`, + path: `artifacts/pending-${idx}/pending.pdf`, + size_bytes: idx + 1, + status: 'pending', + })); + firstPage[0] = { + id: 'art-page-1', + kind: 'document', + title: 'First Page Report', + path: 'artifacts/art-page-1/report.pdf', + size_bytes: 100, + status: 'ready', + }; + vi.mocked(callCoreRpc) + .mockResolvedValueOnce({ artifacts: firstPage }) + .mockResolvedValueOnce({ + artifacts: [ + { + id: 'art-page-2', + kind: 'image', + title: 'Second Page Image', + path: 'artifacts/art-page-2/image.png', + size_bytes: 200, + status: 'ready', + }, + ], + }); + + const outcome = await listArtifactsForThread('thread-1'); + + expect(callCoreRpc).toHaveBeenNthCalledWith(1, { + method: 'openhuman.ai_list_artifacts', + params: { thread_id: 'thread-1', offset: 0, limit: 200 }, + }); + expect(callCoreRpc).toHaveBeenNthCalledWith(2, { + method: 'openhuman.ai_list_artifacts', + params: { thread_id: 'thread-1', offset: 200, limit: 200 }, + }); + expect(outcome).toEqual({ + ok: true, + artifacts: [ + { + artifactId: 'art-page-1', + kind: 'document', + title: 'First Page Report', + path: 'artifacts/art-page-1/report.pdf', + sizeBytes: 100, + }, + { + artifactId: 'art-page-2', + kind: 'image', + title: 'Second Page Image', + path: 'artifacts/art-page-2/image.png', + sizeBytes: 200, + }, + ], + }); + }); + + it('returns an empty list for nullish core payloads', async () => { + vi.mocked(callCoreRpc).mockResolvedValueOnce(null); + const outcome = await listArtifactsForThread('thread-1'); + expect(outcome).toEqual({ ok: true, artifacts: [] }); + }); + + it('returns a failed outcome when the core RPC throws', async () => { + vi.mocked(callCoreRpc).mockRejectedValueOnce(new Error('rpc down')); + const outcome = await listArtifactsForThread('thread-1'); + expect(outcome).toEqual({ ok: false, artifacts: [], error: 'rpc down' }); + }); +}); + describe('downloadArtifact', () => { beforeEach(() => { vi.clearAllMocks(); diff --git a/app/src/services/artifactDownloadService.ts b/app/src/services/artifactDownloadService.ts index 538518c2c..c7b47b7f4 100644 --- a/app/src/services/artifactDownloadService.ts +++ b/app/src/services/artifactDownloadService.ts @@ -70,6 +70,22 @@ export interface DeleteArtifactOutcome { error?: string; } +export type ListedArtifactKind = 'presentation' | 'document' | 'image' | 'other'; + +export interface ListedThreadArtifact { + artifactId: string; + kind: ListedArtifactKind; + title: string; + path: string; + sizeBytes: number; +} + +export interface ListThreadArtifactsOutcome { + ok: boolean; + artifacts: ListedThreadArtifact[]; + error?: string; +} + /** * Shape of the `data` field returned by the * `openhuman.ai_get_artifact` JSON-RPC method. We pull only the @@ -89,6 +105,78 @@ interface ResolvedExport { } type ResolveExportResult = ResolvedExport | { ok: false; code: ArtifactErrorCode; error: string }; +interface AiListArtifactsData { + artifacts?: AiListArtifactMeta[]; +} + +interface AiListArtifactMeta { + id?: string; + kind?: string; + title?: string; + path?: string; + size_bytes?: number; + status?: string; +} + +function listedArtifactKind(raw: string | undefined): ListedArtifactKind | null { + switch (raw) { + case 'presentation': + case 'document': + case 'image': + case 'other': + return raw; + default: + return null; + } +} + +function readyListedArtifact(meta: AiListArtifactMeta): ListedThreadArtifact | null { + if (meta.status !== 'ready') return null; + const kind = listedArtifactKind(meta.kind); + if (!kind) return null; + if (!meta.id || !meta.title || !meta.path) return null; + if (typeof meta.size_bytes !== 'number' || !Number.isFinite(meta.size_bytes)) return null; + return { + artifactId: meta.id, + kind, + title: meta.title, + path: meta.path, + sizeBytes: meta.size_bytes, + }; +} + +export async function listArtifactsForThread( + threadId: string +): Promise { + const trimmedThreadId = threadId.trim(); + if (!trimmedThreadId) { + return { ok: false, artifacts: [], error: 'thread id missing' }; + } + try { + const limit = 200; + const artifacts: ListedThreadArtifact[] = []; + + for (let offset = 0; ; offset += limit) { + const raw = await callCoreRpc({ + method: 'openhuman.ai_list_artifacts', + params: { thread_id: trimmedThreadId, offset, limit }, + }); + const page = raw?.artifacts ?? []; + artifacts.push( + ...page + .map(readyListedArtifact) + .filter((artifact): artifact is ListedThreadArtifact => artifact !== null) + ); + if (page.length < limit) break; + } + + return { ok: true, artifacts }; + } catch (err) { + const reason = err instanceof Error ? err.message : String(err); + return { ok: false, artifacts: [], error: reason }; + } +} + /** * Resolve an artifact's absolute on-disk path (via `ai_get_artifact`) * and build the suggested filename. Shared by the Save-As and Downloads