diff --git a/packages/server/api/src/app/ee/chat/chat-service.ts b/packages/server/api/src/app/ee/chat/chat-service.ts index 0777836ea06..50c02e9f61c 100644 --- a/packages/server/api/src/app/ee/chat/chat-service.ts +++ b/packages/server/api/src/app/ee/chat/chat-service.ts @@ -12,6 +12,7 @@ import { GetProviderConfigResponse, isNil, Project, + ProjectType, SeekPage, spreadIfDefined, UpdateChatConversationRequest, @@ -267,11 +268,14 @@ async function getUserProjects({ platformId, userId, log }: { log: FastifyBaseLogger }): Promise { const user = await userService(log).getOneOrFail({ id: userId }) - return projectService(log).getAllForUser({ + const allProjects = await projectService(log).getAllForUser({ platformId, userId, isPrivileged: userService(log).isUserPrivileged(user), }) + return allProjects.filter( + (p) => p.type !== ProjectType.PERSONAL || p.ownerId === userId, + ) } async function assertUserHasProjectAccess({ platformId, userId, projectId, log }: { diff --git a/packages/web/src/app/connections/create-edit-connection-dialog.tsx b/packages/web/src/app/connections/create-edit-connection-dialog.tsx index 1bf22852bd9..10f0b604626 100644 --- a/packages/web/src/app/connections/create-edit-connection-dialog.tsx +++ b/packages/web/src/app/connections/create-edit-connection-dialog.tsx @@ -70,6 +70,7 @@ function CreateOrEditConnectionSection({ selectedAuth, onTryAnotherMethodButtonClicked, showTryAnotherMethodButton, + projectId: projectIdOverride, }: CreateOrEditConnectionSectionProps) { const formSchema = formUtils.buildConnectionSchema( selectedAuth.authProperty, @@ -98,6 +99,7 @@ function CreateOrEditConnectionSection({ oauth2App: selectedAuth.oauth2App, grantType: selectedAuth.grantType, redirectUrl: redirectUrl ?? '', + projectId: projectIdOverride ?? undefined, }), ...(isGlobalConnection ? { scope: AppConnectionScope.PLATFORM } : {}), projectIds: reconnectConnection?.projectIds ?? [], @@ -357,6 +359,7 @@ function CreateOrEditConnectionDialog({ reconnectConnection, isGlobalConnection, externalIdComingFromSdk, + projectId: projectIdOverride, }: ConnectionDialogProps) { const { data: piecesOAuth2AppsMap, isPending: loadingPiecesOAuth2AppsMap } = oauthAppsQueries.usePiecesOAuth2AppsMap(); @@ -391,6 +394,7 @@ function CreateOrEditConnectionDialog({ reconnectConnection={reconnectConnection} isGlobalConnection={isGlobalConnection} externalIdComingFromSdk={externalIdComingFromSdk} + projectId={projectIdOverride} /> )} @@ -481,6 +485,7 @@ type ConnectionDialogProps = { reconnectConnection: AppConnectionWithoutSensitiveData | null; isGlobalConnection: boolean; externalIdComingFromSdk?: string | null; + projectId?: string | null; }; type CreateOrEditConnectionDialogContentProps = { @@ -493,6 +498,7 @@ type CreateOrEditConnectionDialogContentProps = { open: boolean, connection?: AppConnectionWithoutSensitiveData, ) => void; + projectId?: string | null; }; type CreateOrEditConnectionSectionProps = diff --git a/packages/web/src/app/connections/oauth2-connection-settings.tsx b/packages/web/src/app/connections/oauth2-connection-settings.tsx index 2be02eed7e5..41c76607e96 100644 --- a/packages/web/src/app/connections/oauth2-connection-settings.tsx +++ b/packages/web/src/app/connections/oauth2-connection-settings.tsx @@ -201,12 +201,14 @@ async function openPopup({ let authorizationUrl, codeVerifier; try { setLoading(true); + const formProjectId = form.getValues().request.projectId; const result = await appConnectionsApi.getOAuth2AuthorizationUrl({ pieceName, clientId, redirectUrl, pieceVersion, props, + projectId: formProjectId, }); authorizationUrl = result.authorizationUrl; codeVerifier = result.codeVerifier; diff --git a/packages/web/src/app/routes/chat-with-ai/ai-chat-box.tsx b/packages/web/src/app/routes/chat-with-ai/ai-chat-box.tsx index 6f1c1f07fa7..676265e09d4 100644 --- a/packages/web/src/app/routes/chat-with-ai/ai-chat-box.tsx +++ b/packages/web/src/app/routes/chat-with-ai/ai-chat-box.tsx @@ -5,7 +5,7 @@ import { ProjectType, } from '@activepieces/shared'; import { t } from 'i18next'; -import { AlertTriangle, RefreshCw, Square } from 'lucide-react'; +import { AlertTriangle, RefreshCw, Square, X } from 'lucide-react'; import { motion } from 'motion/react'; import { useCallback, useEffect, useMemo, useState } from 'react'; @@ -16,6 +16,7 @@ import { } from '@/components/prompt-kit/chat-container'; import { ScrollButton } from '@/components/prompt-kit/scroll-button'; import { Button } from '@/components/ui/button'; +import { ChatUIMessage, DynamicToolPart } from '@/features/chat/lib/chat-types'; import { useAgentChat } from '@/features/chat/lib/use-chat'; import { useToolApproval } from '@/features/chat/lib/use-tool-approval'; import { aiProviderQueries } from '@/features/platform-admin'; @@ -157,6 +158,14 @@ function ChatBoxContent({ dismiss: dismissApproval, } = useToolApproval({ pendingApprovalRequest }); + const allConversationToolParts = useMemo( + () => + messages.flatMap((m: ChatUIMessage) => + m.parts.filter((p): p is DynamicToolPart => p.type === 'dynamic-tool'), + ), + [messages], + ); + const isEmpty = messages.length === 0 && !isLoadingHistory && !isStreaming; if (isEmpty) { @@ -217,6 +226,7 @@ function ChatBoxContent({ onRetry={handleRetry} selectedProjectId={selectedProjectId} onSelectProject={handleProjectChange} + allConversationToolParts={allConversationToolParts} /> ); })} @@ -265,13 +275,20 @@ function ChatBoxContent({
{activeProject && (
{activeProject.name} +
)} {hasActiveApproval ? ( diff --git a/packages/web/src/app/routes/chat-with-ai/components/build-progress-card.tsx b/packages/web/src/app/routes/chat-with-ai/components/build-progress-card.tsx index 27e1cdaecb6..b82af10e505 100644 --- a/packages/web/src/app/routes/chat-with-ai/components/build-progress-card.tsx +++ b/packages/web/src/app/routes/chat-with-ai/components/build-progress-card.tsx @@ -305,10 +305,17 @@ export function BuildProgressCard({ {progress.title} - {isBuilt ? ( + {isBuilt && + (notesStatus === 'adding' || + (notesStatus === 'none' && isStreaming)) ? ( + + + {t('Finishing up...')} + + ) : isBuilt ? ( - {t('Built')} + {t('Done')} ) : hasError ? ( @@ -407,27 +414,6 @@ export function BuildProgressCard({ })}
- {notesStatus !== 'none' && ( - - {notesStatus === 'adding' ? ( - <> - - {t('Adding notes...')} - - ) : ( - <> - - {t('Notes added')} - - )} - - )} - {isBuilt && ( void; selectedProjectId?: string | null; onSelectProject?: (projectId: string) => void; + allConversationToolParts?: DynamicToolPart[]; }) { if (message.role === 'user') { return ; @@ -54,6 +56,7 @@ export function ChatMessage({ onSend={onSend} selectedProjectId={selectedProjectId} onSelectProject={onSelectProject} + allConversationToolParts={allConversationToolParts} /> ); } @@ -133,6 +136,7 @@ function AssistantMessage({ onSend, selectedProjectId, onSelectProject, + allConversationToolParts, }: { message: ChatUIMessage; isStreaming: boolean; @@ -141,6 +145,7 @@ function AssistantMessage({ onSend: (text: string, files?: File[]) => void; selectedProjectId?: string | null; onSelectProject?: (projectId: string) => void; + allConversationToolParts?: DynamicToolPart[]; }) { const allToolParts = useMemo( () => @@ -231,6 +236,7 @@ function AssistantMessage({ selectedProjectId, onSelectProject, allParts: message.parts, + allConversationToolParts, })} )} @@ -279,6 +285,7 @@ function renderTextParts({ onSelectProject, isLastMessage, allParts, + allConversationToolParts, }: { parts: Array<{ type: 'text'; text: string }>; isStreaming: boolean; @@ -287,6 +294,7 @@ function renderTextParts({ selectedProjectId?: string | null; onSelectProject?: (projectId: string) => void; allParts: ChatUIMessage['parts']; + allConversationToolParts?: DynamicToolPart[]; }): React.ReactNode[] { const fullText = parts.map((p) => p.text).join(''); const { progress: buildProgress } = parseBuildProgress(fullText); @@ -294,7 +302,9 @@ function renderTextParts({ const nodes: React.ReactNode[] = []; if (buildProgress) { - const toolParts = allParts.filter((p) => p.type === 'dynamic-tool'); + const toolParts = + allConversationToolParts ?? + allParts.filter((p) => p.type === 'dynamic-tool'); nodes.push( { + if (!selectedProjectId) return picker; + const filtered = picker.connections.filter( + (c) => c.projectId === selectedProjectId, + ); + return { ...picker, connections: filtered }; + }, [picker, selectedProjectId]); const { pieceModel, isLoading: isPieceLoading } = piecesHooks.usePiece({ name: pieceName, }); @@ -163,7 +171,7 @@ export function ConnectionPickerCard({ fullConnections, isLoading: isLoadingStatuses, } = useLiveConnections({ - connections: picker.connections, + connections: filteredPicker.connections, pieceName, enabled: isInteractive && !selectedConnection, }); @@ -201,7 +209,9 @@ export function ConnectionPickerCard({
-
{picker.displayName}
+
+ {filteredPicker.displayName} +
{t('Connected')}
@@ -216,7 +226,7 @@ export function ConnectionPickerCard({ ); } @@ -237,13 +247,13 @@ export function ConnectionPickerCard({

{t('Which {name} account should I use?', { - name: picker.displayName, + name: filteredPicker.displayName, })}

- {picker.connections.map((conn) => { + {filteredPicker.connections.map((conn) => { const status = liveStatuses[conn.externalId] ?? conn.status; const healthy = isConnectionHealthy(status); return ( @@ -316,7 +326,7 @@ export function ConnectionPickerCard({
{t('Connect a new {name} account', { - name: picker.displayName, + name: filteredPicker.displayName, })}
@@ -336,6 +346,7 @@ export function ConnectionPickerCard({ { setConnectDialogOpen(open); if (createdConnection) { @@ -364,4 +375,5 @@ type ConnectionPickerCardProps = { picker: ConnectionPickerData; onSelect: (text: string) => void; isInteractive?: boolean; + selectedProjectId?: string | null; }; diff --git a/packages/web/src/app/routes/chat-with-ai/components/message-content.tsx b/packages/web/src/app/routes/chat-with-ai/components/message-content.tsx index c8913b5d8a1..8d6795bb77e 100644 --- a/packages/web/src/app/routes/chat-with-ai/components/message-content.tsx +++ b/packages/web/src/app/routes/chat-with-ai/components/message-content.tsx @@ -128,6 +128,7 @@ export function MessageContentWithAuth({ picker={connectionPicker} onSelect={(text) => onSend?.(text)} isInteractive={isLastMessage} + selectedProjectId={selectedProjectId} /> )} {projectPicker && ( @@ -397,6 +398,7 @@ function ConnectionsRequiredCard({ key={activeConnection.piece} piece={pieceModel} open={true} + projectId={selectedProjectId} setOpen={(open, createdConnection) => { if (!open) { if (createdConnection) { diff --git a/packages/web/src/app/routes/chat-with-ai/components/project-picker-card.tsx b/packages/web/src/app/routes/chat-with-ai/components/project-picker-card.tsx index b24420a9b7b..8d609b5917d 100644 --- a/packages/web/src/app/routes/chat-with-ai/components/project-picker-card.tsx +++ b/packages/web/src/app/routes/chat-with-ai/components/project-picker-card.tsx @@ -1,7 +1,8 @@ +import { ProjectType } from '@activepieces/shared'; import { t } from 'i18next'; import { Check, Ellipsis } from 'lucide-react'; import { motion } from 'motion/react'; -import { useState } from 'react'; +import { useMemo, useState } from 'react'; import { Command, @@ -20,6 +21,7 @@ import { getProjectName, projectCollectionUtils, } from '@/features/projects'; +import { userHooks } from '@/hooks/user-hooks'; import { cn } from '@/lib/utils'; import { ProjectPickerData } from '../lib/message-parsers'; @@ -31,7 +33,14 @@ export function ProjectPickerCard({ selectedProjectId, }: ProjectPickerCardProps) { const { data: allProjects } = projectCollectionUtils.useAll(); - const projects = allProjects ?? []; + const { data: currentUser } = userHooks.useCurrentUser(); + const projects = useMemo(() => { + const all = allProjects ?? []; + if (!currentUser) return all; + return all.filter( + (p) => p.type !== ProjectType.PERSONAL || p.ownerId === currentUser.id, + ); + }, [allProjects, currentUser]); const [selected, setSelected] = useState(null); const [dropdownOpen, setDropdownOpen] = useState(false); @@ -82,39 +91,41 @@ export function ProjectPickerCard({ animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.25 }} > - {picker.suggestedProjects.map((suggested, i) => { - const resolvedProject = projects.find((p) => p.id === suggested.id); - return ( - - handleSelect( - suggested.id, - resolvedProject - ? getProjectName(resolvedProject) - : suggested.name, - ) - } - className="inline-flex items-center gap-2 rounded-full border bg-background px-3 py-1.5 text-sm hover:bg-muted transition-colors cursor-pointer" - initial={{ opacity: 0, y: 6 }} - animate={{ opacity: 1, y: 0 }} - transition={{ duration: 0.2, delay: i * 0.04 }} - > - {resolvedProject ? ( - - ) : ( - {suggested.name} - )} - - ); - })} + {picker.suggestedProjects + .filter((s) => projects.some((p) => p.id === s.id)) + .map((suggested, i) => { + const resolvedProject = projects.find((p) => p.id === suggested.id); + return ( + + handleSelect( + suggested.id, + resolvedProject + ? getProjectName(resolvedProject) + : suggested.name, + ) + } + className="inline-flex items-center gap-2 rounded-full border bg-background px-3 py-1.5 text-sm hover:bg-muted transition-colors cursor-pointer" + initial={{ opacity: 0, y: 6 }} + animate={{ opacity: 1, y: 0 }} + transition={{ duration: 0.2, delay: i * 0.04 }} + > + {resolvedProject ? ( + + ) : ( + {suggested.name} + )} + + ); + })} diff --git a/packages/web/src/features/chat/lib/use-chat.ts b/packages/web/src/features/chat/lib/use-chat.ts index b3c4a0e6b96..d6403a6810a 100644 --- a/packages/web/src/features/chat/lib/use-chat.ts +++ b/packages/web/src/features/chat/lib/use-chat.ts @@ -330,12 +330,18 @@ export function useAgentChat({ return [...withoutEmptyAssistant, createPendingAssistantMessage()]; }, [hasPending, uiMessages, pendingMessages]); - // Detect project context changes from AI tool calls + // Tracks whether the conversation was loaded from history (resumed). + // When true, the server sync should NOT re-enable the project label. + const loadedFromHistoryRef = useRef(false); + + // Scan the last assistant message for project selection and build-complete. + // Runs on every uiMessages change but only inspects the tail message + // (the only one that changes during streaming), keeping it O(parts). const buildCompleteRef = useRef(false); useEffect(() => { const lastMsg = uiMessages[uiMessages.length - 1]; if (!lastMsg || lastMsg.role !== 'assistant') return; - let newProjectId: string | null | undefined; + for (const part of lastMsg.parts) { if (part.type !== 'dynamic-tool') continue; if ( @@ -345,10 +351,17 @@ export function useAgentChat({ 'projectId' in part.input && typeof part.input.projectId === 'string' ) { - newProjectId = part.input.projectId; + const projectId = part.input.projectId; + if (projectId !== selectedProjectIdRef.current) { + updateSelectedProjectId(projectId); + } + setProjectSetInSession((prev) => prev || true); + loadedFromHistoryRef.current = false; } if (part.toolName === 'ap_deselect_project') { - newProjectId = null; + if (selectedProjectIdRef.current !== null) { + updateSelectedProjectId(null); + } } if ( part.toolName === 'ap_manage_notes' && @@ -357,13 +370,11 @@ export function useAgentChat({ buildCompleteRef.current = true; } } - if (newProjectId !== undefined) { - updateSelectedProjectId(newProjectId); - setProjectSetInSession(true); - } }, [uiMessages]); - // Sync project context from server after streaming completes + // Server sync: single source of truth for project context. + // After every streaming completion, fetch the conversation's projectId + // from the backend and derive UI state from it. const prevStatusRef = useRef(status); useEffect(() => { const wasStreaming = @@ -372,14 +383,19 @@ export function useAgentChat({ const isNowIdle = status === 'ready' || status === 'error'; prevStatusRef.current = status; if (wasStreaming && isNowIdle && conversationIdRef.current) { - if (buildCompleteRef.current) { + const wasBuildComplete = buildCompleteRef.current; + if (wasBuildComplete) { setProjectSetInSession(false); buildCompleteRef.current = false; } void chatApi .getConversation(conversationIdRef.current) .then((conv) => { - updateSelectedProjectId(conv.projectId ?? null); + const projectId = conv.projectId ?? null; + updateSelectedProjectId(projectId); + if (projectId && !wasBuildComplete && !loadedFromHistoryRef.current) { + setProjectSetInSession(true); + } }) .catch(() => undefined); } @@ -402,6 +418,7 @@ export function useAgentChat({ setModelNameState(null); updateSelectedProjectId(null); setProjectSetInSession(false); + loadedFromHistoryRef.current = false; setUiMessages([]); setLocalError(null); setWasCancelled(false); @@ -522,6 +539,7 @@ export function useAgentChat({ // Set project for backend tool scoping, but don't show it visually (projectSetInSession stays false) updateSelectedProjectId(convResult.data.projectId ?? null); setProjectSetInSession(false); + loadedFromHistoryRef.current = true; } setIsLoadingHistory(false); }, @@ -544,6 +562,7 @@ export function useAgentChat({ updateSelectedProjectId(projectId); if (projectId) { setProjectSetInSession(true); + loadedFromHistoryRef.current = false; } const convId = conversationIdRef.current; if (!convId) return; diff --git a/packages/web/src/features/connections/api/app-connections.ts b/packages/web/src/features/connections/api/app-connections.ts index 339e99fefcf..a9aff6a54c6 100644 --- a/packages/web/src/features/connections/api/app-connections.ts +++ b/packages/web/src/features/connections/api/app-connections.ts @@ -55,12 +55,15 @@ export const appConnectionsApi = { ); }, getOAuth2AuthorizationUrl( - request: Omit, + request: Omit & { + projectId?: string; + }, ): Promise { - const projectId = authenticationSession.getProjectId(); + const { projectId: projectIdOverride, ...rest } = request; + const projectId = projectIdOverride ?? authenticationSession.getProjectId(); return api.post( '/v1/app-connections/oauth2/authorization-url', - { ...request, projectId }, + { ...rest, projectId }, ); }, }; diff --git a/packages/web/src/features/connections/hooks/app-connections-hooks.ts b/packages/web/src/features/connections/hooks/app-connections-hooks.ts index e9286cba40d..f54d7fb0c77 100644 --- a/packages/web/src/features/connections/hooks/app-connections-hooks.ts +++ b/packages/web/src/features/connections/hooks/app-connections-hooks.ts @@ -75,10 +75,11 @@ export const appConnectionsMutations = { mutationFn: async () => { setErrorMessage(''); const formValues = form.getValues().request; - const isNameUnique = await isConnectionNameUnique( + const isNameUnique = await isConnectionNameUnique({ isGlobalConnection, - formValues.displayName, - ); + displayName: formValues.displayName, + projectId: formValues.projectId, + }); if ( !isNameUnique && reconnectConnection?.displayName !== formValues.displayName && @@ -209,10 +210,10 @@ export const appConnectionsMutations = { connectionId: string; displayName: string; }) => { - const existingConnection = await isConnectionNameUnique( - false, + const existingConnection = await isConnectionNameUnique({ + isGlobalConnection: false, displayName, - ); + }); if (!existingConnection && displayName !== currentName) { throw new ConnectionNameAlreadyExists(); } diff --git a/packages/web/src/features/connections/hooks/global-connections-hooks.ts b/packages/web/src/features/connections/hooks/global-connections-hooks.ts index 6450106d71f..8d24a711301 100644 --- a/packages/web/src/features/connections/hooks/global-connections-hooks.ts +++ b/packages/web/src/features/connections/hooks/global-connections-hooks.ts @@ -95,7 +95,10 @@ export const globalConnectionsMutations = { currentName, }) => { if ( - !(await isConnectionNameUnique(true, displayName)) && + !(await isConnectionNameUnique({ + isGlobalConnection: true, + displayName, + })) && displayName !== currentName ) { throw new ConnectionNameAlreadyExists(); diff --git a/packages/web/src/features/connections/utils/utils.ts b/packages/web/src/features/connections/utils/utils.ts index 1b8e76a2d42..97b115f6a82 100644 --- a/packages/web/src/features/connections/utils/utils.ts +++ b/packages/web/src/features/connections/utils/utils.ts @@ -101,8 +101,9 @@ export const newConnectionUtils = { grantType, oauth2App, redirectUrl, + projectId: projectIdOverride, }: DefaultValuesParams): Partial { - const projectId = authenticationSession.getProjectId(); + const projectId = projectIdOverride ?? authenticationSession.getProjectId(); assertNotNullOrUndefined(projectId, 'projectId'); if (!auth) { throw new Error(`Unsupported property type: ${auth}`); @@ -231,16 +232,21 @@ export const newConnectionUtils = { }, }; -export const isConnectionNameUnique = async ( - isGlobalConnection: boolean, - displayName: string, -) => { +export const isConnectionNameUnique = async ({ + isGlobalConnection, + displayName, + projectId, +}: { + isGlobalConnection: boolean; + displayName: string; + projectId?: string; +}) => { const connections = isGlobalConnection ? await globalConnectionsApi.list({ limit: 10000, }) : await appConnectionsApi.list({ - projectId: authenticationSession.getProjectId()!, + projectId: projectId ?? authenticationSession.getProjectId()!, limit: 10000, }); const existingConnection = connections.data.find( @@ -257,4 +263,5 @@ type DefaultValuesParams = { auth: PieceAuthProperty; oauth2App: OAuth2App | null; grantType: OAuth2GrantType | null; + projectId?: string; };