From 86d7e11dcd9837561f206e55a3fa086185e4868b Mon Sep 17 00:00:00 2001 From: axb Date: Sun, 9 Aug 2026 10:51:34 +0800 Subject: [PATCH 1/3] refactor(wework): extract cloud project context --- .../layout/DesktopWorkbenchMain.tsx | 782 ++---------------- .../useWorkbenchCloudProjectContext.test.tsx | 141 ++++ .../layout/useWorkbenchCloudProjectContext.ts | 760 +++++++++++++++++ 3 files changed, 982 insertions(+), 701 deletions(-) create mode 100644 wework/src/components/layout/useWorkbenchCloudProjectContext.test.tsx create mode 100644 wework/src/components/layout/useWorkbenchCloudProjectContext.ts diff --git a/wework/src/components/layout/DesktopWorkbenchMain.tsx b/wework/src/components/layout/DesktopWorkbenchMain.tsx index 0500936520..4931fd9c3a 100644 --- a/wework/src/components/layout/DesktopWorkbenchMain.tsx +++ b/wework/src/components/layout/DesktopWorkbenchMain.tsx @@ -9,7 +9,6 @@ import { } from 'lucide-react' import type { ProjectChatControls } from '@/components/chat/ChatInput' import { ComposerModePill } from '@/components/chat/composer/GoalDraftPill' -import type { ComposerCloudMentionCandidate } from '@/components/chat/composer/composerMentionCandidates' import type { AssistantPlanOpenRequest } from '@/components/chat/AssistantPlanCard' import { RequestUserInputCard } from '@/components/chat/RequestUserInputCard' import { ConnectorAuthCard } from '@/components/chat/ConnectorAuthCard' @@ -20,22 +19,8 @@ import { useAppPreferencesState } from '@/features/app-preferences/useAppPrefere import { useWorkbench, useWorkbenchPaneContext } from '@/features/workbench/useWorkbench' import { getPopoutComposerPlaceholder } from '@/features/workbench/popoutWorkspaceContext' import { DeliveryDialog } from '@/features/delivery/DeliveryDialog' -import type { CloudLoopItem, CloudProject } from '@/api/deliveries' import { TodoBindingPicker } from '@/features/todo/TodoBindingPicker' -import { - findProjectSpaceContextForTask, - projectSpaceApis, - projectSpaceKey, - projectSpaceRef, - runtimeCloudProjectId, -} from '@/features/todo/projectSpaceSelection' -import { - hydrateLocalWorkItems, - loadLocalWorkItems, - saveLocalWorkItems, - type LocalWorkItem, -} from '@/features/todo/todoModel' -import type { WorkbenchServices, WorkspaceSessionApi } from '@/features/workbench/workbenchServices' +import type { WorkspaceSessionApi } from '@/features/workbench/workbenchServices' import { useTranslation } from '@/hooks/useTranslation' import { findWorkbenchDevice, @@ -94,7 +79,6 @@ import { MacOSTitleBarDragRegion } from './MacOSTitleBarDragRegion' import { isTauriRuntime } from '@/lib/runtime-environment' import { getPlatform } from '@/lib/platform' import { getLocalPathKind } from '@/lib/local-terminal' -import { navigateTo } from '@/lib/navigation' import { browserDiagnosticUrl, DEFAULT_EMBEDDED_BROWSER_LABEL, @@ -134,7 +118,6 @@ import { useWorkbenchProjectWorkControls } from './useWorkbenchProjectWorkContro import { useRuntimeTaskContinueInIm } from './useRuntimeTaskContinueInIm' import { requestOpenCloudDeviceSettings } from './workbenchShellEvents' import { SubagentStatusIndicator } from './SubagentStatusIndicator' -import { useOptionalWorkspaceTabs } from '@/features/workspace-tabs/workspaceTabsContextValue' import { SupervisorSuggestionCards, TaskSupervisorControl, @@ -142,7 +125,6 @@ import { } from './TaskSupervisorControl' import { WEWORK_OPEN_TERMINAL_EVENT } from '@/lib/keybindings' import type { - RuntimeAdditionalContext, RuntimeSupervisorMode, RuntimeSupervisorSuggestion, RuntimeTaskAddress, @@ -153,6 +135,7 @@ import { DesktopEmptyTaskLauncher } from './DesktopEmptyTaskLauncher' import { TaskFeedbackDialog } from '@/features/feedback/TaskFeedbackDialog' import { usePluginTrialPromptRefinement } from '@/features/plugins/usePluginTrialPromptRefinement' import { DESKTOP_CHAT_CONTENT_WIDTH_CLASS, DESKTOP_MESSAGE_LIST_CLASS } from './desktopChatLayout' +import { useWorkbenchCloudProjectContext } from './useWorkbenchCloudProjectContext' const DESKTOP_STICKY_COMPOSER_FOOTER_CLASS = 'pt-6 pb-2 bg-gradient-to-t to-transparent' const DESKTOP_STICKY_COMPOSER_LAYER_CLASS = `${DESKTOP_CHAT_CONTENT_WIDTH_CLASS} relative` @@ -172,121 +155,6 @@ const TEMPORARY_CHAT_PANEL_DEFAULT_WIDTH = 420 const MACOS_COLLAPSED_SIDEBAR_CONTROL_ALIGNMENT_CLASS = 'pl-2' const BLANK_BROWSER_MIGRATION_TTL_MS = 2 * 60 * 1000 -function cloudLoopItemStatusLabel( - status: CloudLoopItem['status'], - t: ReturnType['t'] -): string { - switch (status) { - case 'inbox': - return t('workbench.cloud_todo_status_inbox', '收集箱') - case 'pending': - return t('workbench.cloud_todo_status_pending', '待处理') - case 'in_progress': - return t('workbench.cloud_todo_status_in_progress', '进行中') - case 'in_review': - return t('workbench.cloud_todo_status_in_review', '待评审') - case 'completed': - return t('workbench.cloud_todo_status_completed', '已完成') - } - return '' -} - -function cloudItemAsLocalWorkItem( - item: CloudLoopItem, - runtimeTask: RuntimeTaskAddress -): Omit { - return { - id: item.id, - title: item.title, - objective: '', - description: item.description, - state: - item.status === 'completed' - ? 'completed' - : item.status === 'in_review' - ? 'review' - : item.status === 'in_progress' - ? 'started' - : 'backlog', - assignee: item.assignee_user_id - ? { type: 'human', id: String(item.assignee_user_id) } - : { type: 'unassigned' }, - collaborators: [], - blocker: '', - nextAction: '', - priority: item.priority === 'medium' ? 'normal' : item.priority, - attachments: [], - runtimeRefs: [runtimeTask], - events: [], - sortOrder: item.sort_order, - createdAt: item.created_at, - updatedAt: item.updated_at, - } -} - -function cloudProjectAdditionalContext( - project: CloudProject | null, - item: CloudLoopItem | null -): RuntimeAdditionalContext | undefined { - if (!project) return undefined - const projectReference = `cloud://projects/${project.id}` - const todoReference = item ? `${projectReference}/todos/${item.id}` : null - const scope = item - ? [ - `Current cloud project: ${project.name} (id=${project.id}).`, - `Current task: ${item.id} — ${item.title}.`, - item.description ? `Task description: ${item.description}` : null, - `Current task reference: ${todoReference}.`, - ] - : [ - `Current cloud project: ${project.name} (id=${project.id}).`, - 'No specific task is selected.', - `Current project reference: ${projectReference}.`, - ] - return { - cloudCollaboration: { - kind: 'application', - value: [ - ...scope.filter((line): line is string => Boolean(line)), - 'When the user refers to “this project” or “this task”, use this current cloud context.', - 'Use the wegent_delivery MCP tools to inspect task details, shared files, and deliveries when needed. Do not ask for an id that is already provided here.', - ].join('\n'), - }, - } -} - -interface PendingTodoBinding { - project: CloudProject - item: CloudLoopItem | null - target: RuntimeTaskAddress | null - description: string -} - -interface PendingAutoJoinResolution { - target: RuntimeTaskAddress | null - description: string -} - -let pendingTodoBinding: PendingTodoBinding | null = null - -function pendingTodoForTask(address: RuntimeTaskAddress | null) { - if (!pendingTodoBinding) return null - if (!address) return pendingTodoBinding.target ? null : pendingTodoBinding.item - const target = pendingTodoBinding.target - return target?.deviceId === address.deviceId && target.taskId === address.taskId - ? pendingTodoBinding.item - : null -} - -function pendingProjectForTask(address: RuntimeTaskAddress | null) { - if (!pendingTodoBinding) return null - if (!address) return pendingTodoBinding.target ? null : pendingTodoBinding.project - const target = pendingTodoBinding.target - return target?.deviceId === address.deviceId && target.taskId === address.taskId - ? pendingTodoBinding.project - : null -} - const MAX_CACHED_DESKTOP_WORKBENCH_PANES = 1 interface SelectedAssistantPlan { blockId: string @@ -612,7 +480,6 @@ const DesktopWorkbenchPane = memo(function DesktopWorkbenchPane({ startNewChat, } = useWorkbenchPaneContext() const { services } = useWorkbench() - const workspaceTabs = useOptionalWorkspaceTabs() const { t } = useTranslation('common') const { t: tChat } = useTranslation('chat') const currentRuntimeTask = pane.currentRuntimeTask @@ -634,14 +501,6 @@ const DesktopWorkbenchPane = memo(function DesktopWorkbenchPane({ project: currentProject, }) const sendPaneInput = paneSession.send - const todoBindingApis = useMemo(() => projectSpaceApis(services), [services]) - const pendingAutoJoinResolutionRef = useRef(null) - const [deliveryItem, setDeliveryItem] = useState | null>(null) - const [boundCloudProject, setBoundCloudProject] = useState(null) - const [boundCloudItem, setBoundCloudItem] = useState(null) - const [deliveryDialogOpen, setDeliveryDialogOpen] = useState(false) - const [todoBindingPickerOpen, setTodoBindingPickerOpen] = useState(false) - const [deliverAfterBinding, setDeliverAfterBinding] = useState(false) const [pendingSupervisorConfig, setPendingSupervisorConfig] = useState(null) const supervisorTaskKey = currentRuntimeTask @@ -650,26 +509,61 @@ const DesktopWorkbenchPane = memo(function DesktopWorkbenchPane({ const supervisorDialogScopeKey = supervisorTaskKey ?? `${paneKey}:new` const [supervisorDialogTaskKey, setSupervisorDialogTaskKey] = useState(null) const supervisorDialogOpen = supervisorDialogTaskKey === supervisorDialogScopeKey - const [pendingTodoItem, setPendingTodoItemState] = useState(() => - pendingTodoForTask(currentRuntimeTask) - ) - const [pendingCloudProject, setPendingCloudProject] = useState(() => - pendingProjectForTask(currentRuntimeTask) - ) - const [todoBindingError, setTodoBindingError] = useState(null) - const [cloudProjects, setCloudProjects] = useState([]) - const [defaultProjectOptionKey, setDefaultProjectOptionKey] = useState(null) - const [dismissedDefaultCloudProjectKey, setDismissedDefaultCloudProjectKey] = useState< - string | null - >(null) - const [cloudActionNotice, setCloudActionNotice] = useState(null) - const [cloudMentionState, setCloudMentionState] = useState<{ - todoId: string - candidates: ComposerCloudMentionCandidate[] - } | null>(null) const runtimeWork = state.runtimeWork const runtimeTaskSummary = findRuntimeTask(runtimeWork, currentRuntimeTask) const runtimeTaskTitle = truncateRuntimeTaskTitle(runtimeTaskSummary?.title) + const cloudProjectContext = useWorkbenchCloudProjectContext({ + currentRuntimeTask, + currentProjectId: currentProject?.id, + defaultProjectSpace, + paneKey, + runtimeTaskTitle, + services, + userId: state.user?.id, + }) + const { + activeDeliveryItem, + boundCloudItem, + boundCloudProject, + clearCloudActionNotice, + clearPendingProjectContext, + clearTodoBindingError, + closeDeliveryDialog, + closeTodoBindingPicker, + cloudActionNotice, + cloudProjectMentionCandidates, + deliveryDialogOpen, + finishLocalDelivery, + handleSelectCloudProject, + handleTodoBound, + openDelivery, + openTodoManager, + pendingCloudProject, + pendingTodoItem, + prepareSubmission, + todoBindingApis, + todoBindingError, + todoBindingPickerOpen, + visibleCloudMentionCandidates, + } = cloudProjectContext + const composerCloudProject = currentRuntimeTask ? boundCloudProject : pendingCloudProject + const pendingProjectSpaceContext = + !currentRuntimeTask && pendingCloudProject ? ( + + ) : null const supervisor = runtimeTaskSummary?.supervisor ?? null const currentRuntimeTaskSupportsSupervisor = runtimeTaskSummary?.runtime?.toLowerCase() === 'codex' @@ -681,21 +575,6 @@ const DesktopWorkbenchPane = memo(function DesktopWorkbenchPane({ const supervisorModels = projectChat.models.filter( model => model.isActive !== false && !model.compatibilityDisabled ) - const composerCloudProject = currentRuntimeTask ? boundCloudProject : pendingCloudProject - const composerTodoItem = currentRuntimeTask ? boundCloudItem : pendingTodoItem - const defaultCloudProjectSelectionKey = `${paneKey}:${currentProject?.id ?? 'none'}` - const cloudAdditionalContext = useMemo( - () => cloudProjectAdditionalContext(composerCloudProject, composerTodoItem), - [composerCloudProject, composerTodoItem] - ) - const setPendingCloudContext = useCallback( - (project: CloudProject | null, item: CloudLoopItem | null) => { - pendingTodoBinding = project ? { project, item, target: null, description: '' } : null - setPendingCloudProject(project) - setPendingTodoItemState(item) - }, - [] - ) const runtimeWorkApi = services?.runtimeWorkApi const setSupervisorForAddress = useCallback( @@ -720,40 +599,13 @@ const DesktopWorkbenchPane = memo(function DesktopWorkbenchPane({ (value?: string, options?: { guideWhenBusy?: boolean; interruptWhenBusy?: boolean }) => { const supervisorConfig = currentRuntimeTask ? null : pendingSupervisorConfig const description = value ?? paneSession.input - const submissionProject = currentRuntimeTask ? null : pendingCloudProject - const submissionItem = - submissionProject?.id === pendingCloudProject?.id ? pendingTodoItem : null - if (!currentRuntimeTask) { - setPendingCloudContext(submissionProject, submissionItem) - pendingAutoJoinResolutionRef.current = - !submissionProject && - Boolean(defaultProjectSpace) && - dismissedDefaultCloudProjectKey !== defaultCloudProjectSelectionKey && - projectSpaceApis(services).length > 0 - ? { target: null, description } - : null - } - if (pendingTodoBinding) { - pendingTodoBinding = { ...pendingTodoBinding, description } - } + const cloudSubmission = prepareSubmission(description) return sendPaneInput(value, { ...options, - additionalContext: - cloudProjectAdditionalContext(submissionProject, submissionItem) ?? - cloudAdditionalContext, - cloudProjectId: runtimeCloudProjectId(submissionProject), + additionalContext: cloudSubmission.additionalContext, + cloudProjectId: cloudSubmission.cloudProjectId, initialSupervisor: supervisorConfig, - onRuntimeTaskCreated: address => { - if (pendingTodoBinding) { - pendingTodoBinding = { ...pendingTodoBinding, target: address } - } - if (pendingAutoJoinResolutionRef.current) { - pendingAutoJoinResolutionRef.current = { - ...pendingAutoJoinResolutionRef.current, - target: address, - } - } - }, + onRuntimeTaskCreated: cloudSubmission.onRuntimeTaskCreated, onRuntimeTaskReady: () => { if (supervisorConfig) { setPendingSupervisorConfig(null) @@ -762,18 +614,12 @@ const DesktopWorkbenchPane = memo(function DesktopWorkbenchPane({ }) }, [ - cloudAdditionalContext, currentRuntimeTask, - defaultProjectSpace, - defaultCloudProjectSelectionKey, - dismissedDefaultCloudProjectKey, paneSession.input, - pendingCloudProject, - pendingTodoItem, pendingSupervisorConfig, + prepareSubmission, sendPaneInput, - services, - setPendingCloudContext, + setPendingSupervisorConfig, ] ) @@ -818,7 +664,7 @@ const DesktopWorkbenchPane = memo(function DesktopWorkbenchPane({ } return setSupervisorForAddress(currentRuntimeTask, config) }, - [currentRuntimeTask, setSupervisorForAddress] + [currentRuntimeTask, setPendingSupervisorConfig, setSupervisorForAddress] ) const clearTaskSupervisor = useCallback(async () => { @@ -833,7 +679,7 @@ const DesktopWorkbenchPane = memo(function DesktopWorkbenchPane({ if (!response.accepted) { throw new Error(response.error || t('workbench.supervisor_clear_failed')) } - }, [currentRuntimeTask, runtimeWorkApi, t]) + }, [currentRuntimeTask, runtimeWorkApi, setPendingSupervisorConfig, t]) const resolveTaskSupervisorSuggestion = useCallback( async (suggestion: RuntimeSupervisorSuggestion, status: 'accepted' | 'dismissed') => { @@ -853,451 +699,6 @@ const DesktopWorkbenchPane = memo(function DesktopWorkbenchPane({ [currentRuntimeTask, runtimeWorkApi, submitPaneInput, t] ) - const projectSpaceApiFor = useCallback( - (project: CloudProject): NonNullable | undefined => - project.project_store === 'local' || project.task_provider === 'dingtalk_aitable' - ? (services?.projectSpaceApis?.local ?? services?.deliveryApi) - : (services?.projectSpaceApis?.cloud ?? services?.deliveryApi), - [services?.deliveryApi, services?.projectSpaceApis?.cloud, services?.projectSpaceApis?.local] - ) - - useEffect(() => { - let active = true - if (!currentRuntimeTask) { - queueMicrotask(() => { - if (!active) return - setBoundCloudItem(null) - setBoundCloudProject(null) - setDeliveryItem(null) - }) - return () => { - active = false - } - } - const contextApis = projectSpaceApis(services) - if (contextApis.length > 0) { - void findProjectSpaceContextForTask(contextApis, currentRuntimeTask) - .then(context => { - if (!active) return - setBoundCloudProject(context.project) - setBoundCloudItem(context.loop_item) - setDeliveryItem( - context.loop_item - ? cloudItemAsLocalWorkItem(context.loop_item, currentRuntimeTask) - : null - ) - }) - .catch(() => { - if (active) { - setBoundCloudItem(null) - setBoundCloudProject(null) - setDeliveryItem(null) - } - }) - return () => { - active = false - } - } - void hydrateLocalWorkItems(state.user?.id).then(items => { - if (!active) return - setBoundCloudItem(null) - setDeliveryItem( - items.find(item => - item.runtimeRefs.some( - reference => - reference.taskId === currentRuntimeTask.taskId && - reference.deviceId === currentRuntimeTask.deviceId - ) - ) ?? null - ) - }) - return () => { - active = false - } - }, [currentRuntimeTask, services, state.user?.id]) - - useEffect(() => { - const projectToBind = pendingCloudProject ?? pendingTodoBinding?.project ?? null - const itemToBind = pendingTodoItem ?? pendingTodoBinding?.item ?? null - if (!currentRuntimeTask || !projectToBind) return - const pendingBinding = pendingTodoBinding - if ( - pendingBinding?.target && - (pendingBinding.target.deviceId !== currentRuntimeTask.deviceId || - pendingBinding.target.taskId !== currentRuntimeTask.taskId) - ) { - return - } - const api = projectSpaceApiFor(projectToBind) - if (!api) return - const bindingTaskTitle = - runtimeTaskTitle || - truncateRuntimeTaskTitle(pendingBinding?.description) || - t('workbench.untitled_task', '未命名任务') - let active = true - const bindingRequest = itemToBind - ? api - .bindTask(itemToBind.id, currentRuntimeTask, bindingTaskTitle) - .then(() => ({ item: itemToBind })) - : api.trackProjectTask( - projectToBind.id, - currentRuntimeTask, - bindingTaskTitle, - pendingBinding?.description ?? '' - ) - void bindingRequest - .then(({ item }) => { - if (!active) return - setBoundCloudProject(projectToBind) - setBoundCloudItem(item) - setDeliveryItem(cloudItemAsLocalWorkItem(item, currentRuntimeTask)) - pendingTodoBinding = null - setPendingCloudContext(null, null) - }) - .catch(cause => { - if (!active) return - setTodoBindingError( - cause instanceof Error - ? cause.message - : t('workbench.cloud_project_bind_failed', '关联项目空间失败') - ) - }) - return () => { - active = false - } - }, [ - currentRuntimeTask, - pendingCloudProject, - pendingTodoItem, - projectSpaceApiFor, - runtimeTaskTitle, - setPendingCloudContext, - t, - ]) - - useEffect(() => { - let active = true - const api = services?.deliveryApi - if (!api || !composerCloudProject) { - return () => { - active = false - } - } - const projectId = composerCloudProject.id - void Promise.all([ - api.listCloudFiles(projectId), - api.listLoopItems(projectId), - composerTodoItem ? api.listDeliveries(composerTodoItem.id) : Promise.resolve({ items: [] }), - ]) - .then(([files, items, deliveries]) => { - if (!active) return - const candidate = ( - key: string, - title: string, - description: string, - reference: string, - aliases: string[], - statusLabel?: string - ): ComposerCloudMentionCandidate => ({ - kind: 'cloud', - key, - title, - description, - metaLabel: t('workbench.mention_cloud_space', '云空间'), - testId: key.replace(/[^a-zA-Z0-9_-]/g, '-'), - enabled: true, - reference, - searchAliases: aliases, - statusLabel, - }) - setCloudMentionState({ - todoId: composerTodoItem?.id ?? `project:${projectId}`, - candidates: [ - candidate( - `cloud-project:${projectId}`, - t('workbench.mention_cloud_whole_space', '整个空间'), - t('workbench.mention_cloud_whole_space_description', '共享文件 + 看板全部内容'), - `[$${t('workbench.mention_cloud_whole_space', '整个空间')}](cloud://projects/${projectId})`, - ['云项目', 'cloud', 'workspace'] - ), - ...items.items.map(item => - candidate( - `cloud-todo:${item.id}`, - item.id, - item.title, - `[$${t('workbench.mention_cloud_todo_chip', '任务')}:${item.id}](cloud://projects/${projectId}/todos/${item.id})`, - [item.title, item.status, 'TODO', '任务'], - cloudLoopItemStatusLabel(item.status, t) - ) - ), - ...files.items.map(file => - candidate( - `cloud-file:${file.id}`, - file.name, - file.path, - `[$${file.name}](cloud://projects/${projectId}/files/${file.id})`, - [file.path, file.kind, '文件', '目录'] - ) - ), - ...deliveries.items.map(delivery => - candidate( - `cloud-delivery:${delivery.id}`, - `交付 ${delivery.id.slice(0, 8)}`, - delivery.delivered_at ?? delivery.created_at, - `[$交付 ${delivery.id.slice(0, 8)}](cloud://projects/${projectId}/deliveries/${delivery.id})`, - ['交付', 'delivery', delivery.id] - ) - ), - ], - }) - }) - .catch(() => { - if (active) setCloudMentionState(null) - }) - return () => { - active = false - } - }, [composerCloudProject, composerTodoItem, services?.deliveryApi, t]) - const visibleCloudMentionCandidates = - composerCloudProject && - cloudMentionState?.todoId === (composerTodoItem?.id ?? `project:${composerCloudProject.id}`) - ? cloudMentionState.candidates - : [] - - useEffect(() => { - let active = true - const apis = projectSpaceApis(services) - if (!apis.length) { - queueMicrotask(() => { - if (active) { - setCloudProjects([]) - setDefaultProjectOptionKey(null) - } - }) - return () => { - active = false - } - } - void Promise.allSettled( - apis.map(async api => { - const result = await api.listCloudProjects() - return result.items - }) - ) - .then(results => { - if (!active) return - const candidates = results.flatMap(result => - result.status === 'fulfilled' ? result.value : [] - ) - const uniqueProjects = candidates.filter( - (candidate, index) => - candidates.findIndex( - other => other.id === candidate.id && other.project_store === candidate.project_store - ) === index - ) - const defaultProject = defaultProjectSpace - ? uniqueProjects.find( - project => - projectSpaceKey(projectSpaceRef(project)) === projectSpaceKey(defaultProjectSpace) - ) - : null - setCloudProjects(uniqueProjects) - setDefaultProjectOptionKey( - defaultProject ? projectSpaceKey(projectSpaceRef(defaultProject)) : null - ) - const pendingAutoJoin = pendingAutoJoinResolutionRef.current - const pendingTargetMatchesCurrentTask = - pendingAutoJoin?.target && - currentRuntimeTask && - pendingAutoJoin.target.deviceId === currentRuntimeTask.deviceId && - pendingAutoJoin.target.taskId === currentRuntimeTask.taskId - if ( - defaultProject && - !pendingCloudProject && - dismissedDefaultCloudProjectKey !== defaultCloudProjectSelectionKey && - (!currentRuntimeTask || pendingTargetMatchesCurrentTask) - ) { - pendingTodoBinding = { - project: defaultProject, - item: null, - target: pendingAutoJoin?.target ?? null, - description: pendingAutoJoin?.description ?? '', - } - pendingAutoJoinResolutionRef.current = null - setPendingCloudProject(defaultProject) - setPendingTodoItemState(null) - } else if (!defaultProject && pendingAutoJoin) { - pendingAutoJoinResolutionRef.current = null - } - }) - .catch(() => { - if (active) { - setCloudProjects([]) - setDefaultProjectOptionKey(null) - } - }) - return () => { - active = false - } - }, [ - currentRuntimeTask, - defaultProjectSpace, - defaultCloudProjectSelectionKey, - dismissedDefaultCloudProjectKey, - pendingCloudProject, - services, - setPendingCloudContext, - ]) - const cloudProjectMentionCandidates = useMemo( - () => - cloudProjects.map(project => { - const spaceLabel = t('workbench.mention_cloud_project_space', '项目空间') - return { - kind: 'cloud', - key: `cloud-project-space:${project.id}`, - title: project.name, - description: project.description || project.project_key || undefined, - statusLabel: - projectSpaceKey(projectSpaceRef(project)) === defaultProjectOptionKey - ? t('workbench.project_space_auto_join', '自动加入') - : undefined, - metaLabel: t('workbench.mention_cloud_space', '云空间'), - testId: `cloud-project-space-${String(project.id).replace(/[^a-zA-Z0-9_-]/g, '-')}`, - enabled: true, - reference: `[$${spaceLabel}:${project.name}](cloud://projects/${project.id})`, - searchAliases: [ - project.name, - project.project_key, - project.description, - spaceLabel, - 'project space', - 'project-space', - 'cloud', - ].filter(alias => Boolean(alias)), - project, - } - }), - [cloudProjects, defaultProjectOptionKey, t] - ) - const bindComposerCloudProject = useCallback( - (project: CloudProject, notice: string) => { - setCloudActionNotice(notice) - if (!currentRuntimeTask) { - setPendingCloudContext(project, null) - return - } - const api = projectSpaceApiFor(project) - if (!api) return - void api - .bindProjectTask(project.id, currentRuntimeTask, runtimeTaskTitle) - .then(() => { - setBoundCloudProject(project) - setBoundCloudItem(null) - setDeliveryItem(null) - }) - .catch(cause => { - setTodoBindingError( - cause instanceof Error - ? cause.message - : t('workbench.cloud_project_bind_failed', '关联项目空间失败') - ) - }) - }, - [currentRuntimeTask, projectSpaceApiFor, runtimeTaskTitle, setPendingCloudContext, t] - ) - const handleSelectCloudProject = useCallback( - (project: CloudProject) => { - setDismissedDefaultCloudProjectKey(null) - bindComposerCloudProject( - project, - t('workbench.cloud_project_bound_notice', { name: project.name }) - ) - }, - [bindComposerCloudProject, t] - ) - const pendingProjectSpaceContext = - !currentRuntimeTask && pendingCloudProject ? ( - { - setDismissedDefaultCloudProjectKey(defaultCloudProjectSelectionKey) - setPendingCloudContext(null, null) - }} - title={t( - 'workbench.project_space_context_pending_title', - '发送后会在该项目空间的看板中创建任务' - )} - /> - ) : null - - const activeDeliveryItem = - currentRuntimeTask && - deliveryItem?.runtimeRefs.some( - reference => - reference.taskId === currentRuntimeTask.taskId && - reference.deviceId === currentRuntimeTask.deviceId - ) - ? deliveryItem - : null - - const openBoundProjectSpaceTask = useCallback(() => { - if (!boundCloudProject || !boundCloudItem) return - const params = new URLSearchParams() - params.set('projectId', String(boundCloudProject.id)) - params.set('itemId', boundCloudItem.id) - const contentRoute = `/todo?${params.toString()}` - const boardTab = workspaceTabs?.tabs.find(tab => tab.kind === 'board') - if (boardTab && workspaceTabs) { - workspaceTabs.selectTab(boardTab.id, { - title: boundCloudProject.name, - contentRoute, - }) - return - } - if (workspaceTabs) { - workspaceTabs.openTab('board', { - title: boundCloudProject.name, - contentRoute, - }) - return - } - navigateTo(contentRoute) - }, [boundCloudItem, boundCloudProject, workspaceTabs]) - - const finishLocalDelivery = useCallback(async () => { - if (!activeDeliveryItem) return - const items = await loadLocalWorkItems(state.user?.id) - const now = new Date().toISOString() - await saveLocalWorkItems( - state.user?.id, - items.map(item => - item.id === activeDeliveryItem.id - ? { - ...item, - state: 'completed', - updatedAt: now, - events: [ - ...item.events, - { - id: `delivery-${now}`, - type: 'confirmed' as const, - summary: t('delivery.completed_activity', '任务已交付并完成'), - createdAt: now, - }, - ], - } - : item - ) - ) - setDeliveryDialogOpen(false) - navigateTo('/todo') - }, [activeDeliveryItem, setDeliveryDialogOpen, state.user?.id, t]) const projectWork = useWorkbenchProjectWorkControls({ pane, enableShellProjectActions: true, @@ -1498,14 +899,24 @@ const DesktopWorkbenchPane = memo(function DesktopWorkbenchPane({ } onEnvironmentInfoOverlayOpenChange(open) }, - [environmentInfoDocked, onEnvironmentInfoOverlayOpenChange, onEnvironmentInfoPinnedChange] + [ + environmentInfoDocked, + onEnvironmentInfoOverlayOpenChange, + onEnvironmentInfoPinnedChange, + setEnvironmentInfoTransitionEnabled, + ] ) const openSupervisorDialog = useCallback(() => { setSupervisorDialogTaskKey(supervisorDialogScopeKey) if (!environmentInfoDocked) { onEnvironmentInfoOverlayOpenChange(false) } - }, [environmentInfoDocked, onEnvironmentInfoOverlayOpenChange, supervisorDialogScopeKey]) + }, [ + environmentInfoDocked, + onEnvironmentInfoOverlayOpenChange, + setSupervisorDialogTaskKey, + supervisorDialogScopeKey, + ]) useEffect(() => { if (currentRuntimeTask && !environmentInfoDocked) return @@ -2523,14 +1934,7 @@ const DesktopWorkbenchPane = memo(function DesktopWorkbenchPane({ onOpenEnvironmentChangesReview={openDefaultEnvironmentChangesReview} onDeliver={ experimentalFeaturesEnabled && currentRuntimeTask && services?.deliveryApi - ? () => { - if (activeDeliveryItem) { - setDeliveryDialogOpen(true) - } else { - setDeliverAfterBinding(true) - setTodoBindingPickerOpen(true) - } - } + ? openDelivery : undefined } todoLabel={ @@ -2538,12 +1942,7 @@ const DesktopWorkbenchPane = memo(function DesktopWorkbenchPane({ } onManageTodo={ experimentalFeaturesEnabled && currentRuntimeTask && services?.deliveryApi - ? boundCloudItem - ? openBoundProjectSpaceTask - : () => { - setDeliverAfterBinding(false) - setTodoBindingPickerOpen(true) - } + ? openTodoManager : undefined } supervisor={supervisorFeatureAvailable ? supervisor : null} @@ -3401,12 +2800,8 @@ const DesktopWorkbenchPane = memo(function DesktopWorkbenchPane({ tone={continueInIm.notice?.tone} onClear={continueInIm.clearNotice} /> - setTodoBindingError(null)} - /> - setCloudActionNotice(null)} /> + + {deliveryDialogOpen && activeDeliveryItem && currentRuntimeTask && @@ -3417,7 +2812,7 @@ const DesktopWorkbenchPane = memo(function DesktopWorkbenchPane({ runtimeTaskTitle={runtimeTaskTitle} messages={paneSession.messages} deliveryApi={services.deliveryApi} - onCancel={() => setDeliveryDialogOpen(false)} + onCancel={closeDeliveryDialog} onDelivered={() => void finishLocalDelivery()} /> )} @@ -3428,23 +2823,8 @@ const DesktopWorkbenchPane = memo(function DesktopWorkbenchPane({ runtimeTaskTitle={runtimeTaskTitle} currentProject={currentRuntimeTask ? boundCloudProject : pendingCloudProject} currentItem={currentRuntimeTask ? boundCloudItem : pendingTodoItem} - onClose={() => { - setTodoBindingPickerOpen(false) - setDeliverAfterBinding(false) - }} - onBound={(project, item) => { - if (!currentRuntimeTask) { - setPendingCloudContext(project, item) - setTodoBindingPickerOpen(false) - return - } - setBoundCloudProject(project) - setBoundCloudItem(item) - setDeliveryItem(item ? cloudItemAsLocalWorkItem(item, currentRuntimeTask) : null) - setTodoBindingPickerOpen(false) - if (item && deliverAfterBinding) setDeliveryDialogOpen(true) - setDeliverAfterBinding(false) - }} + onClose={closeTodoBindingPicker} + onBound={handleTodoBound} /> )} diff --git a/wework/src/components/layout/useWorkbenchCloudProjectContext.test.tsx b/wework/src/components/layout/useWorkbenchCloudProjectContext.test.tsx new file mode 100644 index 0000000000..1f3a612468 --- /dev/null +++ b/wework/src/components/layout/useWorkbenchCloudProjectContext.test.tsx @@ -0,0 +1,141 @@ +import { act, renderHook, waitFor } from '@testing-library/react' +import { describe, expect, test, vi } from 'vitest' +import '@/i18n' +import type { CloudLoopItem, CloudProject } from '@/api/deliveries' +import type { WorkbenchServices } from '@/features/workbench/workbenchServices' +import { + cloudItemAsLocalWorkItem, + useWorkbenchCloudProjectContext, +} from './useWorkbenchCloudProjectContext' + +function project(id: string, projectStore: 'local' | 'backend' = 'backend'): CloudProject { + return { + id, + public_id: `public-${id}`, + project_key: id.toUpperCase(), + name: `${id} board`, + description: 'Shared project context', + project_store: projectStore, + task_provider: projectStore === 'local' ? 'local' : 'native', + provider_config: {}, + created_by_user_id: 1, + status: 'active', + tags: [], + version: 1, + created_at: '2026-08-09T00:00:00Z', + updated_at: '2026-08-09T00:00:00Z', + } +} + +function loopItem(projectId: string): CloudLoopItem { + return { + id: 'todo-1', + cloud_project_id: projectId, + sequence_number: 1, + parent_id: null, + created_by_user_id: 1, + assignee_user_id: null, + title: 'Ship the refactor', + description: 'Keep the workbench behavior stable', + status: 'in_progress', + priority: 'medium', + due_at: null, + tags: [], + sort_order: 2, + current_delivery_id: null, + version: 1, + created_at: '2026-08-09T00:00:00Z', + updated_at: '2026-08-09T00:00:00Z', + completed_at: null, + } +} + +function renderCloudContext(services?: WorkbenchServices) { + return renderHook(() => + useWorkbenchCloudProjectContext({ + currentRuntimeTask: null, + currentProjectId: 42, + defaultProjectSpace: null, + paneKey: 'project:42', + runtimeTaskTitle: null, + services, + userId: 1, + }) + ) +} + +describe('useWorkbenchCloudProjectContext', () => { + test('prepares a pending backend project and task for runtime creation', async () => { + const cloudProject = project('space-cloud') + const item = loopItem(cloudProject.id) + const { result } = renderCloudContext() + + await act(async () => {}) + act(() => result.current.handleTodoBound(cloudProject, item)) + + let submission: ReturnType | undefined + act(() => { + submission = result.current.prepareSubmission('Implement the selected task') + }) + + expect(submission?.cloudProjectId).toBe(cloudProject.id) + expect(submission?.additionalContext?.cloudCollaboration.value).toContain( + `Current task: ${item.id} — ${item.title}.` + ) + expect(result.current.pendingCloudProject).toEqual(cloudProject) + expect(result.current.pendingTodoItem).toEqual(item) + + act(() => result.current.clearPendingProjectContext()) + }) + + test('automatically selects the configured default project space', async () => { + const defaultProject = project('space-default') + const deliveryApi = { + listCloudProjects: vi.fn().mockResolvedValue({ items: [defaultProject] }), + listCloudFiles: vi.fn().mockResolvedValue({ items: [] }), + listLoopItems: vi.fn().mockResolvedValue({ items: [] }), + } + const services = { + deliveryApi, + } as unknown as WorkbenchServices + const defaultProjectSpace = { + projectStore: defaultProject.project_store, + projectId: defaultProject.id, + } as const + + const { result } = renderHook(() => + useWorkbenchCloudProjectContext({ + currentRuntimeTask: null, + currentProjectId: 42, + defaultProjectSpace, + paneKey: 'project:42', + runtimeTaskTitle: null, + services, + userId: 1, + }) + ) + + await waitFor(() => expect(result.current.pendingCloudProject).toEqual(defaultProject)) + expect(result.current.pendingTodoItem).toBeNull() + expect(deliveryApi.listCloudProjects).toHaveBeenCalledOnce() + + act(() => result.current.clearPendingProjectContext()) + }) + + test('maps a cloud task to the local delivery model', () => { + const item = loopItem('space-cloud') + + expect( + cloudItemAsLocalWorkItem(item, { + deviceId: 'device-1', + taskId: 'runtime-1', + }) + ).toMatchObject({ + id: item.id, + title: item.title, + state: 'started', + priority: 'normal', + runtimeRefs: [{ deviceId: 'device-1', taskId: 'runtime-1' }], + }) + }) +}) diff --git a/wework/src/components/layout/useWorkbenchCloudProjectContext.ts b/wework/src/components/layout/useWorkbenchCloudProjectContext.ts new file mode 100644 index 0000000000..fa8afda856 --- /dev/null +++ b/wework/src/components/layout/useWorkbenchCloudProjectContext.ts @@ -0,0 +1,760 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import type { ComposerCloudMentionCandidate } from '@/components/chat/composer/composerMentionCandidates' +import type { CloudLoopItem, CloudProject } from '@/api/deliveries' +import { + findProjectSpaceContextForTask, + projectSpaceApis, + projectSpaceKey, + projectSpaceRef, + runtimeCloudProjectId, +} from '@/features/todo/projectSpaceSelection' +import type { WorkbenchServices } from '@/features/workbench/workbenchServices' +import { truncateRuntimeTaskTitle } from '@/features/workbench/workbenchRuntimeHelpers' +import { + hydrateLocalWorkItems, + loadLocalWorkItems, + saveLocalWorkItems, + type LocalWorkItem, +} from '@/features/todo/todoModel' +import { useOptionalWorkspaceTabs } from '@/features/workspace-tabs/workspaceTabsContextValue' +import { useTranslation } from '@/hooks/useTranslation' +import { navigateTo } from '@/lib/navigation' +import type { + RuntimeAdditionalContext, + RuntimeProjectSpaceRef, + RuntimeTaskAddress, +} from '@/types/api' + +interface PendingTodoBinding { + project: CloudProject + item: CloudLoopItem | null + target: RuntimeTaskAddress | null + description: string +} + +interface PendingAutoJoinResolution { + target: RuntimeTaskAddress | null + description: string +} + +interface CloudSubmissionContext { + additionalContext: RuntimeAdditionalContext | undefined + cloudProjectId: string | undefined + onRuntimeTaskCreated: (address: RuntimeTaskAddress) => void +} + +interface UseWorkbenchCloudProjectContextOptions { + currentRuntimeTask: RuntimeTaskAddress | null + currentProjectId?: number + defaultProjectSpace: RuntimeProjectSpaceRef | null + paneKey: string + runtimeTaskTitle: string | null + services?: WorkbenchServices + userId?: number +} + +let pendingTodoBinding: PendingTodoBinding | null = null + +function pendingTodoForTask(address: RuntimeTaskAddress | null) { + if (!pendingTodoBinding) return null + if (!address) return pendingTodoBinding.target ? null : pendingTodoBinding.item + const target = pendingTodoBinding.target + return target?.deviceId === address.deviceId && target.taskId === address.taskId + ? pendingTodoBinding.item + : null +} + +function pendingProjectForTask(address: RuntimeTaskAddress | null) { + if (!pendingTodoBinding) return null + if (!address) return pendingTodoBinding.target ? null : pendingTodoBinding.project + const target = pendingTodoBinding.target + return target?.deviceId === address.deviceId && target.taskId === address.taskId + ? pendingTodoBinding.project + : null +} + +function cloudLoopItemStatusLabel( + status: CloudLoopItem['status'], + t: ReturnType['t'] +): string { + switch (status) { + case 'inbox': + return t('workbench.cloud_todo_status_inbox', '收集箱') + case 'pending': + return t('workbench.cloud_todo_status_pending', '待处理') + case 'in_progress': + return t('workbench.cloud_todo_status_in_progress', '进行中') + case 'in_review': + return t('workbench.cloud_todo_status_in_review', '待评审') + case 'completed': + return t('workbench.cloud_todo_status_completed', '已完成') + } + return '' +} + +export function cloudItemAsLocalWorkItem( + item: CloudLoopItem, + runtimeTask: RuntimeTaskAddress +): Omit { + return { + id: item.id, + title: item.title, + objective: '', + description: item.description, + state: + item.status === 'completed' + ? 'completed' + : item.status === 'in_review' + ? 'review' + : item.status === 'in_progress' + ? 'started' + : 'backlog', + assignee: item.assignee_user_id + ? { type: 'human', id: String(item.assignee_user_id) } + : { type: 'unassigned' }, + collaborators: [], + blocker: '', + nextAction: '', + priority: item.priority === 'medium' ? 'normal' : item.priority, + attachments: [], + runtimeRefs: [runtimeTask], + events: [], + sortOrder: item.sort_order, + createdAt: item.created_at, + updatedAt: item.updated_at, + } +} + +function cloudProjectAdditionalContext( + project: CloudProject | null, + item: CloudLoopItem | null +): RuntimeAdditionalContext | undefined { + if (!project) return undefined + const projectReference = `cloud://projects/${project.id}` + const todoReference = item ? `${projectReference}/todos/${item.id}` : null + const scope = item + ? [ + `Current cloud project: ${project.name} (id=${project.id}).`, + `Current task: ${item.id} — ${item.title}.`, + item.description ? `Task description: ${item.description}` : null, + `Current task reference: ${todoReference}.`, + ] + : [ + `Current cloud project: ${project.name} (id=${project.id}).`, + 'No specific task is selected.', + `Current project reference: ${projectReference}.`, + ] + return { + cloudCollaboration: { + kind: 'application', + value: [ + ...scope.filter((line): line is string => Boolean(line)), + 'When the user refers to “this project” or “this task”, use this current cloud context.', + 'Use the wegent_delivery MCP tools to inspect task details, shared files, and deliveries when needed. Do not ask for an id that is already provided here.', + ].join('\n'), + }, + } +} + +export function useWorkbenchCloudProjectContext({ + currentRuntimeTask, + currentProjectId, + defaultProjectSpace, + paneKey, + runtimeTaskTitle, + services, + userId, +}: UseWorkbenchCloudProjectContextOptions) { + const { t } = useTranslation('common') + const workspaceTabs = useOptionalWorkspaceTabs() + const todoBindingApis = useMemo(() => projectSpaceApis(services), [services]) + const pendingAutoJoinResolutionRef = useRef(null) + const [deliveryItem, setDeliveryItem] = useState | null>(null) + const [boundCloudProject, setBoundCloudProject] = useState(null) + const [boundCloudItem, setBoundCloudItem] = useState(null) + const [deliveryDialogOpen, setDeliveryDialogOpen] = useState(false) + const [todoBindingPickerOpen, setTodoBindingPickerOpen] = useState(false) + const [deliverAfterBinding, setDeliverAfterBinding] = useState(false) + const [pendingTodoItem, setPendingTodoItem] = useState(() => + pendingTodoForTask(currentRuntimeTask) + ) + const [pendingCloudProject, setPendingCloudProject] = useState(() => + pendingProjectForTask(currentRuntimeTask) + ) + const [todoBindingError, setTodoBindingError] = useState(null) + const [cloudProjects, setCloudProjects] = useState([]) + const [dismissedDefaultCloudProjectKey, setDismissedDefaultCloudProjectKey] = useState< + string | null + >(null) + const [cloudActionNotice, setCloudActionNotice] = useState(null) + const [cloudMentionState, setCloudMentionState] = useState<{ + todoId: string + candidates: ComposerCloudMentionCandidate[] + } | null>(null) + + const composerCloudProject = currentRuntimeTask ? boundCloudProject : pendingCloudProject + const composerTodoItem = currentRuntimeTask ? boundCloudItem : pendingTodoItem + const defaultCloudProjectSelectionKey = `${paneKey}:${currentProjectId ?? 'none'}` + const defaultProject = useMemo( + () => + defaultProjectSpace + ? (cloudProjects.find( + project => + projectSpaceKey(projectSpaceRef(project)) === projectSpaceKey(defaultProjectSpace) + ) ?? null) + : null, + [cloudProjects, defaultProjectSpace] + ) + const defaultProjectOptionKey = defaultProject + ? projectSpaceKey(projectSpaceRef(defaultProject)) + : null + const cloudAdditionalContext = useMemo( + () => cloudProjectAdditionalContext(composerCloudProject, composerTodoItem), + [composerCloudProject, composerTodoItem] + ) + + const setPendingCloudContext = useCallback( + (project: CloudProject | null, item: CloudLoopItem | null) => { + pendingTodoBinding = project ? { project, item, target: null, description: '' } : null + setPendingCloudProject(project) + setPendingTodoItem(item) + }, + [] + ) + + const projectSpaceApiFor = useCallback( + (project: CloudProject): NonNullable | undefined => + project.project_store === 'local' || project.task_provider === 'dingtalk_aitable' + ? (services?.projectSpaceApis?.local ?? services?.deliveryApi) + : (services?.projectSpaceApis?.cloud ?? services?.deliveryApi), + [services?.deliveryApi, services?.projectSpaceApis?.cloud, services?.projectSpaceApis?.local] + ) + + useEffect(() => { + let active = true + if (!currentRuntimeTask) { + queueMicrotask(() => { + if (!active) return + setBoundCloudItem(null) + setBoundCloudProject(null) + setDeliveryItem(null) + }) + return () => { + active = false + } + } + const contextApis = projectSpaceApis(services) + if (contextApis.length > 0) { + void findProjectSpaceContextForTask(contextApis, currentRuntimeTask) + .then(context => { + if (!active) return + setBoundCloudProject(context.project) + setBoundCloudItem(context.loop_item) + setDeliveryItem( + context.loop_item + ? cloudItemAsLocalWorkItem(context.loop_item, currentRuntimeTask) + : null + ) + }) + .catch(() => { + if (active) { + setBoundCloudItem(null) + setBoundCloudProject(null) + setDeliveryItem(null) + } + }) + return () => { + active = false + } + } + void hydrateLocalWorkItems(userId).then(items => { + if (!active) return + setBoundCloudItem(null) + setDeliveryItem( + items.find(item => + item.runtimeRefs.some( + reference => + reference.taskId === currentRuntimeTask.taskId && + reference.deviceId === currentRuntimeTask.deviceId + ) + ) ?? null + ) + }) + return () => { + active = false + } + }, [currentRuntimeTask, services, userId]) + + useEffect(() => { + const projectToBind = pendingCloudProject ?? pendingTodoBinding?.project ?? null + const itemToBind = pendingTodoItem ?? pendingTodoBinding?.item ?? null + if (!currentRuntimeTask || !projectToBind) return + const pendingBinding = pendingTodoBinding + if ( + pendingBinding?.target && + (pendingBinding.target.deviceId !== currentRuntimeTask.deviceId || + pendingBinding.target.taskId !== currentRuntimeTask.taskId) + ) { + return + } + const api = projectSpaceApiFor(projectToBind) + if (!api) return + const bindingTaskTitle = + runtimeTaskTitle || + truncateRuntimeTaskTitle(pendingBinding?.description) || + t('workbench.untitled_task', '未命名任务') + let active = true + const bindingRequest = itemToBind + ? api + .bindTask(itemToBind.id, currentRuntimeTask, bindingTaskTitle) + .then(() => ({ item: itemToBind })) + : api.trackProjectTask( + projectToBind.id, + currentRuntimeTask, + bindingTaskTitle, + pendingBinding?.description ?? '' + ) + void bindingRequest + .then(({ item }) => { + if (!active) return + setBoundCloudProject(projectToBind) + setBoundCloudItem(item) + setDeliveryItem(cloudItemAsLocalWorkItem(item, currentRuntimeTask)) + pendingTodoBinding = null + setPendingCloudContext(null, null) + }) + .catch(cause => { + if (!active) return + setTodoBindingError( + cause instanceof Error + ? cause.message + : t('workbench.cloud_project_bind_failed', '关联项目空间失败') + ) + }) + return () => { + active = false + } + }, [ + currentRuntimeTask, + pendingCloudProject, + pendingTodoItem, + projectSpaceApiFor, + runtimeTaskTitle, + setPendingCloudContext, + t, + ]) + + useEffect(() => { + let active = true + const api = services?.deliveryApi + if (!api || !composerCloudProject) { + return () => { + active = false + } + } + const projectId = composerCloudProject.id + void Promise.all([ + api.listCloudFiles(projectId), + api.listLoopItems(projectId), + composerTodoItem ? api.listDeliveries(composerTodoItem.id) : Promise.resolve({ items: [] }), + ]) + .then(([files, items, deliveries]) => { + if (!active) return + const candidate = ( + key: string, + title: string, + description: string, + reference: string, + aliases: string[], + statusLabel?: string + ): ComposerCloudMentionCandidate => ({ + kind: 'cloud', + key, + title, + description, + metaLabel: t('workbench.mention_cloud_space', '云空间'), + testId: key.replace(/[^a-zA-Z0-9_-]/g, '-'), + enabled: true, + reference, + searchAliases: aliases, + statusLabel, + }) + setCloudMentionState({ + todoId: composerTodoItem?.id ?? `project:${projectId}`, + candidates: [ + candidate( + `cloud-project:${projectId}`, + t('workbench.mention_cloud_whole_space', '整个空间'), + t('workbench.mention_cloud_whole_space_description', '共享文件 + 看板全部内容'), + `[$${t('workbench.mention_cloud_whole_space', '整个空间')}](cloud://projects/${projectId})`, + ['云项目', 'cloud', 'workspace'] + ), + ...items.items.map(item => + candidate( + `cloud-todo:${item.id}`, + item.id, + item.title, + `[$${t('workbench.mention_cloud_todo_chip', '任务')}:${item.id}](cloud://projects/${projectId}/todos/${item.id})`, + [item.title, item.status, 'TODO', '任务'], + cloudLoopItemStatusLabel(item.status, t) + ) + ), + ...files.items.map(file => + candidate( + `cloud-file:${file.id}`, + file.name, + file.path, + `[$${file.name}](cloud://projects/${projectId}/files/${file.id})`, + [file.path, file.kind, '文件', '目录'] + ) + ), + ...deliveries.items.map(delivery => + candidate( + `cloud-delivery:${delivery.id}`, + `交付 ${delivery.id.slice(0, 8)}`, + delivery.delivered_at ?? delivery.created_at, + `[$交付 ${delivery.id.slice(0, 8)}](cloud://projects/${projectId}/deliveries/${delivery.id})`, + ['交付', 'delivery', delivery.id] + ) + ), + ], + }) + }) + .catch(() => { + if (active) setCloudMentionState(null) + }) + return () => { + active = false + } + }, [composerCloudProject, composerTodoItem, services?.deliveryApi, t]) + + const visibleCloudMentionCandidates = + composerCloudProject && + cloudMentionState?.todoId === (composerTodoItem?.id ?? `project:${composerCloudProject.id}`) + ? cloudMentionState.candidates + : [] + + useEffect(() => { + let active = true + const apis = projectSpaceApis(services) + if (!apis.length) { + queueMicrotask(() => { + if (active) setCloudProjects([]) + }) + return () => { + active = false + } + } + void Promise.allSettled( + apis.map(async api => { + const result = await api.listCloudProjects() + return result.items + }) + ) + .then(results => { + if (!active) return + const candidates = results.flatMap(result => + result.status === 'fulfilled' ? result.value : [] + ) + const uniqueProjects = candidates.filter( + (candidate, index) => + candidates.findIndex( + other => other.id === candidate.id && other.project_store === candidate.project_store + ) === index + ) + setCloudProjects(uniqueProjects) + }) + .catch(() => { + if (active) setCloudProjects([]) + }) + return () => { + active = false + } + }, [services]) + + useEffect(() => { + const pendingAutoJoin = pendingAutoJoinResolutionRef.current + const pendingTargetMatchesCurrentTask = + pendingAutoJoin?.target && + currentRuntimeTask && + pendingAutoJoin.target.deviceId === currentRuntimeTask.deviceId && + pendingAutoJoin.target.taskId === currentRuntimeTask.taskId + if ( + defaultProject && + !pendingCloudProject && + dismissedDefaultCloudProjectKey !== defaultCloudProjectSelectionKey && + (!currentRuntimeTask || pendingTargetMatchesCurrentTask) + ) { + pendingTodoBinding = { + project: defaultProject, + item: null, + target: pendingAutoJoin?.target ?? null, + description: pendingAutoJoin?.description ?? '', + } + pendingAutoJoinResolutionRef.current = null + setPendingCloudProject(defaultProject) + setPendingTodoItem(null) + } else if (!defaultProject && pendingAutoJoin) { + pendingAutoJoinResolutionRef.current = null + } + }, [ + currentRuntimeTask, + defaultCloudProjectSelectionKey, + defaultProject, + dismissedDefaultCloudProjectKey, + pendingCloudProject, + ]) + + const cloudProjectMentionCandidates = useMemo( + () => + cloudProjects.map(project => { + const spaceLabel = t('workbench.mention_cloud_project_space', '项目空间') + return { + kind: 'cloud', + key: `cloud-project-space:${project.id}`, + title: project.name, + description: project.description || project.project_key || undefined, + statusLabel: + projectSpaceKey(projectSpaceRef(project)) === defaultProjectOptionKey + ? t('workbench.project_space_auto_join', '自动加入') + : undefined, + metaLabel: t('workbench.mention_cloud_space', '云空间'), + testId: `cloud-project-space-${String(project.id).replace(/[^a-zA-Z0-9_-]/g, '-')}`, + enabled: true, + reference: `[$${spaceLabel}:${project.name}](cloud://projects/${project.id})`, + searchAliases: [ + project.name, + project.project_key, + project.description, + spaceLabel, + 'project space', + 'project-space', + 'cloud', + ].filter(alias => Boolean(alias)), + project, + } + }), + [cloudProjects, defaultProjectOptionKey, t] + ) + + const bindComposerCloudProject = useCallback( + (project: CloudProject, notice: string) => { + setCloudActionNotice(notice) + if (!currentRuntimeTask) { + setPendingCloudContext(project, null) + return + } + const api = projectSpaceApiFor(project) + if (!api) return + void api + .bindProjectTask(project.id, currentRuntimeTask, runtimeTaskTitle) + .then(() => { + setBoundCloudProject(project) + setBoundCloudItem(null) + setDeliveryItem(null) + }) + .catch(cause => { + setTodoBindingError( + cause instanceof Error + ? cause.message + : t('workbench.cloud_project_bind_failed', '关联项目空间失败') + ) + }) + }, + [currentRuntimeTask, projectSpaceApiFor, runtimeTaskTitle, setPendingCloudContext, t] + ) + + const handleSelectCloudProject = useCallback( + (project: CloudProject) => { + setDismissedDefaultCloudProjectKey(null) + bindComposerCloudProject( + project, + t('workbench.cloud_project_bound_notice', { name: project.name }) + ) + }, + [bindComposerCloudProject, t] + ) + + const activeDeliveryItem = + currentRuntimeTask && + deliveryItem?.runtimeRefs.some( + reference => + reference.taskId === currentRuntimeTask.taskId && + reference.deviceId === currentRuntimeTask.deviceId + ) + ? deliveryItem + : null + + const openBoundProjectSpaceTask = useCallback(() => { + if (!boundCloudProject || !boundCloudItem) return + const params = new URLSearchParams() + params.set('projectId', String(boundCloudProject.id)) + params.set('itemId', boundCloudItem.id) + const contentRoute = `/todo?${params.toString()}` + const boardTab = workspaceTabs?.tabs.find(tab => tab.kind === 'board') + if (boardTab && workspaceTabs) { + workspaceTabs.selectTab(boardTab.id, { + title: boundCloudProject.name, + contentRoute, + }) + return + } + if (workspaceTabs) { + workspaceTabs.openTab('board', { + title: boundCloudProject.name, + contentRoute, + }) + return + } + navigateTo(contentRoute) + }, [boundCloudItem, boundCloudProject, workspaceTabs]) + + const finishLocalDelivery = useCallback(async () => { + if (!activeDeliveryItem) return + const items = await loadLocalWorkItems(userId) + const now = new Date().toISOString() + await saveLocalWorkItems( + userId, + items.map(item => + item.id === activeDeliveryItem.id + ? { + ...item, + state: 'completed', + updatedAt: now, + events: [ + ...item.events, + { + id: `delivery-${now}`, + type: 'confirmed' as const, + summary: t('delivery.completed_activity', '任务已交付并完成'), + createdAt: now, + }, + ], + } + : item + ) + ) + setDeliveryDialogOpen(false) + navigateTo('/todo') + }, [activeDeliveryItem, userId, t]) + + const prepareSubmission = useCallback( + (description: string): CloudSubmissionContext => { + const submissionProject = currentRuntimeTask ? null : pendingCloudProject + const submissionItem = + submissionProject?.id === pendingCloudProject?.id ? pendingTodoItem : null + if (!currentRuntimeTask) { + setPendingCloudContext(submissionProject, submissionItem) + pendingAutoJoinResolutionRef.current = + !submissionProject && + Boolean(defaultProjectSpace) && + dismissedDefaultCloudProjectKey !== defaultCloudProjectSelectionKey && + projectSpaceApis(services).length > 0 + ? { target: null, description } + : null + } + if (pendingTodoBinding) { + pendingTodoBinding = { ...pendingTodoBinding, description } + } + return { + additionalContext: + cloudProjectAdditionalContext(submissionProject, submissionItem) ?? + cloudAdditionalContext, + cloudProjectId: runtimeCloudProjectId(submissionProject), + onRuntimeTaskCreated: address => { + if (pendingTodoBinding) { + pendingTodoBinding = { ...pendingTodoBinding, target: address } + } + if (pendingAutoJoinResolutionRef.current) { + pendingAutoJoinResolutionRef.current = { + ...pendingAutoJoinResolutionRef.current, + target: address, + } + } + }, + } + }, + [ + cloudAdditionalContext, + currentRuntimeTask, + defaultCloudProjectSelectionKey, + defaultProjectSpace, + dismissedDefaultCloudProjectKey, + pendingCloudProject, + pendingTodoItem, + services, + setPendingCloudContext, + ] + ) + + const clearPendingProjectContext = useCallback(() => { + setDismissedDefaultCloudProjectKey(defaultCloudProjectSelectionKey) + setPendingCloudContext(null, null) + }, [defaultCloudProjectSelectionKey, setPendingCloudContext]) + + const openDelivery = useCallback(() => { + if (activeDeliveryItem) { + setDeliveryDialogOpen(true) + return + } + setDeliverAfterBinding(true) + setTodoBindingPickerOpen(true) + }, [activeDeliveryItem]) + + const openTodoManager = useCallback(() => { + if (boundCloudItem) { + openBoundProjectSpaceTask() + return + } + setDeliverAfterBinding(false) + setTodoBindingPickerOpen(true) + }, [boundCloudItem, openBoundProjectSpaceTask]) + + const closeTodoBindingPicker = useCallback(() => { + setTodoBindingPickerOpen(false) + setDeliverAfterBinding(false) + }, []) + + const handleTodoBound = useCallback( + (project: CloudProject | null, item: CloudLoopItem | null) => { + if (!currentRuntimeTask) { + setPendingCloudContext(project, item) + setTodoBindingPickerOpen(false) + return + } + setBoundCloudProject(project) + setBoundCloudItem(item) + setDeliveryItem(item ? cloudItemAsLocalWorkItem(item, currentRuntimeTask) : null) + setTodoBindingPickerOpen(false) + if (item && deliverAfterBinding) setDeliveryDialogOpen(true) + setDeliverAfterBinding(false) + }, + [currentRuntimeTask, deliverAfterBinding, setPendingCloudContext] + ) + + return { + activeDeliveryItem, + boundCloudItem, + boundCloudProject, + clearCloudActionNotice: () => setCloudActionNotice(null), + clearPendingProjectContext, + clearTodoBindingError: () => setTodoBindingError(null), + closeDeliveryDialog: () => setDeliveryDialogOpen(false), + closeTodoBindingPicker, + cloudActionNotice, + cloudProjectMentionCandidates, + deliveryDialogOpen, + finishLocalDelivery, + handleSelectCloudProject, + handleTodoBound, + openDelivery, + openTodoManager, + pendingCloudProject, + pendingTodoItem, + prepareSubmission, + todoBindingApis, + todoBindingError, + todoBindingPickerOpen, + visibleCloudMentionCandidates, + } +} From 90d68fe6b10a2fa4784a4eaaedaf1b905cd4693f Mon Sep 17 00:00:00 2001 From: axb Date: Sun, 9 Aug 2026 11:19:38 +0800 Subject: [PATCH 2/3] fix(wework): harden cloud project context --- .../layout/DesktopWorkbenchMain.tsx | 21 ++-- .../useWorkbenchCloudProjectContext.test.tsx | 50 +++++++++ .../layout/useWorkbenchCloudProjectContext.ts | 100 ++++++++++-------- 3 files changed, 116 insertions(+), 55 deletions(-) diff --git a/wework/src/components/layout/DesktopWorkbenchMain.tsx b/wework/src/components/layout/DesktopWorkbenchMain.tsx index 4931fd9c3a..1a45f8bda5 100644 --- a/wework/src/components/layout/DesktopWorkbenchMain.tsx +++ b/wework/src/components/layout/DesktopWorkbenchMain.tsx @@ -512,15 +512,6 @@ const DesktopWorkbenchPane = memo(function DesktopWorkbenchPane({ const runtimeWork = state.runtimeWork const runtimeTaskSummary = findRuntimeTask(runtimeWork, currentRuntimeTask) const runtimeTaskTitle = truncateRuntimeTaskTitle(runtimeTaskSummary?.title) - const cloudProjectContext = useWorkbenchCloudProjectContext({ - currentRuntimeTask, - currentProjectId: currentProject?.id, - defaultProjectSpace, - paneKey, - runtimeTaskTitle, - services, - userId: state.user?.id, - }) const { activeDeliveryItem, boundCloudItem, @@ -532,6 +523,7 @@ const DesktopWorkbenchPane = memo(function DesktopWorkbenchPane({ closeTodoBindingPicker, cloudActionNotice, cloudProjectMentionCandidates, + composerCloudProject, deliveryDialogOpen, finishLocalDelivery, handleSelectCloudProject, @@ -545,8 +537,15 @@ const DesktopWorkbenchPane = memo(function DesktopWorkbenchPane({ todoBindingError, todoBindingPickerOpen, visibleCloudMentionCandidates, - } = cloudProjectContext - const composerCloudProject = currentRuntimeTask ? boundCloudProject : pendingCloudProject + } = useWorkbenchCloudProjectContext({ + currentRuntimeTask, + currentProjectId: currentProject?.id, + defaultProjectSpace, + paneKey, + runtimeTaskTitle, + services, + userId: state.user?.id, + }) const pendingProjectSpaceContext = !currentRuntimeTask && pendingCloudProject ? ( { await waitFor(() => expect(result.current.pendingCloudProject).toEqual(defaultProject)) expect(result.current.pendingTodoItem).toBeNull() expect(deliveryApi.listCloudProjects).toHaveBeenCalledOnce() + await waitFor(() => expect(deliveryApi.listCloudFiles).toHaveBeenCalledOnce()) + expect(deliveryApi.listLoopItems).toHaveBeenCalledOnce() act(() => result.current.clearPendingProjectContext()) }) + test('loads mentions from the selected local project-space API', async () => { + const localProject = project('space-local', 'local') + const item = loopItem(localProject.id) + const localApi = { + listCloudProjects: vi.fn().mockResolvedValue({ items: [localProject] }), + listCloudFiles: vi.fn().mockResolvedValue({ items: [] }), + listLoopItems: vi.fn().mockResolvedValue({ items: [item] }), + listDeliveries: vi.fn().mockResolvedValue({ items: [] }), + } + const cloudApi = { + listCloudProjects: vi.fn().mockResolvedValue({ items: [] }), + listCloudFiles: vi.fn().mockResolvedValue({ items: [] }), + listLoopItems: vi.fn().mockResolvedValue({ items: [] }), + listDeliveries: vi.fn().mockResolvedValue({ items: [] }), + } + const services = { + projectSpaceApis: { + local: localApi, + cloud: cloudApi, + }, + } as unknown as WorkbenchServices + const { result } = renderCloudContext(services) + + act(() => result.current.handleTodoBound(localProject, item)) + + await waitFor(() => expect(result.current.visibleCloudMentionCandidates).not.toHaveLength(0)) + expect(localApi.listCloudFiles).toHaveBeenCalledWith(localProject.id) + expect(localApi.listLoopItems).toHaveBeenCalledWith(localProject.id) + expect(localApi.listDeliveries).toHaveBeenCalledWith(item.id) + expect(cloudApi.listCloudFiles).not.toHaveBeenCalled() + expect(cloudApi.listLoopItems).not.toHaveBeenCalled() + + act(() => result.current.clearPendingProjectContext()) + }) + + test('keeps transient notice clear callbacks stable across renders', () => { + const { result, rerender } = renderCloudContext() + const clearCloudActionNotice = result.current.clearCloudActionNotice + const clearTodoBindingError = result.current.clearTodoBindingError + const closeDeliveryDialog = result.current.closeDeliveryDialog + + rerender() + + expect(result.current.clearCloudActionNotice).toBe(clearCloudActionNotice) + expect(result.current.clearTodoBindingError).toBe(clearTodoBindingError) + expect(result.current.closeDeliveryDialog).toBe(closeDeliveryDialog) + }) + test('maps a cloud task to the local delivery model', () => { const item = loopItem('space-cloud') diff --git a/wework/src/components/layout/useWorkbenchCloudProjectContext.ts b/wework/src/components/layout/useWorkbenchCloudProjectContext.ts index fa8afda856..0e6b70cf53 100644 --- a/wework/src/components/layout/useWorkbenchCloudProjectContext.ts +++ b/wework/src/components/layout/useWorkbenchCloudProjectContext.ts @@ -243,7 +243,7 @@ export function useWorkbenchCloudProjectContext({ active = false } } - const contextApis = projectSpaceApis(services) + const contextApis = todoBindingApis if (contextApis.length > 0) { void findProjectSpaceContextForTask(contextApis, currentRuntimeTask) .then(context => { @@ -267,23 +267,30 @@ export function useWorkbenchCloudProjectContext({ active = false } } - void hydrateLocalWorkItems(userId).then(items => { - if (!active) return - setBoundCloudItem(null) - setDeliveryItem( - items.find(item => - item.runtimeRefs.some( - reference => - reference.taskId === currentRuntimeTask.taskId && - reference.deviceId === currentRuntimeTask.deviceId - ) - ) ?? null - ) - }) + void hydrateLocalWorkItems(userId) + .then(items => { + if (!active) return + setBoundCloudItem(null) + setDeliveryItem( + items.find(item => + item.runtimeRefs.some( + reference => + reference.taskId === currentRuntimeTask.taskId && + reference.deviceId === currentRuntimeTask.deviceId + ) + ) ?? null + ) + }) + .catch(() => { + if (active) { + setBoundCloudItem(null) + setDeliveryItem(null) + } + }) return () => { active = false } - }, [currentRuntimeTask, services, userId]) + }, [currentRuntimeTask, todoBindingApis, userId]) useEffect(() => { const projectToBind = pendingCloudProject ?? pendingTodoBinding?.project ?? null @@ -346,8 +353,13 @@ export function useWorkbenchCloudProjectContext({ useEffect(() => { let active = true - const api = services?.deliveryApi - if (!api || !composerCloudProject) { + if (!composerCloudProject) { + return () => { + active = false + } + } + const api = projectSpaceApiFor(composerCloudProject) + if (!api) { return () => { active = false } @@ -426,7 +438,7 @@ export function useWorkbenchCloudProjectContext({ return () => { active = false } - }, [composerCloudProject, composerTodoItem, services?.deliveryApi, t]) + }, [composerCloudProject, composerTodoItem, projectSpaceApiFor, t]) const visibleCloudMentionCandidates = composerCloudProject && @@ -436,7 +448,7 @@ export function useWorkbenchCloudProjectContext({ useEffect(() => { let active = true - const apis = projectSpaceApis(services) + const apis = todoBindingApis if (!apis.length) { queueMicrotask(() => { if (active) setCloudProjects([]) @@ -450,27 +462,23 @@ export function useWorkbenchCloudProjectContext({ const result = await api.listCloudProjects() return result.items }) - ) - .then(results => { - if (!active) return - const candidates = results.flatMap(result => - result.status === 'fulfilled' ? result.value : [] - ) - const uniqueProjects = candidates.filter( - (candidate, index) => - candidates.findIndex( - other => other.id === candidate.id && other.project_store === candidate.project_store - ) === index - ) - setCloudProjects(uniqueProjects) - }) - .catch(() => { - if (active) setCloudProjects([]) - }) + ).then(results => { + if (!active) return + const candidates = results.flatMap(result => + result.status === 'fulfilled' ? result.value : [] + ) + const uniqueProjects = candidates.filter( + (candidate, index) => + candidates.findIndex( + other => other.id === candidate.id && other.project_store === candidate.project_store + ) === index + ) + setCloudProjects(uniqueProjects) + }) return () => { active = false } - }, [services]) + }, [todoBindingApis]) useEffect(() => { const pendingAutoJoin = pendingAutoJoinResolutionRef.current @@ -641,15 +649,14 @@ export function useWorkbenchCloudProjectContext({ const prepareSubmission = useCallback( (description: string): CloudSubmissionContext => { const submissionProject = currentRuntimeTask ? null : pendingCloudProject - const submissionItem = - submissionProject?.id === pendingCloudProject?.id ? pendingTodoItem : null + const submissionItem = submissionProject ? pendingTodoItem : null if (!currentRuntimeTask) { setPendingCloudContext(submissionProject, submissionItem) pendingAutoJoinResolutionRef.current = !submissionProject && Boolean(defaultProjectSpace) && dismissedDefaultCloudProjectKey !== defaultCloudProjectSelectionKey && - projectSpaceApis(services).length > 0 + todoBindingApis.length > 0 ? { target: null, description } : null } @@ -682,8 +689,8 @@ export function useWorkbenchCloudProjectContext({ dismissedDefaultCloudProjectKey, pendingCloudProject, pendingTodoItem, - services, setPendingCloudContext, + todoBindingApis, ] ) @@ -715,6 +722,10 @@ export function useWorkbenchCloudProjectContext({ setDeliverAfterBinding(false) }, []) + const clearCloudActionNotice = useCallback(() => setCloudActionNotice(null), []) + const clearTodoBindingError = useCallback(() => setTodoBindingError(null), []) + const closeDeliveryDialog = useCallback(() => setDeliveryDialogOpen(false), []) + const handleTodoBound = useCallback( (project: CloudProject | null, item: CloudLoopItem | null) => { if (!currentRuntimeTask) { @@ -736,13 +747,14 @@ export function useWorkbenchCloudProjectContext({ activeDeliveryItem, boundCloudItem, boundCloudProject, - clearCloudActionNotice: () => setCloudActionNotice(null), + clearCloudActionNotice, clearPendingProjectContext, - clearTodoBindingError: () => setTodoBindingError(null), - closeDeliveryDialog: () => setDeliveryDialogOpen(false), + clearTodoBindingError, + closeDeliveryDialog, closeTodoBindingPicker, cloudActionNotice, cloudProjectMentionCandidates, + composerCloudProject, deliveryDialogOpen, finishLocalDelivery, handleSelectCloudProject, From a1991e0869fe3bcddc2666952bd5fcf3de575134 Mon Sep 17 00:00:00 2001 From: axb Date: Sun, 9 Aug 2026 11:23:10 +0800 Subject: [PATCH 3/3] test(wework): cover cloud project runtime binding --- .../useWorkbenchCloudProjectContext.test.tsx | 53 ++++++++++++++++--- 1 file changed, 46 insertions(+), 7 deletions(-) diff --git a/wework/src/components/layout/useWorkbenchCloudProjectContext.test.tsx b/wework/src/components/layout/useWorkbenchCloudProjectContext.test.tsx index c322d7f9b4..5a409cba3f 100644 --- a/wework/src/components/layout/useWorkbenchCloudProjectContext.test.tsx +++ b/wework/src/components/layout/useWorkbenchCloudProjectContext.test.tsx @@ -1,8 +1,9 @@ import { act, renderHook, waitFor } from '@testing-library/react' -import { describe, expect, test, vi } from 'vitest' +import { afterEach, describe, expect, test, vi } from 'vitest' import '@/i18n' import type { CloudLoopItem, CloudProject } from '@/api/deliveries' import type { WorkbenchServices } from '@/features/workbench/workbenchServices' +import type { RuntimeTaskAddress } from '@/types/api' import { cloudItemAsLocalWorkItem, useWorkbenchCloudProjectContext, @@ -65,10 +66,43 @@ function renderCloudContext(services?: WorkbenchServices) { } describe('useWorkbenchCloudProjectContext', () => { + afterEach(() => { + const { result, unmount } = renderCloudContext() + act(() => result.current.clearPendingProjectContext()) + unmount() + }) + test('prepares a pending backend project and task for runtime creation', async () => { const cloudProject = project('space-cloud') const item = loopItem(cloudProject.id) - const { result } = renderCloudContext() + const runtimeTask = { + deviceId: 'device-1', + taskId: 'runtime-1', + } + const deliveryApi = { + bindTask: vi.fn().mockResolvedValue(undefined), + findCloudContextForTask: vi.fn().mockRejectedValue(new Error('Not bound yet')), + listCloudProjects: vi.fn().mockResolvedValue({ items: [cloudProject] }), + listCloudFiles: vi.fn().mockResolvedValue({ items: [] }), + listLoopItems: vi.fn().mockResolvedValue({ items: [item] }), + listDeliveries: vi.fn().mockResolvedValue({ items: [] }), + } + const services = { + deliveryApi, + } as unknown as WorkbenchServices + const { result, rerender } = renderHook( + ({ currentRuntimeTask }: { currentRuntimeTask: RuntimeTaskAddress | null }) => + useWorkbenchCloudProjectContext({ + currentRuntimeTask, + currentProjectId: 42, + defaultProjectSpace: null, + paneKey: 'project:42', + runtimeTaskTitle: null, + services, + userId: 1, + }), + { initialProps: { currentRuntimeTask: null } } + ) await act(async () => {}) act(() => result.current.handleTodoBound(cloudProject, item)) @@ -85,7 +119,16 @@ describe('useWorkbenchCloudProjectContext', () => { expect(result.current.pendingCloudProject).toEqual(cloudProject) expect(result.current.pendingTodoItem).toEqual(item) - act(() => result.current.clearPendingProjectContext()) + act(() => submission?.onRuntimeTaskCreated(runtimeTask)) + rerender({ currentRuntimeTask: runtimeTask }) + + await waitFor(() => + expect(deliveryApi.bindTask).toHaveBeenCalledWith( + item.id, + runtimeTask, + 'Implement the selected task' + ) + ) }) test('automatically selects the configured default project space', async () => { @@ -120,8 +163,6 @@ describe('useWorkbenchCloudProjectContext', () => { expect(deliveryApi.listCloudProjects).toHaveBeenCalledOnce() await waitFor(() => expect(deliveryApi.listCloudFiles).toHaveBeenCalledOnce()) expect(deliveryApi.listLoopItems).toHaveBeenCalledOnce() - - act(() => result.current.clearPendingProjectContext()) }) test('loads mentions from the selected local project-space API', async () => { @@ -155,8 +196,6 @@ describe('useWorkbenchCloudProjectContext', () => { expect(localApi.listDeliveries).toHaveBeenCalledWith(item.id) expect(cloudApi.listCloudFiles).not.toHaveBeenCalled() expect(cloudApi.listLoopItems).not.toHaveBeenCalled() - - act(() => result.current.clearPendingProjectContext()) }) test('keeps transient notice clear callbacks stable across renders', () => {