- {hasRepository ? (
+ if (hasRepository) {
+ return (
+
+
- ) : (
-
- )}
+
+
+ {buttonText}
+
+
+
+ );
+ }
+
+ return (
+
);
}
diff --git a/frontend/src/components/features/chat/git-control-bar.tsx b/frontend/src/components/features/chat/git-control-bar.tsx
index 551d1e79c7..51d3b9fd06 100644
--- a/frontend/src/components/features/chat/git-control-bar.tsx
+++ b/frontend/src/components/features/chat/git-control-bar.tsx
@@ -1,4 +1,6 @@
+import { useState, useRef, useEffect } from "react";
import { useTranslation } from "react-i18next";
+import { useParams } from "react-router";
import { GitControlBarRepoButton } from "./git-control-bar-repo-button";
import { GitControlBarBranchButton } from "./git-control-bar-branch-button";
import { GitControlBarPullButton } from "./git-control-bar-pull-button";
@@ -7,9 +9,16 @@ import { GitControlBarPrButton } from "./git-control-bar-pr-button";
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
import { useTaskPolling } from "#/hooks/query/use-task-polling";
import { useUnifiedWebSocketStatus } from "#/hooks/use-unified-websocket-status";
+import { useSendMessage } from "#/hooks/use-send-message";
+import { useUpdateConversationRepository } from "#/hooks/mutation/use-update-conversation-repository";
import { Provider } from "#/types/settings";
+import { Branch, GitRepository } from "#/types/git";
import { I18nKey } from "#/i18n/declaration";
import { GitControlBarTooltipWrapper } from "./git-control-bar-tooltip-wrapper";
+import { OpenRepositoryModal } from "./open-repository-modal";
+import { displayErrorToast } from "#/utils/custom-toast-handlers";
+import { useHomeStore } from "#/stores/home-store";
+import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message-store";
interface GitControlBarProps {
onSuggestionsClick: (value: string) => void;
@@ -17,10 +26,24 @@ interface GitControlBarProps {
export function GitControlBar({ onSuggestionsClick }: GitControlBarProps) {
const { t } = useTranslation();
+ const { conversationId } = useParams<{ conversationId: string }>();
+ const [isOpenRepoModalOpen, setIsOpenRepoModalOpen] = useState(false);
+ const { addRecentRepository } = useHomeStore();
+ const { setOptimisticUserMessage } = useOptimisticUserMessageStore();
const { data: conversation } = useActiveConversation();
const { repositoryInfo } = useTaskPolling();
const webSocketStatus = useUnifiedWebSocketStatus();
+ const webSocketStatusRef = useRef(webSocketStatus);
+ useEffect(() => {
+ webSocketStatusRef.current = webSocketStatus;
+ }, [webSocketStatus]);
+ const { send } = useSendMessage();
+ const sendRef = useRef(send);
+ useEffect(() => {
+ sendRef.current = send;
+ }, [send]);
+ const { mutate: updateRepository } = useUpdateConversationRepository();
// Priority: conversation data > task data
// This ensures we show repository info immediately from task, then transition to conversation data
@@ -36,19 +59,67 @@ export function GitControlBar({ onSuggestionsClick }: GitControlBarProps) {
// Enable buttons only when conversation exists and WS is connected
const isConversationReady = !!conversation && webSocketStatus === "CONNECTED";
+ const handleLaunchRepository = (
+ repository: GitRepository,
+ branch: Branch,
+ ) => {
+ if (!conversationId) return;
+
+ // Persist to recent repositories list (matches home page behavior)
+ addRecentRepository(repository);
+
+ // Note: We update repository metadata first, then send clone command.
+ // The clone command is sent to the agent via WebSocket (fire-and-forget).
+ // If cloning fails, the agent will report the error in the chat,
+ // and the user can retry or change the repository.
+ // This is a trade-off: immediate UI feedback vs. strict atomicity.
+ updateRepository(
+ {
+ conversationId,
+ repository: repository.full_name,
+ branch: branch.name,
+ gitProvider: repository.git_provider,
+ },
+ {
+ onSuccess: () => {
+ // Use ref to read the latest WebSocket status (avoids stale closure)
+ if (webSocketStatusRef.current !== "CONNECTED") {
+ displayErrorToast(
+ t(I18nKey.CONVERSATION$CLONE_COMMAND_FAILED_DISCONNECTED),
+ );
+ return;
+ }
+
+ // Send clone command to agent after metadata is updated
+ // Use ref to always call the latest send function (avoids stale closure
+ // where V1 sendMessage holds a reference to a now-closed WebSocket)
+ // Include git provider in prompt so agent clones from correct source
+ const providerName =
+ repository.git_provider.charAt(0).toUpperCase() +
+ repository.git_provider.slice(1);
+ const clonePrompt = `Clone ${repository.full_name} from ${providerName} and checkout branch ${branch.name}.`;
+ setOptimisticUserMessage(clonePrompt);
+ sendRef.current({
+ action: "message",
+ args: {
+ content: clonePrompt,
+ timestamp: new Date().toISOString(),
+ },
+ });
+ },
+ },
+ );
+ };
+
return (
-
-
-
+ setIsOpenRepoModalOpen(true)}
+ disabled={!isConversationReady}
+ />
) : null}
+
+
setIsOpenRepoModalOpen(false)}
+ onLaunch={handleLaunchRepository}
+ defaultProvider={gitProvider}
+ />
);
}
diff --git a/frontend/src/components/features/chat/open-repository-modal.tsx b/frontend/src/components/features/chat/open-repository-modal.tsx
new file mode 100644
index 0000000000..e2806811b7
--- /dev/null
+++ b/frontend/src/components/features/chat/open-repository-modal.tsx
@@ -0,0 +1,170 @@
+import React, { useState, useCallback, useEffect } from "react";
+import { useTranslation } from "react-i18next";
+import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
+import { ModalBody } from "#/components/shared/modals/modal-body";
+import { BrandButton } from "#/components/features/settings/brand-button";
+import { BaseModalTitle } from "#/components/shared/modals/confirmation-modals/base-modal";
+import { I18nKey } from "#/i18n/declaration";
+import { Provider } from "#/types/settings";
+import { Branch, GitRepository } from "#/types/git";
+import { GitRepoDropdown } from "#/components/features/home/git-repo-dropdown/git-repo-dropdown";
+import { GitBranchDropdown } from "#/components/features/home/git-branch-dropdown/git-branch-dropdown";
+import { GitProviderDropdown } from "#/components/features/home/git-provider-dropdown/git-provider-dropdown";
+import { useUserProviders } from "#/hooks/use-user-providers";
+import RepoForkedIcon from "#/icons/repo-forked.svg?react";
+
+interface OpenRepositoryModalProps {
+ isOpen: boolean;
+ onClose: () => void;
+ onLaunch: (repository: GitRepository, branch: Branch) => void;
+ defaultProvider?: Provider;
+}
+
+export function OpenRepositoryModal({
+ isOpen,
+ onClose,
+ onLaunch,
+ defaultProvider = "github",
+}: OpenRepositoryModalProps) {
+ const { t } = useTranslation();
+ const { providers } = useUserProviders();
+
+ const [selectedProvider, setSelectedProvider] = useState
(
+ null,
+ );
+ const [selectedRepository, setSelectedRepository] =
+ useState(null);
+ const [selectedBranch, setSelectedBranch] = useState(null);
+
+ // Auto-select provider: single provider auto-selects, multiple uses defaultProvider if available
+ useEffect(() => {
+ if (providers.length === 1 && !selectedProvider) {
+ setSelectedProvider(providers[0]);
+ } else if (providers.length > 1 && !selectedProvider && defaultProvider) {
+ if (providers.includes(defaultProvider)) {
+ setSelectedProvider(defaultProvider);
+ }
+ }
+ }, [providers, selectedProvider, defaultProvider]);
+
+ const handleProviderChange = useCallback(
+ (provider: Provider | null) => {
+ if (provider === selectedProvider) return;
+ setSelectedProvider(provider);
+ setSelectedRepository(null);
+ setSelectedBranch(null);
+ },
+ [selectedProvider],
+ );
+
+ const handleRepositoryChange = useCallback((repository?: GitRepository) => {
+ if (repository) {
+ setSelectedRepository(repository);
+ setSelectedBranch(null);
+ } else {
+ setSelectedRepository(null);
+ setSelectedBranch(null);
+ }
+ }, []);
+
+ const handleBranchSelect = useCallback((branch: Branch | null) => {
+ setSelectedBranch(branch);
+ }, []);
+
+ const handleLaunch = () => {
+ if (!selectedRepository || !selectedBranch) return;
+
+ onLaunch(selectedRepository, selectedBranch);
+ setSelectedRepository(null);
+ setSelectedBranch(null);
+ onClose();
+ };
+
+ const handleClose = () => {
+ setSelectedProvider(null);
+ setSelectedRepository(null);
+ setSelectedBranch(null);
+ onClose();
+ };
+
+ if (!isOpen) return null;
+
+ const activeProvider =
+ selectedRepository?.git_provider || selectedProvider || defaultProvider;
+ const canLaunch = !!selectedRepository && !!selectedBranch;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ {t(I18nKey.CONVERSATION$SELECT_OR_INSERT_LINK)}
+
+ {providers.length > 1 && (
+
+ )}
+
+
+
+
+
+
+
+
+
+ event.stopPropagation()}
+ >
+
+ {t(I18nKey.BUTTON$LAUNCH)}
+
+
+ {t(I18nKey.BUTTON$CANCEL)}
+
+
+
+
+ );
+}
diff --git a/frontend/src/hooks/mutation/use-update-conversation-repository.ts b/frontend/src/hooks/mutation/use-update-conversation-repository.ts
new file mode 100644
index 0000000000..47bbed1515
--- /dev/null
+++ b/frontend/src/hooks/mutation/use-update-conversation-repository.ts
@@ -0,0 +1,83 @@
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { useTranslation } from "react-i18next";
+import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
+import { I18nKey } from "#/i18n/declaration";
+import {
+ displaySuccessToast,
+ displayErrorToast,
+} from "#/utils/custom-toast-handlers";
+import { Provider } from "#/types/settings";
+
+interface UpdateRepositoryVariables {
+ conversationId: string;
+ repository: string | null;
+ branch?: string | null;
+ gitProvider?: Provider | null;
+}
+
+export const useUpdateConversationRepository = () => {
+ const queryClient = useQueryClient();
+ const { t } = useTranslation();
+
+ return useMutation({
+ mutationFn: (variables: UpdateRepositoryVariables) =>
+ V1ConversationService.updateConversationRepository(
+ variables.conversationId,
+ variables.repository,
+ variables.branch,
+ variables.gitProvider,
+ ),
+ onMutate: async (variables) => {
+ // Cancel any outgoing refetches
+ await queryClient.cancelQueries({
+ queryKey: ["user", "conversation", variables.conversationId],
+ });
+
+ // Snapshot the previous value
+ const previousConversation = queryClient.getQueryData([
+ "user",
+ "conversation",
+ variables.conversationId,
+ ]);
+
+ // Optimistically update the conversation
+ queryClient.setQueryData(
+ ["user", "conversation", variables.conversationId],
+ (old: unknown) =>
+ old && typeof old === "object"
+ ? {
+ ...old,
+ selected_repository: variables.repository,
+ selected_branch: variables.branch,
+ git_provider: variables.gitProvider,
+ }
+ : old,
+ );
+
+ return { previousConversation };
+ },
+ onError: (err, variables, context) => {
+ // Rollback on error
+ if (context?.previousConversation) {
+ queryClient.setQueryData(
+ ["user", "conversation", variables.conversationId],
+ context.previousConversation,
+ );
+ }
+ displayErrorToast(t(I18nKey.CONVERSATION$FAILED_TO_UPDATE_REPOSITORY));
+ },
+ onSuccess: () => {
+ displaySuccessToast(t(I18nKey.CONVERSATION$REPOSITORY_UPDATED));
+ },
+ onSettled: (data, error, variables) => {
+ // Always refetch after error or success
+ queryClient.invalidateQueries({
+ queryKey: ["user", "conversation", variables.conversationId],
+ });
+ // Also invalidate the conversations list to update any cached data
+ queryClient.invalidateQueries({
+ queryKey: ["user", "conversations"],
+ });
+ },
+ });
+};
diff --git a/frontend/src/hooks/use-send-message.ts b/frontend/src/hooks/use-send-message.ts
index c6655b8230..4da5eafc2e 100644
--- a/frontend/src/hooks/use-send-message.ts
+++ b/frontend/src/hooks/use-send-message.ts
@@ -2,6 +2,7 @@ import { useCallback } from "react";
import { useWsClient } from "#/context/ws-client-provider";
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
import { useConversationWebSocket } from "#/contexts/conversation-websocket-context";
+import { useConversationId } from "#/hooks/use-conversation-id";
import { V1MessageContent } from "#/api/conversation-service/v1-conversation-service.types";
/**
@@ -10,13 +11,19 @@ import { V1MessageContent } from "#/api/conversation-service/v1-conversation-ser
* - For V1 conversations: Uses native WebSocket via ConversationWebSocketProvider
*/
export function useSendMessage() {
+ const { conversationId } = useConversationId();
const { data: conversation } = useActiveConversation();
const { send: v0Send } = useWsClient();
// Get V1 context (will be null if not in V1 provider)
const v1Context = useConversationWebSocket();
- const isV1Conversation = conversation?.conversation_version === "V1";
+ // Check if this is a V1 conversation - match logic in useUnifiedWebSocketStatus
+ // Use both ID prefix and conversation_version to handle cases where conversation
+ // data is temporarily undefined during refetch
+ const isV1Conversation =
+ conversationId.startsWith("task-") ||
+ conversation?.conversation_version === "V1";
const send = useCallback(
async (event: Record) => {
@@ -64,7 +71,7 @@ export function useSendMessage() {
v0Send(event);
}
},
- [isV1Conversation, v1Context, v0Send],
+ [isV1Conversation, v1Context, v0Send, conversationId],
);
return { send };
diff --git a/frontend/src/i18n/declaration.ts b/frontend/src/i18n/declaration.ts
index 71f4c963b4..dd02d391d0 100644
--- a/frontend/src/i18n/declaration.ts
+++ b/frontend/src/i18n/declaration.ts
@@ -211,6 +211,7 @@ export enum I18nKey {
MODAL$END_SESSION_TITLE = "MODAL$END_SESSION_TITLE",
MODAL$END_SESSION_MESSAGE = "MODAL$END_SESSION_MESSAGE",
BUTTON$END_SESSION = "BUTTON$END_SESSION",
+ BUTTON$LAUNCH = "BUTTON$LAUNCH",
BUTTON$CANCEL = "BUTTON$CANCEL",
EXIT_PROJECT$CONFIRM = "EXIT_PROJECT$CONFIRM",
EXIT_PROJECT$TITLE = "EXIT_PROJECT$TITLE",
@@ -999,6 +1000,14 @@ export enum I18nKey {
CONVERSATION$SHARE_PUBLICLY = "CONVERSATION$SHARE_PUBLICLY",
CONVERSATION$PUBLIC_SHARING_UPDATED = "CONVERSATION$PUBLIC_SHARING_UPDATED",
CONVERSATION$FAILED_TO_UPDATE_PUBLIC_SHARING = "CONVERSATION$FAILED_TO_UPDATE_PUBLIC_SHARING",
+ CONVERSATION$REPOSITORY_UPDATED = "CONVERSATION$REPOSITORY_UPDATED",
+ CONVERSATION$FAILED_TO_UPDATE_REPOSITORY = "CONVERSATION$FAILED_TO_UPDATE_REPOSITORY",
+ CONVERSATION$CLONE_COMMAND_FAILED_DISCONNECTED = "CONVERSATION$CLONE_COMMAND_FAILED_DISCONNECTED",
+ CONVERSATION$CHANGE_REPOSITORY = "CONVERSATION$CHANGE_REPOSITORY",
+ CONVERSATION$ATTACH_REPOSITORY = "CONVERSATION$ATTACH_REPOSITORY",
+ CONVERSATION$OPEN_REPOSITORY = "CONVERSATION$OPEN_REPOSITORY",
+ CONVERSATION$SELECT_OR_INSERT_LINK = "CONVERSATION$SELECT_OR_INSERT_LINK",
+ CONVERSATION$NO_REPO_CONNECTED = "CONVERSATION$NO_REPO_CONNECTED",
CONVERSATION$NOT_FOUND = "CONVERSATION$NOT_FOUND",
CONVERSATION$NO_HISTORY_AVAILABLE = "CONVERSATION$NO_HISTORY_AVAILABLE",
CONVERSATION$SHARED_CONVERSATION = "CONVERSATION$SHARED_CONVERSATION",
diff --git a/frontend/src/i18n/translation.json b/frontend/src/i18n/translation.json
index 8db2e15e4f..a39cc33dcc 100644
--- a/frontend/src/i18n/translation.json
+++ b/frontend/src/i18n/translation.json
@@ -3375,6 +3375,22 @@
"de": "Sitzung beenden",
"uk": "Закінчити сеанс"
},
+ "BUTTON$LAUNCH": {
+ "en": "Launch",
+ "ja": "起動",
+ "zh-CN": "启动",
+ "zh-TW": "啟動",
+ "ko-KR": "시작",
+ "no": "Start",
+ "it": "Avvia",
+ "pt": "Iniciar",
+ "es": "Iniciar",
+ "ar": "إطلاق",
+ "fr": "Lancer",
+ "tr": "Başlat",
+ "de": "Starten",
+ "uk": "Запустити"
+ },
"BUTTON$CANCEL": {
"en": "Cancel",
"ja": "キャンセル",
@@ -15983,6 +15999,134 @@
"de": "Fehler beim Aktualisieren der öffentlichen Freigabe",
"uk": "Не вдалося оновити публічний доступ"
},
+ "CONVERSATION$REPOSITORY_UPDATED": {
+ "en": "Repository updated successfully",
+ "ja": "リポジトリが正常に更新されました",
+ "zh-CN": "仓库更新成功",
+ "zh-TW": "儲存庫更新成功",
+ "ko-KR": "저장소가 성공적으로 업데이트되었습니다",
+ "no": "Repository oppdatert",
+ "it": "Repository aggiornato con successo",
+ "pt": "Repositório atualizado com sucesso",
+ "es": "Repositorio actualizado correctamente",
+ "ar": "تم تحديث المستودع بنجاح",
+ "fr": "Dépôt mis à jour avec succès",
+ "tr": "Depo başarıyla güncellendi",
+ "de": "Repository erfolgreich aktualisiert",
+ "uk": "Репозиторій успішно оновлено"
+ },
+ "CONVERSATION$FAILED_TO_UPDATE_REPOSITORY": {
+ "en": "Failed to update repository",
+ "ja": "リポジトリの更新に失敗しました",
+ "zh-CN": "更新仓库失败",
+ "zh-TW": "更新儲存庫失敗",
+ "ko-KR": "저장소 업데이트에 실패했습니다",
+ "no": "Kunne ikke oppdatere repository",
+ "it": "Impossibile aggiornare il repository",
+ "pt": "Falha ao atualizar repositório",
+ "es": "Error al actualizar repositorio",
+ "ar": "فشل في تحديث المستودع",
+ "fr": "Échec de la mise à jour du dépôt",
+ "tr": "Depo güncellenemedi",
+ "de": "Fehler beim Aktualisieren des Repositorys",
+ "uk": "Не вдалося оновити репозиторій"
+ },
+ "CONVERSATION$CLONE_COMMAND_FAILED_DISCONNECTED": {
+ "en": "Repository updated but clone command could not be sent. Please reconnect and manually clone.",
+ "ja": "リポジトリは更新されましたが、クローンコマンドを送信できませんでした。再接続して手動でクローンしてください。",
+ "zh-CN": "仓库已更新,但无法发送克隆命令。请重新连接并手动克隆。",
+ "zh-TW": "儲存庫已更新,但無法傳送複製命令。請重新連線並手動複製。",
+ "ko-KR": "저장소가 업데이트되었지만 복제 명령을 보낼 수 없습니다. 다시 연결하고 수동으로 복제하세요.",
+ "no": "Repository oppdatert, men klonekommando kunne ikke sendes. Koble til på nytt og klon manuelt.",
+ "it": "Repository aggiornato ma il comando di clone non può essere inviato. Riconnettiti e clona manualmente.",
+ "pt": "Repositório atualizado, mas o comando de clone não pôde ser enviado. Reconecte e clone manualmente.",
+ "es": "Repositorio actualizado pero no se pudo enviar el comando de clonación. Reconéctese y clone manualmente.",
+ "ar": "تم تحديث المستودع ولكن تعذر إرسال أمر الاستنساخ. يرجى إعادة الاتصال والاستنساخ يدويًا.",
+ "fr": "Dépôt mis à jour mais la commande de clonage n'a pas pu être envoyée. Reconnectez-vous et clonez manuellement.",
+ "tr": "Depo güncellendi ancak klonlama komutu gönderilemedi. Lütfen yeniden bağlanın ve manuel olarak klonlayın.",
+ "de": "Repository aktualisiert, aber Klon-Befehl konnte nicht gesendet werden. Bitte erneut verbinden und manuell klonen.",
+ "uk": "Репозиторій оновлено, але команду клонування не вдалося надіслати. Підключіться знову та клонуйте вручну."
+ },
+ "CONVERSATION$CHANGE_REPOSITORY": {
+ "en": "Change Repository",
+ "ja": "リポジトリを変更",
+ "zh-CN": "更改仓库",
+ "zh-TW": "變更儲存庫",
+ "ko-KR": "저장소 변경",
+ "no": "Endre repository",
+ "it": "Cambia repository",
+ "pt": "Alterar repositório",
+ "es": "Cambiar repositorio",
+ "ar": "تغيير المستودع",
+ "fr": "Changer de dépôt",
+ "tr": "Depoyu değiştir",
+ "de": "Repository ändern",
+ "uk": "Змінити репозиторій"
+ },
+ "CONVERSATION$ATTACH_REPOSITORY": {
+ "en": "Attach Repository",
+ "ja": "リポジトリを添付",
+ "zh-CN": "附加仓库",
+ "zh-TW": "附加儲存庫",
+ "ko-KR": "저장소 연결",
+ "no": "Legg til repository",
+ "it": "Allega repository",
+ "pt": "Anexar repositório",
+ "es": "Adjuntar repositorio",
+ "ar": "إرفاق المستودع",
+ "fr": "Attacher un dépôt",
+ "tr": "Depo ekle",
+ "de": "Repository anhängen",
+ "uk": "Прикріпити репозиторій"
+ },
+ "CONVERSATION$OPEN_REPOSITORY": {
+ "en": "Open Repository",
+ "ja": "リポジトリを開く",
+ "zh-CN": "打开仓库",
+ "zh-TW": "開啟儲存庫",
+ "ko-KR": "저장소 열기",
+ "no": "Åpne repository",
+ "it": "Apri repository",
+ "pt": "Abrir repositório",
+ "es": "Abrir repositorio",
+ "ar": "فتح المستودع",
+ "fr": "Ouvrir le dépôt",
+ "tr": "Depoyu aç",
+ "de": "Repository öffnen",
+ "uk": "Відкрити репозиторій"
+ },
+ "CONVERSATION$SELECT_OR_INSERT_LINK": {
+ "en": "Select or insert a link",
+ "ja": "リンクを選択または挿入",
+ "zh-CN": "选择或插入链接",
+ "zh-TW": "選擇或插入連結",
+ "ko-KR": "링크 선택 또는 삽입",
+ "no": "Velg eller sett inn en lenke",
+ "it": "Seleziona o inserisci un link",
+ "pt": "Selecione ou insira um link",
+ "es": "Selecciona o inserta un enlace",
+ "ar": "حدد أو أدخل رابطًا",
+ "fr": "Sélectionner ou insérer un lien",
+ "tr": "Bir bağlantı seçin veya ekleyin",
+ "de": "Link auswählen oder einfügen",
+ "uk": "Виберіть або вставте посилання"
+ },
+ "CONVERSATION$NO_REPO_CONNECTED": {
+ "en": "No Repo Connected",
+ "ja": "リポジトリ未接続",
+ "zh-CN": "未连接仓库",
+ "zh-TW": "未連接儲存庫",
+ "ko-KR": "저장소 연결 안 됨",
+ "no": "Ingen repository tilkoblet",
+ "it": "Nessun repository collegato",
+ "pt": "Nenhum repositório conectado",
+ "es": "Sin repositorio conectado",
+ "ar": "لا يوجد مستودع متصل",
+ "fr": "Aucun dépôt connecté",
+ "tr": "Bağlı depo yok",
+ "de": "Kein Repository verbunden",
+ "uk": "Репозиторій не підключено"
+ },
"CONVERSATION$NOT_FOUND": {
"en": "Conversation not found",
"ja": "会話が見つかりません",
diff --git a/openhands/app_server/app_conversation/app_conversation_models.py b/openhands/app_server/app_conversation/app_conversation_models.py
index 83c3842c5d..a30b40e56c 100644
--- a/openhands/app_server/app_conversation/app_conversation_models.py
+++ b/openhands/app_server/app_conversation/app_conversation_models.py
@@ -170,7 +170,15 @@ class AppConversationStartRequest(OpenHandsModel):
class AppConversationUpdateRequest(BaseModel):
- public: bool
+ """Request model for updating conversation metadata.
+
+ All fields are optional - only provided fields will be updated.
+ """
+
+ public: bool | None = None
+ selected_repository: str | None = None
+ selected_branch: str | None = None
+ git_provider: ProviderType | None = None
class AppConversationStartTaskStatus(Enum):
diff --git a/openhands/app_server/app_conversation/live_status_app_conversation_service.py b/openhands/app_server/app_conversation/live_status_app_conversation_service.py
index 885c8dfcbf..b7edac10d0 100644
--- a/openhands/app_server/app_conversation/live_status_app_conversation_service.py
+++ b/openhands/app_server/app_conversation/live_status_app_conversation_service.py
@@ -1283,20 +1283,97 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
f'Successfully updated agent-server conversation {conversation_id} title to "{new_title}"'
)
+ def _validate_repository_update(
+ self,
+ request: AppConversationUpdateRequest,
+ existing_branch: str | None = None,
+ ) -> None:
+ """Validate repository-related fields in the update request.
+
+ Args:
+ request: The update request containing fields to validate
+ existing_branch: The conversation's current branch (if any)
+
+ Raises:
+ ValueError: If validation fails
+ """
+ # Check if repository is being set
+ if 'selected_repository' in request.model_fields_set:
+ repo = request.selected_repository
+ if repo is not None:
+ # Validate repository format (owner/repo)
+ if '/' not in repo or repo.count('/') != 1:
+ raise ValueError(
+ f"Invalid repository format: '{repo}'. Expected 'owner/repo'."
+ )
+
+ # Sanitize: check for dangerous characters
+ if any(c in repo for c in [';', '&', '|', '$', '`', '\n', '\r']):
+ raise ValueError(f"Invalid characters in repository name: '{repo}'")
+
+ # If setting a repository, branch should also be provided
+ # (either in this request or already exists in conversation)
+ if (
+ 'selected_branch' not in request.model_fields_set
+ and existing_branch is None
+ ):
+ _logger.warning(
+ f'Repository {repo} set without branch in the same request '
+ 'and no existing branch in conversation'
+ )
+ else:
+ # Repository is being removed (set to null)
+ # Enforce consistency: branch and provider must also be cleared
+ if 'selected_branch' in request.model_fields_set:
+ if request.selected_branch is not None:
+ raise ValueError(
+ 'When removing repository, branch must also be cleared'
+ )
+ if 'git_provider' in request.model_fields_set:
+ if request.git_provider is not None:
+ raise ValueError(
+ 'When removing repository, git_provider must also be cleared'
+ )
+
+ # Validate branch if provided
+ if 'selected_branch' in request.model_fields_set:
+ branch = request.selected_branch
+ if branch is not None:
+ # Sanitize: check for dangerous characters
+ if any(c in branch for c in [';', '&', '|', '$', '`', '\n', '\r', ' ']):
+ raise ValueError(f"Invalid characters in branch name: '{branch}'")
+
async def update_app_conversation(
self, conversation_id: UUID, request: AppConversationUpdateRequest
) -> AppConversation | None:
- """Update an app conversation and return it. Return None if the conversation
- did not exist.
+ """Update an app conversation and return it.
+
+ Return None if the conversation did not exist.
+
+ Only fields that are explicitly set in the request will be updated.
+ This allows partial updates where only specific fields are modified.
+ Fields can be set to None to clear them (e.g., removing a repository).
+
+ Raises:
+ ValueError: If repository/branch validation fails
"""
info = await self.app_conversation_info_service.get_app_conversation_info(
conversation_id
)
if info is None:
return None
- for field_name in AppConversationUpdateRequest.model_fields:
+
+ # Validate repository-related fields before updating
+ # Pass existing branch to avoid false warnings when only updating repository
+ self._validate_repository_update(request, existing_branch=info.selected_branch)
+
+ # Only update fields that were explicitly provided in the request
+ # This uses Pydantic's model_fields_set to detect which fields were set,
+ # allowing us to distinguish between "not provided" and "explicitly set to None"
+ for field_name in request.model_fields_set:
value = getattr(request, field_name)
setattr(info, field_name, value)
+
info = await self.app_conversation_info_service.save_app_conversation_info(info)
conversations = await self._build_app_conversations([info])
return conversations[0]
diff --git a/tests/unit/controller/test_agent_controller.py b/tests/unit/controller/test_agent_controller.py
index 469a940677..0294375f55 100644
--- a/tests/unit/controller/test_agent_controller.py
+++ b/tests/unit/controller/test_agent_controller.py
@@ -1680,8 +1680,8 @@ async def test_condenser_metrics_included(mock_agent_with_stats, test_event_stre
assert last_action.llm_metrics is not None
# Verify that both agent and condenser metrics are included
- assert (
- last_action.llm_metrics.accumulated_cost == 0.08
+ assert last_action.llm_metrics.accumulated_cost == pytest.approx(
+ 0.08
) # 0.05 from agent + 0.03 from condenser
# The accumulated token usage should include both agent and condenser metrics