Skip to content

Commit b3f0a19

Browse files
authored
feat(playground): threaded chat with sidebar, LLM titles, star/rename/delete, and scroll persistence (#1915)
* fix(layout): set relative positioning on Main for overlay child layouts * feat(db): add title_edited_by_user column to threads (migration 002) * feat(db): add starred column to threads (migration 003) * feat(db): persist and hydrate titleEditedByUser and starred on threads * feat(ipc): add generate-thread-title handler and expose via preload chat API * feat(playground): add usePlaygroundThreads hook for thread CRUD, star, and rename * feat(chat): add useAutoScroll hook with per-thread scroll position persistence * feat(playground): add PlaygroundSidebar with thread list, star, rename, and delete * feat(chat): add ThreadTitleBar with inline rename, star toggle, and delete confirm * refactor(chat): replace TitlePage with ThreadTitleBar in ChatInterface * feat(playground): add thread sidebar layout with per-thread ChatInterface routing * test(db): cover migrations 002-003 and titleEditedByUser/starred round-trips * test(chat): cover generateThreadTitle success, failure paths, and provider branches * test(playground): add tests for useAutoScroll, usePlaygroundThreads, PlaygroundSidebar, ThreadTitleBar, ChatInterface, and Playground route * refactor: fine tuning * refactor: update thread title after messages * fix(chat): use valid ScrollBehavior 'auto' instead of 'instant' in useAutoScroll * fix(a11y): add aria-label and title to scroll-to-bottom button * fix(playground): re-sort thread list by lastEditTimestamp after mutations * fix(chat): re-check titleEditedByUser before writing LLM-generated title * refactor: adjust types * test: e2e adjustment * fix(playground): use opacity instead of display:none for sidebar dropdown trigger Replace hidden/group-hover:flex with opacity-0/pointer-events-none so getBoundingClientRect() always returns valid coordinates for floating-ui positioning. Change dropdown side to bottom and remove fixed positioning from the sidebar aside element. * feat(playground): show sidebar immediately when first message is sent Publish a threadStarted query cache signal on status=submitted so usePlaygroundThreads picks up the new thread before streaming finishes. Unify the playground layout into a single tree so ChatInterface stays mounted across the no-threads to has-threads transition, preserving the active streaming session. * test: e2e adjustment * refactor: auto-scroll
1 parent 3ff1e8a commit b3f0a19

30 files changed

Lines changed: 3533 additions & 280 deletions

e2e-tests/playground.spec.ts

Lines changed: 10 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -39,11 +39,15 @@ async function warmupOllamaModel(): Promise<void> {
3939
}
4040

4141
async function waitForPlaygroundReady(window: Page): Promise<void> {
42-
const loadingText = window.getByText(/loading chat history/i)
43-
// May never appear if already loaded
44-
await loadingText
45-
.waitFor({ state: 'hidden', timeout: 10_000 })
46-
.catch(() => {})
42+
// Wait past both the thread-list spinner (dots, no text) and the
43+
// "Loading chat history..." phase by waiting for any interactive element
44+
// that is visible in every possible ready state.
45+
const readyLocator = window
46+
.getByRole('button', { name: /configure your providers/i })
47+
.or(window.getByTestId('model-selector'))
48+
.or(window.getByPlaceholder(/type your message/i))
49+
50+
await readyLocator.first().waitFor({ state: 'visible', timeout: 15_000 })
4751
}
4852

4953
async function openProviderSettingsDialog(window: Page): Promise<void> {
@@ -105,18 +109,7 @@ async function selectOllamaModel(window: Page): Promise<void> {
105109

106110
async function clearPlaygroundState(window: Page): Promise<void> {
107111
await window.getByRole('link', { name: 'Playground' }).click()
108-
await expect(
109-
window.getByRole('heading', { name: 'Playground', level: 1 })
110-
).toBeVisible()
111-
112112
await waitForPlaygroundReady(window)
113-
114-
const clearChatButton = window.getByRole('button', { name: /clear chat/i })
115-
if (await clearChatButton.isVisible().catch(() => false)) {
116-
await clearChatButton.click()
117-
await window.getByRole('button', { name: /delete/i }).click()
118-
}
119-
120113
await removeOllamaProvider(window)
121114
}
122115

@@ -189,9 +182,7 @@ test.describe('Playground chat with Ollama', () => {
189182
).toBeVisible({ timeout: 30_000 })
190183

191184
await window.getByRole('link', { name: 'Playground' }).click()
192-
await expect(
193-
window.getByRole('heading', { name: 'Playground', level: 1 })
194-
).toBeVisible()
185+
await waitForPlaygroundReady(window)
195186

196187
await openProviderSettingsDialog(window)
197188

Lines changed: 301 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,301 @@
1+
import { describe, it, expect, vi, beforeEach } from 'vitest'
2+
3+
// ---------------------------------------------------------------------------
4+
// Hoisted mock factories — must be defined before vi.mock() calls
5+
// ---------------------------------------------------------------------------
6+
const mockGenerateText = vi.hoisted(() => vi.fn())
7+
const mockConvertToModelMessages = vi.hoisted(() => vi.fn())
8+
const mockGetThread = vi.hoisted(() => vi.fn())
9+
const mockUpdateThread = vi.hoisted(() => vi.fn())
10+
const mockReadSelectedModel = vi.hoisted(() => vi.fn())
11+
const mockReadChatProvider = vi.hoisted(() => vi.fn())
12+
const mockCreateModelFromRequest = vi.hoisted(() => vi.fn())
13+
14+
// ---------------------------------------------------------------------------
15+
// Module mocks
16+
// ---------------------------------------------------------------------------
17+
18+
vi.mock('ai', () => ({
19+
generateText: mockGenerateText,
20+
convertToModelMessages: mockConvertToModelMessages,
21+
}))
22+
23+
vi.mock('../threads-storage', () => ({
24+
getThread: mockGetThread,
25+
updateThread: mockUpdateThread,
26+
}))
27+
28+
vi.mock('../../db/readers/chat-settings-reader', () => ({
29+
readSelectedModel: mockReadSelectedModel,
30+
readChatProvider: mockReadChatProvider,
31+
}))
32+
33+
vi.mock('../utils', () => ({
34+
createModelFromRequest: mockCreateModelFromRequest,
35+
}))
36+
37+
vi.mock('../providers', () => ({
38+
CHAT_PROVIDERS: [
39+
{ id: 'openai', name: 'OpenAI' },
40+
{ id: 'ollama', name: 'Ollama' },
41+
],
42+
}))
43+
44+
vi.mock('../constants', () => ({
45+
LOCAL_PROVIDER_IDS: ['ollama', 'lmstudio'],
46+
}))
47+
48+
vi.mock('../../logger', () => ({
49+
default: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
50+
}))
51+
52+
// ---------------------------------------------------------------------------
53+
// Subject under test (imported after mocks)
54+
// ---------------------------------------------------------------------------
55+
import { generateThreadTitle } from '../generate-thread-title'
56+
57+
// ---------------------------------------------------------------------------
58+
// Helpers
59+
// ---------------------------------------------------------------------------
60+
61+
const fakeModel = { id: 'fake-model' }
62+
const fakeConvertedMessages = [{ role: 'user', content: 'hello' }]
63+
64+
function makeThread(overrides: Record<string, unknown> = {}) {
65+
return {
66+
id: 'thread-1',
67+
title: 'Old title',
68+
titleEditedByUser: false,
69+
messages: [
70+
{ id: 'm1', role: 'user', parts: [{ type: 'text', text: 'Hello' }] },
71+
{
72+
id: 'm2',
73+
role: 'assistant',
74+
parts: [{ type: 'text', text: 'Hi there' }],
75+
},
76+
],
77+
...overrides,
78+
}
79+
}
80+
81+
// ---------------------------------------------------------------------------
82+
// Tests
83+
// ---------------------------------------------------------------------------
84+
85+
describe('generateThreadTitle', () => {
86+
beforeEach(() => {
87+
vi.clearAllMocks()
88+
mockGetThread.mockReturnValue(makeThread())
89+
mockReadSelectedModel.mockReturnValue({
90+
provider: 'openai',
91+
model: 'gpt-4o',
92+
})
93+
mockReadChatProvider.mockReturnValue({ apiKey: 'sk-test' })
94+
mockCreateModelFromRequest.mockReturnValue(fakeModel)
95+
mockConvertToModelMessages.mockResolvedValue(fakeConvertedMessages)
96+
mockGenerateText.mockResolvedValue({ text: 'Great Title' })
97+
})
98+
99+
describe('failure paths', () => {
100+
it('returns failure when thread is not found', async () => {
101+
mockGetThread.mockReturnValue(null)
102+
const result = await generateThreadTitle('thread-1')
103+
expect(result).toMatchObject({
104+
success: false,
105+
error: 'Thread not found',
106+
})
107+
expect(mockUpdateThread).not.toHaveBeenCalled()
108+
})
109+
110+
it('returns failure when thread has no user message', async () => {
111+
mockGetThread.mockReturnValue(makeThread({ messages: [] }))
112+
const result = await generateThreadTitle('thread-1')
113+
expect(result).toMatchObject({
114+
success: false,
115+
error: 'No user message in thread',
116+
})
117+
})
118+
119+
it('returns failure when no model is selected', async () => {
120+
mockReadSelectedModel.mockReturnValue({ provider: '', model: '' })
121+
const result = await generateThreadTitle('thread-1')
122+
expect(result).toMatchObject({
123+
success: false,
124+
error: 'No model selected',
125+
})
126+
})
127+
128+
it('returns failure when provider is unknown', async () => {
129+
mockReadSelectedModel.mockReturnValue({
130+
provider: 'unknown-provider',
131+
model: 'x',
132+
})
133+
const result = await generateThreadTitle('thread-1')
134+
expect(result).toMatchObject({ success: false })
135+
expect(result.error).toContain('Unknown provider')
136+
})
137+
138+
it('returns failure when provider settings are not found', async () => {
139+
mockReadChatProvider.mockReturnValue(null)
140+
const result = await generateThreadTitle('thread-1')
141+
expect(result).toMatchObject({
142+
success: false,
143+
error: 'Provider settings not found',
144+
})
145+
})
146+
147+
it('returns failure when LLM returns only whitespace', async () => {
148+
mockGenerateText.mockResolvedValue({ text: ' ' })
149+
const result = await generateThreadTitle('thread-1')
150+
expect(result).toMatchObject({
151+
success: false,
152+
error: 'Empty title generated',
153+
})
154+
expect(mockUpdateThread).not.toHaveBeenCalled()
155+
})
156+
157+
it('returns failure with error message when generateText throws', async () => {
158+
mockGenerateText.mockRejectedValue(new Error('API timeout'))
159+
const result = await generateThreadTitle('thread-1')
160+
expect(result).toMatchObject({ success: false, error: 'API timeout' })
161+
})
162+
163+
it('returns failure with "Unknown error" for non-Error throws', async () => {
164+
mockGenerateText.mockRejectedValue('some string error')
165+
const result = await generateThreadTitle('thread-1')
166+
expect(result).toMatchObject({ success: false, error: 'Unknown error' })
167+
})
168+
})
169+
170+
describe('success path', () => {
171+
it('returns success with the generated title', async () => {
172+
mockGenerateText.mockResolvedValue({
173+
text: 'Patching Security Vulnerabilities',
174+
})
175+
const result = await generateThreadTitle('thread-1')
176+
expect(result).toMatchObject({
177+
success: true,
178+
title: 'Patching Security Vulnerabilities',
179+
})
180+
})
181+
182+
it('calls updateThread with title and titleEditedByUser: false', async () => {
183+
mockGenerateText.mockResolvedValue({ text: 'Auto Title' })
184+
await generateThreadTitle('thread-1')
185+
expect(mockUpdateThread).toHaveBeenCalledWith('thread-1', {
186+
title: 'Auto Title',
187+
titleEditedByUser: false,
188+
})
189+
})
190+
191+
it('strips trailing punctuation from the title', async () => {
192+
mockGenerateText.mockResolvedValue({ text: 'Deploy to Production.' })
193+
const result = await generateThreadTitle('thread-1')
194+
expect(result.title).toBe('Deploy to Production')
195+
})
196+
197+
it('strips trailing question marks', async () => {
198+
mockGenerateText.mockResolvedValue({ text: 'How To Deploy?' })
199+
const result = await generateThreadTitle('thread-1')
200+
expect(result.title).toBe('How To Deploy')
201+
})
202+
203+
it('calls convertToModelMessages with the context messages', async () => {
204+
await generateThreadTitle('thread-1')
205+
expect(mockConvertToModelMessages).toHaveBeenCalledWith(
206+
expect.arrayContaining([expect.objectContaining({ role: 'user' })])
207+
)
208+
})
209+
210+
it('calls generateText with the correct system prompt and maxOutputTokens', async () => {
211+
await generateThreadTitle('thread-1')
212+
expect(mockGenerateText).toHaveBeenCalledWith(
213+
expect.objectContaining({
214+
model: fakeModel,
215+
messages: fakeConvertedMessages,
216+
maxOutputTokens: 20,
217+
system: expect.stringContaining('6 words or fewer'),
218+
})
219+
)
220+
})
221+
222+
it('works when thread has only a user message (no assistant reply yet)', async () => {
223+
mockGetThread.mockReturnValue(
224+
makeThread({
225+
messages: [
226+
{
227+
id: 'm1',
228+
role: 'user',
229+
parts: [{ type: 'text', text: 'Hello' }],
230+
},
231+
],
232+
})
233+
)
234+
mockGenerateText.mockResolvedValue({ text: 'Solo User Message' })
235+
const result = await generateThreadTitle('thread-1')
236+
expect(result).toMatchObject({
237+
success: true,
238+
title: 'Solo User Message',
239+
})
240+
})
241+
})
242+
243+
describe('remote provider (apiKey path)', () => {
244+
it('passes apiKey in the request for a non-local provider', async () => {
245+
mockReadSelectedModel.mockReturnValue({
246+
provider: 'openai',
247+
model: 'gpt-4o',
248+
})
249+
mockReadChatProvider.mockReturnValue({ apiKey: 'sk-remote-key' })
250+
251+
await generateThreadTitle('thread-1')
252+
253+
expect(mockCreateModelFromRequest).toHaveBeenCalledWith(
254+
expect.objectContaining({ id: 'openai' }),
255+
expect.objectContaining({ apiKey: 'sk-remote-key' })
256+
)
257+
expect(mockCreateModelFromRequest).toHaveBeenCalledWith(
258+
expect.anything(),
259+
expect.not.objectContaining({ endpointURL: expect.anything() })
260+
)
261+
})
262+
})
263+
264+
describe('local provider (endpointURL path)', () => {
265+
it('passes endpointURL in the request for a local provider', async () => {
266+
mockReadSelectedModel.mockReturnValue({
267+
provider: 'ollama',
268+
model: 'llama3',
269+
})
270+
mockReadChatProvider.mockReturnValue({
271+
endpointURL: 'http://localhost:11434',
272+
})
273+
274+
await generateThreadTitle('thread-1')
275+
276+
expect(mockCreateModelFromRequest).toHaveBeenCalledWith(
277+
expect.objectContaining({ id: 'ollama' }),
278+
expect.objectContaining({ endpointURL: 'http://localhost:11434' })
279+
)
280+
expect(mockCreateModelFromRequest).toHaveBeenCalledWith(
281+
expect.anything(),
282+
expect.not.objectContaining({ apiKey: expect.anything() })
283+
)
284+
})
285+
286+
it('uses empty string for endpointURL when not configured', async () => {
287+
mockReadSelectedModel.mockReturnValue({
288+
provider: 'ollama',
289+
model: 'llama3',
290+
})
291+
mockReadChatProvider.mockReturnValue({}) // no endpointURL
292+
293+
await generateThreadTitle('thread-1')
294+
295+
expect(mockCreateModelFromRequest).toHaveBeenCalledWith(
296+
expect.anything(),
297+
expect.objectContaining({ endpointURL: '' })
298+
)
299+
})
300+
})
301+
})

0 commit comments

Comments
 (0)