Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 44 additions & 6 deletions app/src/components/chat/ChatFilesChip.tsx
Original file line number Diff line number Diff line change
@@ -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';

/**
Expand All @@ -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]
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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 (
Expand Down Expand Up @@ -67,7 +105,7 @@ export default function ChatFilesChip({ threadId }: ChatFilesChipProps) {
</button>
{open && (
<ChatFilesPanel
threadId={threadId}
threadId={normalizedThreadId}
artifacts={readyArtifacts}
onClose={() => setOpen(false)}
/>
Expand Down
105 changes: 103 additions & 2 deletions app/src/components/chat/__tests__/ChatFilesChip.test.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,23 @@
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,
upsertArtifactReadyForThread,
} 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() {
Expand All @@ -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(
Expand All @@ -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(
Expand Down
138 changes: 138 additions & 0 deletions app/src/services/__tests__/artifactDownloadService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
deleteArtifact,
downloadArtifact,
listArtifactsForThread,
revealArtifactInFileManager,
saveArtifactViaDialog,
} from '../artifactDownloadService';
Expand All @@ -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();
Expand Down
Loading
Loading