mirror of
https://github.com/humanlayer/humanlayer.git
synced 2025-08-20 19:01:22 +03:00
Suppress approval notifications for already-approved messages (and some other stuff) (#391)
* Fix auto-accept edits toggle to update store directly - Update SessionDetail shift+tab handler to directly update store - Ensures immediate UI feedback when toggling auto-accept mode - Add debug logging to track toggle behavior * Add SessionSettingsChanged event for real-time settings updates Implements a new event type to propagate session settings changes (like auto-accept edits) from backend to frontend via SSE. This ensures the UI stays in sync when settings are modified through any interface (REST API or RPC). Key changes: - Add EventSessionSettingsChanged constant to event bus types - Add UpdateSessionSettings method to SessionManager interface to centralize event publishing - Update REST API handler to use session manager instead of direct store updates - Fix SSE handler to recognize and forward session_settings_changed events - Add TypeScript types and event handling in frontend - Wire up event handler in Layout.tsx to update local state - Add make task for regenerating SDKs from OpenAPI specs The implementation follows the existing architectural pattern where managers (not handlers or stores) are responsible for publishing events to maintain consistency. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * Add approval notifications to Layout component Implements proper notification handling for approval events in the main Layout component. This ensures users are notified when AI agents request approval for tool usage. Key changes: - Add notification tracking with isItemNotified/addNotifiedItem to prevent duplicate notifications - Implement onNewApproval handler to show notifications when approvals are requested - Move session fetching outside completion block for better code reuse - Add proper TypeScript types for event data - Show tool-specific approval messages with fallback for error cases The implementation follows the event-based notification pattern, as the investigation showed that status-based notifications (waiting_input transitions) never occur in practice. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * Add delay to prevent notifications for auto-approved items Implements a 100ms delay in the onNewApproval handler to check if an approval was auto-resolved before showing a notification. This prevents the brief flash of approval notifications for edit operations when auto-accept mode is enabled. The solution works by: - Waiting 100ms after receiving a new_approval event - Checking if the session is still in waiting_input status - Skipping the notification if the status has already changed (indicating auto-approval) This handles the race condition where the backend sends both new_approval and approval_resolved events in quick succession for auto-approved items. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * Fix auto-approval notification logic with approval cache Replaces the flawed session status check with a proper approval resolution cache. The previous approach incorrectly skipped notifications for manual approvals (like Bash) because it checked session status before updating it to waiting_input. The new implementation: - Tracks resolved approvals in a cache when onApprovalResolved fires - Checks this cache after 100ms delay to determine if an approval was auto-resolved - Works correctly for both auto-approved (Edit tools) and manual approval cases Also adds timestamps to console logs for better debugging of event timing. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * feat(wui): rework shift+tab to toggle auto-accept edits for selected sessions - Add bulkSetAutoAcceptEdits method to store for bulk updating auto-accept settings - Replace shift+tab view mode toggle with auto-accept toggle functionality - Similar behavior to 'e' archive hotkey: works on selected sessions or focused session - Shows success/error toast notifications for user feedback - Intelligently toggles status: if all selected sessions have auto-accept enabled, disables it; otherwise enables it 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * feat(wui): improve archive hotkey behavior and add auto-accept indicator - Fix archive hotkey ('e') to properly handle mixed selection states - Show warning when trying to bulk archive sessions with different archived states - Add visual indicator (⏵⏵) for running sessions with auto-accept edits enabled - Improve error handling and logging for archive operations 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * MCT * test tweaks --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
8
Makefile
8
Makefile
@@ -150,6 +150,14 @@ publish-ts: build-ts
|
||||
.PHONY: build-and-publish
|
||||
build-and-publish: build publish ## Build and publish.
|
||||
|
||||
.PHONY: generate-sdks
|
||||
generate-sdks: ## Regenerate all SDKs from OpenAPI specs
|
||||
@echo "Regenerating TypeScript SDK from OpenAPI spec..."
|
||||
@$(MAKE) -C hld generate-sdks
|
||||
@echo "Updating SDK in humanlayer-wui..."
|
||||
@$(MAKE) -C humanlayer-wui install
|
||||
@echo "SDK regeneration complete!"
|
||||
|
||||
.PHONY: help
|
||||
help:
|
||||
grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}'
|
||||
|
||||
12
hld/Makefile
12
hld/Makefile
@@ -135,6 +135,18 @@ generate:
|
||||
openapi.yaml
|
||||
@echo "Code generation complete"
|
||||
|
||||
# Generate TypeScript SDK from OpenAPI spec
|
||||
generate-sdk-ts:
|
||||
@echo "Generating TypeScript SDK from OpenAPI spec..."
|
||||
@cd sdk/typescript && bun run generate
|
||||
@echo "Building TypeScript SDK..."
|
||||
@cd sdk/typescript && bun run build
|
||||
@echo "TypeScript SDK generation complete"
|
||||
|
||||
# Generate all SDKs
|
||||
generate-sdks: generate-sdk-ts
|
||||
@echo "All SDK generation complete"
|
||||
|
||||
# Format code
|
||||
fmt:
|
||||
go fmt ./...
|
||||
|
||||
@@ -258,7 +258,7 @@ func (h *SessionHandlers) UpdateSession(ctx context.Context, req api.UpdateSessi
|
||||
update.Title = req.Body.Title
|
||||
}
|
||||
|
||||
err := h.store.UpdateSession(ctx, string(req.Id), update)
|
||||
err := h.manager.UpdateSessionSettings(ctx, string(req.Id), update)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return api.UpdateSession404JSONResponse{
|
||||
|
||||
@@ -339,8 +339,8 @@ func TestSessionHandlers_UpdateSession(t *testing.T) {
|
||||
router := setupTestRouter(t, handlers, nil, nil)
|
||||
|
||||
t.Run("update auto-accept edits", func(t *testing.T) {
|
||||
mockStore.EXPECT().
|
||||
UpdateSession(gomock.Any(), "sess-123", store.SessionUpdate{
|
||||
mockManager.EXPECT().
|
||||
UpdateSessionSettings(gomock.Any(), "sess-123", store.SessionUpdate{
|
||||
AutoAcceptEdits: boolPtr(true),
|
||||
}).
|
||||
Return(nil)
|
||||
@@ -374,8 +374,8 @@ func TestSessionHandlers_UpdateSession(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("archive session", func(t *testing.T) {
|
||||
mockStore.EXPECT().
|
||||
UpdateSession(gomock.Any(), "sess-456", store.SessionUpdate{
|
||||
mockManager.EXPECT().
|
||||
UpdateSessionSettings(gomock.Any(), "sess-456", store.SessionUpdate{
|
||||
Archived: boolPtr(true),
|
||||
}).
|
||||
Return(nil)
|
||||
@@ -408,8 +408,8 @@ func TestSessionHandlers_UpdateSession(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("update session title", func(t *testing.T) {
|
||||
mockStore.EXPECT().
|
||||
UpdateSession(gomock.Any(), "sess-789", store.SessionUpdate{
|
||||
mockManager.EXPECT().
|
||||
UpdateSessionSettings(gomock.Any(), "sess-789", store.SessionUpdate{
|
||||
Title: stringPtr("Updated Task Title"),
|
||||
}).
|
||||
Return(nil)
|
||||
@@ -444,8 +444,8 @@ func TestSessionHandlers_UpdateSession(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("session not found", func(t *testing.T) {
|
||||
mockStore.EXPECT().
|
||||
UpdateSession(gomock.Any(), "sess-999", gomock.Any()).
|
||||
mockManager.EXPECT().
|
||||
UpdateSessionSettings(gomock.Any(), "sess-999", gomock.Any()).
|
||||
Return(sql.ErrNoRows)
|
||||
|
||||
updateReq := api.UpdateSessionRequest{
|
||||
|
||||
@@ -31,6 +31,8 @@ func parseEventTypes(types []string) []bus.EventType {
|
||||
eventTypes = append(eventTypes, bus.EventSessionStatusChanged)
|
||||
case "conversation_updated":
|
||||
eventTypes = append(eventTypes, bus.EventConversationUpdated)
|
||||
case "session_settings_changed":
|
||||
eventTypes = append(eventTypes, bus.EventSessionSettingsChanged)
|
||||
}
|
||||
// Ignore unknown event types
|
||||
}
|
||||
|
||||
@@ -1158,6 +1158,7 @@ components:
|
||||
- approval_resolved
|
||||
- session_status_changed
|
||||
- conversation_updated
|
||||
- session_settings_changed
|
||||
description: Type of system event
|
||||
|
||||
Event:
|
||||
|
||||
@@ -67,10 +67,11 @@ const (
|
||||
|
||||
// Defines values for EventType.
|
||||
const (
|
||||
ApprovalResolved EventType = "approval_resolved"
|
||||
ConversationUpdated EventType = "conversation_updated"
|
||||
NewApproval EventType = "new_approval"
|
||||
SessionStatusChanged EventType = "session_status_changed"
|
||||
ApprovalResolved EventType = "approval_resolved"
|
||||
ConversationUpdated EventType = "conversation_updated"
|
||||
NewApproval EventType = "new_approval"
|
||||
SessionSettingsChanged EventType = "session_settings_changed"
|
||||
SessionStatusChanged EventType = "session_status_changed"
|
||||
)
|
||||
|
||||
// Defines values for HealthResponseStatus.
|
||||
@@ -2057,84 +2058,84 @@ func (sh *strictHandler) GetSessionSnapshots(ctx *gin.Context, id SessionId) {
|
||||
// Base64 encoded, gzipped, json marshaled Swagger object
|
||||
var swaggerSpec = []string{
|
||||
|
||||
"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",
|
||||
}
|
||||
|
||||
// GetSwagger returns the content of the embedded swagger specification file
|
||||
|
||||
@@ -15,12 +15,15 @@ func TestEventBus_Subscribe(t *testing.T) {
|
||||
sub := eb.Subscribe(ctx, EventFilter{})
|
||||
if sub == nil {
|
||||
t.Fatal("expected subscriber, got nil")
|
||||
return // this return exists purely to satisfy the linter
|
||||
}
|
||||
if sub.ID == "" {
|
||||
t.Error("expected subscriber ID, got empty string")
|
||||
return // this return exists purely to satisfy the linter
|
||||
}
|
||||
if sub.Channel == nil {
|
||||
t.Error("expected channel, got nil")
|
||||
return // this return exists purely to satisfy the linter
|
||||
}
|
||||
|
||||
// Verify subscriber count
|
||||
|
||||
@@ -17,6 +17,8 @@ const (
|
||||
EventSessionStatusChanged EventType = "session_status_changed"
|
||||
// EventConversationUpdated indicates new conversation content has been added to a session
|
||||
EventConversationUpdated EventType = "conversation_updated"
|
||||
// EventSessionSettingsChanged indicates session settings have been updated
|
||||
EventSessionSettingsChanged EventType = "session_settings_changed"
|
||||
)
|
||||
|
||||
// Event represents an event in the system
|
||||
|
||||
@@ -510,11 +510,10 @@ func (h *SessionHandlers) HandleUpdateSessionSettings(ctx context.Context, param
|
||||
// Publish event for UI updates
|
||||
if h.eventBus != nil && req.AutoAcceptEdits != nil {
|
||||
h.eventBus.Publish(bus.Event{
|
||||
Type: bus.EventSessionStatusChanged,
|
||||
Type: bus.EventSessionSettingsChanged,
|
||||
Data: map[string]interface{}{
|
||||
"session_id": req.SessionID,
|
||||
"auto_accept_edits": *req.AutoAcceptEdits,
|
||||
"event_type": "settings_updated",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -21,7 +21,8 @@ export const EventType = {
|
||||
NewApproval: 'new_approval',
|
||||
ApprovalResolved: 'approval_resolved',
|
||||
SessionStatusChanged: 'session_status_changed',
|
||||
ConversationUpdated: 'conversation_updated'
|
||||
ConversationUpdated: 'conversation_updated',
|
||||
SessionSettingsChanged: 'session_settings_changed'
|
||||
} as const;
|
||||
export type EventType = typeof EventType[keyof typeof EventType];
|
||||
|
||||
|
||||
@@ -1399,3 +1399,26 @@ func (m *Manager) forceKillRemaining() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateSessionSettings updates session settings and publishes appropriate events
|
||||
func (m *Manager) UpdateSessionSettings(ctx context.Context, sessionID string, updates store.SessionUpdate) error {
|
||||
// First update the store
|
||||
if err := m.store.UpdateSession(ctx, sessionID, updates); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// If auto-accept edits was updated, publish the settings changed event
|
||||
if updates.AutoAcceptEdits != nil {
|
||||
if m.eventBus != nil {
|
||||
m.eventBus.Publish(bus.Event{
|
||||
Type: bus.EventSessionSettingsChanged,
|
||||
Data: map[string]interface{}{
|
||||
"session_id": sessionID,
|
||||
"auto_accept_edits": *updates.AutoAcceptEdits,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -92,6 +92,9 @@ type SessionManager interface {
|
||||
|
||||
// StopAllSessions gracefully stops all active sessions with a timeout
|
||||
StopAllSessions(timeout time.Duration) error
|
||||
|
||||
// UpdateSessionSettings updates session settings and publishes events
|
||||
UpdateSessionSettings(ctx context.Context, sessionID string, updates store.SessionUpdate) error
|
||||
}
|
||||
|
||||
// ReadToolResult represents the JSON structure of a Read tool result
|
||||
|
||||
@@ -14,6 +14,10 @@ Logs include output from the WUI backend, daemon stderr (prefixed with [Daemon])
|
||||
|
||||
The WUI communicates with the daemon via JSON-RPC over a Unix socket at ~/.humanlayer/daemon.sock. All session and approval data comes from the daemon - the WUI is purely a presentation layer.
|
||||
|
||||
To regenerate TypeScript types from the hld-sdk after OpenAPI spec changes:
|
||||
|
||||
- Run `make generate-sdks` from the root directory
|
||||
|
||||
For UI development, we use Radix UI components styled with Tailwind CSS. State management is handled by Zustand. The codebase follows React best practices with TypeScript for type safety.
|
||||
|
||||
## Tips and Tricks
|
||||
|
||||
@@ -27,6 +27,7 @@ interface StoreState {
|
||||
interruptSession: (sessionId: string) => Promise<void>
|
||||
archiveSession: (sessionId: string, archived: boolean) => Promise<void>
|
||||
bulkArchiveSessions: (sessionIds: string[], archived: boolean) => Promise<void>
|
||||
bulkSetAutoAcceptEdits: (sessionIds: string[], autoAcceptEdits: boolean) => Promise<void>
|
||||
setViewMode: (mode: ViewMode) => void
|
||||
toggleSessionSelection: (sessionId: string) => void
|
||||
clearSelection: () => void
|
||||
@@ -50,6 +51,10 @@ interface StoreState {
|
||||
isItemNotified: (notificationId: string) => boolean
|
||||
clearNotificationsForSession: (sessionId: string) => void
|
||||
|
||||
recentResolvedApprovalsCache: Set<string>
|
||||
addRecentResolvedApprovalToCache: (approvalId: string) => void
|
||||
isRecentResolvedApproval: (approvalId: string) => boolean
|
||||
|
||||
/* Navigation tracking */
|
||||
recentNavigations: Map<string, number> // sessionId -> timestamp
|
||||
trackNavigationFrom: (sessionId: string) => void
|
||||
@@ -195,6 +200,33 @@ export const useStore = create<StoreState>((set, get) => ({
|
||||
throw error
|
||||
}
|
||||
},
|
||||
bulkSetAutoAcceptEdits: async (sessionIds: string[], autoAcceptEdits: boolean) => {
|
||||
try {
|
||||
const results = await Promise.allSettled(
|
||||
sessionIds.map(sessionId =>
|
||||
daemonClient.updateSessionSettings(sessionId, { auto_accept_edits: autoAcceptEdits }),
|
||||
),
|
||||
)
|
||||
|
||||
// Check if any failed
|
||||
const failedCount = results.filter(r => r.status === 'rejected').length
|
||||
if (failedCount > 0) {
|
||||
console.error(`Failed to update ${failedCount} sessions`)
|
||||
throw new Error(`Failed to update ${failedCount} sessions`)
|
||||
}
|
||||
|
||||
// Update local state for all successful sessions
|
||||
sessionIds.forEach(sessionId => {
|
||||
get().updateSession(sessionId, { autoAcceptEdits })
|
||||
})
|
||||
|
||||
// Clear selection after bulk operation
|
||||
get().clearSelection()
|
||||
} catch (error) {
|
||||
console.error('Failed to bulk update auto-accept settings:', error)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
setViewMode: (mode: ViewMode) => {
|
||||
set({ viewMode: mode })
|
||||
// Refresh sessions when view mode changes
|
||||
@@ -466,6 +498,21 @@ export const useStore = create<StoreState>((set, get) => ({
|
||||
return { notifiedItems: newSet }
|
||||
}),
|
||||
|
||||
recentResolvedApprovalsCache: new Set<string>(),
|
||||
addRecentResolvedApprovalToCache: (approvalId: string) =>
|
||||
set(state => {
|
||||
const newSet = new Set(state.recentResolvedApprovalsCache)
|
||||
newSet.add(approvalId)
|
||||
|
||||
// Limit to 50 items by converting to array, slicing, and converting back to Set
|
||||
const limitedSet = new Set(Array.from(newSet).slice(-50))
|
||||
|
||||
return { recentResolvedApprovalsCache: limitedSet }
|
||||
}),
|
||||
isRecentResolvedApproval: (approvalId: string) => {
|
||||
return get().recentResolvedApprovalsCache.has(approvalId)
|
||||
},
|
||||
|
||||
// Navigation tracking
|
||||
recentNavigations: new Map(),
|
||||
trackNavigationFrom: (sessionId: string) =>
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Outlet } from 'react-router-dom'
|
||||
import { useHotkeys } from 'react-hotkeys-hook'
|
||||
import { daemonClient, SessionStatus } from '@/lib/daemon'
|
||||
import {
|
||||
ApprovalResolvedEventData,
|
||||
daemonClient,
|
||||
NewApprovalEventData,
|
||||
SessionSettingsChangedEventData,
|
||||
SessionStatus,
|
||||
SessionStatusChangedEventData,
|
||||
} from '@/lib/daemon'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ThemeSelector } from '@/components/ThemeSelector'
|
||||
import { SessionLauncher } from '@/components/SessionLauncher'
|
||||
@@ -12,7 +19,7 @@ import { useDaemonConnection } from '@/hooks/useDaemonConnection'
|
||||
import { useStore } from '@/AppStore'
|
||||
import { useSessionSubscriptions } from '@/hooks/useSubscriptions'
|
||||
import { Toaster } from 'sonner'
|
||||
import { notificationService } from '@/services/NotificationService'
|
||||
import { notificationService, type NotificationOptions } from '@/services/NotificationService'
|
||||
import { useTheme } from '@/contexts/ThemeContext'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { MessageCircle, Bug } from 'lucide-react'
|
||||
@@ -44,27 +51,145 @@ export function Layout() {
|
||||
})
|
||||
|
||||
// Get store actions
|
||||
const updateSession = useStore(state => state.updateSession)
|
||||
const updateSessionStatus = useStore(state => state.updateSessionStatus)
|
||||
const refreshActiveSessionConversation = useStore(state => state.refreshActiveSessionConversation)
|
||||
const clearNotificationsForSession = useStore(state => state.clearNotificationsForSession)
|
||||
const wasRecentlyNavigatedFrom = useStore(state => state.wasRecentlyNavigatedFrom)
|
||||
const addNotifiedItem = useStore(state => state.addNotifiedItem)
|
||||
const isItemNotified = useStore(state => state.isItemNotified)
|
||||
const addRecentResolvedApprovalToCache = useStore(state => state.addRecentResolvedApprovalToCache)
|
||||
const isRecentResolvedApproval = useStore(state => state.isRecentResolvedApproval)
|
||||
|
||||
// Set up single SSE subscription for all events
|
||||
useSessionSubscriptions(connected, {
|
||||
onSessionStatusChanged: async data => {
|
||||
logger.log('useSessionSubscriptions.onSessionStatusChanged', data)
|
||||
const { session_id, new_status } = data
|
||||
updateSessionStatus(session_id, new_status as SessionStatus)
|
||||
onSessionStatusChanged: async (data: SessionStatusChangedEventData) => {
|
||||
logger.log('useSessionSubscriptions.onSessionStatusChanged', Date.now(), data)
|
||||
const { session_id, new_status: nextStatus } = data
|
||||
const targetSession = useStore.getState().sessions.find(s => s.id === session_id)
|
||||
const previousStatus = targetSession?.status
|
||||
const sessionResponse = await daemonClient.getSessionState(data.session_id)
|
||||
const session = sessionResponse.session
|
||||
|
||||
if (!nextStatus) {
|
||||
logger.warn('useSessionSubscriptions.onSessionStatusChanged: nextStatus is undefined', data)
|
||||
return
|
||||
}
|
||||
|
||||
// Clear notifications if session is no longer waiting_input
|
||||
if (nextStatus !== undefined && nextStatus !== SessionStatus.WaitingInput) {
|
||||
clearNotificationsForSession(session_id)
|
||||
}
|
||||
|
||||
// Completed or Failed, but not in series
|
||||
if (
|
||||
(previousStatus !== SessionStatus.Completed && nextStatus === SessionStatus.Completed) ||
|
||||
(previousStatus !== SessionStatus.Failed && nextStatus === SessionStatus.Failed)
|
||||
) {
|
||||
logger.log(
|
||||
`Session ${data.session_id} completed. Previous status: ${previousStatus}, checking navigation tracking...`,
|
||||
)
|
||||
|
||||
if (wasRecentlyNavigatedFrom(data.session_id)) {
|
||||
logger.log(
|
||||
`Suppressing completion notification for recently navigated session ${data.session_id}`,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
let notificationOptions: NotificationOptions = {
|
||||
type: 'session_completed',
|
||||
title: `Session Completed (${data.session_id.slice(0, 8)})`,
|
||||
body: `Completed: ${session.query}`,
|
||||
metadata: {
|
||||
sessionId: data.session_id,
|
||||
model: session.model,
|
||||
},
|
||||
// Don't make this sticky - let it auto-dismiss
|
||||
duration: undefined,
|
||||
}
|
||||
|
||||
if (nextStatus === SessionStatus.Failed) {
|
||||
notificationOptions.type = 'session_failed'
|
||||
notificationOptions.title = `Session Failed (${data.session_id.slice(0, 8)})`
|
||||
notificationOptions.body = session.errorMessage || `Failed: ${session.query}`
|
||||
notificationOptions.priority = 'high'
|
||||
}
|
||||
|
||||
await notificationService.notify(notificationOptions)
|
||||
} catch (error) {
|
||||
logger.error('Failed to show completion notification:', error)
|
||||
}
|
||||
}
|
||||
|
||||
updateSessionStatus(session_id, nextStatus)
|
||||
|
||||
await refreshActiveSessionConversation(session_id)
|
||||
},
|
||||
onNewApproval: async data => {
|
||||
logger.log('useSessionSubscriptions.onNewApproval', data)
|
||||
updateSessionStatus(data.session_id, SessionStatus.WaitingInput)
|
||||
await refreshActiveSessionConversation(data.session_id)
|
||||
onNewApproval: async (data: NewApprovalEventData) => {
|
||||
logger.log('useSessionSubscriptions.onNewApproval', Date.now(), data)
|
||||
const { approval_id: approvalId, session_id: sessionId, tool_name: toolName } = data
|
||||
|
||||
if (!approvalId || !sessionId) {
|
||||
logger.error('Invalid approval event data:', data)
|
||||
return
|
||||
}
|
||||
|
||||
const notificationId = `approval_required:${sessionId}:${approvalId}`
|
||||
if (isItemNotified(notificationId)) {
|
||||
return
|
||||
}
|
||||
|
||||
// Wait a brief moment to see if an approval_resolved event follows immediately
|
||||
// This handles auto-approved cases where both events fire in quick succession
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
|
||||
// // Check if this approval was already resolved (auto-approved)
|
||||
if (isRecentResolvedApproval(approvalId)) {
|
||||
console.log('Skipping notification for auto-approved item', { sessionId, approvalId })
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const sessionState = await daemonClient.getSessionState(sessionId)
|
||||
const model = sessionState.session?.model || 'AI Agent'
|
||||
|
||||
await notificationService.notifyApprovalRequired(
|
||||
sessionId,
|
||||
approvalId,
|
||||
`${toolName} approval required`,
|
||||
model,
|
||||
)
|
||||
addNotifiedItem(notificationId)
|
||||
} catch (error) {
|
||||
logger.error(`Failed to get session state for ${sessionId}:`, error)
|
||||
// Still show notification with limited info
|
||||
await notificationService.notifyApprovalRequired(
|
||||
sessionId,
|
||||
approvalId,
|
||||
`${toolName} approval required`,
|
||||
'AI Agent',
|
||||
)
|
||||
addNotifiedItem(notificationId)
|
||||
}
|
||||
|
||||
updateSessionStatus(sessionId, SessionStatus.WaitingInput)
|
||||
await refreshActiveSessionConversation(sessionId)
|
||||
},
|
||||
onApprovalResolved: async data => {
|
||||
logger.log('useSessionSubscriptions.onApprovalResolved', data)
|
||||
onApprovalResolved: async (data: ApprovalResolvedEventData) => {
|
||||
logger.log('useSessionSubscriptions.onApprovalResolved', Date.now(), data)
|
||||
addRecentResolvedApprovalToCache(data.approval_id)
|
||||
updateSessionStatus(data.session_id, SessionStatus.Running)
|
||||
await refreshActiveSessionConversation(data.session_id)
|
||||
},
|
||||
// CODEREVIEW: Why did this previously exist? Sundeep wants to talk about this do not merge.
|
||||
onSessionSettingsChanged: async (data: SessionSettingsChangedEventData) => {
|
||||
// Placeholder handler - to be implemented based on requirements
|
||||
logger.log('useSessionSubscriptions.onSessionSettingsChanged', data)
|
||||
const { session_id, auto_accept_edits } = data
|
||||
updateSession(session_id, { autoAcceptEdits: auto_accept_edits })
|
||||
},
|
||||
})
|
||||
|
||||
// Global hotkey for toggling hotkey panel
|
||||
|
||||
@@ -348,13 +348,16 @@ function SessionDetail({ session, onClose }: SessionDetailProps) {
|
||||
useHotkeys(
|
||||
'shift+tab',
|
||||
async () => {
|
||||
console.log('shift+tab setAutoAcceptEdits', autoAcceptEdits)
|
||||
try {
|
||||
const newState = !autoAcceptEdits
|
||||
await daemonClient.updateSessionSettings(session.id, {
|
||||
const updatedSession = await daemonClient.updateSessionSettings(session.id, {
|
||||
auto_accept_edits: newState,
|
||||
})
|
||||
|
||||
// State will be updated via event subscription
|
||||
if (updatedSession.success) {
|
||||
useStore.getState().updateSession(session.id, { autoAcceptEdits: newState })
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to toggle auto-accept mode:', error)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Session } from '@/lib/daemon/types'
|
||||
import { Session, SessionStatus } from '@/lib/daemon/types'
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../ui/table'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip'
|
||||
import { useHotkeys, useHotkeysContext } from 'react-hotkeys-hook'
|
||||
@@ -241,18 +241,32 @@ export default function SessionTable({
|
||||
async () => {
|
||||
try {
|
||||
// Find the current session from the sessions array to get the latest archived status
|
||||
const currentSession = sessions.find(s => s.id === focusedSession?.id)
|
||||
if (!currentSession) return
|
||||
|
||||
logger.log('Archive hotkey pressed:', {
|
||||
sessionId: currentSession.id,
|
||||
archived: currentSession.archived,
|
||||
willArchive: !currentSession.archived,
|
||||
currentSession: sessions.find(s => s.id === focusedSession?.id),
|
||||
selectedSessions,
|
||||
})
|
||||
|
||||
// If there are selected sessions, bulk archive them
|
||||
if (selectedSessions.size > 0) {
|
||||
const isArchiving = !currentSession.archived
|
||||
logger.log('selectedSessions', selectedSessions)
|
||||
|
||||
// Convert selectedSessions Set to array and get the sessions
|
||||
const selectedSessionObjects = Array.from(selectedSessions)
|
||||
.map(sessionId => sessions.find(s => s.id === sessionId))
|
||||
.filter(Boolean)
|
||||
|
||||
// Check if all selected sessions have the same archived status
|
||||
const archivedStatuses = selectedSessionObjects.map(s => s?.archived)
|
||||
const allSameStatus = archivedStatuses.every(status => status === archivedStatuses[0])
|
||||
|
||||
if (!allSameStatus) {
|
||||
toast.warning(
|
||||
'Cannot bulk change archived status for archived and unarchived sessions at the same time (deselect one or the other)',
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const isArchiving = !archivedStatuses[0] // If all are unarchived, we're archiving
|
||||
|
||||
// Find next session to focus after bulk archive
|
||||
const nonSelectedSessions = sessions.filter(s => !selectedSessions.has(s.id))
|
||||
@@ -275,6 +289,11 @@ export default function SessionTable({
|
||||
)
|
||||
} else {
|
||||
// Single session archive
|
||||
const currentSession = sessions.find(s => s.id === focusedSession?.id)
|
||||
if (!currentSession) {
|
||||
logger.log('No current session found')
|
||||
return
|
||||
}
|
||||
const isArchiving = !currentSession.archived
|
||||
|
||||
// Find the index of current session and determine next focus
|
||||
@@ -404,6 +423,11 @@ export default function SessionTable({
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className={getStatusTextClass(session.status)}>
|
||||
{session.status === SessionStatus.Running && session.autoAcceptEdits && (
|
||||
<span className="align-text-top text-[var(--terminal-warning)] text-base leading-none animate-pulse-warning">
|
||||
{'⏵⏵ '}
|
||||
</span>
|
||||
)}
|
||||
{renderSessionStatus(session)}
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[200px]">
|
||||
|
||||
@@ -6,12 +6,14 @@ import type {
|
||||
SessionStatusChangedEventData,
|
||||
NewApprovalEventData,
|
||||
ApprovalResolvedEventData,
|
||||
SessionSettingsChangedEventData,
|
||||
} from '@/lib/daemon/types'
|
||||
|
||||
export interface SessionSubscriptionHandlers {
|
||||
onSessionStatusChanged?: (data: SessionStatusChangedEventData, timestamp: string) => void
|
||||
onNewApproval?: (data: NewApprovalEventData) => void
|
||||
onApprovalResolved?: (data: ApprovalResolvedEventData) => void
|
||||
onSessionSettingsChanged?: (data: SessionSettingsChangedEventData) => void
|
||||
}
|
||||
|
||||
export function useSessionSubscriptions(
|
||||
@@ -61,7 +63,12 @@ export function useSessionSubscriptions(
|
||||
isSubscribedRef.current = true
|
||||
|
||||
const subscription = daemonClient.subscribeToEvents({
|
||||
event_types: ['session_status_changed', 'new_approval', 'approval_resolved'],
|
||||
event_types: [
|
||||
'session_status_changed',
|
||||
'new_approval',
|
||||
'approval_resolved',
|
||||
'session_settings_changed',
|
||||
],
|
||||
onEvent: (event: Event) => {
|
||||
if (!isActive) return
|
||||
|
||||
@@ -87,6 +94,13 @@ export function useSessionSubscriptions(
|
||||
handlersRef.current.onApprovalResolved?.(data)
|
||||
break
|
||||
}
|
||||
case 'session_settings_changed': {
|
||||
const data = event.data as SessionSettingsChangedEventData
|
||||
|
||||
// Call handler if provided
|
||||
handlersRef.current.onSessionSettingsChanged?.(data)
|
||||
break
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -216,8 +216,13 @@ export interface ApprovalResolvedEventData {
|
||||
|
||||
export interface SessionStatusChangedEventData {
|
||||
session_id: string
|
||||
old_status: string
|
||||
new_status: string
|
||||
old_status: SessionStatus
|
||||
new_status: SessionStatus
|
||||
}
|
||||
|
||||
export interface SessionSettingsChangedEventData {
|
||||
session_id: string
|
||||
auto_accept_edits: boolean
|
||||
}
|
||||
|
||||
// Conversation types
|
||||
|
||||
@@ -103,16 +103,60 @@ export function SessionTablePage() {
|
||||
{ enableOnFormTags: false, scopes: SessionTableHotkeysScope, enabled: !isSessionLauncherOpen },
|
||||
)
|
||||
|
||||
// Handle Shift+Tab to toggle backwards (same effect for only 2 modes)
|
||||
// Handle Shift+Tab to trigger auto-accept for selected sessions
|
||||
useHotkeys(
|
||||
'shift+tab',
|
||||
e => {
|
||||
async e => {
|
||||
e.preventDefault()
|
||||
setViewMode(viewMode === ViewMode.Normal ? ViewMode.Archived : ViewMode.Normal)
|
||||
// Clear search when switching views
|
||||
setSearchQuery('')
|
||||
|
||||
// Find sessions to apply auto-accept to
|
||||
let sessionsToUpdate: string[] = []
|
||||
|
||||
if (selectedSessions.size > 0) {
|
||||
// If sessions are selected, use those
|
||||
sessionsToUpdate = Array.from(selectedSessions)
|
||||
} else if (focusedSession) {
|
||||
// Otherwise, use the focused session
|
||||
sessionsToUpdate = [focusedSession.id]
|
||||
}
|
||||
|
||||
if (sessionsToUpdate.length === 0) return
|
||||
|
||||
try {
|
||||
// Get the sessions to check their status
|
||||
const sessionsData = sessionsToUpdate
|
||||
.map(id => sessions.find(s => s.id === id))
|
||||
.filter(Boolean) as any[]
|
||||
|
||||
// Check if all selected sessions have the same auto-accept status
|
||||
const autoAcceptStatuses = sessionsData.map(s => s.autoAcceptEdits)
|
||||
const allSameStatus = autoAcceptStatuses.every(status => status === autoAcceptStatuses[0])
|
||||
|
||||
// Toggle the auto-accept status (if all true, turn off; otherwise turn on)
|
||||
const newAutoAcceptStatus = allSameStatus ? !autoAcceptStatuses[0] : true
|
||||
|
||||
// Call the bulk update method
|
||||
await useStore.getState().bulkSetAutoAcceptEdits(sessionsToUpdate, newAutoAcceptStatus)
|
||||
|
||||
// Show success notification
|
||||
const action = newAutoAcceptStatus ? 'enabled' : 'disabled'
|
||||
const sessionText =
|
||||
sessionsToUpdate.length === 1 ? 'session' : `${sessionsToUpdate.length} sessions`
|
||||
|
||||
// Use toast from sonner
|
||||
const { toast } = await import('sonner')
|
||||
toast.success(`Auto-accept edits ${action} for ${sessionText}`, {
|
||||
duration: 3000,
|
||||
})
|
||||
} catch (error) {
|
||||
const { toast } = await import('sonner')
|
||||
toast.error('Failed to update auto-accept settings', {
|
||||
description: error instanceof Error ? error.message : 'Unknown error',
|
||||
})
|
||||
}
|
||||
},
|
||||
{ enableOnFormTags: false, scopes: SessionTableHotkeysScope, enabled: !isSessionLauncherOpen },
|
||||
[selectedSessions, focusedSession, sessions],
|
||||
)
|
||||
|
||||
// Handle 'gg' to jump to top of list (vim-style)
|
||||
|
||||
Reference in New Issue
Block a user