chore: refactor to use useStream

This commit is contained in:
Christian Bromann
2026-01-13 14:13:13 -08:00
parent cad513a519
commit 6c3ba7dd1f
15 changed files with 1818 additions and 566 deletions
+3 -1
View File
@@ -28,6 +28,7 @@
"@langchain/core": "^1.1.12",
"@langchain/langgraph": "^1.0.15",
"@langchain/langgraph-checkpoint": "^1.0.0",
"@langchain/langgraph-sdk": "^1.5.3",
"@langchain/openai": "^1.2.1",
"@radix-ui/react-context-menu": "^2.2.16",
"@radix-ui/react-dialog": "^1.1.15",
@@ -77,5 +78,6 @@
"tailwindcss": "^4.0.0",
"typescript": "^5.9.3",
"vite": "^7.2.6"
}
},
"packageManager": "pnpm@10.28.0+sha512.05df71d1421f21399e053fde567cea34d446fa02c76571441bfc1c7956e98e363088982d940465fd34480d4d90a0668bc12362f8aa88000a64e83d0b0e47be48"
}
+865
View File
File diff suppressed because it is too large Load Diff
+16 -11
View File
@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import { createDeepAgent } from 'deepagents'
import { app } from 'electron'
import { join } from 'path'
@@ -6,6 +7,11 @@ import { ChatAnthropic } from '@langchain/anthropic'
import { ChatOpenAI } from '@langchain/openai'
import { SqlJsSaver } from '../checkpointer/sqljs-saver'
import type * as _lcTypes from 'langchain'
import type * as _lcMessages from '@langchain/core/messages'
import type * as _lcLanggraph from '@langchain/langgraph'
import type * as _lcZodTypes from '@langchain/core/utils/types'
// Singleton checkpointer instance
let checkpointer: SqlJsSaver | null = null
@@ -19,7 +25,7 @@ export async function getCheckpointer(): Promise<SqlJsSaver> {
}
// Get the appropriate model instance based on configuration
function getModelInstance(modelId?: string) {
function getModelInstance(modelId?: string): ChatAnthropic | ChatOpenAI | string {
const model = modelId || getDefaultModel()
console.log('[Runtime] Using model:', model)
@@ -54,30 +60,29 @@ function getModelInstance(modelId?: string) {
}
// Create agent runtime with configured model and checkpointer
export type AgentRuntime = ReturnType<typeof createDeepAgent>
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
export async function createAgentRuntime(modelId?: string) {
console.log('[Runtime] Creating agent runtime...')
const model = getModelInstance(modelId)
console.log('[Runtime] Model instance created:', typeof model)
const saver = await getCheckpointer()
console.log('[Runtime] Checkpointer ready')
// Using type assertion to work around version compatibility issues
// between @langchain packages and deepagentsjs types
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const agent = createDeepAgent({
model: model as any,
checkpointer: saver as any
model: model,
checkpointer: saver
})
console.log('[Runtime] Deep agent created')
console.log('[Runtime] Deep agent created')
return agent
}
// Clean up resources
export async function closeRuntime() {
export async function closeRuntime(): Promise<void> {
if (checkpointer) {
await checkpointer.close()
checkpointer = null
+6 -10
View File
@@ -4,7 +4,7 @@ import { dirname, join } from 'path'
import { app } from 'electron'
// Database path in user data directory
const getDbPath = () => join(app.getPath('userData'), 'openwork.sqlite')
const getDbPath = (): string => join(app.getPath('userData'), 'openwork.sqlite')
let db: SqlJsDatabase | null = null
let saveTimer: ReturnType<typeof setTimeout> | null = null
@@ -176,10 +176,7 @@ export function getThread(threadId: string): Thread | null {
return thread
}
export function createThread(
threadId: string,
metadata?: Record<string, unknown>
): Thread {
export function createThread(threadId: string, metadata?: Record<string, unknown>): Thread {
const database = getDb()
const now = Date.now()
@@ -217,7 +214,9 @@ export function updateThread(
if (updates.metadata !== undefined) {
setClauses.push('metadata = ?')
values.push(typeof updates.metadata === 'string' ? updates.metadata : JSON.stringify(updates.metadata))
values.push(
typeof updates.metadata === 'string' ? updates.metadata : JSON.stringify(updates.metadata)
)
}
if (updates.status !== undefined) {
setClauses.push('status = ?')
@@ -234,10 +233,7 @@ export function updateThread(
values.push(threadId)
database.run(
`UPDATE threads SET ${setClauses.join(', ')} WHERE thread_id = ?`,
values
)
database.run(`UPDATE threads SET ${setClauses.join(', ')} WHERE thread_id = ?`, values)
saveToDisk()
+189 -213
View File
@@ -1,239 +1,215 @@
import { IpcMain, BrowserWindow } from 'electron'
import { HumanMessage } from '@langchain/core/messages'
import { HumanMessage, BaseMessage, AIMessage, AIMessageChunk } from '@langchain/core/messages'
import { createAgentRuntime } from '../agent/runtime'
import type { HITLDecision, StreamEvent } from '../types'
import type { HITLDecision } from '../types'
// Track active runs for cancellation
const activeRuns = new Map<string, AbortController>()
export function registerAgentHandlers(ipcMain: IpcMain) {
/**
* Serialize a LangChain message to a plain object for IPC
*/
function serializeMessage(msg: BaseMessage): {
id: string
type: 'human' | 'ai' | 'tool' | 'system'
content: string
tool_calls?: Array<{ id: string; name: string; args: Record<string, unknown> }>
} {
// Extract content as string
let content = ''
if (typeof msg.content === 'string') {
content = msg.content
} else if (Array.isArray(msg.content)) {
content = msg.content
.filter((block): block is { type: 'text'; text: string } => block.type === 'text')
.map((block) => block.text)
.join('')
}
// Map LangChain message types
const type =
msg.type === 'human'
? 'human'
: msg.type === 'ai'
? 'ai'
: msg.type === 'tool'
? 'tool'
: 'system'
// Extract tool calls from AI messages
const toolCalls =
msg instanceof AIMessage && msg.tool_calls?.length
? msg.tool_calls.map((tc) => ({
id: tc.id || crypto.randomUUID(),
name: tc.name,
args: tc.args as Record<string, unknown>
}))
: undefined
return {
id: msg.id || crypto.randomUUID(),
type,
content,
tool_calls: toolCalls
}
}
export function registerAgentHandlers(ipcMain: IpcMain): void {
console.log('[Agent] Registering agent handlers...')
// Handle agent invocation with streaming
ipcMain.on('agent:invoke', async (event, { threadId, message }: { threadId: string; message: string }) => {
const channel = `agent:stream:${threadId}`
const window = BrowserWindow.fromWebContents(event.sender)
console.log('[Agent] Received invoke request:', { threadId, message: message.substring(0, 50) })
if (!window) {
console.error('[Agent] No window found')
return
}
ipcMain.on(
'agent:invoke',
async (event, { threadId, message }: { threadId: string; message: string }) => {
const channel = `agent:stream:${threadId}`
const window = BrowserWindow.fromWebContents(event.sender)
const abortController = new AbortController()
activeRuns.set(threadId, abortController)
console.log('[Agent] Received invoke request:', {
threadId,
message: message.substring(0, 50)
})
try {
console.log('[Agent] Creating runtime...')
const agent = await createAgentRuntime()
console.log('[Agent] Runtime created, starting stream...')
// Create proper HumanMessage
const humanMessage = new HumanMessage(message)
// Track seen message IDs to avoid duplicates
const seenMessageIds = new Set<string>()
// Stream with values mode to get full state after each step
// Note: 'messages' mode was causing tool call corruption, so we stick with 'values'
const stream = await agent.stream(
{ messages: [humanMessage] },
{
configurable: { thread_id: threadId },
signal: abortController.signal,
streamMode: 'values',
recursionLimit: 1000 // Match Python deepagents behavior
}
)
console.log('[Agent] Stream started with streamMode: values')
if (!window) {
console.error('[Agent] No window found')
return
}
for await (const chunk of stream) {
if (abortController.signal.aborted) break
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const state = chunk as any
console.log('[Agent] Chunk keys:', Object.keys(state || {}))
// Process messages from state
if (state.messages && Array.isArray(state.messages)) {
for (const msg of state.messages) {
const msgId = msg.id || crypto.randomUUID()
// Skip if we've already sent this message
if (seenMessageIds.has(msgId)) continue
// Determine the role from the message type
let role: 'user' | 'assistant' | 'system' | 'tool' = 'assistant'
if (typeof msg._getType === 'function') {
const msgType = msg._getType()
if (msgType === 'human') role = 'user'
else if (msgType === 'ai') role = 'assistant'
else if (msgType === 'system') role = 'system'
else if (msgType === 'tool') role = 'tool'
}
// Extract content
let content: string = ''
if (typeof msg.content === 'string') {
content = msg.content
} else if (Array.isArray(msg.content)) {
content = msg.content
.filter((block: { type?: string }) => block.type === 'text')
.map((block: { text?: string }) => block.text || '')
.join('')
}
// Only send assistant messages with content
if (role === 'assistant' && content) {
seenMessageIds.add(msgId)
const streamEvent: StreamEvent = {
type: 'message',
message: {
id: msgId,
role,
content,
tool_calls: msg.tool_calls,
created_at: new Date()
}
const abortController = new AbortController()
activeRuns.set(threadId, abortController)
try {
const agent = await createAgentRuntime()
const humanMessage = new HumanMessage(message)
// Track state for deduplication
const seenMessageIds = new Set<string>()
let currentMessageId: string | null = null
// Stream with both modes:
// - 'messages' for real-time token streaming
// - 'values' for full state (todos, files, etc.)
const stream = await agent.stream(
{ messages: [humanMessage] },
{
configurable: { thread_id: threadId },
signal: abortController.signal,
streamMode: ['messages', 'values'],
recursionLimit: 1000
}
)
for await (const chunk of stream) {
if (abortController.signal.aborted) break
// With multiple stream modes, chunks are tuples: [mode, data]
const [mode, data] = chunk as [string, unknown]
if (mode === 'messages') {
// Messages mode returns [message, metadata] tuples
const [msgChunk, metadata] = data as [AIMessageChunk, { langgraph_node?: string }]
console.log('[Agent] Message chunk:', {
type: msgChunk?.constructor?.name,
node: metadata?.langgraph_node
})
// Process AI message chunks (from any node that produces AI messages)
if (msgChunk instanceof AIMessageChunk) {
const content =
typeof msgChunk.content === 'string'
? msgChunk.content
: Array.isArray(msgChunk.content)
? msgChunk.content
.filter((b): b is { type: 'text'; text: string } => b.type === 'text')
.map((b) => b.text)
.join('')
: ''
if (content) {
// Track message ID for grouping tokens
const msgId = msgChunk.id || currentMessageId || crypto.randomUUID()
currentMessageId = msgId
console.log('[Agent] Sending token:', content.substring(0, 50))
window.webContents.send(channel, {
type: 'token',
messageId: msgId,
token: content
})
}
window.webContents.send(channel, streamEvent)
console.log('[Agent] Sent message:', msgId.substring(0, 20))
}
}
}
// Check for todos in agent state
if (state.todos && Array.isArray(state.todos)) {
const todosEvent: StreamEvent = {
type: 'todos',
todos: (state.todos as Array<{ id?: string; content?: string; status?: string }>).map((t) => ({
id: t.id || crypto.randomUUID(),
content: t.content || '',
status: (t.status || 'pending') as 'pending' | 'in_progress' | 'completed' | 'cancelled'
}))
}
window.webContents.send(channel, todosEvent)
}
// Check for workspace/file state
// deepagents stores files as Record<string, FileData> (object keyed by path)
const filesObj = state.files as Record<string, { content?: string; lastModified?: number }> | undefined
const workspacePath = (state.workspacePath as string) || process.cwd()
if (filesObj && typeof filesObj === 'object' && !Array.isArray(filesObj)) {
// Convert object format to array format
const files = Object.entries(filesObj).map(([filePath, data]) => ({
path: filePath,
is_dir: false,
size: typeof data?.content === 'string' ? data.content.length : undefined
}))
if (files.length > 0) {
console.log('[Agent] Sending workspace event with', files.length, 'files')
const workspaceEvent: StreamEvent = {
type: 'workspace',
files,
path: workspacePath
// Handle tool calls in the chunk
if (msgChunk.tool_call_chunks?.length) {
window.webContents.send(channel, {
type: 'tool_call',
messageId: currentMessageId,
tool_calls: msgChunk.tool_call_chunks
})
}
}
window.webContents.send(channel, workspaceEvent)
}
} else if (Array.isArray(filesObj)) {
// Handle legacy array format if present
const files = (filesObj as Array<{ path: string; is_dir?: boolean; size?: number }>)
if (files.length > 0) {
const workspaceEvent: StreamEvent = {
type: 'workspace',
files: files.map((f) => ({
path: f.path,
is_dir: f.is_dir,
size: f.size
})),
path: workspacePath
} else if (mode === 'values') {
// Values mode returns the full state
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const state = data as any
// Send complete messages (for final state)
const messages = (state.messages as BaseMessage[] | undefined)
?.filter((msg) => {
const id = msg.id || ''
if (seenMessageIds.has(id)) return false
if (msg.type === 'ai' && msg.content) {
seenMessageIds.add(id)
return true
}
return false
})
.map(serializeMessage)
// Reset current message ID when we get a complete message
if (messages?.length) {
currentMessageId = null
}
window.webContents.send(channel, workspaceEvent)
window.webContents.send(channel, {
type: 'values',
data: {
messages,
todos: state.todos,
files: state.files,
workspacePath: state.workspacePath,
subagents: state.subagents,
interrupt: state.__interrupt__
}
})
}
}
// Check for subagents in agent state
const subagentsRaw = state.subagents as Array<{
id?: string
name?: string
type?: string
description?: string
status?: string
startedAt?: Date | string
completedAt?: Date | string
}> | undefined
if (subagentsRaw && Array.isArray(subagentsRaw) && subagentsRaw.length > 0) {
console.log('[Agent] Sending subagents event with', subagentsRaw.length, 'subagents')
const subagentsEvent: StreamEvent = {
type: 'subagents',
subagents: subagentsRaw.map((s) => ({
id: s.id || crypto.randomUUID(),
name: s.name || s.type || 'Subagent',
description: s.description || '',
status: (s.status || 'pending') as 'pending' | 'running' | 'completed' | 'failed',
startedAt: s.startedAt ? new Date(s.startedAt) : undefined,
completedAt: s.completedAt ? new Date(s.completedAt) : undefined
}))
}
window.webContents.send(channel, subagentsEvent)
}
// Check for interrupts (HITL)
const interrupt = state.__interrupt__ as { id?: string; tool_call?: unknown } | undefined
if (interrupt) {
const streamEvent: StreamEvent = {
type: 'interrupt',
request: {
id: interrupt.id || crypto.randomUUID(),
tool_call: interrupt.tool_call as { id: string; name: string; args: Record<string, unknown> },
allowed_decisions: ['approve', 'reject', 'edit']
}
}
window.webContents.send(channel, streamEvent)
}
// Send done event
window.webContents.send(channel, { type: 'done' })
} catch (error) {
console.error('[Agent] Error:', error)
window.webContents.send(channel, {
type: 'error',
error: error instanceof Error ? error.message : 'Unknown error'
})
} finally {
activeRuns.delete(threadId)
}
// Send done event
console.log('[Agent] Stream complete, sending done event')
const doneEvent: StreamEvent = { type: 'done', result: null }
window.webContents.send(channel, doneEvent)
} catch (error) {
console.error('[Agent] Error:', error)
const errorEvent: StreamEvent = {
type: 'error',
error: error instanceof Error ? error.message : 'Unknown error'
}
window.webContents.send(channel, errorEvent)
} finally {
activeRuns.delete(threadId)
}
})
)
// Handle HITL interrupt response
ipcMain.handle('agent:interrupt', async (_event, { threadId, decision }: { threadId: string; decision: HITLDecision }) => {
const agent = await createAgentRuntime()
// Get the current state
const config = { configurable: { thread_id: threadId } }
// Resume with the decision
if (decision.type === 'approve') {
// Continue execution
await agent.invoke(null, config)
} else if (decision.type === 'reject') {
// Cancel the tool call
// The agent will handle this via Command
} else if (decision.type === 'edit') {
// Update the tool call args and continue
// This requires updating state before resuming
ipcMain.handle(
'agent:interrupt',
async (_event, { threadId, decision }: { threadId: string; decision: HITLDecision }) => {
const agent = await createAgentRuntime()
const config = { configurable: { thread_id: threadId } }
if (decision.type === 'approve') {
await agent.invoke(null, config)
}
// reject and edit handled by Command in future
}
})
)
// Handle cancellation
ipcMain.handle('agent:cancel', async (_event, { threadId }: { threadId: string }) => {
+6
View File
@@ -16,6 +16,12 @@ interface ElectronAPI {
interface CustomAPI {
agent: {
invoke: (threadId: string, message: string, onEvent: (event: StreamEvent) => void) => () => void
streamAgent: (
threadId: string,
message: string,
command: unknown,
onEvent: (event: StreamEvent) => void
) => () => void
interrupt: (threadId: string, decision: HITLDecision) => Promise<void>
cancel: (threadId: string) => Promise<void>
}
+45 -8
View File
@@ -25,28 +25,65 @@ const api = {
agent: {
// Send message and receive events via callback
invoke: (
threadId: string,
message: string,
threadId: string,
message: string,
onEvent: (event: StreamEvent) => void
): (() => void) => {
console.log('[Preload] invoke() called', { threadId, message: message.substring(0, 50) })
const channel = `agent:stream:${threadId}`
const handler = (_: unknown, data: StreamEvent) => {
const handler = (_: unknown, data: StreamEvent): void => {
console.log('[Preload] Received event:', data.type)
onEvent(data)
// Clean up listener on terminal events
if (data.type === 'done' || data.type === 'error') {
ipcRenderer.removeListener(channel, handler)
}
}
ipcRenderer.on(channel, handler)
console.log('[Preload] Sending agent:invoke IPC')
ipcRenderer.send('agent:invoke', { threadId, message })
// Return cleanup function
return () => {
ipcRenderer.removeListener(channel, handler)
}
},
// Stream agent events for useStream transport
streamAgent: (
threadId: string,
message: string,
command: unknown,
onEvent: (event: StreamEvent) => void
): (() => void) => {
console.log('[Preload] streamAgent() called', { threadId, message: message.substring(0, 50) })
const channel = `agent:stream:${threadId}`
const handler = (_: unknown, data: StreamEvent): void => {
console.log('[Preload] Received stream event:', data.type)
onEvent(data)
// Clean up listener on terminal events
if (data.type === 'done' || data.type === 'error') {
ipcRenderer.removeListener(channel, handler)
}
}
ipcRenderer.on(channel, handler)
// If we have a command, it might be a resume/retry
if (command) {
console.log('[Preload] Sending agent:resume IPC')
ipcRenderer.send('agent:resume', { threadId, command })
} else {
console.log('[Preload] Sending agent:invoke IPC')
ipcRenderer.send('agent:invoke', { threadId, message })
}
// Return cleanup function
return () => {
ipcRenderer.removeListener(channel, handler)
+13 -10
View File
@@ -4,18 +4,21 @@ import { ChatContainer } from '@/components/chat/ChatContainer'
import { RightPanel } from '@/components/panels/RightPanel'
import { useAppStore } from '@/lib/store'
function App() {
function App(): React.JSX.Element {
const { currentThreadId, loadThreads, createThread, setSettingsOpen } = useAppStore()
const [isLoading, setIsLoading] = useState(true)
// Keyboard shortcuts
const handleKeyDown = useCallback((e: KeyboardEvent) => {
// Cmd+, for settings
if ((e.metaKey || e.ctrlKey) && e.key === ',') {
e.preventDefault()
setSettingsOpen(true)
}
}, [setSettingsOpen])
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
// Cmd+, for settings
if ((e.metaKey || e.ctrlKey) && e.key === ',') {
e.preventDefault()
setSettingsOpen(true)
}
},
[setSettingsOpen]
)
useEffect(() => {
window.addEventListener('keydown', handleKeyDown)
@@ -23,7 +26,7 @@ function App() {
}, [handleKeyDown])
useEffect(() => {
async function init() {
async function init(): Promise<void> {
try {
await loadThreads()
// Create a default thread if none exist
@@ -52,7 +55,7 @@ function App() {
<div className="flex flex-col h-screen overflow-hidden bg-background">
{/* Draggable titlebar region */}
<div className="h-8 w-full shrink-0 app-drag-region bg-sidebar" />
{/* Main content area */}
<div className="flex flex-1 overflow-hidden">
{/* Left Sidebar - Thread List */}
@@ -1,54 +1,251 @@
import { useState, useRef, useEffect } from 'react'
import { useState, useRef, useEffect, useMemo, useCallback } from 'react'
import { Send, Square, Loader2 } from 'lucide-react'
import { useStream } from '@langchain/langgraph-sdk/react'
import { Button } from '@/components/ui/button'
import { ScrollArea } from '@/components/ui/scroll-area'
import { useAppStore } from '@/lib/store'
import { MessageBubble } from './MessageBubble'
import { ApprovalDialog } from '@/components/hitl/ApprovalDialog'
import { ElectronIPCTransport } from '@/lib/electron-transport'
import type { Message } from '@/types'
interface ChatContainerProps {
threadId: string
}
export function ChatContainer({ threadId }: ChatContainerProps) {
// Define custom event data types
interface TodoEventData {
id?: string
content?: string
status?: string
}
interface FileEventData {
path: string
is_dir?: boolean
size?: number
}
interface SubagentEventData {
id?: string
name?: string
description?: string
status?: string
startedAt?: Date
completedAt?: Date
}
interface MessageEventData {
id?: string
type?: string
role?: string
content?: string
tool_calls?: unknown[]
created_at?: Date
}
interface CustomEventData {
type?: string
message?: MessageEventData
todos?: TodoEventData[]
files?: FileEventData[]
path?: string
subagents?: SubagentEventData[]
request?: unknown
}
export function ChatContainer({ threadId }: ChatContainerProps): React.JSX.Element {
const [input, setInput] = useState('')
const inputRef = useRef<HTMLTextAreaElement>(null)
const scrollRef = useRef<HTMLDivElement>(null)
const {
messages,
isThreadStreaming,
getStreamingContent,
const {
messages: storeMessages,
pendingApproval,
sendMessage
setTodos,
setWorkspaceFiles,
setWorkspacePath,
setSubagents,
setPendingApproval,
appendMessage,
loadThreads,
generateTitleForFirstMessage
} = useAppStore()
// Get streaming state for this specific thread
const isStreaming = isThreadStreaming(threadId)
const streamingContent = getStreamingContent(threadId)
// Create transport instance (memoized to avoid recreating)
const transport = useMemo(() => new ElectronIPCTransport(), [])
// Handle custom events from the stream
const handleCustomEvent = useCallback(
(data: CustomEventData): void => {
console.log('[ChatContainer] Custom event:', data)
switch (data.type) {
case 'message':
if (data.message) {
const msg = data.message
const storeMsg: Message = {
id: msg.id || crypto.randomUUID(),
role:
msg.role === 'user' || msg.type === 'human'
? 'user'
: msg.role === 'assistant' || msg.type === 'ai'
? 'assistant'
: msg.role === 'tool' || msg.type === 'tool'
? 'tool'
: 'system',
content: msg.content || '',
tool_calls: msg.tool_calls as Message['tool_calls'],
created_at: msg.created_at ? new Date(msg.created_at) : new Date()
}
console.log('[ChatContainer] Adding message:', storeMsg)
appendMessage(storeMsg)
}
break
case 'todos':
if (Array.isArray(data.todos)) {
setTodos(
data.todos.map((t) => ({
id: t.id || crypto.randomUUID(),
content: t.content || '',
status: (t.status || 'pending') as
| 'pending'
| 'in_progress'
| 'completed'
| 'cancelled'
}))
)
}
break
case 'workspace':
if (Array.isArray(data.files)) {
setWorkspaceFiles(
data.files.map((f) => ({
path: f.path,
is_dir: f.is_dir,
size: f.size
}))
)
}
if (data.path) {
setWorkspacePath(data.path)
}
break
case 'subagents':
if (Array.isArray(data.subagents)) {
setSubagents(
data.subagents.map((s) => ({
id: s.id || crypto.randomUUID(),
name: s.name || 'Subagent',
description: s.description || '',
status: (s.status || 'pending') as 'pending' | 'running' | 'completed' | 'failed',
startedAt: s.startedAt,
completedAt: s.completedAt
}))
)
}
break
case 'interrupt':
if (data.request) {
setPendingApproval(data.request as Parameters<typeof setPendingApproval>[0])
}
break
}
},
[setTodos, setWorkspaceFiles, setWorkspacePath, setSubagents, setPendingApproval, appendMessage]
)
// Use the useStream hook with our custom transport
const stream = useStream({
transport,
threadId,
messagesKey: 'messages',
onCustomEvent: (data): void => {
handleCustomEvent(data as CustomEventData)
},
onError: (error): void => {
console.error('[ChatContainer] Stream error:', error)
}
})
console.log('[ChatContainer] Stream:', stream.messages)
// Refresh threads when loading state changes from true to false (stream completed)
const prevLoadingRef = useRef(false)
useEffect(() => {
if (prevLoadingRef.current && !stream.isLoading) {
// Stream just completed
loadThreads()
}
prevLoadingRef.current = stream.isLoading
}, [stream.isLoading, loadThreads])
// Combine store messages with streaming messages
const displayMessages = useMemo(() => {
// Get IDs of messages already in the store
const storeMessageIds = new Set(storeMessages.map((m) => m.id))
// Get streaming messages that aren't in the store yet
const streamingMsgs: Message[] = (stream.messages || [])
.filter((m): m is typeof m & { id: string } => !!m.id && !storeMessageIds.has(m.id))
.map((m) => ({
id: m.id,
role: (m.type === 'human' ? 'user' : 'assistant') as Message['role'],
content: typeof m.content === 'string' ? m.content : '',
created_at: new Date()
}))
return [...storeMessages, ...streamingMsgs]
}, [storeMessages, stream.messages])
// Auto-scroll on new messages
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
}
}, [messages, streamingContent, threadId])
}, [displayMessages, stream.isLoading, threadId])
// Focus input on mount
useEffect(() => {
inputRef.current?.focus()
}, [threadId])
const handleSubmit = async (e: React.FormEvent) => {
const handleSubmit = async (e: React.FormEvent): Promise<void> => {
e.preventDefault()
if (!input.trim() || isStreaming) return
if (!input.trim() || stream.isLoading) return
const message = input.trim()
setInput('')
await sendMessage(message)
// Check if this is the first message (for title generation)
const isFirstMessage = storeMessages.length === 0
// Add user message to store immediately
const userMessage: Message = {
id: crypto.randomUUID(),
role: 'user',
content: message,
created_at: new Date()
}
appendMessage(userMessage)
// Generate title for first message
if (isFirstMessage) {
generateTitleForFirstMessage(threadId, message)
}
// Submit via useStream
await stream.submit(
{
messages: [{ type: 'human', content: message }]
},
{
config: {
configurable: { thread_id: threadId }
}
}
)
}
const handleKeyDown = (e: React.KeyboardEvent) => {
const handleKeyDown = (e: React.KeyboardEvent): void => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSubmit(e)
@@ -56,7 +253,7 @@ export function ChatContainer({ threadId }: ChatContainerProps) {
}
// Auto-resize textarea based on content
const adjustTextareaHeight = () => {
const adjustTextareaHeight = (): void => {
const textarea = inputRef.current
if (textarea) {
textarea.style.height = 'auto'
@@ -68,8 +265,8 @@ export function ChatContainer({ threadId }: ChatContainerProps) {
adjustTextareaHeight()
}, [input])
const handleCancel = async () => {
await window.api.agent.cancel(threadId)
const handleCancel = async (): Promise<void> => {
await stream.stop()
}
return (
@@ -78,36 +275,24 @@ export function ChatContainer({ threadId }: ChatContainerProps) {
<ScrollArea className="flex-1 min-h-0" ref={scrollRef}>
<div className="p-4">
<div className="max-w-3xl mx-auto space-y-4">
{messages.length === 0 && !isStreaming && (
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
<div className="text-section-header mb-2">NEW THREAD</div>
<div className="text-sm">Start a conversation with the agent</div>
</div>
)}
{displayMessages.length === 0 && !stream.isLoading && (
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
<div className="text-section-header mb-2">NEW THREAD</div>
<div className="text-sm">Start a conversation with the agent</div>
</div>
)}
{messages.map((message) => (
<MessageBubble key={message.id} message={message} />
))}
{displayMessages.map((message) => (
<MessageBubble key={message.id} message={message} />
))}
{/* Streaming indicator */}
{isStreaming && streamingContent && (
<MessageBubble
message={{
id: 'streaming',
role: 'assistant',
content: streamingContent,
created_at: new Date()
}}
isStreaming
/>
)}
{isStreaming && !streamingContent && (
<div className="flex items-center gap-2 text-muted-foreground text-sm">
<Loader2 className="size-4 animate-spin" />
Agent is thinking...
</div>
)}
{/* Streaming indicator - only show if no streaming messages yet */}
{stream.isLoading && stream.messages.length === 0 && (
<div className="flex items-center gap-2 text-muted-foreground text-sm">
<Loader2 className="size-4 animate-spin" />
Agent is thinking...
</div>
)}
</div>
</div>
</ScrollArea>
@@ -122,28 +307,18 @@ export function ChatContainer({ threadId }: ChatContainerProps) {
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Message..."
disabled={isStreaming}
disabled={stream.isLoading}
className="flex-1 min-w-0 resize-none rounded-sm border border-border bg-background px-4 py-3 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:opacity-50"
rows={1}
style={{ minHeight: '48px', maxHeight: '200px' }}
/>
<div className="flex items-center justify-center shrink-0 h-12">
{isStreaming ? (
<Button
type="button"
variant="ghost"
size="icon"
onClick={handleCancel}
>
{stream.isLoading ? (
<Button type="button" variant="ghost" size="icon" onClick={handleCancel}>
<Square className="size-4" />
</Button>
) : (
<Button
type="submit"
variant="default"
size="icon"
disabled={!input.trim()}
>
<Button type="submit" variant="default" size="icon" disabled={!input.trim()}>
<Send className="size-4" />
</Button>
)}
@@ -1,5 +1,5 @@
import { useState } from 'react'
import { Plus, MessageSquare, Trash2, Settings, Pencil, Loader2 } from 'lucide-react'
import { Plus, MessageSquare, Trash2, Settings, Pencil } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Separator } from '@/components/ui/separator'
@@ -12,47 +12,46 @@ import {
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
ContextMenuTrigger
} from '@/components/ui/context-menu'
// Get version from package.json (injected at build time or via preload)
const APP_VERSION = '0.1.0'
export function ThreadSidebar() {
const {
threads,
currentThreadId,
createThread,
selectThread,
export function ThreadSidebar(): React.JSX.Element {
const {
threads,
currentThreadId,
createThread,
selectThread,
deleteThread,
updateThread,
settingsOpen,
setSettingsOpen,
streamingThreads
settingsOpen,
setSettingsOpen
} = useAppStore()
const [editingThreadId, setEditingThreadId] = useState<string | null>(null)
const [editingTitle, setEditingTitle] = useState('')
const startEditing = (threadId: string, currentTitle: string) => {
const startEditing = (threadId: string, currentTitle: string): void => {
setEditingThreadId(threadId)
setEditingTitle(currentTitle || '')
}
const saveTitle = async () => {
const saveTitle = async (): Promise<void> => {
if (editingThreadId && editingTitle.trim()) {
await updateThread(editingThreadId, { title: editingTitle.trim() })
}
setEditingThreadId(null)
setEditingTitle('')
}
const cancelEditing = () => {
const cancelEditing = (): void => {
setEditingThreadId(null)
setEditingTitle('')
}
const handleNewThread = async () => {
const handleNewThread = async (): Promise<void> => {
await createThread({ title: `Thread ${new Date().toLocaleDateString()}` })
}
@@ -62,9 +61,7 @@ export function ThreadSidebar() {
<div className="flex items-center justify-between p-4">
<div className="flex items-center gap-1.5">
<span className="text-section-header tracking-wider">OPENWORK</span>
<span className="text-[10px] text-muted-foreground font-mono">
{APP_VERSION}
</span>
<span className="text-[10px] text-muted-foreground font-mono">{APP_VERSION}</span>
</div>
<Button variant="ghost" size="icon-sm" onClick={handleNewThread} title="New thread">
<Plus className="size-4" />
@@ -81,10 +78,10 @@ export function ThreadSidebar() {
<ContextMenuTrigger asChild>
<div
className={cn(
"group flex items-center gap-2 rounded-sm px-3 py-2 cursor-pointer transition-colors overflow-hidden",
'group flex items-center gap-2 rounded-sm px-3 py-2 cursor-pointer transition-colors overflow-hidden',
currentThreadId === thread.thread_id
? "bg-sidebar-accent text-sidebar-accent-foreground"
: "hover:bg-sidebar-accent/50"
? 'bg-sidebar-accent text-sidebar-accent-fore ground'
: 'hover:bg-sidebar-accent/50'
)}
onClick={() => {
if (editingThreadId !== thread.thread_id) {
@@ -92,11 +89,7 @@ export function ThreadSidebar() {
}
}}
>
{streamingThreads.has(thread.thread_id) ? (
<Loader2 className="size-4 shrink-0 text-status-info animate-spin" />
) : (
<MessageSquare className="size-4 shrink-0 text-muted-foreground" />
)}
<MessageSquare className="size-4 shrink-0 text-muted-foreground" />
<div className="flex-1 min-w-0 overflow-hidden">
{editingThreadId === thread.thread_id ? (
<input
@@ -137,14 +130,12 @@ export function ThreadSidebar() {
</div>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem
onClick={() => startEditing(thread.thread_id, thread.title || '')}
>
<ContextMenuItem onClick={() => startEditing(thread.thread_id, thread.title || '')}>
<Pencil className="size-4 mr-2" />
Rename
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem
<ContextMenuItem
variant="destructive"
onClick={() => deleteThread(thread.thread_id)}
>
@@ -168,10 +159,10 @@ export function ThreadSidebar() {
{/* Model Selector */}
<div className="p-4 space-y-4">
<ModelSelector />
<Button
variant="ghost"
size="sm"
<Button
variant="ghost"
size="sm"
className="w-full justify-start gap-2"
onClick={() => setSettingsOpen(true)}
>
+267
View File
@@ -0,0 +1,267 @@
import type { UseStreamTransport } from '@langchain/langgraph-sdk/react'
import type { StreamPayload, StreamEvent, IPCEvent } from '../../../types'
/**
* Custom transport for useStream that uses Electron IPC instead of HTTP.
* This allows useStream to work seamlessly in an Electron app where the
* LangGraph agent runs in the main process.
*/
export class ElectronIPCTransport implements UseStreamTransport {
async stream(payload: StreamPayload): Promise<AsyncGenerator<StreamEvent>> {
// Extract thread ID from config
const threadId = payload.config?.configurable?.thread_id
if (!threadId) {
return this.createErrorGenerator('MISSING_THREAD_ID', 'Thread ID is required')
}
// Extract the message content from input
const input = payload.input as
| { messages?: Array<{ content: string; type: string }> }
| null
| undefined
const messages = input?.messages ?? []
const lastHumanMessage = messages.find((m) => m.type === 'human')
const messageContent = lastHumanMessage?.content ?? ''
if (!messageContent) {
return this.createErrorGenerator('MISSING_MESSAGE', 'Message content is required')
}
// Create an async generator that bridges IPC events
return this.createStreamGenerator(threadId, messageContent, payload.command, payload.signal)
}
private async *createErrorGenerator(code: string, message: string): AsyncGenerator<StreamEvent> {
yield {
event: 'error',
data: { error: code, message }
}
}
private async *createStreamGenerator(
threadId: string,
message: string,
command: unknown,
signal: AbortSignal
): AsyncGenerator<StreamEvent> {
// Create a queue to buffer events from IPC
const eventQueue: StreamEvent[] = []
let resolveNext: ((value: StreamEvent | null) => void) | null = null
let isDone = false
let hasError = false
// Generate a run ID for this stream
const runId = crypto.randomUUID()
// Emit metadata event first to establish run context
yield {
event: 'metadata',
data: {
run_id: runId,
thread_id: threadId
}
}
// Start the stream via IPC
const cleanup = window.api.agent.streamAgent(threadId, message, command, (ipcEvent) => {
// Convert IPC events to SDK format
const sdkEvents = this.convertToSDKEvents(ipcEvent as IPCEvent, threadId)
for (const sdkEvent of sdkEvents) {
console.log('[Transport] Converted event:', sdkEvent)
if (sdkEvent.event === 'done' || sdkEvent.event === 'error') {
isDone = true
hasError = sdkEvent.event === 'error'
}
// If someone is waiting for the next event, resolve immediately
if (resolveNext) {
const resolve = resolveNext
resolveNext = null
resolve(sdkEvent)
} else {
// Otherwise queue the event
eventQueue.push(sdkEvent)
}
}
})
// Handle abort signal
if (signal) {
signal.addEventListener('abort', () => {
cleanup()
isDone = true
if (resolveNext) {
const resolve = resolveNext
resolveNext = null
resolve(null)
}
})
}
// Yield events as they come in
while (!isDone || eventQueue.length > 0) {
// Check for queued events first
if (eventQueue.length > 0) {
const event = eventQueue.shift()!
if (event.event === 'done') {
break
}
if (event.event !== 'error' || hasError) {
yield event
}
if (hasError) {
break
}
continue
}
// Wait for the next event
const event = await new Promise<StreamEvent | null>((resolve) => {
resolveNext = resolve
})
if (event === null) {
break
}
if (event.event === 'done') {
break
}
yield event
if (event.event === 'error') {
break
}
}
}
/**
* Convert IPC events to LangGraph SDK format
* Returns an array since a single IPC event may produce multiple SDK events
*/
private convertToSDKEvents(event: IPCEvent, threadId: string): StreamEvent[] {
const events: StreamEvent[] = []
switch (event.type) {
// Token streaming for real-time typing effect
case 'token':
events.push({
event: 'messages',
data: [
{ id: event.messageId, type: 'ai', content: event.token },
{ langgraph_node: 'agent' }
]
})
break
// Tool call chunks
case 'tool_call':
events.push({
event: 'custom',
data: {
type: 'tool_call',
messageId: event.messageId,
tool_calls: event.tool_calls
}
})
break
// Full state values
case 'values': {
const { messages, todos, files, workspacePath, subagents, interrupt } = event.data
// Emit complete messages
if (messages?.length) {
for (const msg of messages) {
if (msg.type === 'ai' && msg.content) {
events.push({
event: 'custom',
data: {
type: 'message',
message: {
id: msg.id,
type: msg.type,
content: msg.content,
tool_calls: msg.tool_calls
}
}
})
}
}
}
// Emit todos
if (todos?.length) {
events.push({
event: 'custom',
data: { type: 'todos', todos }
})
}
// Emit files/workspace
if (files) {
const filesList = Array.isArray(files)
? files
: Object.entries(files).map(([path, data]) => ({
path,
is_dir: false,
size:
typeof (data as { content?: string })?.content === 'string'
? (data as { content: string }).content.length
: undefined
}))
if (filesList.length) {
events.push({
event: 'custom',
data: { type: 'workspace', files: filesList, path: workspacePath || '/' }
})
}
}
// Emit subagents
if (subagents?.length) {
events.push({
event: 'custom',
data: { type: 'subagents', subagents }
})
}
// Emit interrupt
if (interrupt) {
events.push({
event: 'custom',
data: {
type: 'interrupt',
request: {
id: interrupt.id || crypto.randomUUID(),
tool_call: interrupt.tool_call,
allowed_decisions: ['approve', 'reject', 'edit']
}
}
})
}
break
}
case 'error':
events.push({
event: 'error',
data: { error: 'STREAM_ERROR', message: event.error }
})
break
case 'done':
events.push({
event: 'done',
data: { thread_id: threadId }
})
break
}
return events
}
}
+72 -209
View File
@@ -5,83 +5,75 @@ interface AppState {
// Threads
threads: Thread[]
currentThreadId: string | null
// Messages for current thread
messages: Message[]
// Streaming state - per-thread to allow concurrent runs
streamingThreads: Set<string>
streamingContent: Record<string, string> // threadId -> content
// HITL state
pendingApproval: HITLRequest | null
// Todos (from agent)
todos: Todo[]
// Workspace files (from agent)
workspaceFiles: FileInfo[]
workspacePath: string | null
// Subagents (from agent)
subagents: Subagent[]
// Models
models: ModelConfig[]
currentModel: string
// Right panel state
rightPanelTab: 'todos' | 'files' | 'subagents'
// Settings dialog state
settingsOpen: boolean
// Sidebar state
sidebarCollapsed: boolean
// Actions
loadThreads: () => Promise<void>
createThread: (metadata?: Record<string, unknown>) => Promise<Thread>
selectThread: (threadId: string) => Promise<void>
deleteThread: (threadId: string) => Promise<void>
updateThread: (threadId: string, updates: Partial<Thread>) => Promise<void>
// Message actions
sendMessage: (content: string) => Promise<void>
appendMessage: (message: Message) => void
setMessages: (messages: Message[]) => void
// Streaming actions
isThreadStreaming: (threadId: string) => boolean
getStreamingContent: (threadId: string) => string
setThreadStreaming: (threadId: string, streaming: boolean) => void
appendStreamingContent: (threadId: string, content: string) => void
clearStreamingContent: (threadId: string) => void
generateTitleForFirstMessage: (threadId: string, content: string) => Promise<void>
// HITL actions
setPendingApproval: (request: HITLRequest | null) => void
respondToApproval: (decision: 'approve' | 'reject' | 'edit', editedArgs?: Record<string, unknown>) => Promise<void>
respondToApproval: (
decision: 'approve' | 'reject' | 'edit',
editedArgs?: Record<string, unknown>
) => Promise<void>
// Todo actions
setTodos: (todos: Todo[]) => void
// Workspace actions
setWorkspaceFiles: (files: FileInfo[]) => void
setWorkspacePath: (path: string | null) => void
// Subagent actions
setSubagents: (subagents: Subagent[]) => void
// Model actions
loadModels: () => Promise<void>
setCurrentModel: (modelId: string) => Promise<void>
// Panel actions
setRightPanelTab: (tab: 'todos' | 'files' | 'subagents') => void
// Settings actions
setSettingsOpen: (open: boolean) => void
// Sidebar actions
toggleSidebar: () => void
setSidebarCollapsed: (collapsed: boolean) => void
@@ -92,8 +84,6 @@ export const useAppStore = create<AppState>((set, get) => ({
threads: [],
currentThreadId: null,
messages: [],
streamingThreads: new Set<string>(),
streamingContent: {},
pendingApproval: null,
todos: [],
workspaceFiles: [],
@@ -109,7 +99,7 @@ export const useAppStore = create<AppState>((set, get) => ({
loadThreads: async () => {
const threads = await window.api.threads.list()
set({ threads })
// Select first thread if none selected
if (!get().currentThreadId && threads.length > 0) {
await get().selectThread(threads[0].thread_id)
@@ -118,7 +108,7 @@ export const useAppStore = create<AppState>((set, get) => ({
createThread: async (metadata?: Record<string, unknown>) => {
const thread = await window.api.threads.create(metadata)
set(state => ({
set((state) => ({
threads: [thread, ...state.threads],
currentThreadId: thread.thread_id,
messages: []
@@ -127,12 +117,19 @@ export const useAppStore = create<AppState>((set, get) => ({
},
selectThread: async (threadId: string) => {
set({ currentThreadId: threadId, messages: [], todos: [], workspaceFiles: [], workspacePath: null, subagents: [] })
set({
currentThreadId: threadId,
messages: [],
todos: [],
workspaceFiles: [],
workspacePath: null,
subagents: []
})
// Load thread history from checkpoints
try {
const history = await window.api.threads.getHistory(threadId)
// Get the most recent checkpoint (first in the list since it's ordered DESC)
if (history.length > 0) {
const latestCheckpoint = history[0] as {
@@ -153,9 +150,9 @@ export const useAppStore = create<AppState>((set, get) => ({
}
}
}
const channelValues = latestCheckpoint.checkpoint?.channel_values
// Extract messages
if (channelValues?.messages && Array.isArray(channelValues.messages)) {
const messages: Message[] = channelValues.messages.map((msg, index) => {
@@ -173,7 +170,7 @@ export const useAppStore = create<AppState>((set, get) => ({
else if (msg.type === 'system') role = 'system'
else if (msg.type === 'tool') role = 'tool'
}
// Handle content - could be string or array of content blocks
let content: Message['content'] = ''
if (typeof msg.content === 'string') {
@@ -181,7 +178,7 @@ export const useAppStore = create<AppState>((set, get) => ({
} else if (Array.isArray(msg.content)) {
content = msg.content as Message['content']
}
return {
id: msg.id || `msg-${index}`,
role,
@@ -190,10 +187,10 @@ export const useAppStore = create<AppState>((set, get) => ({
created_at: new Date()
}
})
set({ messages })
}
// Extract todos if present
if (channelValues?.todos && Array.isArray(channelValues.todos)) {
const todos: Todo[] = channelValues.todos.map((todo, index) => ({
@@ -201,7 +198,7 @@ export const useAppStore = create<AppState>((set, get) => ({
content: todo.content || '',
status: (todo.status as Todo['status']) || 'pending'
}))
set({ todos })
}
}
@@ -215,21 +212,21 @@ export const useAppStore = create<AppState>((set, get) => ({
try {
await window.api.threads.delete(threadId)
console.log('[Store] Thread deleted from backend')
set(state => {
const threads = state.threads.filter(t => t.thread_id !== threadId)
set((state) => {
const threads = state.threads.filter((t) => t.thread_id !== threadId)
const wasCurrentThread = state.currentThreadId === threadId
const newCurrentId = wasCurrentThread
? threads[0]?.thread_id || null
const newCurrentId = wasCurrentThread
? threads[0]?.thread_id || null
: state.currentThreadId
console.log('[Store] Updating state:', {
remainingThreads: threads.length,
wasCurrentThread,
newCurrentId
console.log('[Store] Updating state:', {
remainingThreads: threads.length,
wasCurrentThread,
newCurrentId
})
return {
return {
threads,
currentThreadId: newCurrentId,
// Clear messages if we deleted the current thread
@@ -248,127 +245,18 @@ export const useAppStore = create<AppState>((set, get) => ({
updateThread: async (threadId: string, updates: Partial<Thread>) => {
const updated = await window.api.threads.update(threadId, updates)
set(state => ({
threads: state.threads.map(t => t.thread_id === threadId ? updated : t)
set((state) => ({
threads: state.threads.map((t) => (t.thread_id === threadId ? updated : t))
}))
},
// Message actions
sendMessage: async (content: string) => {
const { currentThreadId } = get()
console.log('[Store] sendMessage called', { currentThreadId, content: content.substring(0, 50) })
if (!currentThreadId) {
console.error('[Store] No currentThreadId!')
return
}
const threadId = currentThreadId
// Add user message immediately
const userMessage: Message = {
id: crypto.randomUUID(),
role: 'user',
content,
created_at: new Date()
}
const isFirstMessage = get().messages.length === 0
set(state => ({
messages: [...state.messages, userMessage]
}))
// Auto-generate title on first message
if (isFirstMessage) {
try {
const generatedTitle = await window.api.threads.generateTitle(content)
await get().updateThread(threadId, { title: generatedTitle })
} catch (error) {
console.error('[Store] Failed to generate title:', error)
}
}
// Set this thread as streaming
get().setThreadStreaming(threadId, true)
get().clearStreamingContent(threadId)
// Stream agent response using callback pattern
try {
console.log('[Store] Checking window.api:', !!window.api, !!window.api?.agent)
console.log('[Store] About to call window.api.agent.invoke')
// The cleanup function is returned but auto-removes on done/error events
window.api.agent.invoke(threadId, content, (event) => {
console.log('[Store] Received event:', event.type)
switch (event.type) {
case 'message':
// Only update if this is still the current thread
if (get().currentThreadId === threadId) {
get().appendMessage(event.message)
}
break
case 'token':
get().appendStreamingContent(threadId, event.token)
break
case 'interrupt':
set({ pendingApproval: event.request })
break
case 'tool_call':
// Could show tool call in progress
break
case 'todos':
// Only update if this is still the current thread
if (get().currentThreadId === threadId) {
get().setTodos(event.todos)
}
break
case 'workspace':
console.log('[Store] Received workspace event:', {
files: event.files.length,
path: event.path,
isCurrentThread: get().currentThreadId === threadId
})
// Only update if this is still the current thread
if (get().currentThreadId === threadId) {
get().setWorkspaceFiles(event.files)
get().setWorkspacePath(event.path)
}
break
case 'subagents':
console.log('[Store] Received subagents event:', {
count: event.subagents.length,
isCurrentThread: get().currentThreadId === threadId
})
// Only update if this is still the current thread
if (get().currentThreadId === threadId) {
get().setSubagents(event.subagents)
}
break
case 'done':
get().setThreadStreaming(threadId, false)
get().clearStreamingContent(threadId)
break
case 'error':
console.error('[Store] Stream error:', event.error)
get().setThreadStreaming(threadId, false)
get().clearStreamingContent(threadId)
break
}
})
console.log('[Store] invoke() called')
} catch (error) {
console.error('[Store] Failed to send message:', error)
get().setThreadStreaming(threadId, false)
}
},
appendMessage: (message: Message) => {
set(state => {
set((state) => {
// Check if message already exists (by id)
const exists = state.messages.some(m => m.id === message.id)
const exists = state.messages.some((m) => m.id === message.id)
if (exists) {
return { messages: state.messages.map(m => m.id === message.id ? message : m) }
return { messages: state.messages.map((m) => (m.id === message.id ? message : m)) }
}
return { messages: [...state.messages, message] }
})
@@ -378,42 +266,14 @@ export const useAppStore = create<AppState>((set, get) => ({
set({ messages })
},
// Streaming actions
isThreadStreaming: (threadId: string) => {
return get().streamingThreads.has(threadId)
},
getStreamingContent: (threadId: string) => {
return get().streamingContent[threadId] || ''
},
setThreadStreaming: (threadId: string, streaming: boolean) => {
set(state => {
const newSet = new Set(state.streamingThreads)
if (streaming) {
newSet.add(threadId)
} else {
newSet.delete(threadId)
}
return { streamingThreads: newSet }
})
},
appendStreamingContent: (threadId: string, content: string) => {
set(state => ({
streamingContent: {
...state.streamingContent,
[threadId]: (state.streamingContent[threadId] || '') + content
}
}))
},
clearStreamingContent: (threadId: string) => {
set(state => {
const newContent = { ...state.streamingContent }
delete newContent[threadId]
return { streamingContent: newContent }
})
// Auto-generate title for first message in a thread
generateTitleForFirstMessage: async (threadId: string, content: string) => {
try {
const generatedTitle = await window.api.threads.generateTitle(content)
await get().updateThread(threadId, { title: generatedTitle })
} catch (error) {
console.error('[Store] Failed to generate title:', error)
}
},
// HITL actions
@@ -421,7 +281,10 @@ export const useAppStore = create<AppState>((set, get) => ({
set({ pendingApproval: request })
},
respondToApproval: async (decision: 'approve' | 'reject' | 'edit', editedArgs?: Record<string, unknown>) => {
respondToApproval: async (
decision: 'approve' | 'reject' | 'edit',
editedArgs?: Record<string, unknown>
) => {
const { currentThreadId, pendingApproval } = get()
if (!currentThreadId || !pendingApproval) return
+64
View File
@@ -0,0 +1,64 @@
import type { UseStreamTransport } from '@langchain/langgraph-sdk/react'
export type StreamPayload = Parameters<UseStreamTransport['stream']>[0]
export type StreamEvent = {
id?: string
event: string
data: unknown
}
// Types for the IPC events from main process
export interface IPCMessage {
id: string
type: 'human' | 'ai' | 'tool' | 'system'
content: string
tool_calls?: { id: string; name: string; args: Record<string, unknown> }[]
}
export interface IPCValuesEvent {
type: 'values'
data: {
messages?: IPCMessage[]
todos?: { id?: string; content?: string; status?: string }[]
files?: Record<string, unknown> | Array<{ path: string; is_dir?: boolean; size?: number }>
workspacePath?: string
subagents?: Array<{
id?: string
name?: string
description?: string
status?: string
startedAt?: Date | string
completedAt?: Date | string
}>
interrupt?: { id?: string; tool_call?: unknown }
}
}
export interface IPCTokenEvent {
type: 'token'
messageId: string
token: string
}
export interface IPCToolCallEvent {
type: 'tool_call'
messageId: string | null
tool_calls: Array<{ id?: string; name?: string; args?: string }>
}
export interface IPCDoneEvent {
type: 'done'
}
export interface IPCErrorEvent {
type: 'error'
error: string
}
export type IPCEvent =
| IPCValuesEvent
| IPCTokenEvent
| IPCToolCallEvent
| IPCDoneEvent
| IPCErrorEvent
+3 -2
View File
@@ -3,7 +3,8 @@
"include": [
"electron.vite.config.*",
"src/main/**/*",
"src/preload/**/*"
"src/preload/**/*",
"src/types.ts"
],
"compilerOptions": {
"composite": true,
@@ -11,4 +12,4 @@
"electron-vite/node"
]
}
}
}
+3 -2
View File
@@ -4,7 +4,8 @@
"src/renderer/src/env.d.ts",
"src/renderer/src/**/*",
"src/renderer/src/**/*.tsx",
"src/preload/*.d.ts"
"src/preload/*.d.ts",
"src/types.ts"
],
"compilerOptions": {
"composite": true,
@@ -19,4 +20,4 @@
]
}
}
}
}