diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml
index 22090bf2..3450e62c 100644
--- a/.github/workflows/docker-publish.yml
+++ b/.github/workflows/docker-publish.yml
@@ -7,6 +7,11 @@ on:
description: 'Tag to build and publish'
required: false
type: string
+ skip_ci:
+ description: 'Skip CI'
+ required: false
+ type: boolean
+ default: false
workflow_dispatch:
inputs:
tag:
@@ -20,7 +25,7 @@ env:
jobs:
ci:
- if: github.event_name != 'workflow_call'
+ if: github.event_name != 'workflow_call' && inputs.skip_ci != true
uses: ./.github/workflows/ci.yml
permissions:
contents: read
diff --git a/.github/workflows/electron-release.yml b/.github/workflows/electron-release.yml
index dadd6413..e597319d 100644
--- a/.github/workflows/electron-release.yml
+++ b/.github/workflows/electron-release.yml
@@ -7,6 +7,11 @@ on:
description: 'Tag to build and publish'
required: false
type: string
+ skip_ci:
+ description: 'Skip CI'
+ required: false
+ type: boolean
+ default: false
workflow_dispatch:
inputs:
tag:
@@ -19,7 +24,7 @@ permissions:
jobs:
ci:
- if: github.event_name != 'workflow_call'
+ if: github.event_name != 'workflow_call' && inputs.skip_ci != true
uses: ./.github/workflows/ci.yml
build:
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 12a481b7..b2d3e8df 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -22,6 +22,7 @@ jobs:
uses: ./.github/workflows/docker-publish.yml
with:
tag: ${{ inputs.tag }}
+ skip_ci: true
secrets: inherit
permissions:
contents: read
@@ -32,6 +33,7 @@ jobs:
uses: ./.github/workflows/electron-release.yml
with:
tag: ${{ inputs.tag }}
+ skip_ci: true
secrets: inherit
permissions:
contents: write
diff --git a/.husky/pre-commit b/.husky/pre-commit
new file mode 100644
index 00000000..2312dc58
--- /dev/null
+++ b/.husky/pre-commit
@@ -0,0 +1 @@
+npx lint-staged
diff --git a/backend/Dockerfile b/backend/Dockerfile
index bebc0d23..e8b4d1ff 100644
--- a/backend/Dockerfile
+++ b/backend/Dockerfile
@@ -28,6 +28,7 @@ COPY --chown=node:node package*.json ./
RUN apt-get update && apt-get install -y --no-install-recommends \
gosu \
libsqlite3-0 \
+ sqlite3 \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
diff --git a/backend/__tests__/conversations.test.js b/backend/__tests__/conversations.test.js
index 9d4aec1a..f5451e3d 100644
--- a/backend/__tests__/conversations.test.js
+++ b/backend/__tests__/conversations.test.js
@@ -170,6 +170,23 @@ describe('GET /v1/conversations', () => {
assert.equal(items[1].id, 'c2');
assert.equal(items[1].provider_id, 'p2');
});
+
+ test('excludes linked comparison conversations from list results', async () => {
+ createConversation({ id: 'parent', sessionId, userId: testUser.id, title: 'parent' });
+ createConversation({
+ id: 'linked',
+ sessionId,
+ userId: testUser.id,
+ title: 'linked',
+ parentConversationId: 'parent',
+ });
+ createConversation({ id: 'standalone', sessionId, userId: testUser.id, title: 'standalone' });
+
+ const app = makeApp();
+ const res = await request(app).get('/v1/conversations').set('x-session-id', sessionId);
+ const ids = res.body.items.map((item) => item.id).sort();
+ assert.deepEqual(ids, ['parent', 'standalone']);
+ });
});
// --- GET /v1/conversations/:id ---
@@ -235,6 +252,76 @@ describe('GET /v1/conversations/:id', () => {
});
});
+// --- GET /v1/conversations/:id/linked ---
+describe('GET /v1/conversations/:id/linked', () => {
+ test('returns linked comparison conversations for the parent', async () => {
+ createConversation({ id: 'parent', sessionId, userId: testUser.id, title: 'parent' });
+ createConversation({
+ id: 'child-1',
+ sessionId,
+ userId: testUser.id,
+ title: 'child 1',
+ parentConversationId: 'parent',
+ });
+ createConversation({
+ id: 'child-2',
+ sessionId,
+ userId: testUser.id,
+ title: 'child 2',
+ parentConversationId: 'parent',
+ });
+
+ const app = makeApp();
+ const res = await request(app)
+ .get('/v1/conversations/parent/linked')
+ .set('x-session-id', sessionId);
+
+ assert.equal(res.status, 200);
+ const ids = res.body.conversations.map((item) => item.id).sort();
+ assert.deepEqual(ids, ['child-1', 'child-2']);
+ });
+
+ test('returns 404 when parent conversation is not owned by the user', async () => {
+ const otherUser = createUser({
+ email: 'other@example.com',
+ passwordHash: 'pw',
+ displayName: 'Other User',
+ });
+ createConversation({ id: 'other-parent', sessionId, userId: otherUser.id, title: 'other' });
+
+ const app = makeApp();
+ const res = await request(app)
+ .get('/v1/conversations/other-parent/linked')
+ .set('x-session-id', sessionId);
+
+ assert.equal(res.status, 404);
+ });
+});
+
+// --- DELETE /v1/conversations/:id ---
+describe('DELETE /v1/conversations/:id', () => {
+ test('soft deletes linked comparison conversations with the parent', async () => {
+ createConversation({ id: 'parent', sessionId, userId: testUser.id, title: 'parent' });
+ createConversation({
+ id: 'linked',
+ sessionId,
+ userId: testUser.id,
+ title: 'linked',
+ parentConversationId: 'parent',
+ });
+
+ const app = makeApp();
+ const res = await request(app).delete('/v1/conversations/parent').set('x-session-id', sessionId);
+ assert.equal(res.status, 204);
+
+ const db = getDb();
+ const parent = db.prepare('SELECT deleted_at FROM conversations WHERE id = ?').get('parent');
+ const linked = db.prepare('SELECT deleted_at FROM conversations WHERE id = ?').get('linked');
+ assert.ok(parent.deleted_at);
+ assert.ok(linked.deleted_at);
+ });
+});
+
// --- DELETE /v1/conversations/:id ---
describe('DELETE /v1/conversations/:id', () => {
test('soft deletes an existing conversation and returns 204', async () => {
diff --git a/backend/package.json b/backend/package.json
index ca0242ba..38dba73f 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -11,6 +11,7 @@
"start": "NODE_ENV=production node src/index.js",
"test": "NODE_OPTIONS=--experimental-vm-modules jest",
"lint": "eslint .",
+ "lint:fix": "eslint . --fix",
"format": "prettier --write .",
"migrate": "node scripts/migrate.js",
"backfill:message-events": "node scripts/backfill-message-events.js"
diff --git a/backend/src/db/conversations.js b/backend/src/db/conversations.js
index c5a50b29..928427d2 100644
--- a/backend/src/db/conversations.js
+++ b/backend/src/db/conversations.js
@@ -15,12 +15,13 @@ export function createConversation({
reasoningEffort = null,
verbosity = null,
metadata = {},
+ parentConversationId = null,
}) {
const db = getDb();
const now = new Date().toISOString();
db.prepare(
- `INSERT INTO conversations (id, session_id, user_id, title, provider_id, model, metadata, streaming_enabled, tools_enabled, quality_level, reasoning_effort, verbosity, created_at, updated_at)
- VALUES (@id, @session_id, @user_id, @title, @provider_id, @model, @metadata, @streaming_enabled, @tools_enabled, @quality_level, @reasoning_effort, @verbosity, @now, @now)`
+ `INSERT INTO conversations (id, session_id, user_id, title, provider_id, model, metadata, streaming_enabled, tools_enabled, quality_level, reasoning_effort, verbosity, parent_conversation_id, created_at, updated_at)
+ VALUES (@id, @session_id, @user_id, @title, @provider_id, @model, @metadata, @streaming_enabled, @tools_enabled, @quality_level, @reasoning_effort, @verbosity, @parent_conversation_id, @now, @now)`
).run({
id,
session_id: sessionId,
@@ -34,6 +35,7 @@ export function createConversation({
quality_level: qualityLevel,
reasoning_effort: reasoningEffort,
verbosity,
+ parent_conversation_id: parentConversationId || null,
now,
});
}
@@ -44,7 +46,7 @@ export function getConversationById({ id, userId }) {
}
const db = getDb();
- const query = `SELECT id, title, provider_id, model, metadata, streaming_enabled, tools_enabled, quality_level, reasoning_effort, verbosity, created_at FROM conversations
+ const query = `SELECT id, title, provider_id, model, metadata, streaming_enabled, tools_enabled, quality_level, reasoning_effort, verbosity, parent_conversation_id, created_at FROM conversations
WHERE id=@id AND user_id=@user_id AND deleted_at IS NULL`;
const result = db.prepare(query).get({ id, user_id: userId });
@@ -190,8 +192,9 @@ export function listConversations({ userId, cursor, limit }) {
const safeLimit = clampLimit(limit, { fallback: 20, min: 1, max: 100 });
const { cursorCreatedAt, cursorId } = parseCreatedAtCursor(cursor);
+ // Exclude linked/comparison conversations (those with parent_conversation_id)
let sql = `SELECT id, title, provider_id, model, created_at FROM conversations
- WHERE user_id=@userId AND deleted_at IS NULL`;
+ WHERE user_id=@userId AND deleted_at IS NULL AND parent_conversation_id IS NULL`;
const params = { userId, cursorCreatedAt, cursorId, limit: safeLimit + 1 };
sql = appendCreatedAtCursor(sql, { cursorCreatedAt, cursorId });
@@ -204,6 +207,25 @@ export function listConversations({ userId, cursor, limit }) {
return { items, next_cursor };
}
+/**
+ * Get linked/comparison conversations for a parent conversation
+ * @param {string} parentId - Parent conversation ID
+ * @param {string} userId - User ID
+ * @returns {Array} Array of linked conversation metadata
+ */
+export function getLinkedConversations({ parentId, userId }) {
+ if (!userId) {
+ throw new Error('userId is required');
+ }
+
+ const db = getDb();
+ const query = `SELECT id, title, provider_id, model, created_at, updated_at FROM conversations
+ WHERE parent_conversation_id=@parentId AND user_id=@userId AND deleted_at IS NULL
+ ORDER BY datetime(created_at) ASC`;
+
+ return db.prepare(query).all({ parentId, userId });
+}
+
export function softDeleteConversation({ id, userId }) {
if (!userId) {
throw new Error('userId is required');
@@ -211,6 +233,14 @@ export function softDeleteConversation({ id, userId }) {
const db = getDb();
const now = new Date().toISOString();
+
+ // Also soft-delete any linked comparison conversations
+ db.prepare(
+ `UPDATE conversations SET deleted_at=@now, updated_at=@now
+ WHERE parent_conversation_id=@id AND user_id=@userId AND deleted_at IS NULL`
+ ).run({ id, userId, now });
+
+ // Delete the parent conversation
const query = `UPDATE conversations SET deleted_at=@now, updated_at=@now WHERE id=@id AND user_id=@userId AND deleted_at IS NULL`;
const info = db.prepare(query).run({ id, userId, now });
return info.changes > 0;
diff --git a/backend/src/db/migrations/023-parent-conversation-id.js b/backend/src/db/migrations/023-parent-conversation-id.js
new file mode 100644
index 00000000..7833af33
--- /dev/null
+++ b/backend/src/db/migrations/023-parent-conversation-id.js
@@ -0,0 +1,22 @@
+/**
+ * Migration: Add parent_conversation_id column for linked comparison conversations
+ *
+ * This enables storing comparison/secondary model responses as separate conversations
+ * that are linked to a primary conversation. Conversations with a non-null parent_conversation_id
+ * are excluded from the main conversation list.
+ */
+
+export default {
+ version: 23,
+ up: `
+ -- Add parent_conversation_id column for linked comparison conversations
+ ALTER TABLE conversations ADD COLUMN parent_conversation_id TEXT DEFAULT NULL;
+
+ -- Create index for efficient lookups of child conversations
+ CREATE INDEX IF NOT EXISTS idx_conversations_parent_id ON conversations(parent_conversation_id);
+ `,
+ down: `
+ -- Note: SQLite doesn't support DROP COLUMN, so the column will remain but be unused
+ DROP INDEX IF EXISTS idx_conversations_parent_id;
+ `
+};
diff --git a/backend/src/lib/openaiProxy.js b/backend/src/lib/openaiProxy.js
index 271927e3..251b2a81 100644
--- a/backend/src/lib/openaiProxy.js
+++ b/backend/src/lib/openaiProxy.js
@@ -337,6 +337,24 @@ function handleProxyError(error, req, res, persistence) {
async function handleRequest(context, req, res) {
const { body, bodyIn, flags, provider, providerId, persistence, userId, abortContext } = context;
+ if (bodyIn?.parent_conversation_id && Array.isArray(bodyIn.messages)) {
+ const summarized = bodyIn.messages.map((msg) => ({
+ role: msg?.role,
+ id: msg?.id,
+ contentLen:
+ typeof msg?.content === 'string'
+ ? msg.content.length
+ : Array.isArray(msg?.content)
+ ? msg.content.length
+ : 0,
+ }));
+ logger.debug('[openaiProxy] comparison request message history', {
+ parentConversationId: bodyIn.parent_conversation_id,
+ count: summarized.length,
+ summary: summarized,
+ });
+ }
+
if (flags.hasTools) {
// Tool orchestration path
if (flags.streamToFrontend) {
diff --git a/backend/src/lib/persistence/ConversationManager.js b/backend/src/lib/persistence/ConversationManager.js
index 08f48257..bd0886e9 100644
--- a/backend/src/lib/persistence/ConversationManager.js
+++ b/backend/src/lib/persistence/ConversationManager.js
@@ -81,7 +81,8 @@ export class ConversationManager {
qualityLevel: params.qualityLevel || null,
reasoningEffort: params.reasoningEffort || null,
verbosity: params.verbosity || null,
- metadata: params.metadata || {}
+ metadata: params.metadata || {},
+ parentConversationId: params.parentConversationId || null,
});
return conversationId;
diff --git a/backend/src/lib/retryUtils.js b/backend/src/lib/retryUtils.js
index 3cd000ba..36249e9e 100644
--- a/backend/src/lib/retryUtils.js
+++ b/backend/src/lib/retryUtils.js
@@ -20,7 +20,7 @@ const DEFAULT_RETRY_CONFIG = {
maxDelayMs: 60000,
backoffMultiplier: 2,
jitterFactor: 0.1,
- shouldRetry: (error, attempt) => {
+ shouldRetry: (error, _attempt) => {
// Only retry on 429 (rate limit) and 5xx server errors
if (error?.status === 429) return true;
if (error?.status >= 500 && error?.status < 600) return true;
@@ -99,7 +99,7 @@ export async function retryWithBackoff(fn, userConfig = {}) {
try {
const clonedResponse = result.clone();
errorBody = await clonedResponse.text();
- } catch (e) {
+ } catch (_e) {
errorBody = 'Could not read error body';
}
diff --git a/backend/src/lib/simplifiedPersistence.js b/backend/src/lib/simplifiedPersistence.js
index 5c6d9bd0..bdd1db7c 100644
--- a/backend/src/lib/simplifiedPersistence.js
+++ b/backend/src/lib/simplifiedPersistence.js
@@ -157,10 +157,13 @@ export class SimplifiedPersistence {
// Create new conversation if needed
if (isNewConversation) {
const settings = await this.persistenceConfig.extractRequestSettingsAsync(bodyIn, userId);
+ // Support linked comparison conversations via parent_conversation_id
+ const parentConversationId = bodyIn.parent_conversation_id || null;
conversationId = this.conversationManager.createNewConversation({
sessionId,
userId,
providerId: this.providerId,
+ parentConversationId,
...settings
});
convo = this.conversationManager.getConversation(conversationId, userId);
@@ -176,7 +179,23 @@ export class SimplifiedPersistence {
* @private
*/
async _processMessageHistory(sessionId, userId, bodyIn, isNewConversation) {
- const messages = this.persistenceConfig.filterNonSystemMessages(bodyIn.messages || []);
+ let messages = this.persistenceConfig.filterNonSystemMessages(bodyIn.messages || []);
+ const emptyAssistantMessages = messages.filter(
+ (msg) =>
+ msg?.role === 'assistant' &&
+ (msg.content === '' || (Array.isArray(msg.content) && msg.content.length === 0)) &&
+ (!Array.isArray(msg.tool_calls) || msg.tool_calls.length === 0) &&
+ (!Array.isArray(msg.tool_outputs) || msg.tool_outputs.length === 0)
+ );
+ if (emptyAssistantMessages.length > 0) {
+ logger.debug('[SimplifiedPersistence] Dropping empty assistant messages from client history', {
+ conversationId: this.conversationId,
+ parentConversationId: bodyIn?.parent_conversation_id ?? null,
+ count: emptyAssistantMessages.length,
+ ids: emptyAssistantMessages.map((msg) => msg?.id).filter(Boolean),
+ });
+ messages = messages.filter((msg) => !emptyAssistantMessages.includes(msg));
+ }
const maxSeq = messages
.map(msg => msg.seq)
.filter(seq => typeof seq === 'number' && seq > 0)
@@ -192,9 +211,10 @@ export class SimplifiedPersistence {
const latestUserMapping = [...this._latestSyncMappings].reverse().find(mapping => mapping.role === 'user');
this.userMessageId = latestUserMapping?.persistedId != null ? String(latestUserMapping.persistedId) : null;
- // Generate title only if this is the first message in a new conversation
- // Fire-and-forget to avoid blocking the response
- if (isNewConversation) {
+ // Generate title when the conversation is new or still lacks a title.
+ // Fire-and-forget to avoid blocking the response.
+ const needsTitle = isNewConversation || !this.conversationMeta?.title;
+ if (needsTitle) {
const lastUser = ConversationTitleService.findLastUserMessage(messages);
if (lastUser) {
// Extract the model being used for the chat to use the same model for title generation
diff --git a/backend/src/lib/streamUtils.js b/backend/src/lib/streamUtils.js
index f0da9d19..fad14f40 100644
--- a/backend/src/lib/streamUtils.js
+++ b/backend/src/lib/streamUtils.js
@@ -158,7 +158,7 @@ export function teeStreamWithPreview(response, options = {}) {
try {
out.write(chunk);
capture.write(chunk);
- } catch (e) {
+ } catch (_e) {
// best-effort capture; ignore
}
});
@@ -167,7 +167,7 @@ export function teeStreamWithPreview(response, options = {}) {
try {
out.end();
capture.end();
- } catch (e) {
+ } catch (_e) {
// ignore
}
});
@@ -176,7 +176,7 @@ export function teeStreamWithPreview(response, options = {}) {
try {
out.destroy(err);
capture.destroy(err);
- } catch (e) {
+ } catch (_e) {
// ignore
}
});
diff --git a/backend/src/lib/toolOrchestrationUtils.js b/backend/src/lib/toolOrchestrationUtils.js
index 2d70a17c..c42b5502 100644
--- a/backend/src/lib/toolOrchestrationUtils.js
+++ b/backend/src/lib/toolOrchestrationUtils.js
@@ -747,7 +747,7 @@ export async function executeToolCallsParallel(toolCalls, userId = null, options
let pLimit;
try {
pLimit = (await import('p-limit')).default;
- } catch (err) {
+ } catch (_err) {
// If p-limit is not available, fall back to Promise.allSettled (unbounded)
pLimit = null;
}
@@ -767,7 +767,7 @@ export async function executeToolCallsParallel(toolCalls, userId = null, options
const out = { ...result, tool_call_id: call.id, index, duration_ms: duration, status: 'success' };
try {
if (typeof onToolComplete === 'function') onToolComplete(out, index);
- } catch (e) {
+ } catch (_e) {
// Swallow callback errors
}
return out;
@@ -783,7 +783,7 @@ export async function executeToolCallsParallel(toolCalls, userId = null, options
};
try {
if (typeof onToolComplete === 'function') onToolComplete(out, index);
- } catch (e) {}
+ } catch (_e) {}
return out;
}
};
diff --git a/backend/src/middleware/exceptionHandler.js b/backend/src/middleware/exceptionHandler.js
index 8076f4bd..9c0d4f72 100644
--- a/backend/src/middleware/exceptionHandler.js
+++ b/backend/src/middleware/exceptionHandler.js
@@ -1,6 +1,6 @@
import { logger } from '../logger.js';
-export function exceptionHandler(err, req, res, next) {
+export function exceptionHandler(err, req, res, _next) {
const statusCode = err.statusCode || 500;
const message = err.message || 'Internal Server Error';
diff --git a/backend/src/routes/conversations.js b/backend/src/routes/conversations.js
index 976d282b..3388769d 100644
--- a/backend/src/routes/conversations.js
+++ b/backend/src/routes/conversations.js
@@ -12,6 +12,7 @@ import {
softDeleteConversation,
listConversationsIncludingDeleted,
forkConversationFromMessage,
+ getLinkedConversations,
} from '../db/conversations.js';
import {
getMessagesPage,
@@ -206,6 +207,25 @@ conversationsRouter.delete('/v1/conversations/:id', (req, res) => {
}
});
+// GET /v1/conversations/:id/linked (get linked comparison conversations)
+conversationsRouter.get('/v1/conversations/:id/linked', (req, res) => {
+ if (!config.persistence.enabled) return notImplemented(res);
+ try {
+ const userId = req.user.id; // Guaranteed by authenticateToken middleware
+
+ getDb();
+ // First verify the parent conversation exists and belongs to the user
+ const parentConvo = getConversationById({ id: req.params.id, userId });
+ if (!parentConvo) return res.status(404).json({ error: 'not_found' });
+
+ const linkedConversations = getLinkedConversations({ parentId: req.params.id, userId });
+ return res.json({ conversations: linkedConversations });
+ } catch (e) {
+ logger.error('[conversations] get linked error', e);
+ return res.status(500).json({ error: 'internal_error' });
+ }
+});
+
// PUT /v1/conversations/:id/messages/:messageId/edit (edit message and fork conversation)
conversationsRouter.put('/v1/conversations/:id/messages/:messageId/edit', (req, res) => {
if (!config.persistence.enabled) return notImplemented(res);
diff --git a/frontend/__tests__/hooks.useChat.test.tsx b/frontend/__tests__/hooks.useChat.test.tsx
index c267823d..1c835273 100644
--- a/frontend/__tests__/hooks.useChat.test.tsx
+++ b/frontend/__tests__/hooks.useChat.test.tsx
@@ -1,7 +1,7 @@
import { renderHook, act, waitFor } from '@testing-library/react';
import type { ChatOptionsExtended } from '../lib/types';
import { useChat } from '../hooks/useChat';
-import { APIError } from '../lib/streaming';
+import { APIError, StreamingNotSupportedError } from '../lib/streaming';
jest.mock('../lib/api', () => ({
conversations: {
@@ -187,6 +187,7 @@ describe('useChat hook', () => {
});
test('sendMessage streams tokens, tool events, and finalizes assistant message', async () => {
+ // chat.sendMessage receives unqualified model IDs (without provider prefix)
mockChat.sendMessage.mockImplementation(async (options: ChatOptionsExtended) => {
options.onToken?.('Hello');
options.onEvent?.({
@@ -586,4 +587,199 @@ describe('useChat hook', () => {
expect(result.current.modelToProvider['provider1::model-1']).toBe('provider1');
expect(result.current.modelToProvider['provider2::model-2']).toBe('provider2');
});
+
+ test('normalizes comparison models using provider mapping', async () => {
+ const { result } = renderUseChat();
+
+ await waitFor(() => expect(result.current.modelOptions.length).toBeGreaterThan(0));
+
+ act(() => {
+ result.current.setCompareModels(['gpt-4o', 'openai::gpt-4o']);
+ });
+
+ await waitFor(() => {
+ expect(result.current.compareModels).toEqual(['openai::gpt-4o']);
+ });
+ });
+
+ test('sendMessage executes comparison models and stores their results', async () => {
+ const now = new Date().toISOString();
+ mockConversations.create.mockResolvedValue({
+ id: 'conv-primary',
+ title: 'Primary',
+ created_at: now,
+ });
+
+ mockChat.sendMessage.mockImplementation(async (options: ChatOptionsExtended) => {
+ options.onToken?.('X');
+ const convId = options.conversationId || `conv-${options.model}`;
+ return {
+ content: `reply-${options.model}`,
+ conversation: {
+ id: convId,
+ title: `title-${options.model}`,
+ created_at: now,
+ },
+ };
+ });
+
+ const { result } = renderUseChat();
+
+ await waitFor(() => expect(result.current.providerId).toBe('openai'));
+
+ act(() => {
+ result.current.setModel('openai::gpt-4o');
+ result.current.setCompareModels(['openai::gpt-4o-mini']);
+ result.current.setInput('Compare models');
+ });
+
+ await act(async () => {
+ await result.current.sendMessage();
+ });
+
+ expect(mockConversations.create).toHaveBeenCalled();
+ expect(mockChat.sendMessage).toHaveBeenCalledTimes(2);
+ expect(mockChat.sendMessage).toHaveBeenCalledWith(expect.objectContaining({ model: 'gpt-4o' }));
+ expect(mockChat.sendMessage).toHaveBeenCalledWith(
+ expect.objectContaining({ model: 'gpt-4o-mini' })
+ );
+
+ const assistantMessage = result.current.messages[1];
+ expect(assistantMessage.comparisonResults?.['openai::gpt-4o-mini']).toMatchObject({
+ content: 'reply-gpt-4o-mini',
+ status: 'complete',
+ });
+ expect(result.current.linkedConversations['openai::gpt-4o-mini']).toBe('conv-gpt-4o-mini');
+ });
+
+ test('restores saved draft on mount after user profile loads', async () => {
+ jest.useFakeTimers();
+ const draftKey = 'chatforge_draft_user-123_new';
+ window.localStorage.setItem(draftKey, 'Saved draft text');
+
+ const { result } = renderUseChat();
+
+ await waitFor(() => expect(result.current.user?.id).toBe('user-123'));
+
+ await act(async () => {
+ jest.advanceTimersByTime(150);
+ });
+
+ expect(result.current.input).toBe('Saved draft text');
+ jest.useRealTimers();
+ });
+
+ test('debounces draft saving while typing', async () => {
+ jest.useFakeTimers();
+ const draftKey = 'chatforge_draft_user-123_new';
+ const { result } = renderUseChat();
+
+ await waitFor(() => expect(result.current.user?.id).toBe('user-123'));
+
+ act(() => {
+ result.current.setInput('Draft to persist');
+ });
+
+ await act(async () => {
+ jest.advanceTimersByTime(1000);
+ });
+
+ expect(window.localStorage.getItem(draftKey)).toBe('Draft to persist');
+ jest.useRealTimers();
+ });
+
+ test('sendMessage retries without provider streaming when unsupported', async () => {
+ jest.useFakeTimers();
+ const now = new Date().toISOString();
+
+ mockChat.sendMessage
+ .mockRejectedValueOnce(new StreamingNotSupportedError('Streaming not supported'))
+ .mockResolvedValueOnce({
+ content: 'Retried response',
+ conversation: { id: 'conv-retry', title: 'Retry', created_at: now },
+ });
+
+ const { result } = renderUseChat();
+
+ await act(async () => {
+ await result.current.sendMessage('Hello');
+ });
+
+ await act(async () => {
+ jest.runOnlyPendingTimers();
+ });
+
+ await waitFor(() => expect(mockChat.sendMessage).toHaveBeenCalledTimes(2));
+ expect(mockChat.sendMessage.mock.calls[1][0].providerStream).toBe(false);
+ await waitFor(() => expect(result.current.status).toBe('idle'));
+ expect(result.current.messages).toHaveLength(2);
+ jest.useRealTimers();
+ });
+
+ test('sendMessage includes files and images in the outgoing payload', async () => {
+ const now = new Date().toISOString();
+ mockChat.sendMessage.mockResolvedValue({
+ content: 'ok',
+ conversation: { id: 'conv-files', title: 'Files', created_at: now },
+ });
+
+ const { result } = renderUseChat();
+
+ act(() => {
+ result.current.setInput('Please review the files');
+ result.current.setFiles([{ name: 'example.ts', content: 'console.log(1);' }]);
+ result.current.setImages([
+ { url: 'http://example.com/image.png', downloadUrl: 'http://cdn/image.png' },
+ ]);
+ });
+
+ await act(async () => {
+ await result.current.sendMessage();
+ });
+
+ const payload = mockChat.sendMessage.mock.calls[0][0];
+ const userMessage = payload.messages[0];
+ expect(Array.isArray(userMessage.content)).toBe(true);
+ expect(userMessage.content[0]).toMatchObject({
+ type: 'text',
+ });
+ expect(userMessage.content[0].text).toContain('File: example.ts');
+ expect(userMessage.content[0].text).toContain('```typescript');
+ expect(userMessage.content[0].text).toContain('Please review the files');
+ expect(userMessage.content[1]).toEqual({
+ type: 'image_url',
+ image_url: { url: 'http://cdn/image.png' },
+ });
+ expect(result.current.input).toBe('');
+ expect(result.current.files).toEqual([]);
+ expect(result.current.images).toEqual([]);
+ });
+
+ test('regenerate reuses the original user message id without duplication', async () => {
+ const now = new Date().toISOString();
+ mockChat.sendMessage.mockResolvedValue({
+ content: 'regenerated',
+ conversation: { id: 'conv-regenerate', title: 'Regenerated', created_at: now },
+ });
+
+ const { result } = renderUseChat();
+
+ act(() => {
+ result.current.setModel('openai::gpt-4o');
+ });
+
+ const baseMessages = [
+ { id: 'user-1', role: 'user', content: 'Original question' },
+ { id: 'assistant-1', role: 'assistant', content: 'Old answer' },
+ ];
+
+ await act(async () => {
+ await result.current.regenerate(baseMessages as any);
+ });
+
+ expect(result.current.messages).toHaveLength(3);
+ const payload = mockChat.sendMessage.mock.calls[0][0];
+ const userMessageIds = payload.messages.filter((msg: any) => msg.id === 'user-1');
+ expect(userMessageIds).toHaveLength(1);
+ });
});
diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx
index b9aa6b99..116446d1 100644
--- a/frontend/app/layout.tsx
+++ b/frontend/app/layout.tsx
@@ -3,6 +3,7 @@ import { Geist, Geist_Mono } from 'next/font/google';
import './globals.css';
import { ThemeProvider } from '../contexts/ThemeContext';
import { AuthProvider } from '../contexts/AuthContext';
+import { ToastProvider } from '../components/ui/Toast';
const geistSans = Geist({
variable: '--font-geist-sans',
@@ -28,7 +29,9 @@ export default function RootLayout({
- {children}
+
+ {children}
+
diff --git a/frontend/components/ChatHeader.tsx b/frontend/components/ChatHeader.tsx
index d97efdc4..fa7c9fda 100644
--- a/frontend/components/ChatHeader.tsx
+++ b/frontend/components/ChatHeader.tsx
@@ -2,6 +2,7 @@ import React from 'react';
import { Sun, Moon, Settings, RefreshCw, Loader2, PanelLeft, PanelRight, Plus } from 'lucide-react';
import { useTheme } from '../contexts/ThemeContext';
import ModelSelector from './ui/ModelSelector';
+import CompareSelector from './ui/CompareSelector';
import { type Group as TabGroup } from './ui/TabbedSelect';
import { AuthButton } from './auth/AuthButton';
@@ -24,6 +25,12 @@ interface ChatHeaderProps {
onToggleRightSidebar?: () => void;
showLeftSidebarButton?: boolean;
showRightSidebarButton?: boolean;
+ selectedComparisonModels?: string[];
+ onComparisonModelsChange?: (models: string[]) => void;
+ comparisonLocked?: boolean;
+ comparisonLockReason?: string;
+ modelSelectionLocked?: boolean;
+ modelSelectionLockReason?: string;
}
export function ChatHeader({
@@ -44,6 +51,12 @@ export function ChatHeader({
showLeftSidebarButton = false,
showRightSidebarButton = false,
onNewChat,
+ selectedComparisonModels = [],
+ onComparisonModelsChange,
+ comparisonLocked = false,
+ comparisonLockReason = 'Model comparison is locked after the first message.',
+ modelSelectionLocked = false,
+ modelSelectionLockReason = 'Primary model is locked after comparison starts.',
}: ChatHeaderProps) {
const { theme, setTheme, resolvedTheme } = useTheme();
@@ -135,7 +148,21 @@ export function ChatHeader({
className="text-sm sm:text-base md:text-lg"
ariaLabel="Model"
onAfterChange={onFocusMessageInput}
+ disabled={modelSelectionLocked}
+ disabledReason={modelSelectionLockReason}
/>
+ {onComparisonModelsChange && (
+
+ )}
{onRefreshModels && (