Compare commits

...

4 Commits

Author SHA1 Message Date
amanape
f71325f50d fix(frontend): Fix stream parsing, navigation, WebSocket URL construction and inline status indicator 2025-10-10 18:21:32 +04:00
amanape
c79652142b feat(frontend): Add setup status indicator with Zustand store 2025-10-10 17:15:06 +04:00
amanape
aa15020749 fix(frontend): Add eslint disable comments for console warnings
- Add eslint-disable-next-line no-console for debugging console statements
- Maintain console warnings for development debugging while satisfying linter

Co-authored-by: openhands <openhands@all-hands.dev>
2025-10-10 16:37:37 +04:00
amanape
74c337f200 feat(frontend): Add V1 conversation API with streaming setup flow
- Add new app-conversation-service API layer with streaming support
- Implement useStreamStartAppConversation hook with TanStack Query integration
- Add ConversationSetupFlow component with real-time progress tracking
- Integrate setup mode into conversation routing with visual progress indicators
- Support conversation startup phases: sandbox setup, repository preparation, git hooks, etc.
- Include cancellation support and automatic query invalidation
- Remove documentation markdown file

Co-authored-by: openhands <openhands@all-hands.dev>
2025-10-10 16:37:33 +04:00
12 changed files with 831 additions and 28 deletions

View File

