fix: hydrate chat files from artifact ledger (#4232)

Co-authored-by: Sami Rusani <sr@samirusani>
Co-authored-by: M3gA-Mind <megamind@mahadao.com>
This commit is contained in:
Sam
2026-06-30 19:23:30 +05:30
committed by GitHub
co-authored by Sami Rusani M3gA-Mind
parent 06220ada5d
commit 57bac2a07a
4 changed files with 373 additions and 8 deletions
+44 -6
View File
@@ -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) {
</button>
{open && (
<ChatFilesPanel
threadId={threadId}
threadId={normalizedThreadId}
artifacts={readyArtifacts}
onClose={() => setOpen(false)}
/>
@@ -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(
<Provider store={store}>
<ChatFilesChip threadId={THREAD} />
</Provider>
);
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(
<Provider store={store}>
<ChatFilesChip threadId={` ${THREAD} `} />
</Provider>
);
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(
<Provider store={store}>
<ChatFilesChip threadId={THREAD} />
</Provider>
);
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(
@@ -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();
@@ -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<ListThreadArtifactsOutcome> {
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<AiListArtifactsData>({
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