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
7 changes: 5 additions & 2 deletions src/frontend/components/_features/[app]/toast/index.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
import * as PrimitiveToast from '@radix-ui/react-toast'
import { cva, type VariantProps } from 'cva'
import { ComponentPropsWithoutRef, ElementRef, forwardRef, ReactElement } from 'react'
Expand All @@ -13,7 +13,10 @@
>(({ className, ...props }, ref) => (
<PrimitiveToast.ToastViewport
ref={ref}
className={cn('absolute bottom-4 right-0 z-[100] flex h-fit max-h-36 w-[292px] px-4 xl:w-[420px]', className)}
className={cn(
'absolute left-1/2 top-4 z-[100] flex h-fit max-h-36 w-[292px] -translate-x-1/2 px-4 xl:w-[420px]',
className,
)}
// -> For fail toasts to be on top
{...props}
/>
Expand All @@ -23,7 +26,7 @@

const toastVariants = cva(
// 'group relative pointer-events-auto flex flex-col flex-1 w-full text-neutral-1000 dark:text-white items-start rounded-md shadow-lg px-4 py-3 font-display border border-neutral-200 bg-white dark:bg-neutral-950 dark:border-neutral-850 transition-all data-[swipe=cancel]:translate-y-0 data-[swipe=up]:translate-y-[var(--radix-toast-swipe-end-y)] data-[swipe=move]:translate-y-[var(--radix-toast-swipe-move-y)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=up]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-bottom-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full',
'group relative pointer-events-auto flex flex-col flex-1 w-full text-neutral-1000 dark:text-white items-start rounded-md shadow-lg px-4 py-3 font-display border border-neutral-200 bg-white dark:bg-neutral-950 dark:border-neutral-850 transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full',
'group relative pointer-events-auto flex flex-col flex-1 w-full text-neutral-1000 dark:text-white items-start rounded-md shadow-lg px-4 py-3 font-display border border-neutral-200 bg-white dark:bg-neutral-950 dark:border-neutral-850 transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full',
{
variants: {
variant: {
Expand Down
6 changes: 3 additions & 3 deletions src/frontend/screens/workspace-screen.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
import * as Tabs from '@radix-ui/react-tabs'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { ImperativePanelHandle } from 'react-resizable-panels'
Expand Down Expand Up @@ -661,9 +661,9 @@
<ResizablePanel
id='chatPanel'
order={3}
defaultSize={16}
minSize={16}
maxSize={25}
defaultSize={30}
minSize={20}
maxSize={50}
className='relative flex h-full min-h-0 w-full'
>
<ChatPanel />
Expand Down
177 changes: 176 additions & 1 deletion src/frontend/store/__tests__/ai-slice.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
import { createStore, StoreApi } from 'zustand/vanilla'

import { createAISlice, createAISliceFactory } from '../slices/ai/slice'
Expand Down Expand Up @@ -50,6 +50,9 @@
expect(ai.error).toBeNull()
expect(ai.preferences).toEqual({ inlineCompletionsEnabled: true })
expect(ai.pendingDiffs).toEqual({})
expect(ai.conversationId).toBeNull()
expect(ai.conversations).toEqual([])
expect(ai.isLoadingConversation).toBe(false)
})
})

Expand Down Expand Up @@ -281,21 +284,24 @@
})

describe('clearConversation', () => {
it('clears all messages and the error', () => {
it('clears messages, error, and the active conversation id', () => {
store.getState().aiActions.addMessage(makeMessage({ id: 'msg-1' }))
store.getState().aiActions.addMessage(makeMessage({ id: 'msg-2' }))
store.getState().aiActions.setAIError('some error')
store.getState().aiActions.setConversationId('conv-1')

store.getState().aiActions.clearConversation()

expect(store.getState().ai.messages).toHaveLength(0)
expect(store.getState().ai.error).toBeNull()
expect(store.getState().ai.conversationId).toBeNull()
})

it('is a no-op on empty state (no error)', () => {
store.getState().aiActions.clearConversation()
expect(store.getState().ai.messages).toHaveLength(0)
expect(store.getState().ai.error).toBeNull()
expect(store.getState().ai.conversationId).toBeNull()
})
})

Expand Down Expand Up @@ -404,6 +410,175 @@
expect(store.getState().ai.pendingDiffs).toEqual({})
})
})

// ---------------------------------------------------------------------------
// Conversation management (DOPE-2)
// ---------------------------------------------------------------------------

