Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
915c41a
feat: Add `skip_ci` input to electron and docker release workflows to…
qduc Dec 28, 2025
33ede02
fix: newline not render properly in user's message
qduc Dec 28, 2025
d07c138
feat: auto-hide functionality for scroll buttons
qduc Dec 28, 2025
4b65fd3
feat: send one prompt to multiple models for comparison
qduc Dec 29, 2025
4c6e08b
feat: persist comparison conversation
qduc Dec 29, 2025
a15a433
refactor: Unify the shared dropdown/tabs/search/list behavior into a …
qduc Dec 29, 2025
d7fd8c8
fix: comparison mode
qduc Dec 29, 2025
7feca38
fix lint
qduc Dec 29, 2025
53d27d5
feat: provide full feature parity between primary and secondary model…
qduc Dec 29, 2025
8d9ea6c
feat: support lazy loading for CompareSelector
qduc Dec 29, 2025
ded6483
feat: render multiple models' response side by side
qduc Dec 29, 2025
64beb74
feat: enhance layout for multi-column comparison mode in MessageList
qduc Dec 29, 2025
334d63b
fix: message toolbar only show in one column
qduc Dec 29, 2025
f798838
feat: add "select all" and single model mode for comparison model too…
qduc Dec 29, 2025
3235a70
fix lint
qduc Dec 29, 2025
71557f3
feat: hardened comparison mode flow
qduc Dec 29, 2025
5d34417
feat: add model selector disabled reason
qduc Dec 29, 2025
54529c9
feat: add linting scripts and husky pre-commit hook for improved code…
qduc Dec 29, 2025
b2ac7ef
feat: add sqlite3 package to Dockerfile for database support
qduc Dec 29, 2025
5c294ef
feat: implement mobile responsiveness for comparison model selection
qduc Dec 29, 2025
e21002a
fix lint
qduc Dec 29, 2025
6e5d4dd
feat: update title generation logic to handle new conversations and m…
qduc Dec 29, 2025
63694ea
Initial plan
Copilot Dec 29, 2025
feb6e15
test: cover model comparison flow
Copilot Dec 29, 2025
c53497d
chore: clarify comparison test mock
Copilot Dec 29, 2025
01df9f5
fix: remove the duplicate aria-label on the outer wrapper div so only…
qduc Dec 30, 2025
ef694ef
feat: make the model tabs render only on the first assistant turn and…
qduc Dec 30, 2025
3765f4c
feat: isolated comparison histories
qduc Dec 30, 2025
1c67959
feat: forking a chat in comparison mode will go back to single model …
qduc Dec 30, 2025
a01899a
fix: copy button missing in user's message
qduc Dec 30, 2025
1c5f0ae
feat: improve test coverage
qduc Dec 30, 2025
20864d7
feat: improve test coverage
qduc Dec 30, 2025
62ddb43
feat: guard all send-like actions to prevent accidental sends when m…
qduc Dec 30, 2025
7206449
feat: toast notification
qduc Dec 30, 2025
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
7 changes: 6 additions & 1 deletion .github/workflows/docker-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down
7 changes: 6 additions & 1 deletion .github/workflows/electron-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ jobs:
uses: ./.github/workflows/docker-publish.yml
with:
tag: ${{ inputs.tag }}
skip_ci: true
secrets: inherit
permissions:
contents: read
Expand All @@ -32,6 +33,7 @@ jobs:
uses: ./.github/workflows/electron-release.yml
with:
tag: ${{ inputs.tag }}
skip_ci: true
secrets: inherit
permissions:
contents: write
1 change: 1 addition & 0 deletions .husky/pre-commit
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
npx lint-staged
1 change: 1 addition & 0 deletions backend/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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/*

Expand Down
87 changes: 87 additions & 0 deletions backend/__tests__/conversations.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 ---
Expand Down Expand Up @@ -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 () => {
Expand Down
1 change: 1 addition & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
38 changes: 34 additions & 4 deletions backend/src/db/conversations.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -34,6 +35,7 @@ export function createConversation({
quality_level: qualityLevel,
reasoning_effort: reasoningEffort,
verbosity,
parent_conversation_id: parentConversationId || null,
now,
});
}
Expand All @@ -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 });

Expand Down Expand Up @@ -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 });
Expand All @@ -204,13 +207,40 @@ 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');
}

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;
Expand Down
22 changes: 22 additions & 0 deletions backend/src/db/migrations/023-parent-conversation-id.js
Original file line number Diff line number Diff line change
@@ -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;
`
};
18 changes: 18 additions & 0 deletions backend/src/lib/openaiProxy.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
3 changes: 2 additions & 1 deletion backend/src/lib/persistence/ConversationManager.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
4 changes: 2 additions & 2 deletions backend/src/lib/retryUtils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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';
}

Expand Down
28 changes: 24 additions & 4 deletions backend/src/lib/simplifiedPersistence.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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)
Expand All @@ -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
Expand Down
Loading
Loading