Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions app/src/renderer/src/components/chat/ChatListRefreshButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { useApolloClient } from '@apollo/client'
import { Button } from '@renderer/components/ui/button'
import { RefreshCw } from 'lucide-react'
import { GetChatsDocument } from '@renderer/graphql/generated/graphql'
import { useState } from 'react'

interface ChatListRefreshButtonProps {
className?: string
size?: 'default' | 'sm' | 'lg' | 'icon'
variant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link'
showText?: boolean
}

export function ChatListRefreshButton({
className,
size = 'icon',
variant = 'ghost',
showText = false
}: ChatListRefreshButtonProps) {
const client = useApolloClient()
const [isRefreshing, setIsRefreshing] = useState(false)

const handleRefresh = async () => {
setIsRefreshing(true)
try {
// Refetch the chat list
await client.refetchQueries({
include: [GetChatsDocument]
})

// Clear cache for chat list to force fresh data
await client.cache.evict({ fieldName: 'getChats' })
await client.cache.gc()
} catch (error) {
console.error('Failed to refresh chat list:', error)
} finally {
setIsRefreshing(false)
}
}

return (
<Button
variant={variant}
size={size}
onClick={handleRefresh}
disabled={isRefreshing}
className={className}
title="Refresh chat list"
>
<RefreshCw className={`h-4 w-4 ${isRefreshing ? 'animate-spin' : ''}`} />
{showText && <span className="text-sm ml-2">Refresh</span>}
</Button>
)
}
27 changes: 27 additions & 0 deletions app/src/renderer/src/components/chat/Sidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Link, useNavigate, useRouter, useRouterState } from '@tanstack/react-router'
import { useCallback, useState, useEffect } from 'react'

Check warning on line 2 in app/src/renderer/src/components/chat/Sidebar.tsx

View workflow job for this annotation

GitHub Actions / lint-build-test

'useEffect' is defined but never used

import {
Chat,
Expand Down Expand Up @@ -42,6 +42,7 @@
import { formatShortcutForDisplay } from '@renderer/lib/utils/shortcuts'
import { useOmnibarStore } from '@renderer/lib/stores/omnibar'
import { checkHolonsDisabled, checkTasksDisabled } from '@renderer/lib/utils'
import { ChatListRefreshButton } from './ChatListRefreshButton'

interface SidebarProps {
chats: Chat[]
Expand Down Expand Up @@ -313,6 +314,32 @@
)}
</Tooltip>
</TooltipProvider>