describe('conversation management', () => {
const summaryA = {
id: 'conv-a',
title: 'Refactor motor control',
lastModel: 'sonnet' as const,
createdAt: '2026-05-04T10:00:00Z',
updatedAt: '2026-05-04T11:00:00Z',
}
const summaryB = {
id: 'conv-b',
title: 'Add timer',
lastModel: 'sonnet' as const,
createdAt: '2026-05-04T09:00:00Z',
updatedAt: '2026-05-04T09:30:00Z',
}

describe('setConversationId', () => {
it('sets the active conversation id', () => {
store.getState().aiActions.setConversationId('conv-a')
expect(store.getState().ai.conversationId).toBe('conv-a')
})

it('clears the active conversation id when set to null', () => {
store.getState().aiActions.setConversationId('conv-a')
store.getState().aiActions.setConversationId(null)
expect(store.getState().ai.conversationId).toBeNull()
})
})

describe('setConversations', () => {
it('replaces the conversations list', () => {
store.getState().aiActions.setConversations([summaryA, summaryB])
expect(store.getState().ai.conversations).toEqual([summaryA, summaryB])
})

it('replaces with an empty list', () => {
store.getState().aiActions.setConversations([summaryA])
store.getState().aiActions.setConversations([])
expect(store.getState().ai.conversations).toEqual([])
})
})

describe('prependConversation', () => {
it('inserts a new conversation at the top', () => {
store.getState().aiActions.setConversations([summaryB])
store.getState().aiActions.prependConversation(summaryA)
expect(store.getState().ai.conversations.map((c) => c.id)).toEqual(['conv-a', 'conv-b'])
})

it('drops a duplicate id before prepending (no double rows)', () => {
store.getState().aiActions.setConversations([summaryA, summaryB])
store.getState().aiActions.prependConversation({ ...summaryA, title: 'Renamed' })
const ids = store.getState().ai.conversations.map((c) => c.id)
expect(ids).toEqual(['conv-a', 'conv-b'])
expect(store.getState().ai.conversations[0].title).toBe('Renamed')
})
})

describe('removeConversation', () => {
it('drops a conversation from the list', () => {
store.getState().aiActions.setConversations([summaryA, summaryB])
store.getState().aiActions.removeConversation('conv-a')
expect(store.getState().ai.conversations).toEqual([summaryB])
})

it('clears messages + active id when removing the active conversation', () => {
store.getState().aiActions.setConversations([summaryA])
store.getState().aiActions.setConversationId('conv-a')
store.getState().aiActions.addMessage(makeMessage({ id: 'msg-1' }))

store.getState().aiActions.removeConversation('conv-a')

expect(store.getState().ai.conversations).toEqual([])
expect(store.getState().ai.conversationId).toBeNull()
expect(store.getState().ai.messages).toHaveLength(0)
})

it('keeps messages + active id when removing a different conversation', () => {
store.getState().aiActions.setConversations([summaryA, summaryB])
store.getState().aiActions.setConversationId('conv-a')
store.getState().aiActions.addMessage(makeMessage({ id: 'msg-1' }))

store.getState().aiActions.removeConversation('conv-b')

expect(store.getState().ai.conversationId).toBe('conv-a')
expect(store.getState().ai.messages).toHaveLength(1)
})

it('is a no-op for unknown ids', () => {
store.getState().aiActions.setConversations([summaryA])
store.getState().aiActions.removeConversation('does-not-exist')
expect(store.getState().ai.conversations).toEqual([summaryA])
})
})

describe('updateConversationTitle', () => {
it('updates the title of an existing summary', () => {
store.getState().aiActions.setConversations([summaryA, summaryB])
store.getState().aiActions.updateConversationTitle('conv-a', 'New title')
expect(store.getState().ai.conversations[0].title).toBe('New title')
expect(store.getState().ai.conversations[1].title).toBe(summaryB.title)
})

it('is a no-op for unknown ids', () => {
store.getState().aiActions.setConversations([summaryA])
store.getState().aiActions.updateConversationTitle('nope', 'x')
expect(store.getState().ai.conversations[0].title).toBe(summaryA.title)
})
})

describe('replaceMessages', () => {
it('replaces the messages list wholesale', () => {
store.getState().aiActions.addMessage(makeMessage({ id: 'old-1' }))
const replacement = [
makeMessage({ id: 'new-1', role: 'user', content: 'one' }),
makeMessage({ id: 'new-2', role: 'assistant', content: 'two' }),
]
store.getState().aiActions.replaceMessages(replacement)

expect(store.getState().ai.messages.map((m) => m.id)).toEqual(['new-1', 'new-2'])
})

it('clears the error', () => {
store.getState().aiActions.setAIError('boom')
store.getState().aiActions.replaceMessages([])
expect(store.getState().ai.error).toBeNull()
})

it('caps at MAX_CONVERSATION_MESSAGES, keeping the most recent', () => {
const many: ChatMessage[] = []
for (let i = 0; i < MAX_CONVERSATION_MESSAGES + 7; i++) {
many.push(makeMessage({ id: `m-${i}`, content: `Message ${i}` }))
}
store.getState().aiActions.replaceMessages(many)

const messages = store.getState().ai.messages
expect(messages).toHaveLength(MAX_CONVERSATION_MESSAGES)
expect(messages[0].id).toBe('m-7')
expect(messages[MAX_CONVERSATION_MESSAGES - 1].id).toBe(`m-${MAX_CONVERSATION_MESSAGES + 6}`)
})
})

describe('setLoadingConversation', () => {
it('toggles the isLoadingConversation flag', () => {
store.getState().aiActions.setLoadingConversation(true)
expect(store.getState().ai.isLoadingConversation).toBe(true)
store.getState().aiActions.setLoadingConversation(false)
expect(store.getState().ai.isLoadingConversation).toBe(false)
})
})
})

