diff --git a/packages/iios-messaging-ui/src/hooks/use-conversations.test.tsx b/packages/iios-messaging-ui/src/hooks/use-conversations.test.tsx
new file mode 100644
index 0000000..0970680
--- /dev/null
+++ b/packages/iios-messaging-ui/src/hooks/use-conversations.test.tsx
@@ -0,0 +1,49 @@
+import { describe, it, expect, vi } from 'vitest';
+import { renderHook, waitFor, act } from '@testing-library/react';
+import type { ReactNode } from 'react';
+import { MessagingProvider } from '../provider';
+import { MockAdapter } from '../adapters/mock';
+import { useConversations } from './use-conversations';
+import type { MessagingAdapter } from '../adapter';
+
+const wrap = (adapter: MessagingAdapter) =>
+ function Wrapper({ children }: { children: ReactNode }) {
+ return {children};
+ };
+
+describe('useConversations', () => {
+ it('starts loading, then resolves the adapter list', async () => {
+ const { result } = renderHook(() => useConversations(), { wrapper: wrap(new MockAdapter()) });
+ expect(result.current.loading).toBe(true);
+
+ await waitFor(() => expect(result.current.loading).toBe(false));
+ expect(result.current.conversations).toHaveLength(2);
+ expect(result.current.conversations[0]!.threadId).toBe('th_mock_1');
+ expect(result.current.error).toBeNull();
+ });
+
+ it('surfaces adapter failure as error state and never throws', async () => {
+ const adapter = new MockAdapter();
+ vi.spyOn(adapter, 'listConversations').mockRejectedValue(new Error('data door down'));
+
+ const { result } = renderHook(() => useConversations(), { wrapper: wrap(adapter) });
+ await waitFor(() => expect(result.current.loading).toBe(false));
+ expect(result.current.error).toBe('data door down');
+ expect(result.current.conversations).toEqual([]);
+ });
+
+ it('refetch picks up newly opened threads', async () => {
+ const adapter = new MockAdapter();
+ const { result } = renderHook(() => useConversations(), { wrapper: wrap(adapter) });
+ await waitFor(() => expect(result.current.conversations).toHaveLength(2));
+
+ await act(async () => {
+ await adapter.openThread({ participantIds: ['pp_dan'] });
+ });
+ await act(async () => {
+ result.current.refetch();
+ });
+
+ await waitFor(() => expect(result.current.conversations).toHaveLength(3));
+ });
+});
diff --git a/packages/iios-messaging-ui/src/hooks/use-conversations.ts b/packages/iios-messaging-ui/src/hooks/use-conversations.ts
new file mode 100644
index 0000000..f8507eb
--- /dev/null
+++ b/packages/iios-messaging-ui/src/hooks/use-conversations.ts
@@ -0,0 +1,45 @@
+import { useCallback, useEffect, useState } from 'react';
+import { useAdapter } from '../provider';
+import type { Conversation } from '../types';
+
+export interface ConversationsState {
+ conversations: Conversation[];
+ loading: boolean;
+ error: string | null;
+ refetch: () => void;
+}
+
+export function useConversations(): ConversationsState {
+ const adapter = useAdapter();
+ const [conversations, setConversations] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [nonce, setNonce] = useState(0);
+
+ useEffect(() => {
+ let alive = true;
+ setLoading(true);
+ adapter
+ .listConversations()
+ .then((list) => {
+ if (!alive) return;
+ setConversations(list);
+ setError(null);
+ })
+ .catch((e: unknown) => {
+ if (!alive) return;
+ setError(e instanceof Error ? e.message : String(e));
+ setConversations([]);
+ })
+ .finally(() => {
+ if (alive) setLoading(false);
+ });
+ return () => {
+ alive = false;
+ };
+ }, [adapter, nonce]);
+
+ const refetch = useCallback(() => setNonce((n) => n + 1), []);
+
+ return { conversations, loading, error, refetch };
+}