<TooltipProvider>
<Tooltip delayDuration={collapsed ? 300 : 1000}>
<TooltipTrigger asChild>
<div className={cn(collapsed ? 'flex justify-center' : 'flex justify-start px-2')}>
<ChatListRefreshButton
variant="ghost"
size={collapsed ? 'icon' : 'default'}
showText={!collapsed}
className={cn(
'group transition-all',
collapsed
? 'p-0 w-10 h-10 text-foreground/60 hover:text-foreground hover:bg-accent'
: 'w-full justify-start text-sidebar-foreground/60 hover:text-sidebar-foreground h-9'
)}
/>
</div>
</TooltipTrigger>
{collapsed && (
<TooltipContent side="right" align="center">
<span>Refresh chat list</span>
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>

{!isTasksDisabled && (
<TooltipProvider>
<Tooltip delayDuration={collapsed ? 300 : 1000}>
Expand Down
60 changes: 60 additions & 0 deletions app/src/renderer/src/hooks/useChatListPolling.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { useEffect, useState } from 'react'
import { useQuery } from '@apollo/client'
import { GetChatsDocument, Chat } from '@renderer/graphql/generated/graphql'

interface UseChatListPollingOptions {
/** Polling interval in milliseconds when tab is visible (default: 30000ms = 30s) */
pollInterval?: number
/** First/limit parameter for pagination (default: 20) */
first?: number
/** Offset parameter for pagination (default: 0) */
offset?: number
}

export function useChatListPolling(options: UseChatListPollingOptions = {}) {
const { pollInterval = 30000, first = 20, offset = 0 } = options
const [isVisible, setIsVisible] = useState(!document.hidden)

// Use Apollo useQuery with smart polling
const { data, loading, error, refetch } = useQuery(GetChatsDocument, {
variables: { first, offset },

// Only poll when tab is visible to save resources
pollInterval: isVisible ? pollInterval : 0,

// Use cache-first to avoid unnecessary network requests
fetchPolicy: 'cache-first',

// Don't show loading state during polling to avoid UI flicker
notifyOnNetworkStatusChange: false,

// Don't break UI on network errors during polling
errorPolicy: 'ignore'
})

// Track visibility changes
useEffect(() => {
const handleVisibilityChange = () => {
const visible = !document.hidden
setIsVisible(visible)

// If tab becomes visible, refetch immediately to get latest data
if (visible && refetch) {
refetch()
}
}

document.addEventListener('visibilitychange', handleVisibilityChange)
return () => document.removeEventListener('visibilitychange', handleVisibilityChange)
}, [refetch])

const chats: Chat[] = data?.getChats || []

return {
chats,
loading,
error,
refetch,
isPolling: isVisible && pollInterval > 0
}
}
10 changes: 5 additions & 5 deletions app/src/renderer/src/routes/__root.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
import { useEffect, useState } from 'react'
import { createRootRoute, Outlet, useNavigate, useRouterState } from '@tanstack/react-router'
import { useQuery } from '@apollo/client'

import AdminKeyboardShortcuts from '@renderer/components/AdminKeyboardShortcuts'
import { Omnibar } from '@renderer/components/Omnibar'
import { GlobalIndexingStatus } from '@renderer/components/GlobalIndexingStatus'
import { NotificationsProvider } from '@renderer/hooks/NotificationsContextProvider'
import { LayoutGroup, motion, AnimatePresence } from 'framer-motion'
import { Sidebar } from '@renderer/components/chat/Sidebar'
import { GetChatsDocument, Chat } from '@renderer/graphql/generated/graphql'
import { useChatListPolling } from '@renderer/hooks/useChatListPolling'
import { useOnboardingStore } from '@renderer/lib/stores/onboarding'
import { useOmnibarStore } from '@renderer/lib/stores/omnibar'
import { useSidebarStore } from '@renderer/lib/stores/sidebar'
Expand All @@ -22,10 +21,11 @@ function RootComponent() {
const { location } = useRouterState()

const { isCompleted } = useOnboardingStore()
const { data: chatsData } = useQuery(GetChatsDocument, {
variables: { first: 20, offset: 0 }
const { chats } = useChatListPolling({
first: 20,
offset: 0,
pollInterval: 30000 // Poll every 30 seconds
})
const chats: Chat[] = chatsData?.getChats || []

// Get keyboard shortcuts from store
const [shortcuts, setShortcuts] = useState<
Expand Down
2 changes: 1 addition & 1 deletion backend/golang/cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -598,7 +598,7 @@ func bootstrapTemporalWorker(

// Register the planned agent v2 workflow
aiAgent := agent.NewAgent(input.logger, input.nc, input.aiCompletionsService, input.envs.CompletionsModel, input.envs.ReasoningModel, nil, nil)
schedulerActivities := scheduler.NewTaskSchedulerActivities(input.logger, input.aiCompletionsService, aiAgent, input.toolsRegistry, input.envs.CompletionsModel, input.store, input.notifications)
schedulerActivities := scheduler.NewTaskSchedulerActivities(input.logger, input.aiCompletionsService, aiAgent, input.toolsRegistry, input.envs.CompletionsModel, input.store, input.notifications, input.twinchatService)
schedulerActivities.RegisterWorkflowsAndActivities(w)

// Register identity activities
Expand Down
57 changes: 49 additions & 8 deletions backend/golang/pkg/agent/scheduler/activities.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ type userProfile interface {
GetOAuthTokensArray(ctx context.Context, provider string) ([]db.OAuthTokens, error)
}

type chatStorage interface {
GetChat(ctx context.Context, id string) (model.Chat, error)
CreateChat(ctx context.Context, name string, category model.ChatCategory, holonThreadID *string) (model.Chat, error)
}

type TaskSchedulerActivities struct {
AIService *ai.Service
Agent *agent.Agent
Expand All @@ -32,9 +37,10 @@ type TaskSchedulerActivities struct {
logger *log.Logger
userStorage userProfile
notifications *notifications.Service
chatStorage chatStorage
}

func NewTaskSchedulerActivities(logger *log.Logger, AIService *ai.Service, Agent *agent.Agent, Tools tools.ToolRegistry, completionsModel string, userStorage userProfile, notifications *notifications.Service) *TaskSchedulerActivities {
func NewTaskSchedulerActivities(logger *log.Logger, AIService *ai.Service, Agent *agent.Agent, Tools tools.ToolRegistry, completionsModel string, userStorage userProfile, notifications *notifications.Service, chatStorage chatStorage) *TaskSchedulerActivities {
return &TaskSchedulerActivities{
AIService: AIService,
Agent: Agent,
Expand All @@ -43,6 +49,7 @@ func NewTaskSchedulerActivities(logger *log.Logger, AIService *ai.Service, Agent
logger: logger,
userStorage: userStorage,
notifications: notifications,
chatStorage: chatStorage,
}
}

Expand All @@ -59,10 +66,41 @@ type ExecuteTaskActivityInput struct {
Name string
}

func (s *TaskSchedulerActivities) executeActivity(ctx context.Context, input ExecuteTaskActivityInput) (string, error) {
systemPrompt, err := s.buildSystemPrompt(ctx, input.ChatID, input.PreviousResult)
func (s *TaskSchedulerActivities) executeActivity(ctx context.Context, input ExecuteTaskActivityInput) (ExecuteTaskActivityOutput, error) {
currentChatID := input.ChatID

if currentChatID != "" {
_, err := s.chatStorage.GetChat(ctx, currentChatID)
if err != nil {
s.logger.Warn("Scheduled task chat not found, creating new chat",
"original_chat_id", currentChatID,
"error", err)

chat, createErr := s.chatStorage.CreateChat(ctx, "Scheduled Task Chat", model.ChatCategoryText, nil)
if createErr != nil {
s.logger.Error("failed to create replacement chat for scheduled task", "error", createErr)
return ExecuteTaskActivityOutput{}, createErr
}

s.logger.Info("Created new chat for scheduled task",
"original_chat_id", currentChatID,
"new_chat_id", chat.ID)
currentChatID = chat.ID
}
} else {
// If no chat specified, create one
chat, err := s.chatStorage.CreateChat(ctx, "Scheduled Task Chat", model.ChatCategoryText, nil)
if err != nil {
s.logger.Error("failed to create chat for scheduled task", "error", err)
return ExecuteTaskActivityOutput{}, err
}
s.logger.Info("Created new chat for scheduled task", "chat_id", chat.ID)
currentChatID = chat.ID
}

systemPrompt, err := s.buildSystemPrompt(ctx, currentChatID, input.PreviousResult)
if err != nil {
return "", err
return ExecuteTaskActivityOutput{}, err
}

tools := s.ToolsRegistry.Excluding("schedule_task").GetAll()
Expand All @@ -76,7 +114,7 @@ func (s *TaskSchedulerActivities) executeActivity(ctx context.Context, input Exe
}
response, err := s.Agent.Execute(ctx, map[string]any{}, messages, tools)
if err != nil {
return "", err
return ExecuteTaskActivityOutput{}, err
}

if input.Notify {
Expand All @@ -85,19 +123,22 @@ func (s *TaskSchedulerActivities) executeActivity(ctx context.Context, input Exe
Title: input.Name,
Message: response.Content,
CreatedAt: time.Now().Format(time.RFC3339),
Link: helpers.Ptr("twin://chat/" + input.ChatID),
Link: helpers.Ptr("twin://chat/" + currentChatID),
}
if len(response.ImageURLs) > 0 {
notification.Image = &response.ImageURLs[0]
s.logger.Debug("Sending notification with image", "image", notification.Image)
}
err = s.notifications.SendNotification(ctx, notification)
if err != nil {
return "", err
return ExecuteTaskActivityOutput{}, err
}
}

return response.String(), nil
return ExecuteTaskActivityOutput{
Completion: response.String(),
ChatID: currentChatID,
}, nil
}

func (s *TaskSchedulerActivities) buildSystemPrompt(ctx context.Context, chatID string, previousResult *string) (string, error) {
Expand Down
19 changes: 15 additions & 4 deletions backend/golang/pkg/agent/scheduler/workflow.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@ type TaskScheduleWorkflowInput struct {
type TaskScheduleWorkflowOutput struct {
Result string `json:"result"`
Progress string `json:"progress"`
ChatID string `json:"chat_id"`
}

type ExecuteTaskActivityOutput struct {
Completion string `json:"completion"`
ChatID string `json:"chat_id"`
}

func TaskScheduleWorkflow(ctx workflow.Context, input *TaskScheduleWorkflowInput) (TaskScheduleWorkflowOutput, error) {
Expand All @@ -42,28 +48,33 @@ func TaskScheduleWorkflow(ctx workflow.Context, input *TaskScheduleWorkflowInput
}

var lastWorkflowResult *string
currentChatID := input.ChatID
if lastWorkflowOutput != nil {
lastWorkflowResult = &lastWorkflowOutput.Result
if lastWorkflowOutput.ChatID != "" {
currentChatID = lastWorkflowOutput.ChatID
}
}

var completion string
var result ExecuteTaskActivityOutput
executeTaskInput := ExecuteTaskActivityInput{
Task: input.Task,
PreviousResult: lastWorkflowResult,
ChatID: input.ChatID,
ChatID: currentChatID,
Notify: input.Notify,
Name: input.Name,
}
if err := workflow.ExecuteActivity(
ctx,
a.executeActivity,
executeTaskInput,
).Get(ctx, &completion); err != nil {
).Get(ctx, &result); err != nil {
return TaskScheduleWorkflowOutput{}, err
}

return TaskScheduleWorkflowOutput{
Result: completion,
Result: result.Completion,
Progress: "Completed",
ChatID: result.ChatID,
}, nil
}
6 changes: 4 additions & 2 deletions backend/golang/pkg/twinchat/tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ func (e *sendToChat) Execute(ctx context.Context, inputs map[string]any) (types.

chatId, ok := inputs["chat_id"].(string)
if !ok {
return nil, errors.New("chat_id is not a string")
return nil, errors.New("chat_id is required")
}

if chatId == "" {
Expand Down Expand Up @@ -79,7 +79,6 @@ func (e *sendToChat) Execute(ctx context.Context, inputs map[string]any) (types.
dbMessage.ImageURLsStr = helpers.Ptr(string(imageURLsJSON))
}

// Note: This is from the send_to_chat tool, not regular chat flow
id, err := e.chatStorage.AddMessageToChat(ctx, dbMessage)
if err != nil {
return nil, fmt.Errorf("❌ Failed to store send_to_chat message to database: %w", err)
Expand All @@ -103,6 +102,9 @@ func (e *sendToChat) Execute(ctx context.Context, inputs map[string]any) (types.
ToolParams: map[string]any{
"chat_id": chatId,
},
Output: map[string]any{
"content": "Message sent successfully",
},
}, nil
}

Expand Down
Loading