describe('updateMessageContent with block array', () => {
it('replaces a streamed text content with a block array (used at agentic-loop iteration boundary)', () => {
store.getState().aiActions.addMessage(makeMessage({ id: 'msg-1', role: 'assistant', content: 'streamed text' }))
const blocks = [
{ type: 'text' as const, text: 'streamed text' },
{ type: 'tool_use' as const, id: 'toolu_1', name: 'create_pou', input: { name: 'Foo' } },
]
store.getState().aiActions.updateMessageContent('msg-1', blocks)

expect(store.getState().ai.messages[0].content).toEqual(blocks)
})
})
})

// ---------------------------------------------------------------------------
Expand Down
67 changes: 67 additions & 0 deletions src/frontend/store/slices/ai/slice.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
import { produce } from 'immer'
import { StateCreator } from 'zustand'

Expand All @@ -22,6 +22,9 @@
isChatOpen: false,
error: null,
pendingDiffs: {},
conversationId: null,
conversations: [],
isLoadingConversation: false,
}

export function createAISliceFactory(config?: AIFeatureConfig): StateCreator<AISlice, [], [], AISlice> {
Expand Down Expand Up @@ -142,6 +145,10 @@
produce(({ ai }: AISlice) => {
ai.messages = []
ai.error = null
// Drop the active conversation pointer so the next /ai/chat call
// is treated as a fresh conversation. The list itself stays —
// the user can still see prior conversations and resume one.
ai.conversationId = null
}),
)
},
Expand Down Expand Up @@ -200,6 +207,66 @@
}),
)
},
setConversationId: (id) => {
setState(
produce(({ ai }: AISlice) => {
ai.conversationId = id
}),
)
},
setConversations: (conversations) => {
setState(
produce(({ ai }: AISlice) => {
ai.conversations = conversations
}),
)
},
prependConversation: (conversation) => {
setState(
produce(({ ai }: AISlice) => {
// Defensive: drop any existing entry with the same id
// before prepending so we never end up with duplicates.
ai.conversations = [conversation, ...ai.conversations.filter((c) => c.id !== conversation.id)]
}),
)
},
removeConversation: (id) => {
setState(
produce(({ ai }: AISlice) => {
ai.conversations = ai.conversations.filter((c) => c.id !== id)
if (ai.conversationId === id) {
ai.conversationId = null
ai.messages = []
}
}),
)
},
updateConversationTitle: (id, title) => {
setState(
produce(({ ai }: AISlice) => {
const summary = ai.conversations.find((c) => c.id === id)
if (summary) {
summary.title = title
}
}),
)
},
replaceMessages: (messages) => {
setState(
produce(({ ai }: AISlice) => {
ai.messages =
messages.length > MAX_CONVERSATION_MESSAGES ? messages.slice(-MAX_CONVERSATION_MESSAGES) : messages
ai.error = null
}),
)
},
setLoadingConversation: (loading) => {
setState(
produce(({ ai }: AISlice) => {
ai.isLoadingConversation = loading
}),
)
},
},
})
}
Expand Down
Loading
Loading