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
14 changes: 3 additions & 11 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -113,15 +113,8 @@ function App() {
[persistTurn],
);

const {
messages,
streamingContent,
ask,
cancel,
isGenerating,
reset,
loadMessages,
} = useOllama(handleTurnComplete);
const { messages, ask, cancel, isGenerating, reset, loadMessages } =
useOllama(handleTurnComplete);

const inputRef = useRef<HTMLTextAreaElement>(null);

Expand Down Expand Up @@ -196,7 +189,7 @@ function App() {
* complete response. We check for an assistant message rather than any message
* so the button never appears during the very first user-only half-turn.
*/
const canSave = messages.some((m) => m.role === 'assistant');
const canSave = !isGenerating && messages.some((m) => m.role === 'assistant');
const shouldRenderOverlay = overlayState === 'visible';

/**
Expand Down Expand Up @@ -1214,7 +1207,6 @@ function App() {
? [...messages, pendingUserMessage]
: messages
}
streamingContent={streamingContent}
isGenerating={isGenerating || isSubmitPending}
onClose={handleCloseOverlay}
onSave={handleSave}
Expand Down
2 changes: 1 addition & 1 deletion src/components/ChatBubble.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ export function ChatBubble({
<MarkdownRenderer content={content} isStreaming={isStreaming} />
)}
</div>
{!errorKind && (
{!errorKind && !isStreaming && (
<div className="h-6 flex items-center">
<CopyButton content={content} align="left" />
</div>
Expand Down
56 changes: 32 additions & 24 deletions src/hooks/__tests__/useOllama.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,20 +71,27 @@ describe('useOllama', () => {
});
});

it('adds user message immediately on ask', async () => {
it('adds user message and empty assistant placeholder immediately on ask', async () => {
const { result } = renderHook(() => useOllama());

await act(async () => {
await result.current.ask('my question');
});

expect(result.current.messages).toHaveLength(2);
expect(result.current.messages[0]).toEqual(
expect.objectContaining({
role: 'user',
content: 'my question',
}),
);
expect(result.current.messages[0].id).toEqual(expect.any(String));
expect(result.current.messages[1]).toEqual(
expect.objectContaining({
role: 'assistant',
content: '',
}),
);
});

it('stores quotedText on user message when provided', async () => {
Expand Down Expand Up @@ -119,7 +126,7 @@ describe('useOllama', () => {
);
});

it('accumulates streaming tokens into streamingContent', async () => {
it('accumulates streaming tokens into the assistant message', async () => {
const { result } = renderHook(() => useOllama());

await act(async () => {
Expand All @@ -134,10 +141,13 @@ describe('useOllama', () => {
channel!.simulateMessage({ type: 'Token', data: ', world' });
});

expect(result.current.streamingContent).toBe('Hello, world');
const assistantMsg = result.current.messages.find(
(m) => m.role === 'assistant',
);
expect(assistantMsg?.content).toBe('Hello, world');
});

it('moves content to messages on Done chunk', async () => {
it('keeps assistant message in place on Done chunk', async () => {
const { result } = renderHook(() => useOllama());

await act(async () => {
Expand All @@ -152,7 +162,6 @@ describe('useOllama', () => {
channel!.simulateMessage({ type: 'Done' });
});

expect(result.current.streamingContent).toBe('');
expect(result.current.isGenerating).toBe(false);
expect(result.current.messages).toContainEqual(
expect.objectContaining({
Expand Down Expand Up @@ -231,8 +240,8 @@ describe('useOllama', () => {
await result.current.ask('', undefined, ['/tmp/img1.jpg']);
});

// Should have created a user message (not returned early)
expect(result.current.messages).toHaveLength(1);
// Should have created a user message + assistant placeholder
expect(result.current.messages).toHaveLength(2);
expect(result.current.messages[0]).toEqual(
expect.objectContaining({
role: 'user',
Expand Down Expand Up @@ -352,7 +361,7 @@ describe('useOllama', () => {
expect(result.current.isGenerating).toBe(false);
});

it('Error chunk creates assistant message with errorKind set', async () => {
it('Error chunk updates assistant placeholder with errorKind', async () => {
const { result } = renderHook(() => useOllama());

await act(async () => {
Expand Down Expand Up @@ -381,7 +390,7 @@ describe('useOllama', () => {
);
});

it('Error chunk with partial tokens preserves prior content in separate message', async () => {
it('Error chunk with partial tokens replaces content with error', async () => {
const { result } = renderHook(() => useOllama());

await act(async () => {
Expand All @@ -399,7 +408,7 @@ describe('useOllama', () => {
});
});

// The error message should appear as its own assistant message with errorKind
// The error replaces the assistant placeholder content
const errorMsg = result.current.messages.find((m) => m.errorKind);
expect(errorMsg).toBeDefined();
expect(errorMsg?.errorKind).toBe('Other');
Expand All @@ -415,11 +424,11 @@ describe('useOllama', () => {
await result.current.ask('test');
});

const assistantMsg = result.current.messages.find(
(m) => m.role === 'assistant',
const errorMsg = result.current.messages.find(
(m) => m.errorKind === 'Other',
);
expect(assistantMsg?.errorKind).toBe('Other');
expect(assistantMsg?.content).toBeTruthy();
expect(errorMsg?.errorKind).toBe('Other');
expect(errorMsg?.content).toBeTruthy();
});
});

Expand All @@ -440,8 +449,11 @@ describe('useOllama', () => {
channel!.simulateMessage({ type: 'Token', data: '' });
});

// streamingContent should still be empty (no crash)
expect(result.current.streamingContent).toBe('');
// Assistant content should still be empty (no crash)
const assistantMsg = result.current.messages.find(
(m) => m.role === 'assistant',
);
expect(assistantMsg?.content).toBe('');
});
});

Expand Down Expand Up @@ -494,7 +506,7 @@ describe('useOllama', () => {
// ─── Cancelled chunk handling ───────────────────────────────────────────────

describe('Cancelled chunk', () => {
it('finalizes partial content as assistant message on Cancelled', async () => {
it('keeps partial content as assistant message on Cancelled', async () => {
const { result } = renderHook(() => useOllama());

await act(async () => {
Expand All @@ -510,7 +522,6 @@ describe('useOllama', () => {
channel!.simulateMessage({ type: 'Cancelled' });
});

expect(result.current.streamingContent).toBe('');
expect(result.current.isGenerating).toBe(false);
expect(result.current.messages).toContainEqual(
expect.objectContaining({
Expand All @@ -520,7 +531,7 @@ describe('useOllama', () => {
);
});

it('does not add empty assistant message when cancelled with no tokens', async () => {
it('removes assistant placeholder when cancelled with no tokens', async () => {
const { result } = renderHook(() => useOllama());

await act(async () => {
Expand All @@ -534,9 +545,8 @@ describe('useOllama', () => {
channel!.simulateMessage({ type: 'Cancelled' });
});

expect(result.current.streamingContent).toBe('');
expect(result.current.isGenerating).toBe(false);
// Only the user message should exist — no empty assistant message
// Only the user message should exist — empty assistant placeholder was removed
expect(result.current.messages).toHaveLength(1);
expect(result.current.messages[0].role).toBe('user');
});
Expand Down Expand Up @@ -566,7 +576,6 @@ describe('useOllama', () => {
});

expect(result.current.messages).toEqual([]);
expect(result.current.streamingContent).toBe('');
expect(result.current.isGenerating).toBe(false);
// Should also reset backend conversation history
expect(invoke).toHaveBeenCalledWith('reset_conversation');
Expand Down Expand Up @@ -663,7 +672,7 @@ describe('useOllama', () => {
expect(result.current.messages).toEqual(loaded);
});

it('clears streaming and generating state when loading messages', async () => {
it('clears generating state when loading messages', async () => {
invoke.mockRejectedValueOnce(new Error('boom'));
const { result } = renderHook(() => useOllama());

Expand All @@ -676,7 +685,6 @@ describe('useOllama', () => {
result.current.loadMessages([]);
});

expect(result.current.streamingContent).toBe('');
expect(result.current.isGenerating).toBe(false);
});
});
Expand Down
76 changes: 34 additions & 42 deletions src/hooks/useOllama.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,15 +43,14 @@ export function useOllama(
onTurnComplete?: (userMsg: Message, assistantMsg: Message) => void,
) {
const [messages, setMessages] = useState<Message[]>([]);
const [streamingContent, setStreamingContent] = useState('');
const [isGenerating, setIsGenerating] = useState(false);

/**
* Submits a message to the Ollama backend and initiates the streaming response.
* The backend manages conversation history — only the new user message is sent.
*
* Avoids continuous array copy operations during streaming by maintaining the streaming
* chunk state separately from the main messages state until generation finishes.
* Streams tokens directly into the messages array. An empty assistant placeholder
* is added immediately, then updated in-place on each token until generation finishes.
*
* @param displayContent The user's query as it should appear in the chat bubble.
* @param quotedText Optional selected text quoted alongside this message.
Expand All @@ -78,57 +77,54 @@ export function useOllama(
imagePaths && imagePaths.length > 0 ? imagePaths : undefined,
};

setMessages((prev) => [...prev, userMsg]);
setStreamingContent('');
const assistantId = crypto.randomUUID();
const assistantMsg: Message = {
id: assistantId,
role: 'assistant',
content: '',
};

setMessages((prev) => [...prev, userMsg, assistantMsg]);
setIsGenerating(true);

const channel = new Channel<StreamChunk>();
// Use block-scoped variable to accumulate the stream and occasionally flush to React state,
// mitigating rendering lag from hundreds of fast chunk events.
let currentContent = '';

channel.onmessage = (chunk) => {
if (chunk.type === 'Token') {
currentContent += chunk.data;
setStreamingContent(currentContent);
setMessages((prev) =>
prev.map((m) =>
m.id === assistantId ? { ...m, content: currentContent } : m,
),
);
} else if (chunk.type === 'Done') {
const assistantMsg: Message = {
id: crypto.randomUUID(),
role: 'assistant',
content: currentContent,
};
setMessages((prev) => [...prev, assistantMsg]);
setStreamingContent('');
setIsGenerating(false);
// Notify the caller that a complete turn has finished so it can
// persist both messages to SQLite if the conversation is saved.
onTurnComplete?.(userMsg, assistantMsg);
onTurnComplete?.(userMsg, {
...assistantMsg,
content: currentContent,
});
} else if (chunk.type === 'Cancelled') {
// Finalize partial content as a complete message so the user
// retains everything generated before they hit stop.
if (currentContent) {
setMessages((prev) => [
...prev,
{
id: crypto.randomUUID(),
role: 'assistant',
content: currentContent,
},
]);
// Remove the empty assistant placeholder if nothing was generated.
if (!currentContent) {
setMessages((prev) => prev.filter((m) => m.id !== assistantId));
}
setStreamingContent('');
setIsGenerating(false);
} else {
setMessages((prev) => [
...prev,
{
id: crypto.randomUUID(),
role: 'assistant',
content: chunk.data.message,
errorKind: chunk.data.kind,
},
]);
setStreamingContent('');
// Replace the streaming placeholder with an error message.
setMessages((prev) =>
prev.map((m) =>
m.id === assistantId
? {
...m,
content: chunk.data.message,
errorKind: chunk.data.kind,
}
: m,
),
);
setIsGenerating(false);
}
};
Expand All @@ -150,7 +146,6 @@ export function useOllama(
errorKind: 'Other' as const,
},
]);
setStreamingContent('');
setIsGenerating(false);
}
},
Expand All @@ -166,7 +161,6 @@ export function useOllama(
/** Resets all conversation state to prepare for a fresh session. */
const reset = useCallback(() => {
setMessages([]);
setStreamingContent('');
setIsGenerating(false);
void invoke('reset_conversation');
}, []);
Expand All @@ -182,13 +176,11 @@ export function useOllama(
*/
const loadMessages = useCallback((msgs: Message[]) => {
setMessages(msgs);
setStreamingContent('');
setIsGenerating(false);
}, []);

return {
messages,
streamingContent,
ask,
cancel,
isGenerating,
Expand Down
Loading