diff --git a/backend/alembic/versions/6fb23040d929_add_onboarding_fields_to_users.py b/backend/alembic/versions/6fb23040d929_add_onboarding_fields_to_users.py new file mode 100644 index 0000000..a792b5a --- /dev/null +++ b/backend/alembic/versions/6fb23040d929_add_onboarding_fields_to_users.py @@ -0,0 +1,35 @@ +"""add_onboarding_fields_to_users + +Revision ID: 6fb23040d929 +Revises: srcpc01 +Create Date: 2026-03-25 15:25:08.171602 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "6fb23040d929" +down_revision: Union[str, None] = "srcpc01" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + "users", + sa.Column("is_onboarded", sa.Boolean(), nullable=False, server_default="false"), + ) + op.add_column( + "users", + sa.Column("completed_onboarding_steps", sa.JSON(), nullable=False, server_default="[]"), + ) + + +def downgrade() -> None: + op.drop_column("users", "completed_onboarding_steps") + op.drop_column("users", "is_onboarded") diff --git a/backend/app/agents/project_chat_assistant/assistant.py b/backend/app/agents/project_chat_assistant/assistant.py index 78aba0d..ec86cd0 100644 --- a/backend/app/agents/project_chat_assistant/assistant.py +++ b/backend/app/agents/project_chat_assistant/assistant.py @@ -660,7 +660,11 @@ def _convert_messages_to_llm_format( return llm_messages - def _parse_response(self, response_text: str) -> tuple[ProjectChatAssistantResponse, bool]: + def _parse_response( + self, + response_text: str, + can_explore_code: bool = False, + ) -> tuple[ProjectChatAssistantResponse, bool]: """ Parse the JSON response from the LLM. @@ -669,6 +673,7 @@ def _parse_response(self, response_text: str) -> tuple[ProjectChatAssistantRespo Args: response_text: Raw response text from the LLM. + can_explore_code: Whether code exploration is available (has repos + explorer enabled). Returns: Tuple of (parsed response, json_was_valid). @@ -736,8 +741,10 @@ def _parse_response(self, response_text: str) -> tuple[ProjectChatAssistantRespo # Fallback: Detect exploration intent when JSON parsing failed or was incomplete # This catches cases where the agent says "Let me explore..." without proper JSON # Skip if web search is requested (via JSON field or block) - "let me search" should NOT trigger code exploration + # Only attempt if code exploration is actually available (has repos + explorer enabled) if ( - not data.get("wants_code_exploration") + can_explore_code + and not data.get("wants_code_exploration") and not data.get("wants_web_search") and not has_web_search_block(response_text) ): @@ -836,10 +843,10 @@ def _parse_response(self, response_text: str) -> tuple[ProjectChatAssistantRespo "proposed_feature_module_id": data.get("proposed_feature_module_id"), "proposed_feature_module_title": data.get("proposed_feature_module_title"), "proposed_feature_module_description": data.get("proposed_feature_module_description"), - # Code exploration fields - "wants_code_exploration": data.get("wants_code_exploration", False), - "code_exploration_prompt": data.get("code_exploration_prompt"), - "code_exploration_branch": data.get("code_exploration_branch"), + # Code exploration fields — only if exploration is actually available + "wants_code_exploration": data.get("wants_code_exploration", False) and can_explore_code, + "code_exploration_prompt": data.get("code_exploration_prompt") if can_explore_code else None, + "code_exploration_branch": data.get("code_exploration_branch") if can_explore_code else None, # Web search fields "wants_web_search": data.get("wants_web_search", False), "web_search_query": data.get("web_search_query"), @@ -937,7 +944,8 @@ async def generate_response( response_text = " ".join(str(item) for item in response_text) # Parse the response (handles JSON extraction and Pydantic validation) - parsed, json_parsed = self._parse_response(response_text) + can_explore = context.code_explorer_enabled and context.has_repositories + parsed, json_parsed = self._parse_response(response_text, can_explore_code=can_explore) if json_parsed: # Successfully parsed JSON diff --git a/backend/app/mcp/vfs/metadata.py b/backend/app/mcp/vfs/metadata.py index b798dc8..c2e22e5 100644 --- a/backend/app/mcp/vfs/metadata.py +++ b/backend/app/mcp/vfs/metadata.py @@ -8,7 +8,7 @@ from app.mcp.vfs.errors import InvalidPathError, PermissionDeniedError from app.mcp.vfs.path_resolver import NodeType, ResolvedPath -from app.models.feature import Feature, FeatureCompletionStatus +from app.models.feature import Feature, FeatureCompletionStatus, FeatureProvenance # Reserved metadata keys that are computed and cannot be set COMPUTED_KEYS = { @@ -248,6 +248,35 @@ def _set_is_complete( if feature: FeatureService._broadcast_feature_update(db, feature, "completion_status") + # Onboarding hook: Step 15 / Step 18 — implementation marked complete via MCP + if is_complete: + try: + from app.models.module import Module + from app.models.project import Project + from app.models.user import User as _OUser + from app.services.onboarding_service import OnboardingService + + _o_feature = feature or db.query(Feature).filter(Feature.id == impl.feature_id).first() + if _o_feature: + _o_module = db.query(Module).filter(Module.id == _o_feature.module_id).first() + if _o_module: + _o_project = db.query(Project).filter(Project.id == _o_module.project_id).first() + if _o_project and _o_project.is_sample: + _o_user = db.query(_OUser).filter(_OUser.id == user_id).first() + _completed = _o_user.completed_onboarding_steps or [] if _o_user else [] + + if 15 not in _completed: + if OnboardingService.complete_step(db, user_id, 15): + db.commit() + OnboardingService.broadcast_step_completed(_o_project.org_id, user_id, 15) + elif 17 in _completed: + if _o_feature.provenance == FeatureProvenance.USER: + if OnboardingService.complete_step(db, user_id, 18): + db.commit() + OnboardingService.broadcast_step_completed(_o_project.org_id, user_id, 18) + except Exception: + pass # Non-critical, don't break MCP flow + return { "path": resolved.path, "key": "is_complete", diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 872f271..cb2d9db 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -8,7 +8,7 @@ from typing import TYPE_CHECKING, Optional from uuid import UUID, uuid4 -from sqlalchemy import Boolean, DateTime, ForeignKey, String, func +from sqlalchemy import JSON, Boolean, DateTime, ForeignKey, String, func from sqlalchemy.orm import Mapped, mapped_column, relationship from app.database import Base @@ -115,6 +115,23 @@ class User(Base): doc="Timestamp when the user's trial period started (null = grandfathered)", ) + # Onboarding fields + is_onboarded: Mapped[bool] = mapped_column( + Boolean, + nullable=False, + default=False, + server_default="false", + doc="Whether the user has completed or dismissed onboarding", + ) + + completed_onboarding_steps: Mapped[list] = mapped_column( + JSON, + nullable=False, + default=list, + server_default="[]", + doc="Array of completed onboarding step numbers (1-18)", + ) + # Relationships current_org: Mapped[Optional["Organization"]] = relationship( "Organization", diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index a947205..412ca83 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -37,6 +37,8 @@ from app.rate_limit import limiter from app.schemas.api_key import ApiKeyCreate from app.schemas.auth import ( + OnboardingDismissRequest, + OnboardingStepRequest, OrgMembershipResponse, RegistrationResponse, ResendVerificationRequest, @@ -49,6 +51,7 @@ from app.schemas.oauth import OAuthProviderInfo from app.services.api_key_service import ApiKeyService from app.services.email_service import EmailService +from app.services.onboarding_service import OnboardingService from app.services.org_service import OrgService from app.services.user_service import UserService @@ -374,6 +377,8 @@ def get_current_user_info( display_name=current_user.display_name, org_id=org_id, created_at=current_user.created_at, + is_onboarded=current_user.is_onboarded, + completed_onboarding_steps=current_user.completed_onboarding_steps or [], ) @@ -507,6 +512,59 @@ def switch_current_org( display_name=current_user.display_name, org_id=request.org_id, created_at=current_user.created_at, + is_onboarded=current_user.is_onboarded, + completed_onboarding_steps=current_user.completed_onboarding_steps or [], + ) + + +@router.patch("/me/onboarding", response_model=UserResponse) +@limiter.limit("30/minute") +def dismiss_onboarding( + request: Request, + body: OnboardingDismissRequest, + current_user: Annotated[User, Depends(get_current_user)], + db: Annotated[Session, Depends(get_db)], +) -> UserResponse: + """Dismiss onboarding permanently. Sets is_onboarded=True.""" + if not body.is_onboarded: + raise HTTPException(status_code=400, detail="is_onboarded must be true") + OnboardingService.dismiss(db, current_user) + db.commit() + + org_id = current_user.current_org_id + return UserResponse( + id=current_user.id, + email=current_user.email, + display_name=current_user.display_name, + org_id=org_id, + created_at=current_user.created_at, + is_onboarded=current_user.is_onboarded, + completed_onboarding_steps=current_user.completed_onboarding_steps or [], + ) + + +@router.patch("/me/onboarding/steps", response_model=UserResponse) +@limiter.limit("30/minute") +def mark_onboarding_step_complete( + request: Request, + body: OnboardingStepRequest, + current_user: Annotated[User, Depends(get_current_user)], + db: Annotated[Session, Depends(get_db)], +) -> UserResponse: + """Mark an onboarding step as complete. Idempotent.""" + OnboardingService.complete_step(db, current_user.id, body.step) + db.commit() + db.refresh(current_user) + + org_id = current_user.current_org_id + return UserResponse( + id=current_user.id, + email=current_user.email, + display_name=current_user.display_name, + org_id=org_id, + created_at=current_user.created_at, + is_onboarded=current_user.is_onboarded, + completed_onboarding_steps=current_user.completed_onboarding_steps or [], ) diff --git a/backend/app/routers/inbox_deep_link.py b/backend/app/routers/inbox_deep_link.py index 2c4ab0d..e268a5d 100644 --- a/backend/app/routers/inbox_deep_link.py +++ b/backend/app/routers/inbox_deep_link.py @@ -90,6 +90,7 @@ async def resolve_deep_link( chat_title=result.get("chat_title"), feature_key=result.get("feature_key"), feature_id=result.get("feature_id"), + feature_short_id=result.get("feature_short_id"), phase_title=result.get("phase_title"), ) diff --git a/backend/app/routers/projects.py b/backend/app/routers/projects.py index f83c38e..258cb79 100644 --- a/backend/app/routers/projects.py +++ b/backend/app/routers/projects.py @@ -41,6 +41,24 @@ router = APIRouter(tags=["projects"]) +def _broadcast_project_list_changed(org_id: UUID) -> None: + """Broadcast that the project list changed so sidebar and onboarding panel refresh.""" + try: + from app.services.kafka_producer import get_sync_kafka_producer + + message = { + "type": "project_list_changed", + "org_id": str(org_id), + } + get_sync_kafka_producer().publish( + topic="mfbt.websocket.broadcasts", + message=message, + key=str(org_id), + ) + except Exception: + logger.debug("Failed to broadcast project_list_changed") + + @router.post( "/orgs/{org_id}/projects", response_model=ProjectResponse, @@ -89,6 +107,7 @@ def create_project( # Note: GitHub repos are now added via POST /projects/{id}/repositories # Grounding file generation is triggered when the first repository is added + _broadcast_project_list_changed(org_id) return project @@ -232,6 +251,7 @@ def load_sample_project( partition_key=str(phase.id), ) + _broadcast_project_list_changed(org_id) return project @@ -463,6 +483,7 @@ def archive_project( ) archived = ProjectService.archive_project(db, project.id) + _broadcast_project_list_changed(project.org_id) return archived @@ -501,6 +522,7 @@ def delete_project( ) deleted = ProjectService.soft_delete_project(db, project.id) + _broadcast_project_list_changed(project.org_id) return deleted diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py index 2812d0b..12685ae 100644 --- a/backend/app/schemas/auth.py +++ b/backend/app/schemas/auth.py @@ -75,6 +75,34 @@ class UserResponse(BaseModel): ..., description="Timestamp when the user was created", ) + is_onboarded: bool = Field( + False, + description="Whether the user has completed or dismissed onboarding", + ) + completed_onboarding_steps: list[int] = Field( + default_factory=list, + description="Array of completed onboarding step numbers (1-18)", + ) + + +class OnboardingDismissRequest(BaseModel): + """Schema for dismissing onboarding.""" + + is_onboarded: bool = Field( + True, + description="Must be true to dismiss onboarding", + ) + + +class OnboardingStepRequest(BaseModel): + """Schema for marking an onboarding step as complete.""" + + step: int = Field( + ..., + ge=1, + le=18, + description="Onboarding step number (1-18)", + ) class TokenResponse(BaseModel): diff --git a/backend/app/schemas/inbox_conversation.py b/backend/app/schemas/inbox_conversation.py index 56b7b4e..0544acd 100644 --- a/backend/app/schemas/inbox_conversation.py +++ b/backend/app/schemas/inbox_conversation.py @@ -47,6 +47,7 @@ class UnifiedConversation(BaseModel): last_message_author: Optional[str] = None feature_key: Optional[str] = None # For FEATURE type feature_id: Optional[str] = None # Feature UUID for FEATURE type (used in ?feature= param) + feature_short_id: Optional[str] = None # Feature short_id for building feature detail URLs module_title: Optional[str] = None # For FEATURE type phase_title: Optional[str] = None # For PHASE and FEATURE types phase_short_id: Optional[str] = None # Phase short_id for FEATURE type URL routing diff --git a/backend/app/schemas/project.py b/backend/app/schemas/project.py index 0230edb..f3b5cd7 100644 --- a/backend/app/schemas/project.py +++ b/backend/app/schemas/project.py @@ -129,6 +129,8 @@ class ProjectResponse(BaseModel): url_identifier: str # Repositories (multi-repo support) repositories: list[ProjectRepositoryBrief] = [] + # Sample project flag + is_sample: bool = False # Project Membership Schemas diff --git a/backend/app/services/feature_content_version_service.py b/backend/app/services/feature_content_version_service.py index b7b47e5..27187b9 100644 --- a/backend/app/services/feature_content_version_service.py +++ b/backend/app/services/feature_content_version_service.py @@ -87,6 +87,32 @@ def create_version( if broadcast: FeatureContentVersionService._broadcast_version_update(db, version) + # Onboarding hook: Step 9/11/13 (spec generation) in sample project + try: + if created_by is not None and feature: + module = db.query(Module).filter(Module.id == feature.module_id).first() + if module: + project = db.query(Project).filter(Project.id == module.project_id).first() + if project and project.is_sample and content_type == FeatureContentType.SPEC: + from app.models.user import User as _OUser + from app.services.onboarding_service import OnboardingService + + _o_user = db.query(_OUser).filter(_OUser.id == created_by).first() + _completed = _o_user.completed_onboarding_steps or [] if _o_user else [] + # Map feature_key suffix to onboarding step + _key = feature.feature_key or "" + _suffix_step = {"-010": 9, "-011": 11, "-012": 13} + _step = next( + (s for suffix, s in _suffix_step.items() if _key.endswith(suffix) and s not in _completed), + None, + ) + + if _step and OnboardingService.complete_step(db, created_by, _step): + db.commit() + OnboardingService.broadcast_step_completed(project.org_id, created_by, _step) + except Exception: + logger.debug("Failed to complete onboarding step for feature content version") + return version @staticmethod diff --git a/backend/app/services/feature_service.py b/backend/app/services/feature_service.py index 0febb2b..f217e38 100644 --- a/backend/app/services/feature_service.py +++ b/backend/app/services/feature_service.py @@ -88,6 +88,30 @@ def create_feature( db.add(feature) db.commit() db.refresh(feature) + + # Onboarding hook: Step 17 — user-created feature in sample project + try: + if provenance == FeatureProvenance.USER: + project = db.query(Project).filter(Project.id == project_id).first() + if project and project.is_sample: + from app.services.onboarding_service import OnboardingService + + if OnboardingService.complete_step(db, created_by, 17): + db.commit() + OnboardingService.broadcast_step_completed( + project.org_id, + created_by, + 17, + metadata={ + "feature_key": feature.feature_key, + "feature_url_id": feature.url_identifier, + }, + ) + except Exception: + import logging + + logging.getLogger(__name__).debug("Failed to complete onboarding step 17 for feature creation") + return feature @staticmethod diff --git a/backend/app/services/implementation_service.py b/backend/app/services/implementation_service.py index b0a2169..826ada2 100644 --- a/backend/app/services/implementation_service.py +++ b/backend/app/services/implementation_service.py @@ -8,7 +8,7 @@ from sqlalchemy import asc from sqlalchemy.orm import Session -from app.models.feature import Feature, FeatureCompletionStatus +from app.models.feature import Feature, FeatureCompletionStatus, FeatureProvenance from app.models.implementation import Implementation from app.models.module import Module from app.models.project import Project @@ -257,6 +257,37 @@ def mark_complete( db.commit() db.refresh(implementation) + + # Onboarding hook: Step 15 (first impl complete) / Step 18 (user's own feature) in sample project + # Step 15: first implementation marked complete in sample project. + # Step 18: implementation marked complete on a feature created AFTER the sample project + # was loaded (i.e., the user's own feature from step 17). + logger.debug("Onboarding hook: mark_complete called for impl %s by user %s", implementation_id, user_id) + try: + feature = db.query(Feature).filter(Feature.id == implementation.feature_id).first() + if feature: + module = db.query(Module).filter(Module.id == feature.module_id).first() + if module: + project = db.query(Project).filter(Project.id == module.project_id).first() + if project and project.is_sample: + from app.models.user import User as _OUser + from app.services.onboarding_service import OnboardingService + + _o_user = db.query(_OUser).filter(_OUser.id == user_id).first() + _completed = _o_user.completed_onboarding_steps or [] if _o_user else [] + + if 15 not in _completed: + if OnboardingService.complete_step(db, user_id, 15): + db.commit() + OnboardingService.broadcast_step_completed(project.org_id, user_id, 15) + elif 17 in _completed: + if feature.provenance == FeatureProvenance.USER: + if OnboardingService.complete_step(db, user_id, 18): + db.commit() + OnboardingService.broadcast_step_completed(project.org_id, user_id, 18) + except Exception as e: + logger.warning("Failed to complete onboarding step for implementation completion: %s", e) + return implementation @staticmethod diff --git a/backend/app/services/inbox_conversation_service.py b/backend/app/services/inbox_conversation_service.py index 0ace476..7b20bfb 100644 --- a/backend/app/services/inbox_conversation_service.py +++ b/backend/app/services/inbox_conversation_service.py @@ -589,6 +589,7 @@ def _get_feature_conversations( last_message_author=last_author, feature_key=feature_key, feature_id=str(feature.id) if feature else None, + feature_short_id=feature_short_id, module_title=module_title, phase_title=phase_title, phase_short_id=phase_short_id_val, @@ -886,9 +887,10 @@ def _resolve_feature_thread( ) -> Optional[dict]: """Resolve a Feature thread for deep linking. - Feature threads are part of brainstorming phases, so the deep link - redirects to the phase conversations page with ?feature= to open - the feature's thread in the side panel. + Feature threads with a brainstorming phase redirect to the phase + conversations page with ?feature= to open the thread in the side panel. + Phase-less features (e.g., created from project chat) redirect to the + feature detail page. """ # Thread IDs are string UUIDs thread = db.query(Thread).filter(Thread.id == conversation_id).first() @@ -929,18 +931,29 @@ def _resolve_feature_thread( if feature.module and feature.module.brainstorming_phase: phase = feature.module.brainstorming_phase - if not phase or not phase.short_id: - return None - - return { - "project_id": str(project.id), - "project_name": project.name, - "project_short_id": project.short_id, - "conversation_title": thread.title or feature.title if feature else "Feature", - "conversation_short_id": phase.short_id, - "phase_title": phase.title, - "feature_id": str(feature.id), - } + if phase and phase.short_id: + # Feature with a brainstorming phase - route to phase conversations + return { + "project_id": str(project.id), + "project_name": project.name, + "project_short_id": project.short_id, + "conversation_title": thread.title or feature.title if feature else "Feature", + "conversation_short_id": phase.short_id, + "phase_title": phase.title, + "feature_id": str(feature.id), + } + else: + # Phase-less feature (e.g., created from project chat) - route to feature detail + return { + "project_id": str(project.id), + "project_name": project.name, + "project_short_id": project.short_id, + "conversation_title": thread.title or feature.title if feature else "Feature", + "conversation_short_id": feature.short_id, + "feature_id": str(feature.id), + "feature_key": feature.feature_key, + "feature_short_id": feature.short_id, + } @staticmethod def _resolve_phase_thread( diff --git a/backend/app/services/onboarding_service.py b/backend/app/services/onboarding_service.py new file mode 100644 index 0000000..ed9542e --- /dev/null +++ b/backend/app/services/onboarding_service.py @@ -0,0 +1,87 @@ +"""Onboarding service for tracking user onboarding progress.""" + +import logging +from datetime import datetime, timezone +from uuid import UUID + +from sqlalchemy.orm import Session +from sqlalchemy.orm.attributes import flag_modified + +from app.models.user import User + +logger = logging.getLogger(__name__) + +VALID_STEPS = set(range(1, 19)) # Steps 1-18 + + +class OnboardingService: + """Service for managing user onboarding step completion.""" + + @staticmethod + def complete_step(db: Session, user_id: UUID, step: int) -> bool: + """Append step to completed_onboarding_steps if not already present. + + Returns True if the step was actually added (for broadcast purposes). + + No-ops gracefully if: + - User not found + - User is already onboarded + - Step is invalid or already completed + """ + if step not in VALID_STEPS: + return False + + user = db.query(User).filter(User.id == user_id).first() + if not user or user.is_onboarded: + return False + + current = user.completed_onboarding_steps or [] + if step not in current: + user.completed_onboarding_steps = current + [step] + flag_modified(user, "completed_onboarding_steps") + return True + return False + + @staticmethod + def broadcast_step_completed( + org_id: UUID, + user_id: UUID, + step: int, + metadata: dict | None = None, + ) -> None: + """Broadcast onboarding step completion via WebSocket (Kafka). + + Should be called after db.commit() so the data is persisted. + + Args: + org_id: Organization UUID for routing + user_id: Target user UUID + step: Step number completed + metadata: Optional extra data (e.g., feature_key for step 13) + """ + try: + from app.services.kafka_producer import get_sync_kafka_producer + + payload = {"step": step} + if metadata: + payload.update(metadata) + + message = { + "type": "onboarding_step_completed", + "org_id": str(org_id), + "target_user_ids": [str(user_id)], + "payload": payload, + "timestamp": datetime.now(timezone.utc).isoformat(), + } + get_sync_kafka_producer().publish( + topic="mfbt.websocket.broadcasts", + message=message, + key=str(org_id), + ) + except Exception: + logger.debug("Failed to broadcast onboarding step completion") + + @staticmethod + def dismiss(db: Session, user: User) -> None: + """Set is_onboarded=True. One-way transition.""" + user.is_onboarded = True diff --git a/backend/app/services/thread_service.py b/backend/app/services/thread_service.py index 772dd5b..2bf6c48 100644 --- a/backend/app/services/thread_service.py +++ b/backend/app/services/thread_service.py @@ -808,6 +808,21 @@ def answer_mcq_item( except Exception as e: logger.warning(f"Failed to trigger MFBTAI for MCQ answer {item_id}: {e}") + # Onboarding hook: Step 12 — Q&A answered in sample project + try: + if answerer_id and thread: + from app.models.project import Project + + _onb_project = db.query(Project).filter(Project.id == thread.project_id).first() + if _onb_project and _onb_project.is_sample: + from app.services.onboarding_service import OnboardingService + + if OnboardingService.complete_step(db, UUID(answerer_id), 12): + db.commit() + OnboardingService.broadcast_step_completed(_onb_project.org_id, UUID(answerer_id), 12) + except Exception: + logger.debug("Failed to complete onboarding step 12 for MCQ answer") + return item @staticmethod diff --git a/backend/app/utils/deep_link.py b/backend/app/utils/deep_link.py index bb8f491..c2848ab 100644 --- a/backend/app/utils/deep_link.py +++ b/backend/app/utils/deep_link.py @@ -75,6 +75,7 @@ def build_redirect_url( chat_title: Optional[str] = None, feature_key: Optional[str] = None, feature_id: Optional[str] = None, + feature_short_id: Optional[str] = None, phase_title: Optional[str] = None, ) -> str: """ @@ -108,14 +109,21 @@ def build_redirect_url( url = f"{project_url}/project-chat/{chat_slug}-{conversation_short_id}" elif conversation_type == InboxConversationType.FEATURE: - # Feature threads live in the brainstorming phase conversations page. - # conversation_short_id is the phase short_id; feature_id opens the - # feature's thread in the side panel via ?feature= query param. - title = phase_title or "phase" - phase_slug = slugify(title) - url = f"{project_url}/brainstorming/{phase_slug}-{conversation_short_id}/conversations" - if feature_id: - query_params.append(f"feature={feature_id}") + if phase_title: + # Feature with a brainstorming phase - route to phase conversations page. + # conversation_short_id is the phase short_id; feature_id opens the + # feature's thread in the side panel via ?feature= query param. + phase_slug = slugify(phase_title) + url = f"{project_url}/brainstorming/{phase_slug}-{conversation_short_id}/conversations" + if feature_id: + query_params.append(f"feature={feature_id}") + else: + # Phase-less feature (e.g., created from project chat) - route to feature detail page + if feature_key and feature_short_id: + feature_slug = f"{feature_key.lower()}-{feature_short_id}" + else: + feature_slug = conversation_short_id + url = f"{project_url}/features/{feature_slug}" elif conversation_type == InboxConversationType.PHASE: title = phase_title or "phase" diff --git a/backend/app/websocket/broadcast_consumer.py b/backend/app/websocket/broadcast_consumer.py index 274a839..2d85368 100644 --- a/backend/app/websocket/broadcast_consumer.py +++ b/backend/app/websocket/broadcast_consumer.py @@ -379,6 +379,17 @@ async def _handle_message(self, message: dict): "payload": message.get("payload"), "timestamp": message.get("timestamp"), } + elif message_type == "onboarding_step_completed": + client_message = { + "type": message_type, + "target_user_ids": message.get("target_user_ids"), + "payload": message.get("payload"), + "timestamp": message.get("timestamp"), + } + elif message_type == "project_list_changed": + client_message = { + "type": message_type, + } else: logger.warning(f"Unknown broadcast message type: {message_type}") return diff --git a/backend/tests/test_inbox_deep_link.py b/backend/tests/test_inbox_deep_link.py index c9a0380..48f7226 100644 --- a/backend/tests/test_inbox_deep_link.py +++ b/backend/tests/test_inbox_deep_link.py @@ -107,6 +107,35 @@ def test_build_redirect_url_feature_with_sequence(self): == "/projects/test-project-proj123/brainstorming/initial-discovery-phase456/conversations?feature=feat-uuid-123&m=5" ) + def test_build_redirect_url_feature_no_phase(self): + """Test building redirect URL for feature without phase (routes to feature detail).""" + url = build_redirect_url( + conversation_type=InboxConversationType.FEATURE, + project_name="Test Project", + project_short_id="proj123", + conversation_short_id="feat456", + phase_title=None, + feature_key="TEST-001", + feature_id="feat-uuid-123", + feature_short_id="feat456", + ) + assert url == "/projects/test-project-proj123/features/test-001-feat456" + + def test_build_redirect_url_feature_no_phase_with_sequence(self): + """Test building redirect URL for phase-less feature with sequence.""" + url = build_redirect_url( + conversation_type=InboxConversationType.FEATURE, + project_name="Test Project", + project_short_id="proj123", + conversation_short_id="feat456", + phase_title=None, + feature_key="TEST-001", + feature_id="feat-uuid-123", + feature_short_id="feat456", + message_sequence=5, + ) + assert url == "/projects/test-project-proj123/features/test-001-feat456?m=5" + def test_build_redirect_url_phase(self): """Test building redirect URL for phase thread.""" url = build_redirect_url( @@ -320,6 +349,64 @@ def test_resolve_feature_thread(self, db: Session, setup_data): assert result["phase_title"] == "Test Phase" assert result["conversation_short_id"] == phase.short_id + def test_resolve_feature_thread_no_phase(self, db: Session, setup_data): + """Test resolving a feature thread with no brainstorming phase (created from project chat).""" + data = setup_data + user = data["user"] + project = data["project"] + + # Create module with NO brainstorming_phase_id (like project chat creates) + module = Module( + id=uuid4(), + project_id=project.id, + brainstorming_phase_id=None, + title="Chat Module", + module_key="MTEST-002", + module_key_number=2, + module_type=ModuleType.IMPLEMENTATION, + provenance=ModuleProvenance.USER, + created_by=user.id, + ) + db.add(module) + + feature = Feature( + id=uuid4(), + module_id=module.id, + title="Chat Feature", + feature_key="TEST-002", + feature_key_number=2, + provenance=FeatureProvenance.USER, + feature_type=FeatureType.IMPLEMENTATION, + created_by=user.id, + ) + db.add(feature) + + thread = Thread( + id=str(uuid4()), + project_id=str(project.id), + context_type=ContextType.BRAINSTORM_FEATURE, + context_id=str(feature.id), + title="Feature Discussion", + created_by=str(user.id), + ) + db.add(thread) + db.commit() + + result = InboxConversationService.resolve_conversation_for_deep_link( + db=db, + user_id=user.id, + conversation_type=InboxConversationType.FEATURE, + conversation_id=thread.id, + ) + + assert result is not None + assert result["project_id"] == str(project.id) + assert result["feature_id"] == str(feature.id) + assert result["feature_key"] == "TEST-002" + assert result["feature_short_id"] == feature.short_id + # Should NOT have phase_title since there's no phase + assert result.get("phase_title") is None + def test_resolve_phase_thread(self, db: Session, setup_data): """Test resolving a phase thread deep link.""" data = setup_data diff --git a/backend/tests/test_onboarding.py b/backend/tests/test_onboarding.py new file mode 100644 index 0000000..66a19fd --- /dev/null +++ b/backend/tests/test_onboarding.py @@ -0,0 +1,182 @@ +"""Tests for onboarding service and API endpoints.""" + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy.orm import Session + +from app.models.user import User +from app.services.onboarding_service import OnboardingService +from app.services.org_service import OrgService + + +@pytest.fixture +def test_user_with_org(db: Session, test_user: User): + """Create a test user with an org (required for UserResponse).""" + org, _ = OrgService.create_org_with_owner(db=db, name="Test Org", owner_user_id=test_user.id) + test_user.current_org_id = org.id + db.commit() + db.refresh(test_user) + return test_user + + +class TestOnboardingService: + """Tests for OnboardingService.""" + + def test_complete_step_appends_step(self, db: Session, test_user: User): + """Step is appended to completed_onboarding_steps.""" + OnboardingService.complete_step(db, test_user.id, 1) + db.commit() + db.refresh(test_user) + assert 1 in test_user.completed_onboarding_steps + + def test_complete_step_is_idempotent(self, db: Session, test_user: User): + """Calling twice with same step results in single entry.""" + OnboardingService.complete_step(db, test_user.id, 3) + db.commit() + OnboardingService.complete_step(db, test_user.id, 3) + db.commit() + db.refresh(test_user) + assert test_user.completed_onboarding_steps.count(3) == 1 + + def test_complete_step_multiple_steps(self, db: Session, test_user: User): + """Multiple different steps can be completed.""" + OnboardingService.complete_step(db, test_user.id, 1) + OnboardingService.complete_step(db, test_user.id, 5) + OnboardingService.complete_step(db, test_user.id, 10) + db.commit() + db.refresh(test_user) + assert set(test_user.completed_onboarding_steps) == {1, 5, 10} + + def test_complete_step_noop_when_onboarded(self, db: Session, test_user: User): + """No-ops when user is already onboarded.""" + test_user.is_onboarded = True + db.commit() + OnboardingService.complete_step(db, test_user.id, 1) + db.commit() + db.refresh(test_user) + assert test_user.completed_onboarding_steps == [] + + def test_complete_step_noop_for_invalid_step(self, db: Session, test_user: User): + """No-ops for step numbers outside 1-18.""" + OnboardingService.complete_step(db, test_user.id, 0) + OnboardingService.complete_step(db, test_user.id, 19) + OnboardingService.complete_step(db, test_user.id, -1) + db.commit() + db.refresh(test_user) + assert test_user.completed_onboarding_steps == [] + + def test_complete_step_noop_for_missing_user(self, db: Session): + """No-ops gracefully if user not found.""" + from uuid import uuid4 + + # Should not raise + OnboardingService.complete_step(db, uuid4(), 1) + + def test_dismiss_sets_is_onboarded(self, db: Session, test_user: User): + """dismiss() sets is_onboarded=True.""" + assert test_user.is_onboarded is False + OnboardingService.dismiss(db, test_user) + db.commit() + db.refresh(test_user) + assert test_user.is_onboarded is True + + +class TestOnboardingEndpoints: + """Tests for onboarding API endpoints.""" + + def test_dismiss_onboarding(self, client: TestClient, db: Session, test_user_with_org: User, auth_headers): + """PATCH /auth/me/onboarding sets is_onboarded=True.""" + headers = auth_headers(test_user_with_org) + response = client.patch( + "/api/v1/auth/me/onboarding", + json={"is_onboarded": True}, + headers=headers, + ) + assert response.status_code == 200 + data = response.json() + assert data["is_onboarded"] is True + + def test_dismiss_onboarding_idempotent( + self, client: TestClient, db: Session, test_user_with_org: User, auth_headers + ): + """Calling dismiss twice is idempotent.""" + headers = auth_headers(test_user_with_org) + client.patch("/api/v1/auth/me/onboarding", json={"is_onboarded": True}, headers=headers) + response = client.patch( + "/api/v1/auth/me/onboarding", + json={"is_onboarded": True}, + headers=headers, + ) + assert response.status_code == 200 + assert response.json()["is_onboarded"] is True + + def test_mark_step_complete(self, client: TestClient, db: Session, test_user_with_org: User, auth_headers): + """PATCH /auth/me/onboarding/steps appends step.""" + headers = auth_headers(test_user_with_org) + response = client.patch( + "/api/v1/auth/me/onboarding/steps", + json={"step": 5}, + headers=headers, + ) + assert response.status_code == 200 + data = response.json() + assert 5 in data["completed_onboarding_steps"] + + def test_mark_step_complete_idempotent( + self, client: TestClient, db: Session, test_user_with_org: User, auth_headers + ): + """Marking the same step twice results in single entry.""" + headers = auth_headers(test_user_with_org) + client.patch("/api/v1/auth/me/onboarding/steps", json={"step": 3}, headers=headers) + response = client.patch( + "/api/v1/auth/me/onboarding/steps", + json={"step": 3}, + headers=headers, + ) + assert response.status_code == 200 + assert response.json()["completed_onboarding_steps"].count(3) == 1 + + def test_mark_step_invalid_range(self, client: TestClient, db: Session, test_user_with_org: User, auth_headers): + """Step out of range 1-18 returns 422.""" + headers = auth_headers(test_user_with_org) + response = client.patch( + "/api/v1/auth/me/onboarding/steps", + json={"step": 19}, + headers=headers, + ) + assert response.status_code == 422 + + response = client.patch( + "/api/v1/auth/me/onboarding/steps", + json={"step": 0}, + headers=headers, + ) + assert response.status_code == 422 + + def test_get_me_returns_onboarding_fields( + self, client: TestClient, db: Session, test_user_with_org: User, auth_headers + ): + """GET /auth/me includes is_onboarded and completed_onboarding_steps.""" + headers = auth_headers(test_user_with_org) + + response = client.get("/api/v1/auth/me", headers=headers) + assert response.status_code == 200 + data = response.json() + assert "is_onboarded" in data + assert "completed_onboarding_steps" in data + assert data["is_onboarded"] is False + assert data["completed_onboarding_steps"] == [] + + def test_unauthenticated_returns_401(self, client: TestClient): + """Unauthenticated requests return 401.""" + response = client.patch( + "/api/v1/auth/me/onboarding", + json={"is_onboarded": True}, + ) + assert response.status_code == 401 + + response = client.patch( + "/api/v1/auth/me/onboarding/steps", + json={"step": 1}, + ) + assert response.status_code == 401 diff --git a/backend/tests/test_worker_concurrency.py b/backend/tests/test_worker_concurrency.py new file mode 100644 index 0000000..ebe66e1 --- /dev/null +++ b/backend/tests/test_worker_concurrency.py @@ -0,0 +1,355 @@ +""" +Tests for worker intra-concurrency via async task pool (MFBT-330). + +Verifies semaphore-based backpressure, concurrent task spawning, +graceful shutdown with concurrent tasks, and WORKER_CONCURRENCY config. +""" + +import asyncio +from unittest.mock import AsyncMock, MagicMock, patch +from uuid import uuid4 + +import pytest + +from workers.core.worker import JobWorker + + +class TestWorkerConcurrencyConfig: + """Tests for WORKER_CONCURRENCY configuration.""" + + def test_default_worker_concurrency(self): + """Default WORKER_CONCURRENCY should be 5.""" + worker = JobWorker() + assert worker._worker_concurrency == 5 + # Semaphore internal value check: acquire 5 times without blocking + assert worker._semaphore._value == 5 + + @patch.dict("os.environ", {"WORKER_CONCURRENCY": "3"}) + def test_custom_worker_concurrency(self): + """WORKER_CONCURRENCY env var should configure semaphore and worker.""" + # Need to reimport to pick up env var change for the module-level constant + # Instead, test via constructor by directly setting the instance attributes + worker = JobWorker() + # Override to simulate custom config (module constant is read at import time) + worker._semaphore = asyncio.Semaphore(3) + worker._worker_concurrency = 3 + assert worker._worker_concurrency == 3 + assert worker._semaphore._value == 3 + + +class TestConcurrentTaskSpawning: + """Tests for concurrent task spawning in _consume.""" + + @pytest.mark.asyncio + async def test_concurrent_tasks_spawn_up_to_concurrency(self): + """Multiple messages should be processed concurrently up to WORKER_CONCURRENCY.""" + concurrency = 3 + worker = JobWorker() + worker._semaphore = asyncio.Semaphore(concurrency) + worker._worker_concurrency = concurrency + worker._shutdown_event = asyncio.Event() + worker.running = True + + active_count = 0 + peak_count = 0 + completed = asyncio.Event() + total_processed = 0 + + original_process = worker._process_message + + async def mock_process(payload): + nonlocal active_count, peak_count, total_processed + active_count += 1 + if active_count > peak_count: + peak_count = active_count + await asyncio.sleep(0.15) + active_count -= 1 + total_processed += 1 + if total_processed >= 5: + completed.set() + + worker._process_message = mock_process + + # Create mock consumer that returns 5 messages then timeouts + mock_consumer = AsyncMock() + call_count = 0 + + async def mock_getmany(*args, **kwargs): + nonlocal call_count + call_count += 1 + if call_count == 1: + # Return 5 messages (more than concurrency limit of 3) + msgs = [] + for i in range(5): + mock_msg = MagicMock() + mock_msg.topic = "test" + mock_msg.partition = 0 + mock_msg.offset = i + mock_msg.value = {"job_id": f"job-{i}"} + msgs.append(mock_msg) + return {("test", 0): msgs} + else: + # Wait for all tasks to complete then shutdown + await completed.wait() + worker._shutdown_event.set() + raise asyncio.TimeoutError() + + mock_consumer.getmany = mock_getmany + mock_consumer.stop = AsyncMock() + worker.consumer = mock_consumer + + await worker._consume() + + # Peak concurrency should be exactly the limit + assert peak_count == concurrency + assert total_processed == 5 + + @pytest.mark.asyncio + async def test_concurrency_one_is_sequential(self): + """With WORKER_CONCURRENCY=1, tasks should not overlap.""" + worker = JobWorker() + worker._semaphore = asyncio.Semaphore(1) + worker._worker_concurrency = 1 + worker._shutdown_event = asyncio.Event() + worker.running = True + + active_count = 0 + peak_count = 0 + completed = asyncio.Event() + total_processed = 0 + + async def mock_process(payload): + nonlocal active_count, peak_count, total_processed + active_count += 1 + if active_count > peak_count: + peak_count = active_count + await asyncio.sleep(0.05) + active_count -= 1 + total_processed += 1 + if total_processed >= 3: + completed.set() + + worker._process_message = mock_process + + mock_consumer = AsyncMock() + call_count = 0 + + async def mock_getmany(*args, **kwargs): + nonlocal call_count + call_count += 1 + if call_count == 1: + msgs = [] + for i in range(3): + mock_msg = MagicMock() + mock_msg.topic = "test" + mock_msg.partition = 0 + mock_msg.offset = i + mock_msg.value = {"job_id": f"job-{i}"} + msgs.append(mock_msg) + return {("test", 0): msgs} + else: + await completed.wait() + worker._shutdown_event.set() + raise asyncio.TimeoutError() + + mock_consumer.getmany = mock_getmany + mock_consumer.stop = AsyncMock() + worker.consumer = mock_consumer + + await worker._consume() + + # Peak should be 1 — sequential + assert peak_count == 1 + assert total_processed == 3 + + +class TestSemaphoreRelease: + """Tests for semaphore release on task completion and failure.""" + + @pytest.mark.asyncio + async def test_semaphore_released_on_exception(self): + """Semaphore should be released even when _process_message raises.""" + concurrency = 3 + worker = JobWorker() + worker._semaphore = asyncio.Semaphore(concurrency) + worker._worker_concurrency = concurrency + worker._shutdown_event = asyncio.Event() + worker.running = True + + completed = asyncio.Event() + total_processed = 0 + + async def mock_process(payload): + nonlocal total_processed + total_processed += 1 + if total_processed >= 3: + completed.set() + raise RuntimeError("handler failed") + + worker._process_message = mock_process + + mock_consumer = AsyncMock() + call_count = 0 + + async def mock_getmany(*args, **kwargs): + nonlocal call_count + call_count += 1 + if call_count == 1: + msgs = [] + for i in range(3): + mock_msg = MagicMock() + mock_msg.topic = "test" + mock_msg.partition = 0 + mock_msg.offset = i + mock_msg.value = {"job_id": f"job-{i}"} + msgs.append(mock_msg) + return {("test", 0): msgs} + else: + await completed.wait() + # Give done_callbacks time to fire + await asyncio.sleep(0.05) + worker._shutdown_event.set() + raise asyncio.TimeoutError() + + mock_consumer.getmany = mock_getmany + mock_consumer.stop = AsyncMock() + worker.consumer = mock_consumer + + await worker._consume() + + # All semaphore slots should be released (back to full capacity) + assert worker._semaphore._value == concurrency + + +class TestConcurrentInFlightTracking: + """Tests for in-flight job tracking with concurrent tasks.""" + + @pytest.mark.asyncio + async def test_in_flight_tracking_concurrent(self): + """All concurrent tasks should be tracked in _in_flight_jobs.""" + worker = JobWorker() + worker._semaphore = asyncio.Semaphore(3) + worker._worker_concurrency = 3 + worker._shutdown_event = asyncio.Event() + worker.consumer = AsyncMock() + worker.running = True + + # Event to hold tasks until we've checked in-flight state + hold = asyncio.Event() + + async def slow_handler(payload, db): + await hold.wait() + return {"status": "done"} + + worker.handlers["test_job_type"] = slow_handler + + job_ids = [str(uuid4()) for _ in range(3)] + + with patch("workers.core.worker.SessionLocal") as mock_session: + mock_db = MagicMock() + mock_session.return_value = mock_db + + with patch("workers.core.worker.JobService") as mock_job_service: + mock_job = MagicMock() + mock_job.job_type.value = "test_job_type" + mock_job.payload = {} + mock_job_service.get_job.return_value = mock_job + + # Spawn 3 concurrent tasks + tasks = [] + for job_id in job_ids: + task = asyncio.create_task(worker._process_message({"job_id": job_id})) + tasks.append(task) + + # Give tasks time to register in _in_flight_jobs and reach handler + await asyncio.sleep(0.1) + + # All 3 should be tracked + assert len(worker._in_flight_jobs) == 3 + assert worker._in_flight_jobs == set(job_ids) + + # Release tasks + hold.set() + + # Wait for all to complete + await asyncio.gather(*tasks) + + # All should be cleared + assert len(worker._in_flight_jobs) == 0 + + +class TestConcurrentShutdown: + """Tests for graceful shutdown with concurrent tasks.""" + + @pytest.mark.asyncio + async def test_shutdown_drains_concurrent_tasks(self): + """Shutdown should wait for all concurrent tasks to complete.""" + worker = JobWorker(shutdown_timeout=5) + worker._semaphore = asyncio.Semaphore(3) + worker._worker_concurrency = 3 + worker._shutdown_event = asyncio.Event() + worker.consumer = AsyncMock() + worker.running = True + + async def slow_handler(payload, db): + await asyncio.sleep(0.3) + return {"status": "done"} + + worker.handlers["test_job_type"] = slow_handler + + job_ids = [str(uuid4()) for _ in range(3)] + + with patch("workers.core.worker.SessionLocal") as mock_session: + mock_db = MagicMock() + mock_session.return_value = mock_db + + with patch("workers.core.worker.JobService") as mock_job_service: + mock_job = MagicMock() + mock_job.job_type.value = "test_job_type" + mock_job.payload = {} + mock_job_service.get_job.return_value = mock_job + + # Spawn 3 concurrent tasks + for job_id in job_ids: + asyncio.create_task(worker._process_message({"job_id": job_id})) + + # Give tasks time to register + await asyncio.sleep(0.05) + assert len(worker._in_flight_jobs) == 3 + + # Trigger shutdown + await worker.stop() + + # All tasks should have drained + assert len(worker._in_flight_jobs) == 0 + worker.consumer.stop.assert_called_once() + + @pytest.mark.asyncio + async def test_getmany_uses_worker_concurrency(self): + """getmany should be called with max_records=WORKER_CONCURRENCY.""" + concurrency = 3 + worker = JobWorker() + worker._semaphore = asyncio.Semaphore(concurrency) + worker._worker_concurrency = concurrency + worker._shutdown_event = asyncio.Event() + worker.running = True + + mock_consumer = AsyncMock() + call_count = 0 + + async def mock_getmany(*args, **kwargs): + nonlocal call_count + call_count += 1 + # Verify max_records is set to worker concurrency + assert kwargs.get("max_records") == concurrency + # Shutdown after first call + worker._shutdown_event.set() + raise asyncio.TimeoutError() + + mock_consumer.getmany = mock_getmany + mock_consumer.stop = AsyncMock() + worker.consumer = mock_consumer + + await worker._consume() + + assert call_count >= 1 diff --git a/backend/tests/test_worker_graceful_shutdown.py b/backend/tests/test_worker_graceful_shutdown.py index 11771aa..915a1c7 100644 --- a/backend/tests/test_worker_graceful_shutdown.py +++ b/backend/tests/test_worker_graceful_shutdown.py @@ -8,6 +8,7 @@ import asyncio import signal from unittest.mock import AsyncMock, MagicMock, patch +from uuid import uuid4 import pytest @@ -146,7 +147,7 @@ async def slow_handler(payload, db): mock_job_service.get_job.return_value = mock_job # Start processing a job - job_id = "test-job-123" + job_id = str(uuid4()) process_task = asyncio.create_task(worker._process_message({"job_id": job_id})) # Give it time to add to in-flight set diff --git a/backend/workers/core/worker.py b/backend/workers/core/worker.py index fb4d381..218e2f4 100644 --- a/backend/workers/core/worker.py +++ b/backend/workers/core/worker.py @@ -9,6 +9,7 @@ import inspect import json import logging +import os import signal from typing import Callable, Dict, Optional, Set from uuid import UUID @@ -39,6 +40,9 @@ DEFAULT_MAX_BACKOFF = 60.0 # seconds DEFAULT_BACKOFF_MULTIPLIER = 2.0 +# Concurrency: max concurrent Kafka message tasks per worker pod +WORKER_CONCURRENCY = int(os.getenv("WORKER_CONCURRENCY", "5")) + class KafkaConnectionError(Exception): """Raised when unable to connect to Kafka after all retries.""" @@ -88,6 +92,8 @@ def __init__( self._shutdown_event: Optional[asyncio.Event] = None self._in_flight_jobs: Set[str] = set() self._in_flight_lock = asyncio.Lock() + self._semaphore = asyncio.Semaphore(WORKER_CONCURRENCY) + self._worker_concurrency = WORKER_CONCURRENCY def register_handler(self, job_type: JobType | str, handler: JobHandler): """ @@ -187,6 +193,7 @@ async def start(self, topics: list[str]): logger.info(f"Worker started, subscribed to: {topics}") self.running = True + logger.info(f"Worker concurrency: {self._worker_concurrency}") # Set up signal handlers for graceful shutdown loop = asyncio.get_running_loop() @@ -323,7 +330,7 @@ async def _consume(self): try: # Poll for messages with a short timeout to allow shutdown checks records = await asyncio.wait_for( - self.consumer.getmany(timeout_ms=1000, max_records=1), + self.consumer.getmany(timeout_ms=1000, max_records=self._worker_concurrency), timeout=2.0, ) except asyncio.TimeoutError: @@ -338,7 +345,7 @@ async def _consume(self): logger.info("Shutdown event received, stopping message consumption") break - # Process received messages + # Process received messages concurrently for tp, messages in records.items(): for message in messages: if not self.running or self._shutdown_event.is_set(): @@ -349,7 +356,12 @@ async def _consume(self): f"partition={message.partition}, offset={message.offset}" ) - await self._process_message(message.value) + await self._semaphore.acquire() + if self._shutdown_event.is_set(): + self._semaphore.release() + break + task = asyncio.create_task(self._process_message(message.value)) + task.add_done_callback(lambda _: self._semaphore.release()) shutdown_task.cancel() diff --git a/backend/workers/handlers/collaboration.py b/backend/workers/handlers/collaboration.py index c0ccb3c..739875d 100644 --- a/backend/workers/handlers/collaboration.py +++ b/backend/workers/handlers/collaboration.py @@ -471,6 +471,23 @@ async def _on_assistant_retry(attempt: int, max_attempts: int) -> None: except Exception as e: logger.warning(f"Failed to auto-advance watermark for triggering user: {e}") + # Onboarding hook: Step 10 — @MFBTAI invoked in sample project + try: + if user_id and created_items: + from app.models.project import Project + + _onb_thread = db.query(Thread).filter(Thread.id == thread_id).first() + if _onb_thread and _onb_thread.project_id: + _onb_project = db.query(Project).filter(Project.id == _onb_thread.project_id).first() + if _onb_project and _onb_project.is_sample: + from app.services.onboarding_service import OnboardingService + + if OnboardingService.complete_step(db, UUID(user_id), 10): + db.commit() + OnboardingService.broadcast_step_completed(_onb_project.org_id, UUID(user_id), 10) + except Exception: + logger.debug("Failed to complete onboarding step 10 for @MFBTAI mention") + # Extract LLM usage stats from model client llm_usage = None model_client = result.get("model_client") diff --git a/backend/workers/handlers/generation.py b/backend/workers/handlers/generation.py index 61b60c7..0be0bb6 100644 --- a/backend/workers/handlers/generation.py +++ b/backend/workers/handlers/generation.py @@ -345,6 +345,36 @@ def progress_callback(progress_data: dict): if not impl: raise ValueError(f"Implementation {implementation_id} not found") + # Onboarding hook: Step 9/11/13 (spec generation) in sample project + # Only fires on spec generation. Step 9 first, then 11, then 13. + # Prompt plan generation is ignored since it's chained from spec. + try: + if created_by_user_id and content_type_str == "spec": + from app.models.feature import Feature as _OFeature + from app.models.module import Module as _OModule + from app.models.project import Project as _OProject + + _o_feature = db.query(_OFeature).filter(_OFeature.id == feature_id).first() + if _o_feature: + _o_module = db.query(_OModule).filter(_OModule.id == _o_feature.module_id).first() + if _o_module: + _o_project = db.query(_OProject).filter(_OProject.id == _o_module.project_id).first() + if _o_project and _o_project.is_sample: + from app.models.user import User as _OUser + from app.services.onboarding_service import OnboardingService + + _o_user = db.query(_OUser).filter(_OUser.id == created_by_user_id).first() + _completed = _o_user.completed_onboarding_steps or [] if _o_user else [] + step = 9 if 9 not in _completed else (11 if 11 not in _completed else 13) + + if OnboardingService.complete_step(db, created_by_user_id, step): + db.commit() + OnboardingService.broadcast_step_completed( + _o_project.org_id, created_by_user_id, step + ) + except Exception: + logger.debug("Failed to complete onboarding step for implementation content generation") + # Check if we should chain to prompt_plan generation chain_prompt_plan = payload.get("chain_prompt_plan", False) chained_job_id = None diff --git a/docker-compose.dev.yaml b/docker-compose.dev.yaml index c2ea2ef..2a478fb 100644 --- a/docker-compose.dev.yaml +++ b/docker-compose.dev.yaml @@ -167,6 +167,7 @@ services: KAFKA_PORT: 29092 REDIS_URL: redis://redis:6379 PYTHONPATH: /app + WORKER_CONCURRENCY: ${WORKER_CONCURRENCY:-5} depends_on: postgres: condition: service_healthy diff --git a/docker-compose.yml b/docker-compose.yml index 4ede0a1..bca2c52 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -129,6 +129,7 @@ services: KAFKA_PORT: 29092 REDIS_URL: redis://redis:6379 PYTHONPATH: /app + WORKER_CONCURRENCY: ${WORKER_CONCURRENCY:-5} depends_on: postgres: condition: service_healthy diff --git a/frontend/app/globals.css b/frontend/app/globals.css index 9d78866..cd2011f 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -408,3 +408,42 @@ body { .dark .animate-highlight-fade { animation: highlight-fade-dark 2.5s ease-out forwards; } + +/* Onboarding tip box slide-in + pulse animation */ +@keyframes tip-slide-in { + 0% { + opacity: 0; + transform: translateY(-6px); + background-color: rgba(30, 58, 95, 0.3); + } + 40% { + opacity: 1; + transform: translateY(0); + } + 50% { + background-color: rgba(30, 58, 138, 0.6); + border-color: rgba(59, 130, 246, 0.5); + box-shadow: 0 0 10px rgba(59, 130, 246, 0.25); + } + 65% { + background-color: rgba(30, 58, 95, 0.3); + border-color: rgba(59, 130, 246, 0.15); + box-shadow: none; + } + 80% { + background-color: rgba(30, 58, 138, 0.5); + border-color: rgba(59, 130, 246, 0.4); + box-shadow: 0 0 6px rgba(59, 130, 246, 0.2); + } + 100% { + opacity: 1; + transform: translateY(0); + background-color: rgba(30, 58, 95, 0.3); + border-color: rgba(59, 130, 246, 0.13); + box-shadow: none; + } +} + +.animate-tip-slide-in { + animation: tip-slide-in 1.2s ease-out forwards; +} diff --git a/frontend/app/projects/ProjectsClient.tsx b/frontend/app/projects/ProjectsClient.tsx index 2b230b5..4d5a032 100644 --- a/frontend/app/projects/ProjectsClient.tsx +++ b/frontend/app/projects/ProjectsClient.tsx @@ -232,7 +232,7 @@ function ProjectsPageContent() { const project = await apiClient.post(`/api/v1/orgs/${orgId}/load-sample-project`, { sample_type: sampleType, }); - // Navigate to the new project + // Navigate to the new project (sidebar + onboarding refresh via WebSocket) router.push(buildProjectUrl(project)); } catch (error) { console.error("Failed to load sample project:", error); diff --git a/frontend/app/projects/[projectId]/brainstorming/[phaseId]/BrainstormingLayoutClient.tsx b/frontend/app/projects/[projectId]/brainstorming/[phaseId]/BrainstormingLayoutClient.tsx index 1491ae5..cf08a0e 100644 --- a/frontend/app/projects/[projectId]/brainstorming/[phaseId]/BrainstormingLayoutClient.tsx +++ b/frontend/app/projects/[projectId]/brainstorming/[phaseId]/BrainstormingLayoutClient.tsx @@ -212,10 +212,21 @@ export default function BrainstormingLayoutClient({ children }: BrainstormingLay const isActive = activeTab === tab.href; const Icon = tab.icon; + // Map tab names to onboarding data attributes + const onboardingAttr = + tab.name === "Specification" + ? "spec-tab" + : tab.name === "Prompt Plan" + ? "prompt-plan-tab" + : tab.name === "Phase Features" + ? "phase-features-tab" + : undefined; + return ( { + for (const container of sortedContainers) { + const cPhases = phasesByContainer.get(container.id); + if (cPhases?.length) return cPhases[0].id; + } + return ungroupedPhases[0]?.id; + }, [sortedContainers, phasesByContainer, ungroupedPhases]); + const handleNewPhase = () => { setIsCreateChoiceOpen(true); }; @@ -315,6 +324,7 @@ function BrainstormingPhasesPageContent() { container={container} onAddExtension={handleAddExtension} onArchiveContainer={handleArchiveContainerClick} + dataOnboarding={phase.id === firstPhaseId ? "phase-row-first" : undefined} /> ); } @@ -332,6 +342,7 @@ function BrainstormingPhasesPageContent() { onArchiveContainer={handleArchiveContainerClick} onAddExtension={handleAddExtension} currentProject={currentProject} + firstPhaseId={firstPhaseId} /> ); })} @@ -345,6 +356,7 @@ function BrainstormingPhasesPageContent() { onClick={() => handlePhaseClick(phase)} onArchive={(e) => handleArchivePhaseClick(phase, e)} onRename={(e) => handleRenamePhaseClick(phase, e)} + dataOnboarding={phase.id === firstPhaseId ? "phase-row-first" : undefined} /> ))} @@ -461,6 +473,7 @@ function ContainerGroup({ onRenamePhase, onArchiveContainer, onAddExtension, + firstPhaseId, }: { container: PhaseContainer; phases: BrainstormingPhase[]; @@ -472,6 +485,7 @@ function ContainerGroup({ onArchiveContainer: (container: PhaseContainer, e: React.MouseEvent) => void; onAddExtension: (container: PhaseContainer, e: React.MouseEvent) => void; currentProject: ReturnType["currentProject"]; + firstPhaseId?: string; }) { const DisclosureIcon = expanded ? ChevronDown : ChevronRight; @@ -528,6 +542,7 @@ function ContainerGroup({ onClick={() => onPhaseClick(phase)} onArchive={(e) => onArchivePhase(phase, e)} onRename={(e) => onRenamePhase(phase, e)} + dataOnboarding={phase.id === firstPhaseId ? "phase-row-first" : undefined} /> ))} @@ -544,6 +559,7 @@ function PhaseRow({ container, onAddExtension, onArchiveContainer, + dataOnboarding, }: { phase: BrainstormingPhase; indented: boolean; @@ -553,11 +569,16 @@ function PhaseRow({ container?: PhaseContainer; onAddExtension?: (container: PhaseContainer, e: React.MouseEvent) => void; onArchiveContainer?: (container: PhaseContainer, e: React.MouseEvent) => void; + dataOnboarding?: string; }) { const label = indented ? getPhaseLabel(phase) : phase.title; return ( - +
diff --git a/frontend/app/projects/[projectId]/project-settings/page.tsx b/frontend/app/projects/[projectId]/project-settings/page.tsx index 412fde4..a8b7771 100644 --- a/frontend/app/projects/[projectId]/project-settings/page.tsx +++ b/frontend/app/projects/[projectId]/project-settings/page.tsx @@ -62,7 +62,7 @@ export default function ProjectSettingsPage() { General - + MCP diff --git a/frontend/components/AppTopNav.tsx b/frontend/components/AppTopNav.tsx index 1b03724..b20b6fa 100644 --- a/frontend/components/AppTopNav.tsx +++ b/frontend/components/AppTopNav.tsx @@ -84,6 +84,17 @@ export function AppTopNav() { ([]); const [selectedKeyId, setSelectedKeyId] = useState(null); const [isLoadingKeys, setIsLoadingKeys] = useState(true); + const { markStepComplete } = useOnboarding(); useEffect(() => { loadConfig(); @@ -208,6 +210,7 @@ export function MCPConnectionInfo({ projectId }: MCPConnectionInfoProps) { try { await navigator.clipboard.writeText(text); setCopiedField(field); + markStepComplete(14); setTimeout(() => setCopiedField(null), 2000); } catch (err) { console.error("Failed to copy:", err); @@ -228,6 +231,7 @@ export function MCPConnectionInfo({ projectId }: MCPConnectionInfoProps) { try { await navigator.clipboard.writeText(textToCopy); + markStepComplete(14); toast({ title: "Copied!", description: `${AGENT_CONFIGS[agentKey].name} configuration copied to clipboard`, @@ -251,6 +255,7 @@ export function MCPConnectionInfo({ projectId }: MCPConnectionInfoProps) { try { await navigator.clipboard.writeText(textToCopy); + markStepComplete(14); toast({ title: "Copied!", description: `${OAUTH_AGENT_CONFIGS[agentKey].name} OAuth configuration copied to clipboard`, @@ -362,7 +367,12 @@ export function MCPConnectionInfo({ projectId }: MCPConnectionInfoProps) { {/* API Key Config Button */} - +
+

+ Load the Flappy McVibe sample project from the{" "} + Start page to begin the guided onboarding + tour. +

+ +
+ ); + } + + const completedCount = completedSteps.length; + + // The first incomplete step — only this step is actionable + const firstIncompleteStep = steps.find((s) => !completedSteps.includes(s.number)); + + const showHighlightTooltip = ( + selector: string, + text: string, + position: "above" | "below" = "above", + onDismiss?: () => void, + ) => { + // Remove any existing tooltip + document.querySelector(".onboarding-highlight-tooltip")?.remove(); + + const tryHighlight = (attempts: number) => { + if (attempts <= 0) return; + const el = document.querySelector(selector) as HTMLElement | null; + if (el) { + // Scroll element into view first, then position tooltip after scroll settles + el.scrollIntoView({ behavior: "smooth", block: "center" }); + + const positionAndShow = () => { + // Create tooltip element + const tooltip = document.createElement("div"); + tooltip.className = "onboarding-highlight-tooltip"; + Object.assign(tooltip.style, { + position: "fixed", + zIndex: "9999", + background: "#1e3a5f", + color: "#fff", + padding: "8px 12px", + borderRadius: "6px", + fontSize: "13px", + maxWidth: "280px", + boxShadow: "0 4px 12px rgba(0,0,0,0.3)", + border: "1px solid rgba(59,130,246,0.3)", + pointerEvents: onDismiss ? "auto" : "none", + transition: "opacity 0.2s", + }); + + // Add text + const textNode = document.createElement("span"); + textNode.textContent = text; + tooltip.appendChild(textNode); + + // Cleanup helper + const cleanupTooltip = () => { + tooltip.style.opacity = "0"; + el.classList.remove("onboarding-pulse-ring"); + el.style.boxShadow = ""; + el.removeEventListener("click", handleTargetClick); + setTimeout(() => tooltip.remove(), 300); + }; + + // Dismiss tooltip when the highlighted element is clicked + const handleTargetClick = () => cleanupTooltip(); + el.addEventListener("click", handleTargetClick, { once: true }); + + // Add OK button if onDismiss callback provided + if (onDismiss) { + const btn = document.createElement("button"); + btn.textContent = "OK"; + Object.assign(btn.style, { + display: "block", + marginTop: "8px", + marginLeft: "auto", + padding: "2px 16px", + borderRadius: "4px", + border: "1px solid rgba(59,130,246,0.5)", + background: "rgba(59,130,246,0.2)", + color: "#93c5fd", + fontSize: "12px", + fontWeight: "500", + cursor: "pointer", + }); + btn.addEventListener("mouseenter", () => { + btn.style.background = "rgba(59,130,246,0.4)"; + }); + btn.addEventListener("mouseleave", () => { + btn.style.background = "rgba(59,130,246,0.2)"; + }); + btn.addEventListener("click", () => { + cleanupTooltip(); + onDismiss(); + }); + tooltip.appendChild(btn); + } + + document.body.appendChild(tooltip); + + const rect = el.getBoundingClientRect(); + const tooltipRect = tooltip.getBoundingClientRect(); + tooltip.style.left = `${rect.left + rect.width / 2 - tooltipRect.width / 2}px`; + + if (position === "below") { + tooltip.style.top = `${rect.bottom + 8}px`; + } else { + tooltip.style.top = `${rect.top - tooltipRect.height - 8}px`; + } + + // Add arrow + const arrow = document.createElement("div"); + if (position === "below") { + Object.assign(arrow.style, { + position: "absolute", + top: "-5px", + left: "50%", + transform: "translateX(-50%)", + width: "0", + height: "0", + borderLeft: "6px solid transparent", + borderRight: "6px solid transparent", + borderBottom: "6px solid #1e3a5f", + }); + } else { + Object.assign(arrow.style, { + position: "absolute", + bottom: "-5px", + left: "50%", + transform: "translateX(-50%)", + width: "0", + height: "0", + borderLeft: "6px solid transparent", + borderRight: "6px solid transparent", + borderTop: "6px solid #1e3a5f", + }); + } + tooltip.appendChild(arrow); + + // Add animated pulsing ring to the element + el.classList.add("onboarding-pulse-ring"); + // Inject keyframes if not already present + if (!document.getElementById("onboarding-pulse-style")) { + const style = document.createElement("style"); + style.id = "onboarding-pulse-style"; + style.textContent = ` + @keyframes onboarding-pulse { + 0% { box-shadow: 0 0 0 0 rgba(250,204,21,0.7); } + 70% { box-shadow: 0 0 0 8px rgba(250,204,21,0); } + 100% { box-shadow: 0 0 0 0 rgba(250,204,21,0); } + } + .onboarding-pulse-ring { + animation: onboarding-pulse 1.5s ease-in-out infinite; + border-radius: 4px; + } + `; + document.head.appendChild(style); + } + + // Auto-dismiss after 10 seconds (skip if OK button present — user must click) + if (!onDismiss) { + setTimeout(cleanupTooltip, 10000); + } + }; // end positionAndShow + + // Wait for scroll to settle before positioning tooltip + setTimeout(positionAndShow, 400); + } else { + setTimeout(() => tryHighlight(attempts - 1), 300); + } + }; + tryHighlight(20); + }; + + const navigateToStep = (step: OnboardingStep) => { + setExpandedTipStep(step.number); + + // Step 1: multi-stage guided navigation + if (step.number === 1 && sampleProject) { + const projectBase = buildProjectUrl(sampleProject); + if (pathname === `${projectBase}/brainstorming`) { + // Already on brainstorming list → show tooltip on Phase 1 row + showHighlightTooltip( + "[data-onboarding='phase-row-first']", + "Click this phase to explore how brainstorming conversations, specs, and prompt plans work together.", + "below", + ); + } else { + // Navigate to sample project if needed, then show brainstorming tab tooltip + if (!pathname.startsWith(projectBase)) { + router.push(`${projectBase}/project-chat`); + } + setTimeout( + () => + showHighlightTooltip( + "[data-onboarding='brainstorming-tab']", + 'Click "Brainstorming" to see the planning phases for this project.', + "below", + ), + 800, + ); + } + return; + } + + // Steps 6-8: Informational feature tour — navigate, highlight, complete when user clicks OK + if ([6, 7, 8].includes(step.number)) { + if (step.routePattern) { + const alreadyOnPage = + step.highlightSelector && document.querySelector(step.highlightSelector); + if (!alreadyOnPage) { + router.push(step.routePattern); + } + } + if (step.highlightSelector && step.highlightText) { + const delay = + step.routePattern && !document.querySelector(step.highlightSelector!) ? 800 : 0; + setTimeout(() => { + showHighlightTooltip( + step.highlightSelector!, + step.highlightText!, + step.highlightPosition, + () => markStepComplete(step.number), + ); + }, delay); + } + return; + } + + // Step 9: Multi-stage — highlight feature row on list, or navigate to detail + if (step.number === 9) { + const projectKey = stepOptions?.projectKey ?? "FMV"; + const featureRowSelector = `[data-feature-key='${projectKey}-010']`; + const featureRow = document.querySelector(featureRowSelector); + + if (featureRow) { + // On features list — highlight the feature #010 row + showHighlightTooltip( + featureRowSelector, + "Click this feature to open its conversation and create a spec + prompt plan.", + "below", + ); + return; + } + // Falls through to standard navigation (routePattern → fmv010Route) + } + + // Step 10: Multi-stage — highlight feature row on list, or navigate to detail + prefill + if (step.number === 10) { + const projectKey = stepOptions?.projectKey ?? "FMV"; + const featureRowSelector = `[data-feature-key='${projectKey}-011']`; + const featureRow = document.querySelector(featureRowSelector); + + if (featureRow) { + // On features list — highlight the feature #011 row + showHighlightTooltip( + featureRowSelector, + "Click this feature to open its conversation and invoke @MFBTAI for AI assistance.", + "below", + ); + return; + } + + // Navigate to feature detail and prefill + if (stepOptions?.fmv011Slug && sampleProject) { + const fmv011Route = `${buildProjectUrl(sampleProject)}/features/${stepOptions.fmv011Slug}`; + router.push(fmv011Route); + if (step.prefillText) { + prefillChatbox(step.prefillText); + } + return; + } + } + + // Steps 11 & 13: Create Spec — if "Create Implementation" button isn't visible, show a waiting message + if (step.number === 11 || step.number === 13) { + // Navigate if needed + if (step.routePattern) { + const alreadyOnPage = + step.highlightSelector && document.querySelector(step.highlightSelector); + if (!alreadyOnPage) { + router.push(step.routePattern); + } + } + + const tryFindButton = (attempts: number) => { + if (attempts <= 0) { + // Button not found — show a waiting message tooltip near the bottom of the conversation + const editor = document.querySelector(".tiptap.ProseMirror") as HTMLElement | null; + if (editor) { + scrollConversationToBottom(); + } + document.querySelector(".onboarding-highlight-tooltip")?.remove(); + const tooltip = document.createElement("div"); + tooltip.className = "onboarding-highlight-tooltip"; + tooltip.textContent = + 'The "Create Implementation from Conversation" button will appear once the decision summary finishes generating. This may take a minute or two — please wait.'; + Object.assign(tooltip.style, { + position: "fixed", + zIndex: "9999", + background: "#1e3a5f", + color: "#fff", + padding: "10px 14px", + borderRadius: "6px", + fontSize: "13px", + maxWidth: "320px", + boxShadow: "0 4px 12px rgba(0,0,0,0.3)", + border: "1px solid rgba(234,179,8,0.4)", + pointerEvents: "none", + transition: "opacity 0.2s", + left: "50%", + top: "50%", + transform: "translate(-50%, -50%)", + }); + document.body.appendChild(tooltip); + setTimeout(() => { + tooltip.style.opacity = "0"; + setTimeout(() => tooltip.remove(), 300); + }, 8000); + return; + } + const btn = document.querySelector(step.highlightSelector!); + if (btn) { + scrollConversationToBottom(); + setTimeout(() => { + showHighlightTooltip( + step.highlightSelector!, + step.highlightText!, + step.highlightPosition, + ); + }, 400); + } else { + setTimeout(() => tryFindButton(attempts - 1), 500); + } + }; + setTimeout(() => tryFindButton(20), 800); + return; + } + + // Step 12: Multi-stage — highlight feature #012 row on list, or show MCQ on detail + if (step.number === 12 && sampleProject) { + const projectKey = stepOptions?.projectKey ?? "FMV"; + const featureRowSelector = `[data-feature-key='${projectKey}-012']`; + const featureRow = document.querySelector(featureRowSelector); + const projectBase = buildProjectUrl(sampleProject); + const onFeature012Detail = + stepOptions?.fmv012Slug && + pathname.startsWith(`${projectBase}/features/`) && + pathname.includes(stepOptions.fmv012Slug); + + if (onFeature012Detail) { + // Already on feature #012 detail — scroll to and highlight MCQ + const tryFindMcq = (attempts: number) => { + if (attempts <= 0) return; + const mcq = document.querySelector( + "[data-onboarding='mcq-unanswered']", + ) as HTMLElement | null; + if (mcq) { + mcq.scrollIntoView({ behavior: "smooth", block: "center" }); + setTimeout(() => { + showHighlightTooltip( + "[data-onboarding='mcq-unanswered']", + "mfbt AI generates easy-to-answer multiple choice questions to clarify gaps in the spec. Answer all unanswered questions to move forward!", + ); + }, 500); + } else { + setTimeout(() => tryFindMcq(attempts - 1), 500); + } + }; + setTimeout(() => tryFindMcq(20), 500); + return; + } + + if (featureRow) { + // On features list — highlight the feature #012 row + showHighlightTooltip( + featureRowSelector, + "Click this feature to see the AI-generated questions that need answering.", + "below", + ); + return; + } + + // Not on features list or detail — navigate to features list, then highlight row + router.push(`${projectBase}/features`); + setTimeout(() => { + showHighlightTooltip( + featureRowSelector, + "Click this feature to see the AI-generated questions that need answering.", + "below", + ); + }, 800); + return; + } + + // Step 14: Multi-stage MCP settings tour + if (step.number === 14 && sampleProject) { + const projectBase = buildProjectUrl(sampleProject); + const onSettingsPage = pathname.startsWith(`${projectBase}/project-settings`); + const mcpTabActive = document.querySelector( + "[data-onboarding='mcp-tab'][data-state='active']", + ); + const apiKeyBtn = document.querySelector("[data-onboarding='mcp-api-key-btn']"); + + if (apiKeyBtn) { + // Stage 3: MCP tab is open, API Key button visible — highlight it + showHighlightTooltip( + "[data-onboarding='mcp-api-key-btn']", + 'Click "API Key" to copy the MCP connection config for your coding agent (Claude Code, Cursor, etc.).', + "below", + ); + return; + } + + if (mcpTabActive || (onSettingsPage && pathname.includes("tab=mcp"))) { + // Stage 2b: MCP tab is active but API key button not yet rendered — wait for it + const tryFindApiKey = (attempts: number) => { + if (attempts <= 0) return; + const btn = document.querySelector("[data-onboarding='mcp-api-key-btn']"); + if (btn) { + showHighlightTooltip( + "[data-onboarding='mcp-api-key-btn']", + 'Click "API Key" to copy the MCP connection config for your coding agent (Claude Code, Cursor, etc.).', + "below", + ); + } else { + setTimeout(() => tryFindApiKey(attempts - 1), 300); + } + }; + setTimeout(() => tryFindApiKey(20), 300); + return; + } + + if (onSettingsPage) { + // Stage 2: On settings page but not on MCP tab — highlight MCP tab + showHighlightTooltip( + "[data-onboarding='mcp-tab']", + 'Click "MCP" to see how to connect your coding agent to this project.', + "below", + ); + return; + } + + // Stage 1: Not on settings page — highlight Settings nav tab + const settingsNav = document.querySelector("[data-onboarding='settings-nav']"); + if (settingsNav) { + showHighlightTooltip( + "[data-onboarding='settings-nav']", + 'Click "Settings" to access the project configuration page.', + "below", + ); + } else { + // Navigate to project first, then highlight Settings + router.push(`${projectBase}/project-chat`); + setTimeout(() => { + showHighlightTooltip( + "[data-onboarding='settings-nav']", + 'Click "Settings" to access the project configuration page.', + "below", + ); + }, 800); + } + return; + } + + // Step 15: show coding agent implementation modal + if (step.number === 15) { + setShowStep15Modal(true); + return; + } + + // Step 16: Multi-stage grounding tour + if (step.number === 16 && sampleProject) { + const projectBase = buildProjectUrl(sampleProject); + const onGroundingPage = pathname.startsWith(`${projectBase}/grounding`); + + if (onGroundingPage) { + // Stage 2: On grounding page — highlight the "main" branch tab + const mainTab = document.querySelector("[data-onboarding='grounding-branch-main']"); + if (mainTab) { + showHighlightTooltip( + "[data-onboarding='grounding-branch-main']", + 'Click the "main" branch tab to see the branch-specific agents.md generated from your implementations.', + "below", + ); + } else { + // Branch tabs may not be loaded yet — wait + const tryFind = (attempts: number) => { + if (attempts <= 0) return; + const tab = document.querySelector("[data-onboarding='grounding-branch-main']"); + if (tab) { + showHighlightTooltip( + "[data-onboarding='grounding-branch-main']", + 'Click the "main" branch tab to see the branch-specific agents.md generated from your implementations.', + "below", + ); + } else { + setTimeout(() => tryFind(attempts - 1), 500); + } + }; + setTimeout(() => tryFind(20), 500); + } + return; + } + + // Stage 1: Not on grounding page — highlight Grounding nav tab + const groundingNav = document.querySelector("[data-onboarding='grounding-nav']"); + if (groundingNav) { + showHighlightTooltip( + "[data-onboarding='grounding-nav']", + 'Click "Grounding" to see the generated agents.md file — your project\'s living knowledge base.', + "below", + ); + } else { + router.push(`${projectBase}/project-chat`); + setTimeout(() => { + showHighlightTooltip( + "[data-onboarding='grounding-nav']", + 'Click "Grounding" to see the generated agents.md file — your project\'s living knowledge base.', + "below", + ); + }, 800); + } + return; + } + + // Step 18: navigate to the feature created in step 17 and show implementation modal + if (step.number === 18 && step17Feature && sampleProject) { + router.push(`${buildProjectUrl(sampleProject)}/features/${step17Feature.feature_url_id}`); + setShowStep18Modal(true); + return; + } + + // Navigate only if the highlight target isn't already on the current page + if (step.routePattern) { + const alreadyOnPage = + step.highlightSelector && document.querySelector(step.highlightSelector); + if (!alreadyOnPage) { + router.push(step.routePattern); + } + } + if (step.prefillText) { + prefillChatbox(step.prefillText); + // Show highlight tooltip after prefill completes + if (step.highlightSelector && step.highlightText) { + // Step 17: use OK button (no auto-dismiss) + const dismissCb = step.number === 17 ? () => {} : undefined; + setTimeout(() => { + showHighlightTooltip( + step.highlightSelector!, + step.highlightText!, + step.highlightPosition, + dismissCb, + ); + }, 1500); + } + } else if (step.scrollToBottom) { + // Scroll after navigation completes, then show highlight if needed + const tryScroll = (attempts: number) => { + if (attempts <= 0) return; + const editor = document.querySelector(".tiptap.ProseMirror"); + if (editor) { + scrollConversationToBottom(); + // Show highlight tooltip after scroll settles + if (step.highlightSelector && step.highlightText) { + setTimeout(() => { + showHighlightTooltip( + step.highlightSelector!, + step.highlightText!, + step.highlightPosition, + ); + }, 400); + } + } else { + setTimeout(() => tryScroll(attempts - 1), 300); + } + }; + setTimeout(() => tryScroll(10), 500); + } else if (step.scrollToElement) { + // Scroll a specific element into view, then show highlight + const tryScrollTo = (attempts: number) => { + if (attempts <= 0) return; + const target = document.querySelector(step.scrollToElement!) as HTMLElement | null; + if (target) { + target.scrollIntoView({ behavior: "smooth", block: "center" }); + if (step.highlightSelector && step.highlightText) { + setTimeout(() => { + showHighlightTooltip( + step.highlightSelector!, + step.highlightText!, + step.highlightPosition, + ); + }, 500); + } + } else { + setTimeout(() => tryScrollTo(attempts - 1), 300); + } + }; + setTimeout(() => tryScrollTo(15), 500); + } else if (step.highlightSelector && step.highlightText) { + // Delay if we navigated to a new page + if (step.routePattern) { + setTimeout(() => { + showHighlightTooltip( + step.highlightSelector!, + step.highlightText!, + step.highlightPosition, + ); + }, 800); + } else { + showHighlightTooltip(step.highlightSelector, step.highlightText, step.highlightPosition); + } + } + }; + + // Minimized bar + if (isPanelMinimized) { + return ( +
+ + Onboarding · {completedCount}/{steps.length} + + +
+ ); + } + + // Contextual compact mode + if (activeStep) { + return ( +
+ {/* Tip info box — above the panel */} + {!dismissedTips.has(activeStep.number) && ( + dismissTip(activeStep.number)} /> + )} +
+
+ + Step {activeStep.number} / {steps.length} + +
+ + +
+
+

{activeStep.guidance}

+ {/* Progress bar */} +
+
+
+
+ +
+ ); + } + + // Full checklist mode + return ( +
+ {/* Tip box — shown above the entire panel */} + {expandedTipStep !== null && + (() => { + const tipStep = steps.find((s) => s.number === expandedTipStep); + const isLockedStep = + tipStep && + !completedSteps.includes(tipStep.number) && + firstIncompleteStep && + tipStep.number !== firstIncompleteStep.number; + return ( + setExpandedTipStep(null)} + /> + ); + })()} + +
+ {/* Header */} +
+ Get Started +
+ + {completedCount}/{steps.length} + + + +
+
+ + {/* Progress bar */} +
+
+
+ + {/* Step list */} +
+ {steps.map((step) => { + const isComplete = completedSteps.includes(step.number); + const hasRoute = !!step.routePattern; + // Step 18 is clickable if step 17 feature info is available + const hasAction = + hasRoute || step.number === 15 || (step.number === 18 && !!step17Feature); + // Only the first incomplete step is actionable; others are locked + const isNextStep = firstIncompleteStep?.number === step.number; + const isClickable = hasAction && isNextStep; + const isLocked = hasAction && !isComplete && !isNextStep; + const isTipExpanded = expandedTipStep === step.number; + + const handleStepClick = () => { + if (isComplete) return; + if (isLocked && firstIncompleteStep) { + // Show the locked step's tip with a "complete step X first" message + setExpandedTipStep(step.number); + return; + } + if (isClickable) { + navigateToStep(step); + } + }; + + return ( +
+ {isComplete ? ( + + ) : isNextStep ? ( + + ) : isLocked ? ( + + ) : ( + + )} + + {step.number}. {step.label} + + {isNextStep && ( + + Next + + )} + +
+ ); + })} +
+ + { + navigator.clipboard.writeText( + "Implement all features in the next unimplemented module via mfbt one by one and stop", + ); + setStep15Copied(true); + setTimeout(() => setStep15Copied(false), 2000); + }} + /> + { + const prompt = `Implement ${step17Feature?.feature_key} via mfbt`; + navigator.clipboard.writeText(prompt); + setStep18Copied(true); + setTimeout(() => setStep18Copied(false), 2000); + }} + onDismissNotReady={() => { + // Scroll down and highlight the "Create Implementation" button + scrollConversationToBottom(); + setTimeout(() => { + showHighlightTooltip( + "[data-onboarding='create-implementation']", + "Click here to create spec and prompt plan from this conversation", + ); + }, 500); + }} + /> + +
+
+ ); +} + +function StepTipBox({ + tip, + lockMessage, + onDismiss, +}: { + tip: string; + lockMessage?: string; + onDismiss: () => void; +}) { + return ( +
+ + {lockMessage && ( +

{lockMessage}

+ )} +

{tip}

+
+ ); +} + +function ImplementStep15Dialog({ + open, + onOpenChange, + copied, + onCopy, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + copied: boolean; + onCopy: () => void; +}) { + const prompt = + "Implement all features in the next unimplemented module via mfbt one by one and stop"; + + return ( + + + + Implement features with your coding agent + + Use the MCP connection you configured in the previous step to let your coding agent + build features automatically. + + + +
+
+

+ 1. Make sure your coding agent (Claude Code, Cursor, etc.) is + configured with the MCP connection details you copied in step 14. +

+

+ 2. Copy the prompt below and paste it into your coding agent: +

+
+ +
+ {prompt} + +
+ +

+ The coding agent will read specs and prompt plans via MCP, implement features one by + one, and mark each as complete. Once any implementation is marked complete, step 15 will + be checked off automatically. +

+
+
+
+ ); +} + +function ImplementStep18Dialog({ + open, + onOpenChange, + featureKey, + featureUrlId, + copied, + onCopy, + onDismissNotReady, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + featureKey: string; + featureUrlId: string; + copied: boolean; + onCopy: () => void; + onDismissNotReady?: () => void; +}) { + const [hasSpec, setHasSpec] = useState(null); + const [hasPromptPlan, setHasPromptPlan] = useState(null); + + // Fetch feature readiness when modal opens + // Check both Feature fields AND Implementation records for spec/prompt_plan + useEffect(() => { + if (!open || !featureUrlId) return; + setHasSpec(null); + setHasPromptPlan(null); + + Promise.all([ + apiClient.get(`/api/v1/features/${featureUrlId}`), + apiClient.get(`/api/v1/features/${featureUrlId}/implementations`), + ]) + .then(([feature, implementations]) => { + const implHasSpec = implementations.some((impl: any) => impl.has_spec); + const implHasPromptPlan = implementations.some((impl: any) => impl.has_prompt_plan); + setHasSpec(!!feature.has_spec || implHasSpec); + setHasPromptPlan(!!feature.has_prompt_plan || implHasPromptPlan); + }) + .catch(() => { + setHasSpec(false); + setHasPromptPlan(false); + }); + }, [open, featureUrlId]); + + const isReady = hasSpec && hasPromptPlan; + const prompt = `Implement ${featureKey} via mfbt`; + + const handleOpenChange = (nextOpen: boolean) => { + onOpenChange(nextOpen); + // When closing and not ready, trigger scroll + highlight on the Create Implementation button + if (!nextOpen && !isReady && onDismissNotReady) { + setTimeout(onDismissNotReady, 300); + } + }; + + return ( + + + + Implement your feature + + {isReady + ? "Your feature is ready to be implemented by a coding agent." + : "Your feature needs a spec and prompt plan before it can be implemented."} + + + +
+ {/* Readiness checklist */} +
+
+ {hasSpec ? ( + + ) : ( + + )} + + Specification +
+
+ {hasPromptPlan ? ( + + ) : ( + + )} + + Prompt Plan +
+
+ + {isReady ? ( + <> + {/* Copy prompt */} +
+

+ Copy this prompt and paste it into the coding agent you connected in step 14: +

+
+ + {prompt} + + +
+
+ +

+ The coding agent will read the spec and prompt plan via MCP, then implement the + feature. Once the agent marks it complete, step 18 will be checked off + automatically. +

+ + ) : ( +
+

+ Answer any unanswered questions mfbt AI has for you. Then, scroll down to the + conversation on this page and click the{" "} + “Create Implementation from Conversation” button to + generate the spec and prompt plan. Once both are ready, click step 18 again. +

+
+ )} +
+
+
+ ); +} + +function DismissDialog({ + open, + onOpenChange, + onConfirm, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + onConfirm: () => void; +}) { + return ( + + + + Hide onboarding? + + The onboarding checklist will be hidden. You can bring it back anytime from the user + menu in the top right corner. + + + + Cancel + Hide + + + + ); +} diff --git a/frontend/components/sidebar/SidebarProjectItem.tsx b/frontend/components/sidebar/SidebarProjectItem.tsx index 03fd3cd..0a9f321 100644 --- a/frontend/components/sidebar/SidebarProjectItem.tsx +++ b/frontend/components/sidebar/SidebarProjectItem.tsx @@ -4,6 +4,7 @@ import { useState } from "react"; import { useRouter } from "next/navigation"; import { Hash, ChevronRight } from "lucide-react"; import type { Project, UnifiedConversation } from "@/lib/api/types"; +import { useOnboarding } from "@/lib/contexts/OnboardingContext"; import { buildProjectUrl } from "@/lib/url"; import { SidebarNotificationItem } from "./SidebarNotificationItem"; @@ -27,12 +28,17 @@ export function SidebarProjectItem({ defaultExpanded = false, }: SidebarProjectItemProps) { const router = useRouter(); + const { isOnboarded } = useOnboarding(); const [expanded, setExpanded] = useState(defaultExpanded); const hasNotifications = notifications.length > 0; const totalUnread = notifications.reduce((sum, n) => sum + n.unread_count, 0); const handleProjectClick = () => { - router.push(`${buildProjectUrl(project)}/project-chat`); + if (project.is_sample && !isOnboarded) { + router.push(`${buildProjectUrl(project)}/brainstorming`); + } else { + router.push(`${buildProjectUrl(project)}/project-chat`); + } }; const handleChevronClick = (e: React.MouseEvent) => { diff --git a/frontend/components/sidebar/SidebarProjectList.tsx b/frontend/components/sidebar/SidebarProjectList.tsx index f21d53b..6dc621d 100644 --- a/frontend/components/sidebar/SidebarProjectList.tsx +++ b/frontend/components/sidebar/SidebarProjectList.tsx @@ -10,6 +10,7 @@ import type { Project, UnifiedConversation } from "@/lib/api/types"; import { InboxSection as InboxSectionEnum } from "@/lib/api/types"; import { useInboxWebSocket } from "@/lib/hooks/useInboxWebSocket"; import { useInboxEventHandler } from "@/lib/hooks/useInboxEventHandler"; +import { useWebSocketSubscription } from "@/lib/websocket/useWebSocketSubscription"; import { buildProjectUrl } from "@/lib/url"; import { SidebarProjectItem } from "./SidebarProjectItem"; @@ -80,6 +81,13 @@ export function SidebarProjectList() { load(); }, [orgId, fetchProjects, fetchConversations]); + // Refresh project list when projects are created/deleted/archived + useWebSocketSubscription({ + messageTypes: ["project_list_changed"], + onMessage: fetchProjects, + enabled: !!orgId, + }); + // WebSocket handlers const handleConversationUpdate = useCallback(() => { fetchConversations(); @@ -172,13 +180,23 @@ export function SidebarProjectList() { break; } case "feature": { - // Navigate to brainstorming phase conversations page with ?feature= to open thread panel - const phaseSlug = conversation.url_identifier || conversation.id; - const queryParams: string[] = []; - if (conversation.feature_id) queryParams.push(`feature=${conversation.feature_id}`); - if (messageSequence) queryParams.push(`m=${messageSequence}`); - const queryString = queryParams.length > 0 ? `?${queryParams.join("&")}` : ""; - url = `${projectPath}/brainstorming/${phaseSlug}/conversations${queryString}`; + if (conversation.phase_short_id) { + // Feature with a brainstorming phase - route to phase conversations page + const phaseSlug = conversation.url_identifier || conversation.id; + const queryParams: string[] = []; + if (conversation.feature_id) queryParams.push(`feature=${conversation.feature_id}`); + if (messageSequence) queryParams.push(`m=${messageSequence}`); + const queryString = queryParams.length > 0 ? `?${queryParams.join("&")}` : ""; + url = `${projectPath}/brainstorming/${phaseSlug}/conversations${queryString}`; + } else { + // Phase-less feature (e.g., created from project chat) - route to feature detail page + const featureSlug = + conversation.feature_key && conversation.feature_short_id + ? `${conversation.feature_key.toLowerCase()}-${conversation.feature_short_id}` + : conversation.feature_id || conversation.id; + const messageParam = messageSequence ? `?m=${messageSequence}` : ""; + url = `${projectPath}/features/${featureSlug}${messageParam}`; + } break; } case "phase": { diff --git a/frontend/lib/api/client.ts b/frontend/lib/api/client.ts index dee76c9..1448c2c 100644 --- a/frontend/lib/api/client.ts +++ b/frontend/lib/api/client.ts @@ -484,6 +484,14 @@ export class ApiClient { return this.get("/api/v1/auth/me/trial-status"); } + async dismissOnboarding(): Promise { + return this.patch("/api/v1/auth/me/onboarding", { is_onboarded: true }); + } + + async markOnboardingStepComplete(step: number): Promise { + return this.patch("/api/v1/auth/me/onboarding/steps", { step }); + } + async updateOrg(orgId: string, data: { name?: string }): Promise { return this.patch(`/api/v1/orgs/${orgId}`, data); } diff --git a/frontend/lib/api/types.ts b/frontend/lib/api/types.ts index a49330e..3629611 100644 --- a/frontend/lib/api/types.ts +++ b/frontend/lib/api/types.ts @@ -4,6 +4,8 @@ export interface User { display_name: string | null; org_id: string; created_at: string; + is_onboarded: boolean; + completed_onboarding_steps: number[]; } export interface PlanStatus { @@ -286,6 +288,7 @@ export interface Project { url_identifier: string; // Multi-repo support repositories: ProjectRepository[]; + is_sample?: boolean; } export type JobStatus = "queued" | "running" | "succeeded" | "failed" | "cancelled"; @@ -2730,6 +2733,7 @@ export interface UnifiedConversation { last_message_author?: string | null; feature_key?: string | null; feature_id?: string | null; + feature_short_id?: string | null; module_title?: string | null; phase_title?: string | null; phase_short_id?: string | null; diff --git a/frontend/lib/contexts/OnboardingContext.tsx b/frontend/lib/contexts/OnboardingContext.tsx new file mode 100644 index 0000000..8ac81df --- /dev/null +++ b/frontend/lib/contexts/OnboardingContext.tsx @@ -0,0 +1,315 @@ +"use client"; + +import { + createContext, + useContext, + useState, + useCallback, + useEffect, + useRef, + ReactNode, +} from "react"; +import { usePathname } from "next/navigation"; +import { useAuth } from "@/lib/auth/AuthContext"; +import { apiClient } from "@/lib/api/client"; +import { + buildStepConfig, + getActiveStep, + type OnboardingStep, + type StepConfigOptions, +} from "@/lib/onboarding/steps"; +import type { Project } from "@/lib/api/types"; +import { slugify } from "@/lib/url"; +import { useWebSocketSubscription } from "@/lib/websocket/useWebSocketSubscription"; + +const MINIMIZE_KEY = "mfbt_onboarding_minimized"; +const HIDDEN_KEY = "mfbt_onboarding_hidden"; +const STEP17_FEATURE_KEY = "mfbt_onboarding_step17_feature"; + +export interface Step17FeatureInfo { + feature_key: string; + feature_url_id: string; +} + +interface OnboardingContextType { + isOnboarded: boolean; + completedSteps: number[]; + activeStep: OnboardingStep | null; + isPanelMinimized: boolean; + isPanelHidden: boolean; + sampleProject: Project | null; + steps: OnboardingStep[]; + step17Feature: Step17FeatureInfo | null; + stepOptions: StepConfigOptions; + markStepComplete: (step: number) => Promise; + dismissOnboarding: () => Promise; + toggleMinimize: () => void; + hidePanel: () => void; + showPanel: () => void; +} + +const OnboardingContext = createContext(undefined); + +export function OnboardingProvider({ children }: { children: ReactNode }) { + const { user, refreshUser } = useAuth(); + const pathname = usePathname(); + + const isOnboarded = user?.is_onboarded ?? true; + const completedSteps = user?.completed_onboarding_steps ?? []; + const orgId = user?.org_id; + + // Fetch projects to find sample project (only for non-onboarded users) + const [sampleProject, setSampleProject] = useState(null); + const [stepOptions, setStepOptions] = useState({}); + const fetchedOrgRef = useRef(null); + + useEffect(() => { + if (isOnboarded || !orgId) return; + // Skip re-fetching if we already found the sample project for this org + if (fetchedOrgRef.current === orgId && sampleProject) return; + fetchedOrgRef.current = orgId; + + apiClient + .get(`/api/v1/orgs/${orgId}/projects`) + .then((projects) => { + const sample = projects.find((p) => p.is_sample) ?? null; + setSampleProject(sample); + if (!sample) return; + + // Fetch phase and feature slugs for direct links + Promise.all([ + apiClient.listBrainstormingPhases(sample.id).catch(() => []), + apiClient + .listFeatures(sample.id) + .then((res) => res.items) + .catch(() => []), + ]).then(([phases, features]) => { + const opts: StepConfigOptions = {}; + if (phases.length > 0) { + opts.firstPhaseSlug = phases[0].url_identifier; + } + // Find features by project key prefix (e.g., FMV-011, FMV2-011) + const projectKey = sample.key || "FMV"; + opts.projectKey = projectKey; + const feat010 = features.find((f: any) => f.feature_key === `${projectKey}-010`); + const feat011 = features.find((f: any) => f.feature_key === `${projectKey}-011`); + const feat012 = features.find((f: any) => f.feature_key === `${projectKey}-012`); + if (feat010) opts.fmv010Slug = feat010.url_identifier; + if (feat011) opts.fmv011Slug = feat011.url_identifier; + if (feat012) opts.fmv012Slug = feat012.url_identifier; + setStepOptions(opts); + }); + }) + .catch(() => { + // Non-critical — onboarding panel just won't show steps + }); + }, [isOnboarded, orgId, sampleProject]); + + // Build sample project slug for route matching + const sampleProjectSlug = sampleProject + ? `${slugify(sampleProject.name)}-${sampleProject.short_id}` + : ""; + + // Step configuration + const steps = sampleProjectSlug ? buildStepConfig(sampleProjectSlug, stepOptions) : []; + + // Active contextual step (derived from route) + const activeStep = + sampleProjectSlug && !isOnboarded + ? getActiveStep(pathname, sampleProjectSlug, completedSteps, stepOptions) + : null; + + // Minimized/hidden state from localStorage (SSR-safe) + const [isPanelMinimized, setIsPanelMinimized] = useState(false); + const [isPanelHidden, setIsPanelHidden] = useState(false); + const [step17Feature, setStep17Feature] = useState(null); + useEffect(() => { + const stored = localStorage.getItem(MINIMIZE_KEY); + if (stored === "true") setIsPanelMinimized(true); + const hidden = localStorage.getItem(HIDDEN_KEY); + if (hidden === "true") setIsPanelHidden(true); + // Restore step 17 feature info from localStorage + const featureStored = localStorage.getItem(STEP17_FEATURE_KEY); + if (featureStored) { + try { + setStep17Feature(JSON.parse(featureStored)); + } catch { + // ignore + } + } + }, []); + + // Fallback: if step 17 is complete but we don't have feature info stored, + // find the user-created feature in the sample project + useEffect(() => { + if (isOnboarded || !sampleProject || step17Feature) return; + if (!completedSteps.includes(17)) return; + + apiClient + .listFeatures(sampleProject.id) + .then((res) => { + // Exclude the seeded user-provenance features (010, 011, 012) + const projectKey = sampleProject.key || "FMV"; + const seededKeys = new Set([`${projectKey}-010`, `${projectKey}-011`, `${projectKey}-012`]); + const userFeature = res.items.find( + (f: any) => f.provenance === "user" && !seededKeys.has(f.feature_key), + ); + if (userFeature) { + const info: Step17FeatureInfo = { + feature_key: userFeature.feature_key, + feature_url_id: userFeature.url_identifier, + }; + setStep17Feature(info); + localStorage.setItem(STEP17_FEATURE_KEY, JSON.stringify(info)); + } + }) + .catch(() => {}); + }, [isOnboarded, sampleProject, step17Feature, completedSteps]); + + // Track steps already fired in this session to avoid duplicate API calls + const firedStepsRef = useRef>(new Set()); + + // Mark step complete + const markStepComplete = useCallback( + async (step: number) => { + if (isOnboarded || completedSteps.includes(step) || firedStepsRef.current.has(step)) return; + firedStepsRef.current.add(step); + try { + await apiClient.markOnboardingStepComplete(step); + await refreshUser(); + } catch { + firedStepsRef.current.delete(step); + } + }, + [isOnboarded, completedSteps, refreshUser], + ); + + // Dismiss onboarding + const dismissOnboarding = useCallback(async () => { + try { + await apiClient.dismissOnboarding(); + localStorage.removeItem(MINIMIZE_KEY); + await refreshUser(); + } catch (error) { + console.error("Failed to dismiss onboarding:", error); + } + }, [refreshUser]); + + // Toggle minimize + const toggleMinimize = useCallback(() => { + setIsPanelMinimized((prev) => { + const next = !prev; + if (next) { + localStorage.setItem(MINIMIZE_KEY, "true"); + } else { + localStorage.removeItem(MINIMIZE_KEY); + } + return next; + }); + }, []); + + // Hide/show panel + const hidePanel = useCallback(() => { + setIsPanelHidden(true); + localStorage.setItem(HIDDEN_KEY, "true"); + }, []); + + const showPanel = useCallback(() => { + setIsPanelHidden(false); + localStorage.removeItem(HIDDEN_KEY); + }, []); + + // Auto-complete passive steps on route visit (only if all prior steps are done) + useEffect(() => { + if (isOnboarded || steps.length === 0) return; + + for (const step of steps) { + if (!step.isPassive || completedSteps.includes(step.number)) continue; + // Enforce step order + const allPriorDone = steps + .filter((s) => s.number < step.number) + .every((s) => completedSteps.includes(s.number)); + if (!allPriorDone) continue; + + if (step.completionRoutePattern) { + // Use completion route pattern (prefix match) + if (pathname.startsWith(step.completionRoutePattern)) { + markStepComplete(step.number); + return; + } + } else if (activeStep?.number === step.number) { + // Original behavior: complete via activeStep route match + markStepComplete(step.number); + return; + } + } + }, [pathname, activeStep, isOnboarded, completedSteps, markStepComplete, steps]); + + // Listen for backend-triggered step completions via WebSocket + const handleOnboardingWsMessage = useCallback( + (message: any) => { + // Extract step 17 feature info if present + const payload = message?.payload; + if (payload?.step === 17 && payload?.feature_key && payload?.feature_url_id) { + const info: Step17FeatureInfo = { + feature_key: payload.feature_key, + feature_url_id: payload.feature_url_id, + }; + setStep17Feature(info); + localStorage.setItem(STEP17_FEATURE_KEY, JSON.stringify(info)); + } + refreshUser(); + }, + [refreshUser], + ); + + useWebSocketSubscription({ + messageTypes: ["onboarding_step_completed"], + onMessage: handleOnboardingWsMessage, + enabled: !isOnboarded, + }); + + // Re-check for sample project when project list changes (create/delete/archive) + const handleProjectListChanged = useCallback(() => { + // Reset so the fetch effect re-runs + fetchedOrgRef.current = null; + setSampleProject(null); + }, []); + + useWebSocketSubscription({ + messageTypes: ["project_list_changed"], + onMessage: handleProjectListChanged, + enabled: !isOnboarded, + }); + + return ( + + {children} + + ); +} + +export function useOnboarding() { + const context = useContext(OnboardingContext); + if (context === undefined) { + throw new Error("useOnboarding must be used within an OnboardingProvider"); + } + return context; +} diff --git a/frontend/lib/onboarding/steps.ts b/frontend/lib/onboarding/steps.ts new file mode 100644 index 0000000..0be6f25 --- /dev/null +++ b/frontend/lib/onboarding/steps.ts @@ -0,0 +1,336 @@ +/** + * Onboarding step configuration. + * + * Defines the 18 onboarding steps with labels, guidance text, + * route patterns, and completion trigger type. + */ + +export interface OnboardingStep { + number: number; + label: string; + guidance: string; + /** Fun tip shown in a dismissable info box when this step is active. */ + tip: string; + /** Text to prefill into the chatbox after navigating to this step's page. */ + prefillText?: string; + /** Route pattern for navigation link. Null = no single target route. */ + routePattern?: string; + /** + * Route pattern for contextual detection (compact panel mode). + * If not set, routePattern is used. Use this to separate "where to link" + * from "when to show contextual hint". + */ + contextRoute?: string; + /** + * Suffix-based context matching. When set, matches any pathname that + * starts with the project's brainstorming base and ends with this suffix. + * E.g. "/spec" matches ".../brainstorming/{any-phase}/spec". + */ + contextSuffix?: string; + /** If true, contextRoute/routePattern must match exactly (not as prefix). */ + exactMatch?: boolean; + /** Whether the step completes on route visit (true) or via backend event (false). */ + isPassive: boolean; + /** If true, scroll the conversation container to the bottom after navigating. */ + scrollToBottom?: boolean; + /** CSS selector of an element to scroll into view after navigating. */ + scrollToElement?: string; + /** CSS selector of an element to highlight with a tooltip after navigating. */ + highlightSelector?: string; + /** Tooltip text to show on the highlighted element. */ + highlightText?: string; + /** Position of the highlight tooltip relative to the element. Default is "above". */ + highlightPosition?: "above" | "below"; + /** Route pattern for passive completion (prefix match). When set, auto-completion + * uses this instead of contextRoute/routePattern. */ + completionRoutePattern?: string; +} + +/** + * Build the step configuration for the given sample project URL slug + * and optionally the first phase's URL slug for direct links. + */ +export interface StepConfigOptions { + firstPhaseSlug?: string; + fmv010Slug?: string; + fmv011Slug?: string; + fmv012Slug?: string; + projectKey?: string; +} + +export function buildStepConfig( + sampleProjectSlug: string, + options?: StepConfigOptions, +): OnboardingStep[] { + const base = `/projects/${sampleProjectSlug}`; + const phase1Base = options?.firstPhaseSlug + ? `${base}/brainstorming/${options.firstPhaseSlug}` + : undefined; + const fmv010Route = options?.fmv010Slug ? `${base}/features/${options.fmv010Slug}` : undefined; + const fmv011Route = options?.fmv011Slug ? `${base}/features/${options.fmv011Slug}` : undefined; + const fmv012Route = options?.fmv012Slug ? `${base}/features/${options.fmv012Slug}` : undefined; + const projectKey = options?.projectKey ?? "FMV"; + + return [ + { + number: 1, + label: "Explore a Brainstorming Phase", + guidance: "Click into a brainstorming phase to see how ideas are organized.", + tip: "🧠 Brainstorming phases are where the magic starts! mfbt turns your rough ideas into structured plans through AI-powered conversations. Each phase focuses on a distinct chunk of your project.", + routePattern: `${base}/brainstorming`, + exactMatch: true, + isPassive: true, + completionRoutePattern: phase1Base ?? `${base}/brainstorming/`, + }, + { + number: 2, + label: "Explore the Phase Spec", + guidance: 'Click the "Specification" tab inside any brainstorming phase.', + tip: '📋 The Spec is your AI-generated blueprint — a detailed "what to build" document distilled from brainstorming conversations. Think of it as your project\'s source of truth that keeps everyone aligned.', + routePattern: phase1Base ? `${phase1Base}/conversations` : undefined, + contextSuffix: "/spec", + isPassive: true, + completionRoutePattern: phase1Base ? `${phase1Base}/spec` : undefined, + highlightSelector: "[data-onboarding='spec-tab']", + highlightText: + 'Click "Specification" to see the AI-generated blueprint for this phase — a detailed "what to build" document.', + highlightPosition: "below" as const, + }, + { + number: 3, + label: "Explore the Phase Prompt Plan", + guidance: 'Click the "Prompt Plan" tab inside any brainstorming phase.', + tip: '🗺️ The Prompt Plan is your "how to build it" guide — step-by-step instructions so detailed that a coding agent can follow them. It bridges the gap between spec and code!', + routePattern: phase1Base ? `${phase1Base}/conversations` : undefined, + contextSuffix: "/prompt-plan", + isPassive: true, + completionRoutePattern: phase1Base ? `${phase1Base}/prompt-plan` : undefined, + highlightSelector: "[data-onboarding='prompt-plan-tab']", + highlightText: + 'Click "Prompt Plan" to see step-by-step instructions your coding agent can follow to build this phase.', + highlightPosition: "below" as const, + }, + { + number: 4, + label: "Explore Phase Features", + guidance: 'Click the "Phase Features" tab inside any brainstorming phase.', + tip: "✨ Features are the building blocks extracted from your brainstorming. Each one has its own spec, prompt plan, and conversation thread — everything a coding agent needs to implement it.", + routePattern: phase1Base ? `${phase1Base}/conversations` : undefined, + contextSuffix: "/features", + isPassive: true, + completionRoutePattern: phase1Base ? `${phase1Base}/features` : undefined, + highlightSelector: "[data-onboarding='phase-features-tab']", + highlightText: + 'Click "Phase Features" to see the individual features extracted from this brainstorming phase.', + highlightPosition: "below" as const, + }, + { + number: 5, + label: "Explore Project Features", + guidance: "Visit the project-wide features list.", + tip: "🔍 The project features view shows ALL features across every phase in one place. Great for tracking progress, spotting gaps, and getting the big picture of what's been planned.", + routePattern: phase1Base ? `${phase1Base}/conversations` : undefined, + contextRoute: `${base}/features`, + exactMatch: true, + isPassive: true, + completionRoutePattern: `${base}/features`, + highlightSelector: "[data-onboarding='features-nav']", + highlightText: 'Click "Features" to see ALL features across every phase in one place.', + highlightPosition: "below" as const, + }, + // --- New steps 6-9: Feature exploration tour --- + { + number: 6, + label: "Understand System Features", + guidance: "Learn what system-generated features are.", + tip: "🏗️ System features are auto-generated from your brainstorming phases. mfbt analyzes conversations and specs to extract actionable features — each one comes with full context from the brainstorming process.", + routePattern: `${base}/features`, + isPassive: false, + highlightSelector: "[data-module-provenance='system']", + highlightText: + "These are System features — auto-generated from brainstorming phases. Each one is tied to a specific phase and comes pre-loaded with context.", + highlightPosition: "below", + }, + { + number: 7, + label: "Understand User Features", + guidance: "Learn what user-created features are.", + tip: "✍️ User features are created ad-hoc — directly from project chats or manually added. They let you capture ideas that don't come from a brainstorming phase, giving you full flexibility.", + routePattern: `${base}/features`, + isPassive: false, + highlightSelector: "[data-module-provenance='user']", + highlightText: + "These are User features — created ad-hoc by users without going through a full brainstorming phase. They're great for capturing quick ideas.", + highlightPosition: "below", + }, + { + number: 8, + label: "Notice Missing Specs & Prompt Plans", + guidance: "See which features still need specs and prompt plans.", + tip: "📊 The icons on the right of each feature tell you its readiness at a glance: spec (document icon), prompt plan (checklist icon), and implementation notes (sticky note icon). Green = ready, faded = missing.", + routePattern: `${base}/features`, + isPassive: false, + highlightSelector: "[data-module-provenance='user'] [data-content-indicators]", + highlightText: + "Notice how all 3 user features are missing specs and prompt plans (faded icons). These features need specs before a coding agent can implement them.", + highlightPosition: "below", + }, + { + number: 9, + label: "Create a Spec from Conversation", + guidance: "Generate a spec and prompt plan for feature #010.", + tip: "⚡ One click and mfbt distills your entire conversation into a polished spec + prompt plan. The AI reads every message, question, and decision to produce implementation-ready docs.", + routePattern: fmv010Route, + scrollToBottom: true, + highlightSelector: "[data-onboarding='create-implementation']", + highlightText: "Click here to create spec and prompt plan from this conversation", + isPassive: false, + }, + // --- Renumbered steps (old 6-14 → new 10-18) --- + { + number: 10, + label: "Invoke @MFBTAI in a conversation", + guidance: "Click the @MFBTAI button in a feature conversation to invoke the AI assistant.", + tip: "🤖 @MFBTAI is your AI collaborator! Mention it in any feature conversation to get instant help — it can answer questions, suggest approaches, and help resolve sticky design decisions.", + prefillText: "@[MFBTAI](mfbtai) resolve these 2 points for us, please!", + routePattern: `${base}/features`, + isPassive: false, + highlightSelector: `[data-feature-key='${projectKey}-011']`, + highlightText: + "Click this feature to open its conversation, then invoke @MFBTAI to get AI assistance.", + highlightPosition: "below", + }, + { + number: 11, + label: "Create a Spec and Prompt Plan", + guidance: "Generate a spec and prompt plan for a feature.", + tip: "⚡ One click and mfbt distills your entire conversation into a polished spec + prompt plan. The AI reads every message, question, and decision to produce implementation-ready docs.", + routePattern: fmv011Route, + scrollToBottom: true, + highlightSelector: "[data-onboarding='create-implementation']", + highlightText: "Click here to create spec and prompt plan from this conversation", + isPassive: false, + }, + { + number: 12, + label: "Answer unanswered questions", + guidance: "Answer one of the suggested questions in a feature conversation.", + tip: "💡 mfbt proactively identifies blind spots by generating targeted questions. Answering them fills in gaps that would otherwise surface as bugs or rework later. Prevention > cure!", + routePattern: `${base}/features`, + highlightSelector: `[data-feature-key='${projectKey}-012']`, + highlightText: "Click this feature to see the AI-generated questions that need answering.", + highlightPosition: "below", + isPassive: false, + }, + { + number: 13, + label: "Create another Spec and Prompt Plan", + guidance: "Generate a spec and prompt plan for another feature.", + tip: "🔄 Practice makes perfect! Each spec + prompt plan you generate teaches you the mfbt workflow. Notice how the AI incorporates your Q&A answers into the output.", + routePattern: fmv012Route, + scrollToBottom: true, + highlightSelector: "[data-onboarding='create-implementation']", + highlightText: "Click here to create spec and prompt plan from this conversation", + isPassive: false, + }, + { + number: 14, + label: "Copy MCP connection instructions", + guidance: "Go to Project Settings and find the MCP connection details.", + tip: "🔌 MCP (Model Context Protocol) connects your coding agent directly to mfbt. Your agent can read specs, prompt plans, and post implementation notes — no copy-pasting needed!", + routePattern: `${base}/project-settings`, + isPassive: false, + highlightSelector: "[data-onboarding='settings-nav']", + highlightText: 'Click "Settings" to access the project configuration page.', + highlightPosition: "below", + }, + { + number: 15, + label: "Implement features using a coding agent", + guidance: "Mark implementations as complete after building them with your coding agent.", + tip: "🚀 This is where specs become real code! Connect your coding agent via MCP, point it at a feature's prompt plan, and watch it build. Mark implementations complete as you go.", + isPassive: false, + }, + { + number: 16, + label: "Inspect Grounding files", + guidance: "Visit the Grounding page to see the generated agents.md file.", + tip: "📚 Grounding files (agents.md) give your coding agent deep context about your project's architecture and patterns. They auto-update as you implement features — your project's living knowledge base!", + routePattern: `${base}/grounding`, + isPassive: false, + highlightSelector: "[data-onboarding='grounding-nav']", + highlightText: + 'Click "Grounding" to see the generated agents.md file — your project\'s living knowledge base.', + highlightPosition: "below", + }, + { + number: 17, + label: "Create a new feature", + guidance: "Create your own feature in the sample project.", + tip: "🎨 Time to go off-script! Create your own feature from scratch and experience the full mfbt workflow — from idea to spec to code. What will you add to Flappy McVibe?", + prefillText: + "I want a new Feature: I want to enhance the bird's looks. Let's use only geometric shapes and no graphics assets to keep things simple.", + routePattern: `${base}/project-chat`, + highlightSelector: "[data-onboarding='chat-send-button']", + highlightText: + "Hit send! We've entered an idea for a new feature in the chat. mfbt AI will chat further with you to refine your idea, then propose a feature you can create with one click.", + highlightPosition: "below", + isPassive: false, + }, + { + number: 18, + label: "Implement your new feature", + guidance: "Mark your new feature's implementation as complete.", + tip: "🏁 The finish line! Implementing your own feature end-to-end proves you've mastered the mfbt workflow. You're ready to tackle real projects now!", + isPassive: false, + }, + ]; +} + +/** + * Check if a pathname matches a step's context route. + */ +function matchesRoute(pathname: string, step: OnboardingStep, brainstormingBase: string): boolean { + // Suffix-based matching: pathname must be under brainstorming and end with the suffix + if (step.contextSuffix) { + return pathname.startsWith(brainstormingBase + "/") && pathname.endsWith(step.contextSuffix); + } + + // Use contextRoute for detection if available, otherwise routePattern + const route = step.contextRoute ?? step.routePattern; + if (!route) return false; + + if (step.exactMatch) { + return pathname === route; + } + return pathname.startsWith(route); +} + +/** + * Given a pathname and sample project slug, find the active contextual step. + * Returns the lowest-numbered incomplete passive step whose context route matches, + * or null if no passive step matches (non-passive steps don't trigger contextual mode). + */ +export function getActiveStep( + pathname: string, + sampleProjectSlug: string, + completedSteps: number[], + options?: StepConfigOptions, +): OnboardingStep | null { + const steps = buildStepConfig(sampleProjectSlug, options); + const brainstormingBase = `/projects/${sampleProjectSlug}/brainstorming`; + + // Only passive steps trigger contextual mode on route visit + const matchingPassiveSteps = steps.filter((step) => { + if (!step.isPassive) return false; + return matchesRoute(pathname, step, brainstormingBase); + }); + + if (matchingPassiveSteps.length === 0) return null; + + // Return the lowest-numbered incomplete passive step, or null if all are done + const incompleteMatch = matchingPassiveSteps.find( + (step) => !completedSteps.includes(step.number), + ); + return incompleteMatch ?? null; +} diff --git a/frontend/lib/websocket/types.ts b/frontend/lib/websocket/types.ts index c0f2a37..78daa53 100644 --- a/frontend/lib/websocket/types.ts +++ b/frontend/lib/websocket/types.ts @@ -29,6 +29,8 @@ export type WebSocketMessageType = | "inbox_thread_updated" | "inbox_read_status_changed" | "inbox_badge_updated" + | "onboarding_step_completed" + | "project_list_changed" | "typing_indicator"; // Base message structure - all messages have a type field