feat(desktop): add conversation switcher and new-session flow
This commit is contained in:
parent
754e604a40
commit
16753d719b
9 changed files with 373 additions and 108 deletions
53
apps/desktop/src/main/electron-env.d.ts
vendored
53
apps/desktop/src/main/electron-env.d.ts
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue