From 247a38d0317d075d472920ddbbdefe4be90f8bf1 Mon Sep 17 00:00:00 2001 From: hanlinyu1030 Date: Mon, 10 Aug 2026 21:06:20 +0800 Subject: [PATCH 1/3] fix(workspace): ensure default session on name collision and reject case-duplicate folder --- .../NewProjectDialog/NewProjectDialog.tsx | 57 +++++++++++++++---- .../review-platform/ReviewPlatformPanel.tsx | 7 ++- .../src/flow_chat/components/ChatInput.tsx | 14 +++-- .../components/btw/BtwSessionPanel.tsx | 9 ++- .../components/modern/ExportImageButton.tsx | 7 ++- .../src/flow_chat/services/FlowChatManager.ts | 10 ++-- .../flow-chat-manager/MessageModule.ts | 12 +++- .../flow-chat-manager/SessionModule.ts | 12 +++- .../api/errors/TauriCommandError.ts | 8 ++- .../services/business/workspaceManager.ts | 8 ++- src/web-ui/src/locales/en-US/common.json | 7 ++- src/web-ui/src/locales/en-US/flow-chat.json | 4 +- src/web-ui/src/locales/zh-CN/common.json | 7 ++- src/web-ui/src/locales/zh-CN/flow-chat.json | 4 +- src/web-ui/src/locales/zh-TW/common.json | 7 ++- src/web-ui/src/locales/zh-TW/flow-chat.json | 4 +- 16 files changed, 140 insertions(+), 37 deletions(-) diff --git a/src/web-ui/src/app/components/NewProjectDialog/NewProjectDialog.tsx b/src/web-ui/src/app/components/NewProjectDialog/NewProjectDialog.tsx index 8f33a81091..123cc6a975 100644 --- a/src/web-ui/src/app/components/NewProjectDialog/NewProjectDialog.tsx +++ b/src/web-ui/src/app/components/NewProjectDialog/NewProjectDialog.tsx @@ -14,7 +14,9 @@ import { } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { createLogger } from '@/shared/utils/logger'; -import { Modal, Button, Input } from '@/component-library'; +import { Modal, Button, Input, Tooltip } from '@/component-library'; +import { systemAPI } from "@/infrastructure"; +import { isTauriCommandError } from '@/infrastructure/api/errors/TauriCommandError'; import './NewProjectDialog.scss'; const log = createLogger('NewProjectDialog'); @@ -76,6 +78,23 @@ export const NewProjectDialog: React.FC = ({ setError(t('newProject.errorEnterName')); return; } + if (projectName.trim().length > 255) { + setError(t('newProject.errorNameTooLong')); + return; + } + + // Pre-creation existence / case-collision check. On Windows/macOS the + // filesystem is case-insensitive, so "MyProject" and "myproject" resolve to + // the same folder; createDirectory is idempotent and would silently succeed + // without creating a new folder. Surface a clear error before attempting. + try { + if (await systemAPI.checkPathExists(fullPath)) { + setError(t('newProject.errorAlreadyExists')); + return; + } + } catch (error) { + log.error('Failed to check path existence', error); + } setIsCreating(true); setError(''); @@ -87,11 +106,19 @@ export const NewProjectDialog: React.FC = ({ onClose(); } catch (error) { log.error('Failed to create project', error); - setError(error instanceof Error ? error.message : t('newProject.errorCreateFailed')); + let message: string; + if (isTauriCommandError(error) && error.isPermissionError()) { + message = t('newProject.errorParentNoAccess'); + } else if (error instanceof Error && /does not exist|not a directory/i.test(error.message)) { + message = t('newProject.errorPathNotFound'); + } else { + message = t('newProject.errorCreateFailed'); + } + setError(message); } finally { setIsCreating(false); } - }, [parentPath, projectName, onConfirm, onClose, t]); + }, [parentPath, projectName, fullPath, onConfirm, onClose, t]); // Reset form and close dialog const handleCancel = useCallback(() => { @@ -135,12 +162,14 @@ export const NewProjectDialog: React.FC = ({
- + + +
{t('newProject.fullPath')} - {fullPath} + + {fullPath} +
)} diff --git a/src/web-ui/src/app/components/panels/review-platform/ReviewPlatformPanel.tsx b/src/web-ui/src/app/components/panels/review-platform/ReviewPlatformPanel.tsx index b37f7e7194..f2e57fe6c9 100644 --- a/src/web-ui/src/app/components/panels/review-platform/ReviewPlatformPanel.tsx +++ b/src/web-ui/src/app/components/panels/review-platform/ReviewPlatformPanel.tsx @@ -436,6 +436,9 @@ function reviewSessionLifecycle(session: Session): LinkedReviewSession['lifecycl } function getSessionTitle(session?: Session, fallback = 'Review session'): string { + if (session?.titleSource === 'i18n' && session.titleI18nKey) { + return i18nService.t(session.titleI18nKey, session.titleI18nParams ?? {}) || fallback; + } return session?.title?.trim() || fallback; } @@ -1852,7 +1855,9 @@ export const ReviewPlatformPanel: React.FC = ({ workspacePath: linked.childSession.workspacePath, expand: true, sessionKind: linked.kind, - sessionTitle: linked.title, + sessionTitle: linked.childSession.titleSource === 'i18n' && linked.childSession.titleI18nKey + ? (i18nService.t(linked.childSession.titleI18nKey, linked.childSession.titleI18nParams ?? {}) || linked.title) + : linked.title, agentType: linked.childSession.config.agentType ?? (linked.kind === 'deep_review' ? 'DeepReview' : 'CodeReview'), }); }; diff --git a/src/web-ui/src/flow_chat/components/ChatInput.tsx b/src/web-ui/src/flow_chat/components/ChatInput.tsx index 798af0812a..09093100c5 100644 --- a/src/web-ui/src/flow_chat/components/ChatInput.tsx +++ b/src/web-ui/src/flow_chat/components/ChatInput.tsx @@ -10,6 +10,8 @@ import { useTranslation } from 'react-i18next'; import { ArrowUp, BotMessageSquare, Image, RotateCcw, Plus, X, Sparkles, Loader2, ChevronRight, Files, MessageSquarePlus, Star } from 'lucide-react'; import { ContextDropZone, useContextStore } from '../../shared/context-system'; import { useActiveSessionState } from '@/flow_chat/hooks'; +import { i18nService } from '@/infrastructure/i18n'; +import { resolveSessionTitle } from '../utils/sessionTitle'; import { RichTextInput, type InlineTriggerState, @@ -584,7 +586,12 @@ export const ChatInput: React.FC = ({ isBtwSession, disabled: !caps.threadGoal, }); - const currentSessionTitle = currentSession?.title?.trim() || t('session.untitled'); + // Resolve via the i18n key (re-localizes on locale switch) instead of the + // persisted localized session.title string, so this tab stays in sync with + // the sidebar/header/toolbar instead of showing a stale old-locale name. + const currentSessionTitle = currentSession + ? resolveSessionTitle(currentSession, (key, options) => i18nService.t(key, options)) + : t('session.untitled'); const activeBtwSession = activeBtwSessionId ? flowChatState.sessions.get(activeBtwSessionId) : undefined; @@ -600,10 +607,9 @@ export const ChatInput: React.FC = ({ const activeBtwTargetLabel = t(`childSession.kinds.${activeBtwKind}.short`, { defaultValue: t('chatInput.targetBtw'), }); + const activeBtwFallbackKey = `childSession.kinds.${activeBtwKind}.title`; const activeBtwSessionTitle = activeBtwSession - ? activeBtwSession.title?.trim() || t(`childSession.kinds.${activeBtwKind}.title`, { - defaultValue: t('btw.threadLabel'), - }) + ? resolveSessionTitle(activeBtwSession, (key, options) => i18nService.t(key, options), activeBtwFallbackKey) || t('btw.threadLabel') : ''; const deferChatStripPassiveGitRefresh = diff --git a/src/web-ui/src/flow_chat/components/btw/BtwSessionPanel.tsx b/src/web-ui/src/flow_chat/components/btw/BtwSessionPanel.tsx index c472e24df5..4b1451f969 100644 --- a/src/web-ui/src/flow_chat/components/btw/BtwSessionPanel.tsx +++ b/src/web-ui/src/flow_chat/components/btw/BtwSessionPanel.tsx @@ -1,5 +1,6 @@ import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {useTranslation} from 'react-i18next'; +import { i18nService } from '@/infrastructure/i18n'; import path from 'path-browserify'; import {CornerUpLeft, Link2, Loader2, Square, Sparkles} from 'lucide-react'; import {FlowChatContext, FlowChatVolatileContext} from '../modern/FlowChatContext'; @@ -91,8 +92,12 @@ const PANEL_CONFIG: FlowChatConfig = { enableVirtualScroll: false, }; -const resolveSessionTitle = (session?: Session | null, fallback = 'Side thread') => - session?.title?.trim() || fallback; +const resolveSessionTitle = (session?: Session | null, fallback = 'Side thread') => { + if (session?.titleSource === 'i18n' && session.titleI18nKey) { + return i18nService.t(session.titleI18nKey, session.titleI18nParams ?? {}) || fallback; + } + return session?.title?.trim() || fallback; +}; const log = createLogger('BtwSessionPanel'); const REVIEW_ACTION_BOTTOM_BLANK_SPACE_PX = 96; const EMPTY_ACTION_ID_SET = new Set(); diff --git a/src/web-ui/src/flow_chat/components/modern/ExportImageButton.tsx b/src/web-ui/src/flow_chat/components/modern/ExportImageButton.tsx index 4799930db1..7525dd57d3 100644 --- a/src/web-ui/src/flow_chat/components/modern/ExportImageButton.tsx +++ b/src/web-ui/src/flow_chat/components/modern/ExportImageButton.tsx @@ -183,7 +183,12 @@ export const ExportImageButton: React.FC = ({ for (const [, session] of state.sessions) { const turn = session.dialogTurns.find((t: DialogTurn) => t.id === turnId); - if (turn) return { turn, sessionTitle: session.title?.trim() || '' }; + if (turn) { + const sessionTitle = session.titleSource === 'i18n' && session.titleI18nKey + ? (i18nService.t(session.titleI18nKey, session.titleI18nParams ?? {}) || '') + : (session.title?.trim() || ''); + return { turn, sessionTitle }; + } } return null; }, [turnId]); diff --git a/src/web-ui/src/flow_chat/services/FlowChatManager.ts b/src/web-ui/src/flow_chat/services/FlowChatManager.ts index aed4f64041..3658aaaeda 100644 --- a/src/web-ui/src/flow_chat/services/FlowChatManager.ts +++ b/src/web-ui/src/flow_chat/services/FlowChatManager.ts @@ -301,10 +301,12 @@ export class FlowChatManager { nextCursor = nextPage.nextCursor; } } - const hasHistoricalSessions = - workspaceSessions.length > 0 || - initialMetadataPage.totalTopLevelCount > 0 || - initialMetadataPage.sessions.length > 0; + // Count only sessions that belong to THIS workspace (already path-filtered + // above). Counting all sessions in the storage dir (totalTopLevelCount / + // initialMetadataPage.sessions.length) incorrectly reports "has history" + // when another workspace shares the slug-collided sessions/ dir, which + // suppresses default-session creation for a newly opened workspace. + const hasHistoricalSessions = workspaceSessions.length > 0; const isCurrentInitializationRequest = () => this.latestInitializationRequestKey === requestKey; const activeSession = state.activeSessionId diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.ts index d4fd9c5221..a0fea7fe3a 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.ts @@ -7,6 +7,7 @@ */ import { notificationService } from '../../../shared/notification-system'; +import { i18nService } from '@/infrastructure/i18n'; import { stateMachineManager } from '../../state-machine'; import { SessionExecutionEvent, SessionExecutionState } from '../../state-machine/types'; import { createLogger } from '@/shared/utils/logger'; @@ -15,7 +16,6 @@ import { isProjectedSessionEmpty } from '../../utils/flowChatTurnIdentity'; import type { ImageContextData as ImageInputContextData } from '@/infrastructure/api/service-api/ImageContextTypes'; import { pendingQueueManager } from './PendingQueueModule'; import { isSessionInUseError } from '@/infrastructure/api/errors/TauriCommandError'; -import { i18nService } from '@/infrastructure/i18n'; import { driverForSession } from '../../session-drivers/registry'; import type { SendMessageOptions, SubmissionDraft, TurnTracker } from '../../session-drivers/types'; @@ -236,7 +236,13 @@ export async function sendMessage( } catch (error) { log.error('Failed to send message', { sessionId: sessionId, error }); - const errorMessage = error instanceof Error ? error.message : 'Failed to send message'; + // Map the "workspace folder deleted/moved" backend error to a localized + // message; other errors pass through verbatim for diagnostics. The + // "Thinking process error" notification title was also hardcoded English. + const rawErrorMessage = error instanceof Error ? error.message : ''; + const errorMessage = /does not resolve to a local workspace/i.test(rawErrorMessage) + ? i18nService.t('flow-chat:errors.workspaceNotResolvable') + : (rawErrorMessage || i18nService.t('flow-chat:errors.sendFailed')); const currentState = stateMachineManager.getCurrentState(sessionId); const activeDialogTurnId = stateMachineManager @@ -304,7 +310,7 @@ export async function sendMessage( if (latestSendBySession.get(sessionId) === sendAttempt) { latestSendBySession.delete(sessionId); notificationService.error(errorMessage, { - title: 'Thinking process error', + title: i18nService.t('flow-chat:errors.thinkingProcessError'), duration: 5000 }); } diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts index 8ad55c599f..d43690a3c7 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts @@ -22,7 +22,7 @@ import type { SessionHistoryHydrationLocation, } from './types'; import type { Session } from '../../types/flow-chat'; -import { touchSessionActivity } from './PersistenceModule'; +import { touchSessionActivity, updateSessionMetadata } from './PersistenceModule'; import { createTextSessionTitleDescriptor, createDefaultSessionTitleDescriptor, @@ -606,7 +606,7 @@ export async function createChatSession( ); const sessionName = titleDescriptor.text; - return driverForCreation(config).createSession(context, { + const sessionId = await driverForCreation(config).createSession(context, { config, agentType, sessionName, @@ -617,6 +617,14 @@ export async function createChatSession( remoteConnectionId, remoteSshHost, }); + + // Persist the title metadata (incl. the i18n title key/params) at creation + // so an empty session can re-localize its default name on a later reload + + // locale switch. createSession only stored the localized sessionName string; + // without this, empty sessions have no title key on disk and can't re-localize. + await updateSessionMetadata(context, sessionId); + + return sessionId; }); pendingSessionCreations.set(creationKey, createPromise); diff --git a/src/web-ui/src/infrastructure/api/errors/TauriCommandError.ts b/src/web-ui/src/infrastructure/api/errors/TauriCommandError.ts index 527dfd772b..915d02c536 100644 --- a/src/web-ui/src/infrastructure/api/errors/TauriCommandError.ts +++ b/src/web-ui/src/infrastructure/api/errors/TauriCommandError.ts @@ -58,10 +58,12 @@ export class TauriCommandError extends Error { public isPermissionError(): boolean { const message = this.message.toLowerCase(); - return message.includes('permission') || - message.includes('access') || + return message.includes('permission') || + message.includes('permitted') || + message.includes('access') || message.includes('unauthorized') || - message.includes('forbidden'); + message.includes('forbidden') || + message.includes('eperm'); } diff --git a/src/web-ui/src/infrastructure/services/business/workspaceManager.ts b/src/web-ui/src/infrastructure/services/business/workspaceManager.ts index 9869dedb37..c151c56506 100644 --- a/src/web-ui/src/infrastructure/services/business/workspaceManager.ts +++ b/src/web-ui/src/infrastructure/services/business/workspaceManager.ts @@ -14,6 +14,7 @@ import { createLogger } from '@/shared/utils/logger'; import { startupTrace } from '@/shared/utils/startupTrace'; import { elapsedMs, nowMs } from '@/shared/utils/timing'; import { listen } from '@tauri-apps/api/event'; +import { i18nService } from '@/infrastructure/i18n'; const log = createLogger('WorkspaceManager'); @@ -632,7 +633,12 @@ class WorkspaceManager { return workspace; } catch (error) { log.error('Failed to open workspace', { path, error }); - const errorMessage = error instanceof Error ? error.message : String(error); + const rawMessage = error instanceof Error ? error.message : String(error); + // Map the common "folder moved/deleted" backend error to a localized + // message; other errors pass through verbatim (callers log the raw error). + const errorMessage = /does not exist|not a directory/i.test(rawMessage) + ? i18nService.t('common:newProject.errorPathNotFound') + : rawMessage; this.updateState({ loading: false, error: errorMessage }, { type: 'workspace:error', error: errorMessage }); throw error; } diff --git a/src/web-ui/src/locales/en-US/common.json b/src/web-ui/src/locales/en-US/common.json index e835484fcd..aa49ad13a2 100644 --- a/src/web-ui/src/locales/en-US/common.json +++ b/src/web-ui/src/locales/en-US/common.json @@ -511,7 +511,12 @@ "creating": "Creating...", "errorSelectParent": "Please select a parent directory", "errorEnterName": "Please enter a project name", - "errorCreateFailed": "Failed to create project" + "errorCreateFailed": "Failed to create project", + "errorInvalidName": "Project name cannot contain these characters: / \\ : * ? \" < > |", + "errorAlreadyExists": "A workspace with this name already exists at the selected location", + "errorParentNoAccess": "No access permission for the selected parent directory", + "errorNameTooLong": "Project name is too long (max 255 characters)", + "errorPathNotFound": "The workspace folder does not exist or has been moved" }, "peerDirectoryPicker": { "loading": "Loading remote directories…", diff --git a/src/web-ui/src/locales/en-US/flow-chat.json b/src/web-ui/src/locales/en-US/flow-chat.json index d1d6b7abbe..c2e78a74db 100644 --- a/src/web-ui/src/locales/en-US/flow-chat.json +++ b/src/web-ui/src/locales/en-US/flow-chat.json @@ -2564,7 +2564,9 @@ } }, "errors": { - "sendFailed": "Failed to send message" + "sendFailed": "Failed to send message", + "thinkingProcessError": "Thinking process error", + "workspaceNotResolvable": "The workspace folder does not exist or has been moved" }, "actions": { "send": "Send" diff --git a/src/web-ui/src/locales/zh-CN/common.json b/src/web-ui/src/locales/zh-CN/common.json index 828ba33a90..e5b5ff6955 100644 --- a/src/web-ui/src/locales/zh-CN/common.json +++ b/src/web-ui/src/locales/zh-CN/common.json @@ -511,7 +511,12 @@ "creating": "创建中...", "errorSelectParent": "请选择父目录", "errorEnterName": "请输入工作区名称", - "errorCreateFailed": "创建工作区失败" + "errorCreateFailed": "创建工作区失败", + "errorInvalidName": "工作区名称不能包含以下字符:/ \\ : * ? \" < > |", + "errorAlreadyExists": "所选位置已存在同名工作区", + "errorParentNoAccess": "没有所选父目录的访问权限", + "errorNameTooLong": "工作区名称过长(最多 255 个字符)", + "errorPathNotFound": "工作区文件夹不存在或已被移动" }, "peerDirectoryPicker": { "loading": "正在加载远程目录…", diff --git a/src/web-ui/src/locales/zh-CN/flow-chat.json b/src/web-ui/src/locales/zh-CN/flow-chat.json index 3878b5327b..cfd1d7efdd 100644 --- a/src/web-ui/src/locales/zh-CN/flow-chat.json +++ b/src/web-ui/src/locales/zh-CN/flow-chat.json @@ -2564,7 +2564,9 @@ } }, "errors": { - "sendFailed": "发送消息失败" + "sendFailed": "发送消息失败", + "thinkingProcessError": "思考过程错误", + "workspaceNotResolvable": "工作区文件夹不存在或已被移动" }, "actions": { "send": "发送" diff --git a/src/web-ui/src/locales/zh-TW/common.json b/src/web-ui/src/locales/zh-TW/common.json index 406c8e6b7b..0fee5b27c5 100644 --- a/src/web-ui/src/locales/zh-TW/common.json +++ b/src/web-ui/src/locales/zh-TW/common.json @@ -511,7 +511,12 @@ "creating": "建立中...", "errorSelectParent": "請選擇父目錄", "errorEnterName": "請輸入工作區名稱", - "errorCreateFailed": "建立工作區失敗" + "errorCreateFailed": "建立工作區失敗", + "errorInvalidName": "工作區名稱不能包含以下字符:/ \\ : * ? \" < > |", + "errorAlreadyExists": "所選位置已存在同名工作區", + "errorParentNoAccess": "沒有所選父目錄的存取權限", + "errorNameTooLong": "工作區名稱過長(最多 255 個字元)", + "errorPathNotFound": "工作區資料夾不存在或已被移動" }, "peerDirectoryPicker": { "loading": "正在載入遠端目錄…", diff --git a/src/web-ui/src/locales/zh-TW/flow-chat.json b/src/web-ui/src/locales/zh-TW/flow-chat.json index 4b136ea7b2..3e875e4248 100644 --- a/src/web-ui/src/locales/zh-TW/flow-chat.json +++ b/src/web-ui/src/locales/zh-TW/flow-chat.json @@ -2564,7 +2564,9 @@ } }, "errors": { - "sendFailed": "傳送訊息失敗" + "sendFailed": "傳送訊息失敗", + "thinkingProcessError": "思考過程錯誤", + "workspaceNotResolvable": "工作區資料夾不存在或已被移動" }, "actions": { "send": "傳送" From b37aa293d598315634f174e7f3026ce7a29f873c Mon Sep 17 00:00:00 2001 From: hanlinyu1030 Date: Mon, 10 Aug 2026 21:22:40 +0800 Subject: [PATCH 2/3] fix(workspace): drag safety net and onDrag/Tooltip for workspace items --- .../sections/workspaces/WorkspaceItem.tsx | 35 ++++- .../workspaces/WorkspaceListSection.scss | 40 ++++++ .../workspaces/WorkspaceListSection.tsx | 122 ++++++++++++++++-- .../NewProjectDialog/NewProjectDialog.scss | 64 +++++---- 4 files changed, 221 insertions(+), 40 deletions(-) diff --git a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx index 8af5e04dee..1c30defe62 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx +++ b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx @@ -62,6 +62,7 @@ interface WorkspaceItemProps { draggable?: boolean; isDragging?: boolean; onDragStart?: React.DragEventHandler; + onDrag?: React.DragEventHandler; onDragEnd?: React.DragEventHandler; } @@ -79,6 +80,7 @@ const WorkspaceItem: React.FC = ({ draggable = false, isDragging = false, onDragStart, + onDrag, onDragEnd, }) => { const { t } = useI18n('common'); @@ -122,7 +124,8 @@ const WorkspaceItem: React.FC = ({ const [acpClientsLoading, setAcpClientsLoading] = useState(false); const menuRef = useRef(null); const menuAnchorRef = useRef(null); - const menuPopoverRef = useRef(null); + const menuPopoverRef = useRef(null); + const popoverResizeObserverRef = useRef(null); const cardRef = useRef(null); const [menuPosition, setMenuPosition] = useState<{ top: number; left: number } | null>(null); const isDefaultAssistantWorkspace = @@ -387,6 +390,26 @@ const WorkspaceItem: React.FC = ({ requestAnimationFrame(apply); }, []); + // Callback ref for the menu popover. The popover only mounts once menuPosition + // is set (chicken-and-egg: menuPosition needs the popover's size), so a + // ResizeObserver created in the menuOpen effect would attach to a null ref. + // Attaching here ties the observer to the element's actual mount/unmount: + // on mount it fires once with the real size (fixing the stale initial height) + // and again whenever async content (ACP client rows, loading toggle, remote + // /git conditional rows, locale label width) changes the popover height. + const setMenuPopoverRef = useCallback((node: HTMLDivElement | null) => { + if (popoverResizeObserverRef.current) { + popoverResizeObserverRef.current.disconnect(); + popoverResizeObserverRef.current = null; + } + menuPopoverRef.current = node; + if (node && typeof ResizeObserver !== 'undefined') { + const ro = new ResizeObserver(() => updateMenuPosition()); + ro.observe(node); + popoverResizeObserverRef.current = ro; + } + }, [updateMenuPosition]); + const handleMenuTriggerClick = useCallback(() => { setMenuOpen(open => !open); }, []); @@ -784,6 +807,7 @@ const WorkspaceItem: React.FC = ({ className="bitfun-nav-panel__assistant-item-card" draggable={draggable} onDragStart={onDragStart} + onDrag={onDrag} onDragEnd={onDragEnd} onClick={() => { void handleCardNameClick(); }} style={{ cursor: 'pointer' }} @@ -816,7 +840,7 @@ const WorkspaceItem: React.FC = ({ - +