Skip to content
Merged
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
14 changes: 10 additions & 4 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,6 @@ function RootLayoutContent() {
const [selectedModel, setSelectedModel] =
useState<string>("gemini-2.5-flash");
const [cliIOLogs, setCliIOLogs] = useState<CliIO[]>([]);
const messagesContainerRef = useRef<HTMLDivElement>(null);
const [sidebarOpen, setSidebarOpen] = useState(true);
const [directoryPanelOpen, setDirectoryPanelOpen] = useState(false);
const [searchOpen, setSearchOpen] = useState(false);
Expand Down Expand Up @@ -158,10 +157,19 @@ function RootLayoutContent() {
updateConversation,
});

// Get yolo mode status from backend config
const isYoloEnabled =
backendState.selectedBackend === "gemini"
? backendState.configs.gemini.yolo
: backendState.selectedBackend === "qwen"
? backendState.configs.qwen.yolo
: false;

const { setupEventListenerForConversation } = useConversationEvents(
setCliIOLogs,
setConfirmationRequests,
updateConversation
updateConversation,
isYoloEnabled
);

const { input, handleInputChange, handleSendMessage } = useMessageHandler({
Expand Down Expand Up @@ -450,7 +458,6 @@ function RootLayoutContent() {
currentConversation,
input,
isCliInstalled,
messagesContainerRef,
cliIOLogs,
handleInputChange,
handleSendMessage,
Expand All @@ -468,7 +475,6 @@ function RootLayoutContent() {
currentConversation,
input,
isCliInstalled,
messagesContainerRef,
cliIOLogs,
handleInputChange,
handleSendMessage,
Expand Down
11 changes: 9 additions & 2 deletions frontend/src/contexts/ConversationContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,16 @@ import { ToolCallConfirmationRequest } from "../utils/toolCallParser";
import { ConversationHistoryEntry } from "../lib/webApi";
import { SessionProgressPayload } from "../types/session";

// Context for sharing conversation state with child routes
/**
* Context type for sharing conversation state with child routes.
* Provides conversation management, message handling, and tool call confirmation.
*/
export interface ConversationContextType {
conversations: Conversation[];
activeConversation: string | null;
currentConversation: Conversation | undefined;
input: string;
isCliInstalled: boolean | null;
messagesContainerRef: React.RefObject<HTMLDivElement | null>;
cliIOLogs: CliIO[];
handleInputChange: (
_event: React.ChangeEvent<HTMLTextAreaElement> | null,
Expand Down Expand Up @@ -43,6 +45,11 @@ export const ConversationContext = createContext<
ConversationContextType | undefined
>(undefined);

/**
* Custom hook to access the conversation context.
* @returns The conversation context value.
* @throws Error if used outside of a ConversationProvider.
*/
export const useConversation = () => {
const context = useContext(ConversationContext);
if (context === undefined) {
Expand Down
95 changes: 82 additions & 13 deletions frontend/src/hooks/useConversationEvents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import React, { useCallback, useRef } from "react";
import type { TextMessagePart } from "../types";
import { listen } from "@/lib/listen";
import { getWebSocketManager } from "../lib/webApi";
import { api } from "../lib/api";
import { Conversation, Message, CliIO } from "../types";
import { ToolCallConfirmationRequest } from "../utils/toolCallParser";
import { type ToolCall } from "../utils/toolCallParser";
Expand Down Expand Up @@ -261,6 +262,16 @@ function getOptionKind(
}
}

/**
* Custom hook to set up event listeners for conversation events.
* Handles CLI I/O logging, tool call confirmations, and AI turn events.
*
* @param setCliIOLogs - State setter for CLI input/output logs
* @param setConfirmationRequests - State setter for tool call confirmation requests
* @param updateConversation - Function to update conversation state
* @param isYoloEnabled - Optional flag to enable yolo mode (auto-approve tool calls)
* @returns Object with setupEventListenerForConversation function
*/
export const useConversationEvents = (
setCliIOLogs: React.Dispatch<React.SetStateAction<CliIO[]>>,
setConfirmationRequests: React.Dispatch<
Expand All @@ -269,13 +280,24 @@ export const useConversationEvents = (
updateConversation: (
conversationId: string,
updateFn: (conv: Conversation, lastMsg: Message) => void
) => void
) => void,
isYoloEnabled?: boolean
) => {
// Buffer agent text chunks seen on CLI output in case the UI misses
// ai-output streaming events (web WS race). Cleared on turn finish.
const pendingAssistantTextRef = useRef<Map<string, string>>(new Map());
const sawAiOutputRef = useRef<Map<string, boolean>>(new Map());

// Use ref to avoid re-creating listeners when yolo mode toggles
const isYoloEnabledRef = useRef<boolean>(isYoloEnabled ?? false);
isYoloEnabledRef.current = isYoloEnabled ?? false;

/**
* Sets up event listeners for a specific conversation.
* Listens for CLI I/O events, tool call confirmations, and AI turn events.
*
* @param conversationId - The ID of the conversation to listen to
* @returns Cleanup function to remove all event listeners
*/
const setupEventListenerForConversation = useCallback(
async (conversationId: string): Promise<() => void> => {
// In web mode, ensure WebSocket connection is ready before registering listeners
Expand Down Expand Up @@ -898,19 +920,66 @@ export const useConversationEvents = (
: [],
};

setConfirmationRequests((prev) => {
const newMap = new Map(prev);
newMap.set(toolCallId, legacyConfirmationRequest);
console.log(
`✅ Stored confirmation request in Map for toolCallId:`,
toolCallId
);
// If yolo mode is enabled, auto-approve the tool call
if (isYoloEnabledRef.current) {
console.log(
`✅ Total confirmation requests in Map:`,
newMap.size
`🤖 [YOLO] Auto-approving tool call: ${toolCallId}`,
legacyConfirmationRequest
);
return newMap;
});
// Find the best auto-approve option, preferring allow_always
// over allow_once so YOLO mode doesn't re-prompt for the same tool.
const autoApproveOption =
legacyConfirmationRequest.options?.find(
(opt) => opt.kind === "allow_always"
) ??
legacyConfirmationRequest.options?.find(
(opt) => opt.kind === "allow_once"
);
const outcome = autoApproveOption?.optionId || "proceed_always";
if (!request.sessionId) {
console.error(
`🤖 [YOLO] No sessionId found for tool call: ${toolCallId}`
);
} else {
// Send approval response to backend
api
.send_tool_call_confirmation_response({
sessionId: request.sessionId,
requestId: legacyConfirmationRequest.requestId,
toolCallId: toolCallId,
outcome: outcome,
})
.catch((err) => {
console.error("Failed to send auto-approve response:", err);
// Fall back to showing the confirmation dialog so the user
// can manually approve and the call doesn't get stuck.
setConfirmationRequests((prev) => {
const newMap = new Map(prev);
newMap.set(toolCallId, legacyConfirmationRequest);
console.log(
`⚠️ [YOLO] Auto-approve failed, falling back to manual confirmation for toolCallId:`,
toolCallId
);
return newMap;
});
});
}
} else {
// Only store confirmation request if yolo mode is disabled
setConfirmationRequests((prev) => {
const newMap = new Map(prev);
newMap.set(toolCallId, legacyConfirmationRequest);
console.log(
`✅ Stored confirmation request in Map for toolCallId:`,
toolCallId
);
console.log(
`✅ Total confirmation requests in Map:`,
newMap.size
);
return newMap;
});
}
}
);
unlistenFunctions.push(unlistenAcpPermissionRequest);
Expand Down
69 changes: 59 additions & 10 deletions frontend/src/pages/HomeDashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,27 +33,76 @@ import { GeminiMessagePart } from "../types";
export const HomeDashboard: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const {
currentConversation,
messagesContainerRef,
handleConfirmToolCall,
confirmationRequests,
} = useConversation();

// Debug logging for currentConversation
console.log("🏠 HomeDashboard - currentConversation:", currentConversation);
const { currentConversation, handleConfirmToolCall, confirmationRequests } =
useConversation();

const { selectedBackend } = useBackend();
const backendText = getBackendText(selectedBackend);

// Use a local ref for auto-scrolling
const localContainerRef = React.useRef<HTMLDivElement>(null);

// Track if user is near bottom (for auto-scroll behavior)
const isNearBottomRef = React.useRef(true);
// Track previous scroll height to detect content growth
const prevScrollHeightRef = React.useRef<number>(0);

// Listen for scroll events to track user position
React.useEffect(() => {
const container = localContainerRef.current;
if (!container) return;

const handleScroll = () => {
const threshold = 100; // pixels from bottom
const position = container.scrollTop + container.clientHeight;
const height = container.scrollHeight;
isNearBottomRef.current = height - position < threshold;
};

container.addEventListener("scroll", handleScroll, { passive: true });
return () => container.removeEventListener("scroll", handleScroll);
}, []);

// Auto-scroll to bottom when content grows (only if user is near bottom)
React.useEffect(() => {
const container = localContainerRef.current;
if (!container || !currentConversation?.messages.length) return;

// Use ResizeObserver to detect when content height changes
const observer = new ResizeObserver(() => {
const newHeight = container.scrollHeight;
const prevHeight = prevScrollHeightRef.current;

// Only scroll if content grew and user is near bottom
if (newHeight > prevHeight && isNearBottomRef.current) {
// Use requestAnimationFrame for smooth scrolling
requestAnimationFrame(() => {
container.scrollTop = container.scrollHeight;
});
}

prevScrollHeightRef.current = newHeight;
});

// Observe the content container (the div with space-y-8)
const contentContainer = container.firstElementChild;
if (contentContainer) {
observer.observe(contentContainer);
// Initial height capture
prevScrollHeightRef.current = container.scrollHeight;
}

return () => observer.disconnect();
}, [currentConversation?.messages.length]);

return (
<>
{currentConversation ? (
currentConversation.messages.length === 0 ? (
<NewChatPlaceholder />
) : (
<div
ref={messagesContainerRef as React.RefObject<HTMLDivElement>}
ref={localContainerRef}
className="flex-1 min-h-0 overflow-y-auto p-6 relative"
>
<div className="space-y-8 pb-4">
Expand Down