feat(desktop): add conversation switcher and new-session flow

This commit is contained in:
Jiayuan Zhang 2026-02-17 02:29:40 +08:00
parent 754e604a40
commit 16753d719b
9 changed files with 373 additions and 108 deletions

View file

@ -96,9 +96,10 @@ interface ProfileData {
userContent: string | undefined
}
interface LocalChatEvent {
agentId: string
streamId?: string
interface LocalChatEvent {
agentId: string
conversationId?: string
streamId?: string
type?: 'error'
content?: string
event?: {
@ -112,10 +113,11 @@ interface LocalChatEvent {
}
}
interface LocalChatApproval {
approvalId: string
agentId: string
command: string
interface LocalChatApproval {
approvalId: string
agentId: string
conversationId?: string
command: string
cwd?: string
riskLevel: 'safe' | 'needs-review' | 'dangerous'
riskReasons: string[]
@ -155,12 +157,13 @@ type MessageSource =
| { type: 'gateway'; deviceId: string }
| { type: 'channel'; channelId: string; accountId: string; conversationId: string }
interface InboundMessageEvent {
agentId: string
content: string
source: MessageSource
timestamp: number
}
interface InboundMessageEvent {
agentId: string
conversationId?: string
content: string
source: MessageSource
timestamp: number
}
interface ElectronAPI {
app: {
@ -174,13 +177,17 @@ interface ElectronAPI {
init: () => Promise<unknown>
getStatus: () => Promise<HubStatus>
getAgentInfo: () => Promise<AgentInfo | null>
info: () => Promise<unknown>
reconnect: (url: string) => Promise<unknown>
listAgents: () => Promise<unknown>
createAgent: (id?: string) => Promise<unknown>
getAgent: (id: string) => Promise<unknown>
closeAgent: (id: string) => Promise<unknown>
sendMessage: (agentId: string, content: string) => Promise<unknown>
info: () => Promise<unknown>
reconnect: (url: string) => Promise<unknown>
listAgents: () => Promise<unknown>
listConversations: () => Promise<unknown>
createAgent: (id?: string) => Promise<unknown>
createConversation: (id?: string) => Promise<unknown>
getAgent: (id: string) => Promise<unknown>
getConversation: (id: string) => Promise<unknown>
closeAgent: (id: string) => Promise<unknown>
closeConversation: (id: string) => Promise<unknown>
sendMessage: (agentId: string, content: string, conversationId?: string) => Promise<unknown>
registerToken: (token: string, agentId: string, expiresAt: number) => Promise<unknown>
onDeviceConfirmRequest: (callback: (deviceId: string, meta?: DeviceMeta) => void) => void
offDeviceConfirmRequest: () => void
@ -250,9 +257,9 @@ interface ElectronAPI {
localChat: {
subscribe: (agentId: string) => Promise<{ ok?: boolean; error?: string; alreadySubscribed?: boolean }>
unsubscribe: (agentId: string) => Promise<{ ok: boolean }>
getHistory: (agentId: string, options?: { offset?: number; limit?: number }) => Promise<{ messages: unknown[]; total: number; offset: number; limit: number; contextWindowTokens?: number }>
send: (agentId: string, content: string) => Promise<{ ok?: boolean; error?: string }>
abort: (agentId: string) => Promise<{ ok?: boolean; error?: string }>
getHistory: (agentId: string, options?: { offset?: number; limit?: number; conversationId?: string }) => Promise<{ messages: unknown[]; total: number; offset: number; limit: number; contextWindowTokens?: number }>
send: (agentId: string, content: string, conversationId?: string) => Promise<{ ok?: boolean; error?: string }>
abort: (agentId: string, conversationId?: string) => Promise<{ ok?: boolean; error?: string }>
resolveExecApproval: (approvalId: string, decision: string) => Promise<{ ok: boolean }>
onEvent: (callback: (event: LocalChatEvent) => void) => void
offEvent: () => void

View file

@ -112,6 +112,7 @@ export function registerHubIpcHandlers(): void {
url: h.url,
connectionState: h.connectionState,
defaultAgentId,
defaultConversationId: defaultAgentId,
}
})
@ -188,6 +189,21 @@ export function registerHubIpcHandlers(): void {
})
})
/**
* List all conversations (alias of listAgents for clearer semantics).
*/
ipcMain.handle('hub:listConversations', async (): Promise<AgentInfo[]> => {
const h = getHub()
const conversationIds = h.listConversations()
return conversationIds.map((id) => {
const conversation = h.getConversation(id)
return {
id,
closed: conversation?.closed ?? true,
}
})
})
/**
* Create a new agent.
*/
@ -200,6 +216,18 @@ export function registerHubIpcHandlers(): void {
}
})
/**
* Create a new conversation (alias of createAgent).
*/
ipcMain.handle('hub:createConversation', async (_event, id?: string) => {
const h = getHub()
const conversation = h.createConversation(id)
return {
id: conversation.sessionId,
closed: conversation.closed,
}
})
/**
* Get a specific agent.
*/
@ -215,6 +243,21 @@ export function registerHubIpcHandlers(): void {
}
})
/**
* Get a specific conversation (alias of getAgent).
*/
ipcMain.handle('hub:getConversation', async (_event, id: string) => {
const h = getHub()
const conversation = h.getConversation(id)
if (!conversation) {
return { error: `Conversation not found: ${id}` }
}
return {
id: conversation.sessionId,
closed: conversation.closed,
}
})
/**
* Close/delete an agent.
*/
@ -224,18 +267,28 @@ export function registerHubIpcHandlers(): void {
return { ok: result }
})
/**
* Close/delete a conversation (alias of closeAgent).
*/
ipcMain.handle('hub:closeConversation', async (_event, id: string) => {
const h = getHub()
const result = h.closeConversation(id)
return { ok: result }
})
/**
* Send a message to an agent (for remote clients via Gateway).
* Note: For local direct chat, use 'localChat:send' instead.
*/
ipcMain.handle('hub:sendMessage', async (_event, agentId: string, content: string) => {
ipcMain.handle('hub:sendMessage', async (_event, agentId: string, content: string, conversationId?: string) => {
const h = getHub()
const agent = h.getAgent(agentId)
const resolvedConversationId = conversationId ?? agentId
const agent = h.getAgent(resolvedConversationId)
if (!agent) {
return { error: `Agent not found: ${agentId}` }
return { error: `Conversation not found: ${resolvedConversationId}` }
}
if (agent.closed) {
return { error: `Agent is closed: ${agentId}` }
return { error: `Conversation is closed: ${resolvedConversationId}` }
}
h.channelManager.clearLastRoute()
agent.write(content)
@ -277,6 +330,7 @@ export function registerHubIpcHandlers(): void {
safeLog(`[IPC] Sending ${event.type} event to renderer`)
mainWindowRef.webContents.send('localChat:event', {
agentId,
conversationId: agentId,
streamId: null,
event,
})
@ -305,6 +359,7 @@ export function registerHubIpcHandlers(): void {
safeLog(`[IPC] Sending event to renderer: ${event.type}, streamId: ${currentStreamId}`)
mainWindowRef.webContents.send('localChat:event', {
agentId,
conversationId: agentId,
streamId: currentStreamId,
event,
})
@ -351,9 +406,14 @@ export function registerHubIpcHandlers(): void {
* Reads from session storage (not in-memory state) so that internal
* orchestration messages are excluded by default.
*/
ipcMain.handle('localChat:getHistory', async (_event, agentId: string, options?: { offset?: number; limit?: number }) => {
ipcMain.handle('localChat:getHistory', async (
_event,
agentId: string,
options?: { offset?: number; limit?: number; conversationId?: string },
) => {
const h = getHub()
const agent = h.getAgent(agentId)
const conversationId = options?.conversationId ?? agentId
const agent = h.getAgent(conversationId)
if (!agent) {
return { messages: [], total: 0, offset: 0, limit: 0, contextWindowTokens: undefined }
}
@ -377,18 +437,19 @@ export function registerHubIpcHandlers(): void {
* Send a message via local direct IPC (no Gateway).
* Events will be pushed to renderer via 'localChat:event' channel.
*/
ipcMain.handle('localChat:send', async (_event, agentId: string, content: string) => {
ipcMain.handle('localChat:send', async (_event, agentId: string, content: string, conversationId?: string) => {
const h = getHub()
const agent = h.getAgent(agentId)
const resolvedConversationId = conversationId ?? agentId
const agent = h.getAgent(resolvedConversationId)
if (!agent) {
return { error: `Agent not found: ${agentId}` }
return { error: `Conversation not found: ${resolvedConversationId}` }
}
if (agent.closed) {
return { error: `Agent is closed: ${agentId}` }
return { error: `Conversation is closed: ${resolvedConversationId}` }
}
// Must be subscribed first to receive events
if (!ipcAgentSubscriptions.has(agentId)) {
if (!ipcAgentSubscriptions.has(resolvedConversationId)) {
return { error: 'Not subscribed to agent events. Call subscribe first.' }
}
@ -397,26 +458,28 @@ export function registerHubIpcHandlers(): void {
// Broadcast as local source (for consistency, though UI already knows)
h.broadcastInbound({
agentId,
conversationId: resolvedConversationId,
content,
source,
timestamp: Date.now(),
})
agent.write(content, { source })
safeLog(`[IPC] Local chat message sent to agent: ${agentId}`)
safeLog(`[IPC] Local chat message sent to conversation: ${resolvedConversationId}`)
return { ok: true }
})
/**
* Abort the current agent run for local chat.
*/
ipcMain.handle('localChat:abort', async (_event, agentId: string) => {
ipcMain.handle('localChat:abort', async (_event, agentId: string, conversationId?: string) => {
const h = getHub()
const agent = h.getAgent(agentId)
const resolvedConversationId = conversationId ?? agentId
const agent = h.getAgent(resolvedConversationId)
if (!agent) {
return { error: `Agent not found: ${agentId}` }
return { error: `Conversation not found: ${resolvedConversationId}` }
}
agent.abort()
safeLog(`[IPC] Abort sent to agent: ${agentId}`)
safeLog(`[IPC] Abort sent to conversation: ${resolvedConversationId}`)
return { ok: true }
})

View file

@ -65,9 +65,10 @@ export interface CurrentProviderInfo {
}
// Local chat event types (for direct IPC communication without Gateway)
export interface LocalChatEvent {
agentId: string
streamId?: string
export interface LocalChatEvent {
agentId: string
conversationId?: string
streamId?: string
type?: 'error'
content?: string
event?: {
@ -87,18 +88,20 @@ export type MessageSource =
| { type: 'gateway'; deviceId: string }
| { type: 'channel'; channelId: string; accountId: string; conversationId: string }
export interface InboundMessageEvent {
agentId: string
content: string
source: MessageSource
timestamp: number
}
export interface InboundMessageEvent {
agentId: string
conversationId?: string
content: string
source: MessageSource
timestamp: number
}
// Local chat approval request (mirrors ExecApprovalRequestPayload from @multica/sdk)
export interface LocalChatApproval {
approvalId: string
agentId: string
command: string
export interface LocalChatApproval {
approvalId: string
agentId: string
conversationId?: string
command: string
cwd?: string
riskLevel: 'safe' | 'needs-review' | 'dangerous'
riskReasons: string[]
@ -156,13 +159,17 @@ const electronAPI = {
getStatus: (): Promise<HubStatus> => ipcRenderer.invoke('hub:getStatus'),
getAgentInfo: (): Promise<AgentInfo | null> => ipcRenderer.invoke('hub:getAgentInfo'),
info: () => ipcRenderer.invoke('hub:info'),
reconnect: (url: string) => ipcRenderer.invoke('hub:reconnect', url),
listAgents: () => ipcRenderer.invoke('hub:listAgents'),
createAgent: (id?: string) => ipcRenderer.invoke('hub:createAgent', id),
getAgent: (id: string) => ipcRenderer.invoke('hub:getAgent', id),
closeAgent: (id: string) => ipcRenderer.invoke('hub:closeAgent', id),
sendMessage: (agentId: string, content: string) =>
ipcRenderer.invoke('hub:sendMessage', agentId, content),
reconnect: (url: string) => ipcRenderer.invoke('hub:reconnect', url),
listAgents: () => ipcRenderer.invoke('hub:listAgents'),
listConversations: () => ipcRenderer.invoke('hub:listConversations'),
createAgent: (id?: string) => ipcRenderer.invoke('hub:createAgent', id),
createConversation: (id?: string) => ipcRenderer.invoke('hub:createConversation', id),
getAgent: (id: string) => ipcRenderer.invoke('hub:getAgent', id),
getConversation: (id: string) => ipcRenderer.invoke('hub:getConversation', id),
closeAgent: (id: string) => ipcRenderer.invoke('hub:closeAgent', id),
closeConversation: (id: string) => ipcRenderer.invoke('hub:closeConversation', id),
sendMessage: (agentId: string, content: string, conversationId?: string) =>
ipcRenderer.invoke('hub:sendMessage', agentId, content, conversationId),
registerToken: (token: string, agentId: string, expiresAt: number) =>
ipcRenderer.invoke('hub:registerToken', token, agentId, expiresAt),
onDeviceConfirmRequest: (callback: (deviceId: string, meta?: { userAgent?: string; platform?: string; language?: string }) => void) => {
@ -317,12 +324,14 @@ const electronAPI = {
/** Unsubscribe from agent events */
unsubscribe: (agentId: string) => ipcRenderer.invoke('localChat:unsubscribe', agentId),
/** Get message history for local chat with pagination (returns raw AgentMessageItem[]) */
getHistory: (agentId: string, options?: { offset?: number; limit?: number }) =>
ipcRenderer.invoke('localChat:getHistory', agentId, options),
/** Send message to agent via direct IPC (no Gateway) */
send: (agentId: string, content: string) => ipcRenderer.invoke('localChat:send', agentId, content),
/** Abort the current agent run */
abort: (agentId: string) => ipcRenderer.invoke('localChat:abort', agentId),
getHistory: (agentId: string, options?: { offset?: number; limit?: number; conversationId?: string }) =>
ipcRenderer.invoke('localChat:getHistory', agentId, options),
/** Send message to agent via direct IPC (no Gateway) */
send: (agentId: string, content: string, conversationId?: string) =>
ipcRenderer.invoke('localChat:send', agentId, content, conversationId),
/** Abort the current agent run */
abort: (agentId: string, conversationId?: string) =>
ipcRenderer.invoke('localChat:abort', agentId, conversationId),
/** Resolve an exec approval request */
resolveExecApproval: (approvalId: string, decision: string) =>
ipcRenderer.invoke('localChat:resolveExecApproval', approvalId, decision),

View file

@ -10,12 +10,14 @@ import { QueuedMessageBar } from './queued-message-bar'
interface LocalChatProps {
initialPrompt?: string
conversationId?: string
}
export function LocalChat({ initialPrompt }: LocalChatProps) {
export function LocalChat({ initialPrompt, conversationId }: LocalChatProps) {
const navigate = useNavigate()
const {
agentId,
conversationId: activeConversationId,
initError,
messages,
streamingIds,
@ -34,7 +36,7 @@ export function LocalChat({ initialPrompt }: LocalChatProps) {
loadMore,
resolveApproval,
clearError,
} = useLocalChat()
} = useLocalChat({ conversationId })
const { providers, current, setProvider: switchProvider, refresh: refreshProviders } = useProviderStore()
@ -71,18 +73,21 @@ export function LocalChat({ initialPrompt }: LocalChatProps) {
// Auto-send initial prompt after a short delay
const lastPromptRef = useRef<string | undefined>(undefined)
useEffect(() => {
if (!agentId || !initialPrompt) return
if (!activeConversationId || !initialPrompt) return
if (initialPrompt === lastPromptRef.current) return
const timer = setTimeout(() => {
lastPromptRef.current = initialPrompt
sendMessage(initialPrompt)
// Remove prompt from URL to prevent re-sending on back navigation
navigate('/chat', { replace: true })
const nextPath = activeConversationId
? `/chat?conversation=${encodeURIComponent(activeConversationId)}`
: '/chat'
navigate(nextPath, { replace: true })
}, 500)
return () => clearTimeout(timer)
}, [agentId, initialPrompt, sendMessage, navigate])
}, [activeConversationId, initialPrompt, sendMessage, navigate])
if (initError) {
return (
@ -92,7 +97,7 @@ export function LocalChat({ initialPrompt }: LocalChatProps) {
)
}
if (!agentId) {
if (!activeConversationId) {
return (
<div className="flex-1 flex items-center justify-center gap-2 text-muted-foreground text-sm">
<Loading />

View file

@ -11,6 +11,7 @@ export interface QRCodeData {
gateway: string
hubId: string
agentId: string
conversationId?: string
token: string
expires: number
}
@ -19,6 +20,7 @@ export interface ConnectionQRCodeProps {
gateway: string
hubId: string
agentId: string
conversationId?: string
expirySeconds?: number
size?: number
}
@ -125,6 +127,7 @@ export function ConnectionQRCode({
gateway,
hubId,
agentId,
conversationId,
expirySeconds = 30,
size = 200,
}: ConnectionQRCodeProps) {
@ -138,10 +141,11 @@ export function ConnectionQRCode({
gateway,
hubId,
agentId,
conversationId: conversationId ?? agentId,
token,
expires: expiresAt,
}),
[gateway, hubId, agentId, token, expiresAt]
[gateway, hubId, agentId, conversationId, token, expiresAt]
)
const connectionUrl = useMemo(() => {
@ -149,11 +153,12 @@ export function ConnectionQRCode({
gateway,
hub: hubId,
agent: agentId,
conversation: conversationId ?? agentId,
token,
exp: expiresAt.toString(),
})
return `multica://connect?${params.toString()}`
}, [gateway, hubId, agentId, token, expiresAt])
}, [gateway, hubId, agentId, conversationId, token, expiresAt])
return (
<div className="flex flex-col items-center gap-4">

View file

@ -8,6 +8,7 @@ export interface TelegramConnectQRProps {
gateway: string
hubId: string
agentId: string
conversationId?: string
expirySeconds?: number
size?: number
}
@ -23,6 +24,7 @@ export function TelegramConnectQR({
gateway,
hubId,
agentId,
conversationId,
expirySeconds = 30,
size = 200,
}: TelegramConnectQRProps) {
@ -44,7 +46,14 @@ export function TelegramConnectQR({
const res = await fetch(`${gateway}/telegram/connect-code`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ gateway, hubId, agentId, token, expires: expiresAt }),
body: JSON.stringify({
gateway,
hubId,
agentId,
conversationId: conversationId ?? agentId,
token,
expires: expiresAt,
}),
})
if (cancelled) return
@ -72,7 +81,7 @@ export function TelegramConnectQR({
fetchCode()
return () => { cancelled = true }
}, [token, expiresAt, gateway, hubId, agentId])
}, [token, expiresAt, gateway, hubId, agentId, conversationId])
if (loading) {
return (

View file

@ -15,11 +15,16 @@ export interface QueuedLocalMessage {
createdAt: number
}
interface UseLocalChatOptions {
conversationId?: string
}
function makeQueueId(): string {
return globalThis.crypto?.randomUUID?.() ?? `queued-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
}
export function useLocalChat() {
export function useLocalChat(options: UseLocalChatOptions = {}) {
const requestedConversationId = options.conversationId
const chat = useChat()
const chatRef = useRef(chat)
chatRef.current = chat
@ -33,6 +38,7 @@ export function useLocalChat() {
const [initError, setInitError] = useState<string | null>(null)
const initRef = useRef(false)
const offsetRef = useRef<number | null>(null)
const activeConversationId = requestedConversationId ?? agentId
// Initialize hub and get default agent ID
useEffect(() => {
@ -41,10 +47,13 @@ export function useLocalChat() {
window.electronAPI.hub.init()
.then((result) => {
const r = result as { defaultAgentId?: string }
console.log('[LocalChat] hub.init → defaultAgentId:', r.defaultAgentId)
if (r.defaultAgentId) {
setAgentId(r.defaultAgentId)
const r = result as { defaultAgentId?: string; defaultConversationId?: string }
const defaultConversationId = r.defaultConversationId ?? r.defaultAgentId
console.log('[LocalChat] hub.init → defaultConversationId:', defaultConversationId)
if (defaultConversationId) {
setAgentId(defaultConversationId)
} else if (requestedConversationId) {
setAgentId(requestedConversationId)
} else {
setInitError('No default agent available')
setIsLoadingHistory(false)
@ -54,14 +63,20 @@ export function useLocalChat() {
setInitError(err.message)
setIsLoadingHistory(false)
})
}, [])
}, [requestedConversationId])
// Subscribe to events + fetch history once agentId is available
// Subscribe to events + fetch history once conversation is available
useEffect(() => {
if (!agentId) return
if (!activeConversationId) return
const resolvedAgentId = agentId ?? activeConversationId
setQueuedMessages([])
offsetRef.current = null
setIsLoading(false)
setIsLoadingHistory(true)
chatRef.current.reset()
// Subscribe to agent events
window.electronAPI.localChat.subscribe(agentId).catch(() => {})
window.electronAPI.localChat.subscribe(activeConversationId).catch(() => {})
// Listen for stream events
window.electronAPI.localChat.onEvent((data) => {
@ -104,24 +119,39 @@ export function useLocalChat() {
// Listen for inbound messages from all sources (gateway, channel)
// This allows the local UI to display messages from other sources
window.electronAPI.hub.onInboundMessage((event: InboundMessageEvent) => {
const eventConversationId = event.conversationId ?? event.agentId
// Only add non-local messages (local messages are added by sendMessage)
if (event.source.type !== 'local' && event.agentId === agentId) {
chatRef.current.addUserMessage(event.content, event.agentId, event.source as MessageSource)
if (event.source.type !== 'local' && eventConversationId === activeConversationId) {
chatRef.current.addUserMessage(
event.content,
event.agentId,
event.source as MessageSource,
eventConversationId,
)
setIsLoading(true)
}
})
// Fetch history with pagination
window.electronAPI.localChat.getHistory(agentId, { limit: DEFAULT_MESSAGES_LIMIT })
window.electronAPI.localChat.getHistory(resolvedAgentId, {
limit: DEFAULT_MESSAGES_LIMIT,
conversationId: activeConversationId,
})
.then((result) => {
console.log('[LocalChat] getHistory result:', result.messages?.length, 'messages, total:', result.total)
if (result.messages?.length) {
chatRef.current.setHistory(result.messages as AgentMessageItem[], agentId, {
chatRef.current.setHistory(result.messages as AgentMessageItem[], resolvedAgentId, {
total: result.total,
offset: result.offset,
contextWindowTokens: result.contextWindowTokens,
})
}, activeConversationId)
offsetRef.current = result.offset
} else {
chatRef.current.setHistory([], resolvedAgentId, {
total: 0,
offset: 0,
contextWindowTokens: result.contextWindowTokens,
}, activeConversationId)
}
})
.catch(() => {})
@ -131,9 +161,9 @@ export function useLocalChat() {
window.electronAPI.localChat.offEvent()
window.electronAPI.localChat.offApproval()
window.electronAPI.hub.offInboundMessage()
window.electronAPI.localChat.unsubscribe(agentId).catch(() => {})
window.electronAPI.localChat.unsubscribe(activeConversationId).catch(() => {})
}
}, [agentId])
}, [agentId, activeConversationId])
useEffect(() => {
isLoadingRef.current = isLoading
@ -141,11 +171,12 @@ export function useLocalChat() {
const dispatchMessageNow = useCallback((text: string) => {
const trimmed = text.trim()
if (!trimmed || !agentId) return
chatRef.current.addUserMessage(trimmed, agentId, { type: 'local' })
if (!trimmed || !activeConversationId) return
const resolvedAgentId = agentId ?? activeConversationId
chatRef.current.addUserMessage(trimmed, resolvedAgentId, { type: 'local' }, activeConversationId)
chatRef.current.setError(null)
setIsLoading(true)
window.electronAPI.localChat.send(agentId, trimmed)
window.electronAPI.localChat.send(resolvedAgentId, trimmed, activeConversationId)
.then((result) => {
const response = result as { ok?: boolean; error?: string } | undefined
if (response?.error) {
@ -155,11 +186,11 @@ export function useLocalChat() {
.catch(() => {
setIsLoading(false)
})
}, [agentId])
}, [agentId, activeConversationId])
const sendMessage = useCallback((text: string) => {
const trimmed = text.trim()
if (!trimmed || !agentId) return
if (!trimmed || !activeConversationId) return
if (isLoadingRef.current) {
setQueuedMessages((prev) => [
@ -174,7 +205,7 @@ export function useLocalChat() {
}
dispatchMessageNow(trimmed)
}, [agentId, dispatchMessageNow])
}, [activeConversationId, dispatchMessageNow])
const removeQueuedMessage = useCallback((id: string) => {
setQueuedMessages((prev) => prev.filter((item) => item.id !== id))
@ -185,35 +216,41 @@ export function useLocalChat() {
}, [])
useEffect(() => {
if (!agentId || isLoading || queuedMessages.length === 0) return
if (!activeConversationId || isLoading || queuedMessages.length === 0) return
const next = queuedMessages[0]
if (!next) return
setQueuedMessages((prev) => prev.slice(1))
dispatchMessageNow(next.text)
}, [agentId, isLoading, queuedMessages, dispatchMessageNow])
}, [activeConversationId, isLoading, queuedMessages, dispatchMessageNow])
const abortGeneration = useCallback(() => {
if (!agentId) return
window.electronAPI.localChat.abort(agentId).catch(() => {})
if (!activeConversationId) return
const resolvedAgentId = agentId ?? activeConversationId
window.electronAPI.localChat.abort(resolvedAgentId, activeConversationId).catch(() => {})
setIsLoading(false)
}, [agentId])
}, [agentId, activeConversationId])
const loadMore = useCallback(async () => {
const currentOffset = offsetRef.current
if (!agentId || currentOffset == null || currentOffset <= 0 || isLoadingMoreRef.current) return
if (!activeConversationId || currentOffset == null || currentOffset <= 0 || isLoadingMoreRef.current) return
isLoadingMoreRef.current = true
setIsLoadingMore(true)
try {
const resolvedAgentId = agentId ?? activeConversationId
const newOffset = Math.max(0, currentOffset - DEFAULT_MESSAGES_LIMIT)
const limit = currentOffset - newOffset
const result = await window.electronAPI.localChat.getHistory(agentId, { offset: newOffset, limit })
const result = await window.electronAPI.localChat.getHistory(resolvedAgentId, {
offset: newOffset,
limit,
conversationId: activeConversationId,
})
if (result.messages?.length) {
chatRef.current.prependHistory(result.messages as AgentMessageItem[], agentId, {
chatRef.current.prependHistory(result.messages as AgentMessageItem[], resolvedAgentId, {
total: result.total,
offset: result.offset,
contextWindowTokens: result.contextWindowTokens,
})
}, activeConversationId)
offsetRef.current = result.offset
}
} catch {
@ -222,7 +259,7 @@ export function useLocalChat() {
isLoadingMoreRef.current = false
setIsLoadingMore(false)
}
}, [agentId])
}, [agentId, activeConversationId])
const resolveApproval = useCallback(
(approvalId: string, decision: ApprovalDecision) => {
@ -238,6 +275,7 @@ export function useLocalChat() {
return {
agentId,
conversationId: activeConversationId,
initError,
messages: chat.messages,
streamingIds: chat.streamingIds,

View file

@ -12,6 +12,7 @@ import {
MessageSquare,
Users,
Clock,
Plus,
ChevronLeft,
ChevronRight,
ChevronDown,
@ -48,6 +49,7 @@ import { LocalChat } from '../components/local-chat'
import { DeviceConfirmDialog } from '../components/device-confirm-dialog'
import { UpdateNotification } from '../components/update-notification'
import { useAuthStore } from '../stores/auth'
import { useHubStore } from '../stores/hub'
const mainNavItems = [
{ path: '/', label: 'Home', icon: Home, exact: true },
@ -72,6 +74,11 @@ const allNavItems: Array<{ path: string; label: string; icon: typeof Home; exact
...bottomNavItems,
]
function shortConversationId(id: string): string {
if (id.length <= 18) return id
return `${id.slice(0, 8)}...${id.slice(-8)}`
}
function NavigationButtons() {
const navigate = useNavigate()
useLocation()
@ -155,6 +162,13 @@ export default function Layout() {
const isAgentActive = location.pathname.startsWith('/agent')
const isOnChat = location.pathname === '/chat'
const { user, clearAuth } = useAuthStore()
const { agents, refresh: refreshAgents, createConversation } = useHubStore()
const [isCreatingConversation, setIsCreatingConversation] = useState(false)
const [conversationError, setConversationError] = useState<string | null>(null)
const selectedConversationId = isOnChat
? new URLSearchParams(location.search).get('conversation') ?? undefined
: undefined
const activeConversationId = selectedConversationId ?? agents[0]?.id
// Lazy mount: only mount Chat on first visit, then keep it mounted forever
const [chatMounted, setChatMounted] = useState(false)
@ -162,6 +176,19 @@ export default function Layout() {
if (isOnChat && !chatMounted) setChatMounted(true)
}, [isOnChat, chatMounted])
useEffect(() => {
if (!isOnChat) return
void refreshAgents()
}, [isOnChat, refreshAgents])
useEffect(() => {
if (!isOnChat || !selectedConversationId || agents.length === 0) return
const exists = agents.some((item) => item.id === selectedConversationId)
if (!exists) {
navigate('/chat', { replace: true })
}
}, [isOnChat, selectedConversationId, agents, navigate])
// Extract initialPrompt from URL search params when navigating to /chat?prompt=...
const initialPrompt = isOnChat
? new URLSearchParams(location.search).get('prompt') ?? undefined
@ -172,6 +199,29 @@ export default function Layout() {
navigate('/login')
}
const openConversation = (id: string) => {
if (!id) return
navigate(`/chat?conversation=${encodeURIComponent(id)}`)
}
const handleCreateConversation = async () => {
setConversationError(null)
setIsCreatingConversation(true)
try {
const created = await createConversation()
if (!created?.id) {
setConversationError('Failed to create session')
return
}
openConversation(created.id)
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
setConversationError(message)
} finally {
setIsCreatingConversation(false)
}
}
return (
<div className="flex h-screen flex-col bg-background text-foreground">
<SidebarProvider className="flex-1 overflow-hidden">
@ -304,7 +354,56 @@ export default function Layout() {
</div>
{chatMounted && (
<div className={cn('h-full flex flex-col overflow-hidden', !isOnChat && 'hidden')}>
<LocalChat initialPrompt={initialPrompt} />
<div className="border-b px-4 py-2 flex items-center justify-between gap-3">
<div className="flex items-center gap-2 min-w-0">
<span className="text-xs text-muted-foreground">Session</span>
{activeConversationId ? (
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button variant="outline" size="sm" className="h-7 max-w-64 justify-start font-mono text-xs" />
}
>
{shortConversationId(activeConversationId)}
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-80">
{agents.length === 0 ? (
<DropdownMenuItem disabled>
No sessions available
</DropdownMenuItem>
) : (
agents.map((item) => (
<DropdownMenuItem key={item.id} onClick={() => openConversation(item.id)}>
<span className="font-mono text-xs truncate">{item.id}</span>
{item.id === activeConversationId && <span className="ml-auto text-xs">Current</span>}
</DropdownMenuItem>
))
)}
</DropdownMenuContent>
</DropdownMenu>
) : (
<span className="text-xs text-muted-foreground">Initializing...</span>
)}
</div>
<Button
variant="outline"
size="sm"
className="h-7 gap-1.5 shrink-0"
onClick={handleCreateConversation}
disabled={isCreatingConversation}
>
<Plus className="size-3.5" />
{isCreatingConversation ? 'Creating...' : 'New Session'}
</Button>
</div>
{conversationError && (
<div className="px-4 py-2 text-xs text-destructive border-b">
{conversationError}
</div>
)}
<LocalChat initialPrompt={initialPrompt} conversationId={activeConversationId} />
</div>
)}
</main>

View file

@ -27,6 +27,8 @@ interface HubStore {
init: () => Promise<void>
refresh: () => Promise<void>
reconnect: (url: string) => Promise<{ ok: boolean; error?: string }>
createConversation: (id?: string) => Promise<AgentInfo | null>
closeConversation: (id: string) => Promise<boolean>
}
export const useHubStore = create<HubStore>()((set, get) => ({
@ -101,6 +103,34 @@ export const useHubStore = create<HubStore>()((set, get) => ({
return { ok: false, error: message }
}
},
createConversation: async (id?: string) => {
try {
const result = await window.electronAPI.hub.createConversation(id) as { id?: string; closed?: boolean }
await get().refresh()
if (!result?.id) return null
return {
id: result.id,
closed: result.closed ?? false,
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
set({ error: message })
return null
}
},
closeConversation: async (id: string) => {
try {
const result = await window.electronAPI.hub.closeConversation(id) as { ok?: boolean }
await get().refresh()
return !!result?.ok
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
set({ error: message })
return false
}
},
}))
// Selector helpers