"""Discord interactive Views for Ralph — root list + per-project actions + propose modal. Critical pattern: every button callback that does I/O MUST call `await interaction.response.defer(ephemeral=True)` FIRST, then use `interaction.followup.send(...)`. Otherwise Discord's 3s interaction timeout kicks in and the user sees "Interaction failed". """ from __future__ import annotations import json import logging from pathlib import Path import discord from src.router import ( _load_approved_tasks, _ralph_approve, _ralph_propose, _ralph_status, _ralph_stop, planning_advance, planning_approve, planning_cancel, start_planning_session, ) from src.planning_session import is_in_planning log = logging.getLogger(__name__) WORKSPACE_DIR = Path("/home/moltbot/workspace") # Status → emoji prefix for project labels _STATUS_EMOJI = { "pending": "📋", "approved": "⏳", "running": "🟢", "complete": "✅", "failed": "❌", "stopped": "⏹", } VIEW_TIMEOUT = 600 # 10 min — matches ralph_flow TTL def _project_label(name: str, status: str | None) -> str: """Return a short button label like '🟢 roa2web' (max 80 chars per Discord).""" emoji = _STATUS_EMOJI.get(status or "", "·") label = f"{emoji} {name}" return label[:80] def _list_workspace_projects() -> list[str]: """Return workspace folder names sorted, skipping hidden dirs.""" if not WORKSPACE_DIR.exists(): return [] return sorted( p.name for p in WORKSPACE_DIR.iterdir() if p.is_dir() and not p.name.startswith(".") ) def _project_status(name: str) -> str | None: """Look up status of a project from approved-tasks.json (or None).""" try: data = _load_approved_tasks() except Exception: return None for p in data.get("projects", []): if p.get("name", "").lower() == name.lower(): return p.get("status") return None def _read_prd(name: str) -> dict | None: prd_path = WORKSPACE_DIR / name / "scripts" / "ralph" / "prd.json" if not prd_path.exists(): return None try: return json.loads(prd_path.read_text(encoding="utf-8")) except (ValueError, OSError): return None # --------------------------------------------------------------------------- # RalphProposeModal — text input for new feature description # --------------------------------------------------------------------------- class RalphProposeModal(discord.ui.Modal, title="Propune feature Ralph"): """Modal asking the user for a feature description for a given project.""" description: discord.ui.TextInput = discord.ui.TextInput( label="Descriere", placeholder="Ce trebuie făcut? (1-3 propoziții)", style=discord.TextStyle.paragraph, required=True, max_length=1000, ) def __init__(self, slug: str) -> None: super().__init__(timeout=VIEW_TIMEOUT) self.slug = slug self.title = f"Propune feature: {slug}"[:45] async def on_submit(self, interaction: discord.Interaction) -> None: try: result = _ralph_propose(self.slug, str(self.description.value).strip()) except Exception as e: log.exception("Ralph propose modal failed for %s", self.slug) await interaction.response.send_message( f"Eroare la propunere: {e}", ephemeral=True ) return await interaction.response.send_message(result, ephemeral=True) # --------------------------------------------------------------------------- # RalphProjectView — per-project action buttons # --------------------------------------------------------------------------- class RalphProjectView(discord.ui.View): """Buttons: Propune feature / Vezi PRD / Start acum / Status / Stop / Înapoi.""" def __init__(self, slug: str) -> None: super().__init__(timeout=VIEW_TIMEOUT) self.slug = slug @discord.ui.button(label="➕ Propune feature", style=discord.ButtonStyle.primary, row=0) async def propose(self, interaction: discord.Interaction, button: discord.ui.Button) -> None: await interaction.response.send_modal(RalphProposeModal(self.slug)) @discord.ui.button(label="👁 Vezi PRD", style=discord.ButtonStyle.secondary, row=0) async def view_prd(self, interaction: discord.Interaction, button: discord.ui.Button) -> None: await interaction.response.defer(ephemeral=True) prd = _read_prd(self.slug) if prd is None: await interaction.followup.send( f"Nu există PRD pentru {self.slug}. Aprobă cu /a {self.slug} și night-execute îl generează.", ephemeral=True, ) return stories = prd.get("userStories", []) done = sum(1 for s in stories if s.get("passes")) total = len(stories) lines = [f"**PRD pentru {self.slug}** — {done}/{total} stories"] for s in stories[:12]: mark = "✅" if s.get("passes") else "⏳" sid = s.get("id", "?") title = (s.get("title") or "")[:80] lines.append(f"{mark} `{sid}` {title}") if total > 12: lines.append(f"\n…și încă {total - 12} stories.") await interaction.followup.send("\n".join(lines), ephemeral=True) @discord.ui.button(label="📊 Status", style=discord.ButtonStyle.secondary, row=0) async def status(self, interaction: discord.Interaction, button: discord.ui.Button) -> None: await interaction.response.defer(ephemeral=True) text = _ralph_status(self.slug) await interaction.followup.send(text, ephemeral=True) @discord.ui.button(label="✅ Aprobă pentru tonight", style=discord.ButtonStyle.success, row=1) async def approve(self, interaction: discord.Interaction, button: discord.ui.Button) -> None: await interaction.response.defer(ephemeral=True) text = _ralph_approve([self.slug]) await interaction.followup.send(text, ephemeral=True) @discord.ui.button(label="🛑 Stop", style=discord.ButtonStyle.danger, row=1) async def stop(self, interaction: discord.Interaction, button: discord.ui.Button) -> None: await interaction.response.defer(ephemeral=True) text = _ralph_stop(self.slug) await interaction.followup.send(text, ephemeral=True) @discord.ui.button(label="🧠 Planifică", style=discord.ButtonStyle.primary, row=2) async def plan(self, interaction: discord.Interaction, button: discord.ui.Button) -> None: await interaction.response.defer(ephemeral=True) # Look up description from approved-tasks.json description = "" try: data = _load_approved_tasks() for p in data.get("projects", []): if p.get("name", "").lower() == self.slug.lower(): description = p.get("description") or "" break except Exception: log.exception("approved-tasks lookup failed") if not description: await interaction.followup.send( f"Nu am descriere pentru `{self.slug}`. " f"Adaugă mai întâi cu `/p {self.slug} `.", ephemeral=True, ) return channel_id = str(interaction.channel_id) await interaction.followup.send( f"🧠 Pornesc planning pentru `{self.slug}`… (durează ~60s)", ephemeral=True, ) try: first = start_planning_session( self.slug, description, channel_id, "discord", ) except Exception as e: log.exception("start_planning_session failed for %s", self.slug) await interaction.followup.send(f"Planning blocat: {e}", ephemeral=True) return # Send first message of the planning agent + active keyboard for chunk in _split_chunks(first, 1900): await interaction.followup.send(chunk, ephemeral=True) await interaction.followup.send( "Răspunde aici. Apasă **Continuă faza** când ești gata să trec la următoarea.", view=PlanningActiveView(), ephemeral=True, ) @discord.ui.button(label="🔙 Înapoi", style=discord.ButtonStyle.secondary, row=2) async def back(self, interaction: discord.Interaction, button: discord.ui.Button) -> None: await interaction.response.defer(ephemeral=True) view = RalphRootView() content = view.render_summary() await interaction.edit_original_response(content=content, view=view) # --------------------------------------------------------------------------- # RalphRootView — workspace + active projects landing # --------------------------------------------------------------------------- class _ProjectButton(discord.ui.Button): """Dynamic button representing a single project; opens RalphProjectView.""" def __init__(self, slug: str, status: str | None, row: int) -> None: super().__init__( label=_project_label(slug, status), style=discord.ButtonStyle.secondary, row=row, custom_id=f"ralph_project:{slug}", ) self.slug = slug async def callback(self, interaction: discord.Interaction) -> None: await interaction.response.defer(ephemeral=True) view = RalphProjectView(self.slug) content = f"**{self.slug}**\nAlege o acțiune:" await interaction.edit_original_response(content=content, view=view) class _RefreshButton(discord.ui.Button): def __init__(self) -> None: super().__init__( label="🔄 Reîncarcă", style=discord.ButtonStyle.primary, row=4, custom_id="ralph_refresh", ) async def callback(self, interaction: discord.Interaction) -> None: await interaction.response.defer(ephemeral=True) view = RalphRootView() await interaction.edit_original_response( content=view.render_summary(), view=view ) class _CloseButton(discord.ui.Button): def __init__(self) -> None: super().__init__( label="❌ Închide", style=discord.ButtonStyle.danger, row=4, custom_id="ralph_close", ) async def callback(self, interaction: discord.Interaction) -> None: await interaction.response.defer(ephemeral=True) await interaction.edit_original_response(content="Închis.", view=None) def _split_chunks(text: str, limit: int = 1900) -> list[str]: """Split a long message into Discord-safe chunks.""" if len(text) <= limit: return [text] chunks: list[str] = [] while text: if len(text) <= limit: chunks.append(text) break cut = text.rfind("\n", 0, limit) if cut == -1: cut = limit chunks.append(text[:cut]) text = text[cut:].lstrip("\n") return chunks # --------------------------------------------------------------------------- # Planning views (W2) — buttons that drive the planning conversation # --------------------------------------------------------------------------- class PlanningActiveView(discord.ui.View): """Buttons shown DURING an active planning session: advance phase / cancel.""" def __init__(self) -> None: super().__init__(timeout=VIEW_TIMEOUT) @discord.ui.button(label="▶️ Continuă faza", style=discord.ButtonStyle.primary, row=0) async def advance(self, interaction: discord.Interaction, button: discord.ui.Button) -> None: await interaction.response.defer(ephemeral=True) channel_id = str(interaction.channel_id) try: text, completed = planning_advance(channel_id, "discord") except Exception as e: log.exception("planning advance failed") await interaction.followup.send(f"Eroare: {e}", ephemeral=True) return for chunk in _split_chunks(text): await interaction.followup.send(chunk, ephemeral=True) view: discord.ui.View = ( PlanningFinalView() if completed else PlanningActiveView() ) await interaction.followup.send( ("Plan gata. Confirmi?" if completed else "Continuăm?"), view=view, ephemeral=True, ) @discord.ui.button(label="🛑 Anulează", style=discord.ButtonStyle.danger, row=0) async def cancel(self, interaction: discord.Interaction, button: discord.ui.Button) -> None: await interaction.response.defer(ephemeral=True) text = planning_cancel(str(interaction.channel_id), "discord") await interaction.followup.send(text, ephemeral=True) class PlanningFinalView(discord.ui.View): """Buttons shown when ALL planning phases finished — Dau drumul / Anulează.""" def __init__(self) -> None: super().__init__(timeout=VIEW_TIMEOUT) @discord.ui.button(label="✅ Dau drumul tonight", style=discord.ButtonStyle.success, row=0) async def approve(self, interaction: discord.Interaction, button: discord.ui.Button) -> None: await interaction.response.defer(ephemeral=True) text = planning_approve(str(interaction.channel_id), "discord") await interaction.followup.send(text, ephemeral=True) @discord.ui.button(label="🛑 Anulează", style=discord.ButtonStyle.danger, row=0) async def cancel(self, interaction: discord.Interaction, button: discord.ui.Button) -> None: await interaction.response.defer(ephemeral=True) text = planning_cancel(str(interaction.channel_id), "discord") await interaction.followup.send(text, ephemeral=True) class RalphRootView(discord.ui.View): """Landing view: workspace projects with status emoji + refresh + close.""" def __init__(self) -> None: super().__init__(timeout=VIEW_TIMEOUT) self._build_buttons() def _build_buttons(self) -> None: projects = _list_workspace_projects() # Discord limit: 5 buttons per row × 5 rows. Reserve last row for refresh/close. # Project buttons go on rows 0..3 (max 20 projects). for idx, slug in enumerate(projects[:20]): row = idx // 5 status = _project_status(slug) self.add_item(_ProjectButton(slug, status, row=row)) self.add_item(_RefreshButton()) self.add_item(_CloseButton()) def render_summary(self) -> str: """Build the message text shown above the buttons.""" try: data = _load_approved_tasks() except Exception: data = {"projects": []} active = [ p for p in data.get("projects", []) if p.get("status") in ("pending", "approved", "running") ] lines = ["📋 **Proiecte Ralph**"] if active: lines.append("") lines.append("**Active:**") for p in active[:10]: emoji = _STATUS_EMOJI.get(p.get("status", ""), "·") desc = (p.get("description") or "")[:60] lines.append(f"{emoji} `{p.get('name')}` — {desc}") lines.append("") lines.append("Apasă pe un proiect pentru acțiuni, sau Reîncarcă pentru status fresh.") return "\n".join(lines)