@@ -0,0 +1,121 @@
import {
AppConversationStartRequest,
AppConversationStartTask,
} from "../open-hands.types";
class AppConversationServiceCallback {
/**
* Start an app conversation with streaming updates using callback pattern
* This approach avoids the no-await-in-loop ESLint warning
* @param request The conversation start request
* @param onProgress Callback function called for each progress update
* @param onComplete Callback function called when streaming is complete
* @param onError Callback function called when an error occurs
* @returns Promise that resolves when the stream starts (not when it completes)
*/
static async streamStartAppConversation(
request: AppConversationStartRequest,
onProgress: (task: AppConversationStartTask) => void,
onComplete: (allTasks: AppConversationStartTask[]) => void,
onError: (error: Error) => void,
): Promise<void> {
const baseURL = `${window.location.protocol}//${
import.meta.env.VITE_BACKEND_BASE_URL || window?.location.host
}`;
const url = `${baseURL}/api/v1/app-conversations/stream-start`;
try {
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(request),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
if (!response.body) {
throw new Error("Response body is null");
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
const allTasks: AppConversationStartTask[] = [];
const processStream = async (): Promise<void> => {
try {
const { done, value } = await reader.read();
if (done) {
// Process any remaining data in buffer
if (buffer.trim()) {
const trimmedBuffer = buffer.trim();
if (trimmedBuffer !== "[" && trimmedBuffer !== "]") {
const cleanBuffer = trimmedBuffer.replace(/,$/, "");
if (cleanBuffer) {
try {
const task: AppConversationStartTask =
JSON.parse(cleanBuffer);
allTasks.push(task);
onProgress(task);
} catch (error) {
// eslint-disable-next-line no-console
console.warn(
"Failed to parse final JSON:",
cleanBuffer,
error,
);
}
}
}
}
onComplete(allTasks);
return;
}
buffer += decoder.decode(value, { stream: true });
// The API returns a JSON array that gets built incrementally
// We need to parse individual JSON objects as they come in
const lines = buffer.split("\n");
buffer = lines.pop() || ""; // Keep the last incomplete line in buffer
for (const line of lines) {
const trimmedLine = line.trim();
if (trimmedLine && trimmedLine !== "[" && trimmedLine !== "]") {
// Remove trailing comma if present
const cleanLine = trimmedLine.replace(/,$/, "");
if (cleanLine) {
try {
const task: AppConversationStartTask = JSON.parse(cleanLine);
allTasks.push(task);
onProgress(task);
} catch (error) {
// eslint-disable-next-line no-console
console.warn("Failed to parse JSON line:", cleanLine, error);
}
}
}
}
// Continue processing the stream
processStream();
} catch (error) {
reader.releaseLock();
onError(error instanceof Error ? error : new Error(String(error)));
}
};
// Start processing the stream
processStream();
} catch (error) {
onError(error instanceof Error ? error : new Error(String(error)));
}
}
}
export default AppConversationServiceCallback;

View File

@@ -0,0 +1,96 @@
import {
AppConversationStartRequest,
AppConversationStartTask,
} from "../open-hands.types";
class AppConversationService {
/**
* Start an app conversation with streaming updates
* @param request The conversation start request
* @returns AsyncGenerator that yields AppConversationStartTask updates
*/
static async *streamStartAppConversation(
request: AppConversationStartRequest,
): AsyncGenerator<AppConversationStartTask, void, unknown> {
const baseURL = `${window.location.protocol}//${
import.meta.env.VITE_BACKEND_BASE_URL || window?.location.host
}`;
const url = `${baseURL}/api/v1/app-conversations/stream-start`;
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(request),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
if (!response.body) {
throw new Error("Response body is null");
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
try {
while (true) {
// eslint-disable-next-line no-await-in-loop -- Sequential reading from stream required
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// The API returns a JSON array that gets built incrementally
// We need to parse individual JSON objects as they come in
const lines = buffer.split("\n");
buffer = lines.pop() || ""; // Keep the last incomplete line in buffer
for (const line of lines) {
const trimmedLine = line.trim();
if (trimmedLine && trimmedLine !== "[" && trimmedLine !== "]") {
// Remove trailing comma if present
const cleanLine = trimmedLine.replace(/,$/, "");
if (cleanLine) {
try {
const task: AppConversationStartTask = JSON.parse(cleanLine);
yield task;
} catch (error) {
// eslint-disable-next-line no-console
console.warn("Failed to parse JSON line:", cleanLine, error);
}
}
}
}
}
// Process any remaining data in buffer
if (buffer.trim()) {
const trimmedBuffer = buffer.trim();
if (trimmedBuffer !== "[" && trimmedBuffer !== "]") {
const cleanBuffer = trimmedBuffer
.replace(/,?\]$/, "")
.replace(/,$/, "");
if (cleanBuffer) {
try {
const task: AppConversationStartTask = JSON.parse(cleanBuffer);
yield task;
} catch (error) {
// eslint-disable-next-line no-console
console.warn("Failed to parse final JSON:", cleanBuffer, error);
}
}
}
}
} finally {
reader.releaseLock();
}
}
}
export default AppConversationService;

View File

@@ -76,6 +76,7 @@ export interface Conversation {
url: string | null;
session_api_key: string | null;
pr_number?: number[] | null;
conversation_version: "V0" | "V1";
}
export interface ResultSet<T> {
@@ -139,3 +140,50 @@ export type GetFilesResponse = string[];
export interface GetFileResponse {
code: string;
}
// App Conversation Types
export interface SendMessageRequest {
message: string;
image_urls?: string[];
}
export interface EventCallbackProcessor {
type: string;
config: Record<string, unknown>;
}
export interface AppConversationStartRequest {
sandbox_id?: string | null;
initial_message?: SendMessageRequest | null;
processors?: EventCallbackProcessor[];
llm_model?: string | null;
selected_repository?: string | null;
selected_branch?: string | null;
git_provider?: Provider | null;
title?: string | null;
trigger?: ConversationTrigger | null;
pr_number?: number[];
}
export type AppConversationStartTaskStatus =
| "WORKING"
| "WAITING_FOR_SANDBOX"
| "PREPARING_REPOSITORY"
| "RUNNING_SETUP_SCRIPT"
| "SETTING_UP_GIT_HOOKS"
| "STARTING_CONVERSATION"
| "READY"
| "ERROR";
export interface AppConversationStartTask {
id: string;
created_by_user_id: string | null;
status: AppConversationStartTaskStatus;
detail?: string | null;
app_conversation_id?: string | null;
sandbox_id?: string | null;
agent_server_url?: string | null;
request: AppConversationStartRequest;
created_at: string;
updated_at: string;
}

View File

@@ -1,6 +1,6 @@
import React from "react";
import posthog from "posthog-js";
import { useParams } from "react-router";
import { useParams, useNavigate } from "react-router";
import { useTranslation } from "react-i18next";
import { convertImageToBase64 } from "#/utils/convert-image-to-base-64";
import { TrajectoryActions } from "../trajectory/trajectory-actions";
@@ -36,6 +36,9 @@ import { validateFiles } from "#/utils/file-validation";
import { useConversationStore } from "#/state/conversation-store";
import ConfirmationModeEnabled from "./confirmation-mode-enabled";
import { isV0Event } from "#/types/v1/type-guards";
import { useStreamStartAppConversation } from "#/hooks/mutation/use-stream-start-app-conversation";
import { AppConversationStartRequest } from "#/api/open-hands.types";
import { useConversationSetupStore } from "#/stores/conversation-setup-store";
function getEntryPoint(
hasRepository: boolean | null,
@@ -54,6 +57,7 @@ export function ChatInterface() {
const { setOptimisticUserMessage, getOptimisticUserMessage } =
useOptimisticUserMessageStore();
const { t } = useTranslation();
const navigate = useNavigate();
const scrollRef = React.useRef<HTMLDivElement>(null);
const {
scrollDomToBottom,
@@ -65,7 +69,55 @@ export function ChatInterface() {
} = useScrollToBottom(scrollRef);
const { data: config } = useConfig();
const { curAgentState } = useAgentStore();
const { curAgentState, setCurrentAgentState } = useAgentStore();
// Get setup state from store
const {
isSetupMode,
conversationId: setupConversationId,
setCurrentTask,
setIsSetupMode,
} = useConversationSetupStore();
const { mutate: startConversation } = useStreamStartAppConversation();
// Start conversation setup when in setup mode
React.useEffect(() => {
if (isSetupMode && setupConversationId) {
setCurrentAgentState(AgentState.LOADING);
const request: AppConversationStartRequest = {
initial_message: {
message: "Hello! I'm ready to help you with your project.",
image_urls: [],
},
};
startConversation({
request,
onProgress: (task) => {
setCurrentTask(task);
// When ready, navigate to the actual conversation and exit setup mode
if (task.status === "READY" && task.app_conversation_id) {
setCurrentAgentState(AgentState.INIT);
setIsSetupMode(false);
// Replace the URL to remove setup parameter
navigate(`/conversations/${task.app_conversation_id}`, {
replace: true,
});
}
},
});
}
}, [
isSetupMode,
setupConversationId,
startConversation,
setCurrentAgentState,
setCurrentTask,
setIsSetupMode,
navigate,
]);
const [feedbackPolarity, setFeedbackPolarity] = React.useState<
"positive" | "negative"

View File

@@ -2,6 +2,8 @@ import { ConversationStatus } from "#/types/conversation-status";
import { ServerStatus } from "#/components/features/controls/server-status";
import { AgentStatus } from "#/components/features/controls/agent-status";
import { Tools } from "../../controls/tools";
import { SetupStatusIndicator } from "./setup-status-indicator";
import { useConversationSetupStore } from "#/stores/conversation-setup-store";
interface ChatInputActionsProps {
conversationStatus: ConversationStatus | null;
@@ -18,10 +20,14 @@ export function ChatInputActions({
handleResumeAgent,
onStop,
}: ChatInputActionsProps) {
// Get setup state from store
const { isSetupMode, currentTask } = useConversationSetupStore();
return (
<div className="w-full flex items-center justify-between">
<div className="flex items-center gap-1">
<Tools />
<SetupStatusIndicator task={currentTask} isActive={isSetupMode} />
<ServerStatus conversationStatus={conversationStatus} />
</div>
<AgentStatus

View File

@@ -0,0 +1,52 @@
import { AppConversationStartTask } from "#/api/open-hands.types";
import DebugStackframeDot from "#/icons/debug-stackframe-dot.svg?react";
interface SetupStatusIndicatorProps {
task: AppConversationStartTask | null;
isActive: boolean;
}
export function SetupStatusIndicator({
task,
isActive,
}: SetupStatusIndicatorProps) {
if (!isActive || !task) {
return null;
}
const getStatusMessage = (status: string) => {
const messages: Record<string, string> = {
WORKING: "Initializing...",
WAITING_FOR_SANDBOX: "Setting up environment...",
PREPARING_REPOSITORY: "Preparing repository...",
RUNNING_SETUP_SCRIPT: "Running setup...",
SETTING_UP_GIT_HOOKS: "Configuring git...",
STARTING_CONVERSATION: "Starting conversation...",
READY: "Ready",
ERROR: "Setup failed",
};
return messages[status] || status;
};
const getStatusColor = (status: string): string => {
if (status === "ERROR") {
return "#FF684E"; // Red
}
if (status === "READY") {
return "#BCFF8C"; // Green
}
return "#FFD600"; // Yellow for in-progress
};
const statusColor = getStatusColor(task.status);
const statusText = getStatusMessage(task.status);
return (
<div className="flex items-center">
<DebugStackframeDot className="w-6 h-6" color={statusColor} />
<span className="text-[11px] text-white font-normal leading-5">
{statusText}
</span>
</div>
);
}

View File

@@ -0,0 +1,238 @@
import React, { useEffect, useState } from "react";
import { useNavigate } from "react-router";
import { useAgentStore } from "#/stores/agent-store";
import { AgentState } from "#/types/agent-state";
import { useStreamStartAppConversation } from "#/hooks/mutation/use-stream-start-app-conversation";
import {
AppConversationStartRequest,
AppConversationStartTask,
} from "#/api/open-hands.types";
import { LoadingSpinner } from "#/components/shared/loading-spinner";
// Component that shows in the chat input area during setup
function ConversationSetupInput({
task,
}: {
task: AppConversationStartTask | null;
}) {
if (!task) {
return (
<div className="flex items-center justify-center p-4">
<LoadingSpinner />
<span className="ml-2">Initializing conversation...</span>
</div>
);
}
const getStatusMessage = (status: string) => {
const messages = {
WORKING: "Starting your conversation...",
WAITING_FOR_SANDBOX: "Setting up secure environment...",
PREPARING_REPOSITORY: "Preparing repository...",
RUNNING_SETUP_SCRIPT: "Running setup scripts...",
SETTING_UP_GIT_HOOKS: "Configuring git integration...",
STARTING_CONVERSATION: "Almost ready...",
READY: "Conversation ready!",
ERROR: "Setup failed",
};
return messages[status] || status;
};
const getProgress = (status: string) => {
const progress = {
WORKING: 10,
WAITING_FOR_SANDBOX: 25,
PREPARING_REPOSITORY: 50,
RUNNING_SETUP_SCRIPT: 70,
SETTING_UP_GIT_HOOKS: 85,
STARTING_CONVERSATION: 95,
READY: 100,
ERROR: 0,
};
return progress[status] || 0;
};
return (
<div className="space-y-3">
{/* Progress bar */}
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-blue-500 h-2 rounded-full transition-all duration-500"
style={{ width: `${getProgress(task.status)}%` }}
/>
</div>
{/* Status message */}
<div className="flex items-center justify-between">
<span className="text-sm font-medium">
{getStatusMessage(task.status)}
</span>
{task.status !== "ERROR" && task.status !== "READY" && (
<LoadingSpinner size="small" />
)}
</div>
{/* Detail message */}
{task.detail && <p className="text-xs text-gray-500">{task.detail}</p>}
{/* Error state */}
{task.status === "ERROR" && (
<div className="flex items-center justify-between">
<span className="text-red-600 text-sm">
Setup failed. Please try again.
</span>
<button
type="button"
onClick={() => {
window.location.href = "/";
}}
className="text-blue-500 hover:underline text-sm"
>
Return to Home
</button>
</div>
)}
</div>
);
}
// Component that shows setup steps in the main chat area
function ConversationSetupProgress({
task,
error,
}: {
task: AppConversationStartTask | null;
error: Error | null;
}) {
const setupSteps = [
{ key: "WORKING", label: "Initializing", completed: false },
{
key: "WAITING_FOR_SANDBOX",
label: "Setting up environment",
completed: false,
},
{
key: "PREPARING_REPOSITORY",
label: "Preparing repository",
completed: false,
},
{ key: "RUNNING_SETUP_SCRIPT", label: "Running setup", completed: false },
{ key: "SETTING_UP_GIT_HOOKS", label: "Configuring git", completed: false },
{
key: "STARTING_CONVERSATION",
label: "Starting conversation",
completed: false,
},
];
// Mark steps as completed based on current status
const currentStepIndex = setupSteps.findIndex(
(step) => step.key === task?.status,
);
const stepsWithStatus = setupSteps.map((step, index) => ({
...step,
completed: index < currentStepIndex,
current: index === currentStepIndex,
}));
return (
<div className="max-w-md space-y-4">
<h3 className="text-lg font-semibold text-center">
Setting up your conversation
</h3>
<div className="space-y-2">
{stepsWithStatus.map((step, index) => (
<div key={step.key} className="flex items-center space-x-3">
<div
className={`w-6 h-6 rounded-full flex items-center justify-center text-sm ${
step.completed
? "bg-green-500 text-white"
: step.current
? "bg-blue-500 text-white"
: "bg-gray-200 text-gray-500"
}`}
>
{step.completed ? "✓" : index + 1}
</div>
<span
className={
step.current
? "font-medium text-blue-600"
: step.completed
? "text-green-600"
: "text-gray-500"
}
>
{step.label}
</span>
{step.current && <LoadingSpinner size="small" />}
</div>
))}
</div>
{error && (
<div className="text-red-600 text-sm text-center">{error.message}</div>
)}
</div>
);
}
interface ConversationSetupFlowProps {
conversationId: string;
}
export function ConversationSetupFlow({
conversationId,
}: ConversationSetupFlowProps) {
const navigate = useNavigate();
const [currentTask, setCurrentTask] =
useState<AppConversationStartTask | null>(null);
const { setCurrentAgentState } = useAgentStore();
const { mutate: startConversation, error } = useStreamStartAppConversation();
useEffect(() => {
// Set agent state to loading during setup
setCurrentAgentState(AgentState.LOADING);
// Start the V1 conversation creation
const request: AppConversationStartRequest = {
// Get from user settings, context, etc.
initial_message: {
message: "Hello! I'm ready to help you with your project.",
image_urls: [],
},
};
startConversation({
request,
onProgress: (task) => {
setCurrentTask(task);
// When ready, replace URL and let existing logic take over
if (task.status === "READY" && task.app_conversation_id) {
// Replace the URL without the setup parameter
navigate(`/conversations/${task.app_conversation_id}`, {
replace: true,
});
// The existing conversation logic will now load the real conversation
}
},
});
}, [conversationId, startConversation, setCurrentAgentState, navigate]);
return (
<div className="flex flex-col h-full">
{/* Empty messages area - could show setup steps here */}
<div className="flex-1 flex items-center justify-center">
<ConversationSetupProgress task={currentTask} error={error} />
</div>
{/* Setup progress in place of chat input */}
<div className="border-t bg-white p-4">
<ConversationSetupInput task={currentTask} />
</div>
</div>
);
}

View File

@@ -1,32 +1,21 @@
import { useNavigate } from "react-router";
import { useTranslation } from "react-i18next";
import { BrandButton } from "../../settings/brand-button";
import { useCreateConversation } from "#/hooks/mutation/use-create-conversation";
import { useIsCreatingConversation } from "#/hooks/use-is-creating-conversation";
export function CreateConversationButton() {
const { t } = useTranslation();
const navigate = useNavigate();
const {
mutate: createConversation,
isPending,
isSuccess,
} = useCreateConversation();
const isCreatingConversationElsewhere = useIsCreatingConversation();
// We check for isSuccess because the app might require time to render
// into the new conversation screen after the conversation is created.
const isCreatingConversation =
isPending || isSuccess || isCreatingConversationElsewhere;
// We check for isCreatingConversationElsewhere to prevent multiple conversations
const isCreatingConversation = isCreatingConversationElsewhere;
const handleCreateConversation = () => {
createConversation(
{},
{
onSuccess: (data) => navigate(`/conversations/${data.conversation_id}`),
},
);
const taskId = crypto.randomUUID();
// Navigate with a special setup parameter
navigate(`/conversations/${taskId}?setup=true`);
};
return (

View File

@@ -18,6 +18,7 @@ import {
isActionEvent,
} from "#/types/v1/type-guards";
import { handleActionEventCacheInvalidation } from "#/utils/cache-utils";
import { useUserConversation } from "#/hooks/query/use-user-conversation";
interface ConversationWebSocketContextType {
connectionState: "CONNECTING" | "OPEN" | "CLOSED" | "CLOSING";
@@ -34,6 +35,9 @@ export function ConversationWebSocketProvider({
children: React.ReactNode;
conversationId?: string;
}) {
const { data: userConversation } = useUserConversation(
conversationId || null,
);
const [connectionState, setConnectionState] = useState<
"CONNECTING" | "OPEN" | "CLOSED" | "CLOSING"
>("CONNECTING");
@@ -81,6 +85,9 @@ export function ConversationWebSocketProvider({
const websocketOptions = useMemo(
() => ({
queryParams: {
session_api_key: userConversation?.session_api_key || "",
},
onOpen: () => {
setConnectionState("OPEN");
removeErrorMessage(); // Clear any previous error messages on successful connection
@@ -100,13 +107,27 @@ export function ConversationWebSocketProvider({
},
onMessage: handleMessage,
}),
[handleMessage, setErrorMessage, removeErrorMessage],
[
userConversation?.session_api_key,
handleMessage,
setErrorMessage,
removeErrorMessage,
],
);
const { socket } = useWebSocket(
"ws://localhost/events/socket",
websocketOptions,
);
// Extract the host and port from the conversation URL
// Expected format: http://localhost:PORT/api/conversations/xxx
// We want to construct: ws://localhost:PORT/sockets/events/{conversationId}
const socketUrl = useMemo(() => {
const url = userConversation?.url || "";
const urlMatch = url.match(/^https?:\/\/([^/]+)/);
const hostWithPort = urlMatch?.[1] || "localhost";
return `ws://${hostWithPort}/sockets/events/${conversationId}`;
}, [userConversation?.url, conversationId]);
// Use the custom useWebSocket hook
const { socket } = useWebSocket(socketUrl, websocketOptions);
useEffect(() => {
if (socket) {

View File

@@ -0,0 +1,124 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useCallback, useRef } from "react";
import AppConversationService from "#/api/app-conversation-service/app-conversation-service.api";
import {
AppConversationStartRequest,
AppConversationStartTask,
} from "#/api/open-hands.types";
interface StreamStartAppConversationVariables {
request: AppConversationStartRequest;
onProgress?: (task: AppConversationStartTask) => void;
}
interface StreamStartAppConversationResult {
finalTask: AppConversationStartTask | null;
allTasks: AppConversationStartTask[];
}
export const useStreamStartAppConversation = () => {
const queryClient = useQueryClient();
const abortControllerRef = useRef<AbortController | null>(null);
const cancelStream = useCallback(() => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
abortControllerRef.current = null;
}
}, []);
const mutation = useMutation({
mutationKey: ["stream-start-app-conversation"],
mutationFn: async (
variables: StreamStartAppConversationVariables,
): Promise<StreamStartAppConversationResult> => {
const { request, onProgress } = variables;
// Create a new AbortController for this request
abortControllerRef.current = new AbortController();
const allTasks: AppConversationStartTask[] = [];
let finalTask: AppConversationStartTask | null = null;
try {
// eslint-disable-next-line no-await-in-loop -- Sequential processing required for streaming
for await (const task of AppConversationService.streamStartAppConversation(
request,
)) {
// Check if the request was aborted
if (abortControllerRef.current?.signal.aborted) {
throw new Error("Request was cancelled");
}
allTasks.push(task);
finalTask = task;
// Call the progress callback if provided
if (onProgress) {
onProgress(task);
}
// If we reach READY or ERROR status, we're done
if (task.status === "READY" || task.status === "ERROR") {
break;
}
}
} catch (error) {
// If it's not a cancellation error, re-throw it
if (
error instanceof Error &&
error.message !== "Request was cancelled"
) {
throw error;
}
// For cancellation, we still return what we have so far
} finally {
abortControllerRef.current = null;
}
return { finalTask, allTasks };
},
onSuccess: async (result) => {
// Invalidate relevant queries when the conversation is successfully started
if (result.finalTask?.status === "READY") {
await queryClient.invalidateQueries({
queryKey: ["app-conversations"],
});
// You might also want to invalidate other related queries
await queryClient.invalidateQueries({
queryKey: ["user", "conversations"],
});
}
},
onError: (error) => {
// eslint-disable-next-line no-console
console.error("Error starting app conversation:", error);
},
});
return {
...mutation,
cancelStream,
isStreaming: mutation.isPending,
};
};
// Additional hook for simpler usage when you just want the final result
export const useStartAppConversation = () => {
const streamMutation = useStreamStartAppConversation();
const startConversation = useCallback(
(request: AppConversationStartRequest) =>
streamMutation.mutateAsync({ request }),
[streamMutation],
);
return {
startConversation,
isLoading: streamMutation.isPending,
error: streamMutation.error,
data: streamMutation.data,
reset: streamMutation.reset,
};
};

View File

@@ -1,5 +1,5 @@
import React from "react";
import { useNavigate } from "react-router";
import { useNavigate, useLocation } from "react-router";
import { useQueryClient } from "@tanstack/react-query";
import { useConversationId } from "#/hooks/use-conversation-id";
@@ -28,17 +28,38 @@ import { ConversationName } from "#/components/features/conversation/conversatio
import { ConversationTabs } from "#/components/features/conversation/conversation-tabs/conversation-tabs";
import { useStartConversation } from "#/hooks/mutation/use-start-conversation";
import { WebSocketProviderWrapper } from "#/contexts/websocket-provider-wrapper";
import { useConversationSetupStore } from "#/stores/conversation-setup-store";
function AppContent() {
useConversationConfig();
const { conversationId } = useConversationId();
const { data: conversation, isFetched, refetch } = useActiveConversation();
const location = useLocation();
const navigate = useNavigate();
const { setIsSetupMode, setConversationId } = useConversationSetupStore();
// Check if we're in setup mode
const searchParams = new URLSearchParams(location.search);
const isSetupMode = searchParams.get("setup") === "true";
// Update the store when setup mode changes
React.useEffect(() => {
setIsSetupMode(isSetupMode);
setConversationId(conversationId);
}, [isSetupMode, conversationId, setIsSetupMode, setConversationId]);
// Only fetch conversation if NOT in setup mode
const {
data: conversation,
isFetched,
refetch,
} = useActiveConversation({
enabled: !isSetupMode,
});
const { mutate: startConversation } = useStartConversation();
const { data: isAuthed } = useIsAuthed();
const { providers } = useUserProviders();
const { resetConversationState } = useConversationStore();
const navigate = useNavigate();
const clearTerminal = useCommandStore((state) => state.clearTerminal);
const setCurrentAgentState = useAgentStore(
(state) => state.setCurrentAgentState,
@@ -59,8 +80,9 @@ function AppContent() {
});
}, [conversationId, queryClient]);
// Modified guard logic - don't redirect if in setup mode
React.useEffect(() => {
if (isFetched && !conversation && isAuthed) {
if (!isSetupMode && isFetched && !conversation && isAuthed) {
displayErrorToast(
"This conversation does not exist, or you do not have permission to access it.",
);
@@ -79,6 +101,7 @@ function AppContent() {
);
}
}, [
isSetupMode,
conversation?.conversation_id,
conversation?.status,
isFetched,
@@ -105,8 +128,13 @@ function AppContent() {
setCurrentAgentState(AgentState.LOADING);
});
const isV1Conversation = conversation?.conversation_version === "V1";
return (
<WebSocketProviderWrapper version={0} conversationId={conversationId}>
<WebSocketProviderWrapper
version={isV1Conversation ? 1 : 0}
conversationId={conversationId}
>
<ConversationSubscriptionsProvider>
<EventHandler>
<div

View File

@@ -0,0 +1,28 @@
import { create } from "zustand";
import { AppConversationStartTask } from "#/api/open-hands.types";
interface ConversationSetupStore {
isSetupMode: boolean;
currentTask: AppConversationStartTask | null;
conversationId: string | null;
setIsSetupMode: (isSetupMode: boolean) => void;
setCurrentTask: (task: AppConversationStartTask | null) => void;
setConversationId: (id: string | null) => void;
reset: () => void;
}
const initialState = {
isSetupMode: false,
currentTask: null,
conversationId: null,
};
export const useConversationSetupStore = create<ConversationSetupStore>(
(set) => ({
...initialState,
setIsSetupMode: (isSetupMode) => set({ isSetupMode }),
setCurrentTask: (task) => set({ currentTask: task }),
setConversationId: (id) => set({ conversationId: id }),
reset: () => set(initialState),
}),
);