Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 23 additions & 23 deletions src/adapters/discord_bot/task_workspace.py
Original file line number Diff line number Diff line change
Expand Up @@ -631,16 +631,17 @@ async def handle_action(
await _maybe_await(interaction.response.send_modal(modal))
return

if action == "edit":
from src.adapters.discord_bot.views.task_modals import TaskEditModal

modal = TaskEditModal(
task=task,
task_service=self.task_service,
auth_service=self.auth_service,
)
await _maybe_await(interaction.response.send_modal(modal))
return
if action in ("edit", "controls"):
try:
await self.render_task_controls(
interaction=interaction,
task=task,
panel="quick_controls",
)
return
except Exception as e:
await send_interaction_error(interaction, e, "opening task controls", logger, ephemeral=True)
return

if action == "deps":
try:
Expand All @@ -666,18 +667,6 @@ async def handle_action(
await send_interaction_error(interaction, e, "opening task dependencies", logger, ephemeral=True)
return

if action == "controls":
try:
await self.render_task_controls(
interaction=interaction,
task=task,
panel="quick_controls",
)
return
except Exception as e:
await send_interaction_error(interaction, e, "opening task controls", logger, ephemeral=True)
return

if action == "claim":
try:
if task.assignee_discord_id and task.assignee_discord_id != interaction.user.id:
Expand Down Expand Up @@ -890,6 +879,9 @@ async def save_task_controls(
due_at: Any = _UNSET,
clear_due_at: bool = False,
watchers: list[int] | None = None,
title: str | None = None,
body: str | None = None,
clear_body: bool = False,
) -> Task | None:
"""Applies staged task control adjustments atomically, syncing thread tags and action card."""
if self.auth_service:
Expand All @@ -910,6 +902,7 @@ async def save_task_controls(
async with unarchive_thread_if_needed(thread, keep_archived=keep_archived):
updated_task = task
actor_id = getattr(interaction.user, "id", None)
title_changed = False

if priority is not None and priority != updated_task.priority:
updated_task = await self.task_service.update_priority(
Expand All @@ -919,6 +912,13 @@ async def save_task_controls(
)

details_kwargs: dict[str, Any] = {}
if title is not None and title != updated_task.title:
details_kwargs["title"] = title
title_changed = True
if body is not None and body != updated_task.body:
details_kwargs["body"] = body
if clear_body:
details_kwargs["clear_body"] = True
if due_at is not _UNSET and due_at != updated_task.due_at:
details_kwargs["due_at"] = due_at
if clear_due_at:
Expand Down Expand Up @@ -946,7 +946,7 @@ async def save_task_controls(

await self.sync_workspace(
updated_task,
sync_title=False,
sync_title=title_changed,
sync_tags=True,
sync_archive=False,
sync_starter_card=True,
Expand Down
97 changes: 74 additions & 23 deletions src/adapters/discord_bot/views/task_buttons.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ def build_task_controls_embed(
assignee_id: Any = _UNSET,
due_at: Any = _UNSET,
watchers: list[int] | None = None,
title: str | None = None,
body: Any = _UNSET,
) -> discord.Embed:
"""Builds a summary embed for the interactive ephemeral task controls."""
prio_map = {
Expand All @@ -57,26 +59,31 @@ def build_task_controls_embed(
actual_watchers = task.watchers if watchers is None else watchers
watchers_str = " ".join(f"<@{uid}>" for uid in actual_watchers) if actual_watchers else "*None*"

actual_title = title if title is not None else task.title
actual_body = task.body if body is _UNSET else body

if error_message:
color = discord.Color.red()
prefix = f"{error_message}\n\n"
else:
color = discord.Color.blue()
color = discord.Color.gold()
prefix = ""

body_section = f"\n• **Description**: {actual_body[:200]}" if actual_body else ""

embed = discord.Embed(
title=f"Quick Controls: [{task.short_id}] {task.title[:70]}",
title=f"Edit Draft: [{task.short_id}] {actual_title[:100]}",
description=(
f"{prefix}"
"Adjust fields above, then click 'Save Changes' to apply.\n\n"
f"• **Priority**: {prio_str}\n"
f"• **Assignee**: {assignee_str}\n"
f"• **Due Date**: {due_str}\n"
f"• **Watchers**: {watchers_str}"
f"{body_section}"
),
color=color,
)
embed.set_footer(text="Adjust fields above, then click 'Save Changes' to apply.")
embed.set_footer(text="⚠️ Unsaved Draft • Click 'Save Changes' to apply or 'Discard Changes' to cancel.")
return embed


Expand All @@ -99,6 +106,8 @@ def __init__(
self.workspace = workspace

# Staged state
self.staged_title: str = task.title
self.staged_body: str | None = task.body
self.staged_priority: PriorityLevel = task.priority
self.staged_assignee_id: int | None = task.assignee_discord_id
self.staged_due_at: datetime | None = task.due_at
Expand Down Expand Up @@ -143,6 +152,8 @@ def _build_embed(self) -> discord.Embed:
assignee_id=self.staged_assignee_id,
due_at=due,
watchers=self.staged_watchers,
title=self.staged_title,
body=self.staged_body,
)

def _rebuild_items(self) -> None:
Expand All @@ -157,13 +168,21 @@ def _rebuild_items(self) -> None:
save_btn.callback = self._on_save_clicked
self.add_item(save_btn)

cancel_btn = discord.ui.Button(
label="Cancel",
discard_btn = discord.ui.Button(
label="Discard Changes",
style=discord.ButtonStyle.danger,
row=0,
)
discard_btn.callback = self._on_cancel_clicked
self.add_item(discard_btn)

edit_text_btn = discord.ui.Button(
label="Edit Title / Body",
style=discord.ButtonStyle.secondary,
row=0,
)
cancel_btn.callback = self._on_cancel_clicked
self.add_item(cancel_btn)
edit_text_btn.callback = self._on_edit_text_clicked
self.add_item(edit_text_btn)

if self.staged_assignee_id:
unassign_btn = discord.ui.Button(
Expand Down Expand Up @@ -244,6 +263,27 @@ def _rebuild_items(self) -> None:
self.watchers_select.callback = self._on_watchers_selected
self.add_item(self.watchers_select)

async def _on_edit_text_clicked(self, interaction: discord.Interaction) -> None:
from src.adapters.discord_bot.views.task_modals import TaskQuickEditTitleModal

modal = TaskQuickEditTitleModal(self)
await interaction.response.send_modal(modal)

async def update_text_content(
self,
interaction: discord.Interaction,
*,
title: str,
body: str | None,
) -> None:
"""Update staged title and description and refresh the draft embed."""
self.staged_title = title
self.staged_body = body
self.error_message = None
self._rebuild_items()
embed = self._build_embed()
await interaction.response.edit_message(embed=embed, view=self)

async def _on_priority_selected(self, interaction: discord.Interaction) -> None:
prio_map = {
"high": PriorityLevel.HIGH,
Expand Down Expand Up @@ -315,6 +355,7 @@ async def _on_watchers_selected(self, interaction: discord.Interaction) -> None:
async def _on_save_clicked(self, interaction: discord.Interaction) -> None:
ws = self.effective_workspace
if ws:
clear_body = self.staged_body is None and bool(self.task.body)
updated_task = await ws.save_task_controls(
interaction,
task=self.task,
Expand All @@ -323,6 +364,9 @@ async def _on_save_clicked(self, interaction: discord.Interaction) -> None:
due_at=self.staged_due_at,
clear_due_at=self.staged_clear_due,
watchers=self.staged_watchers,
title=self.staged_title,
body=self.staged_body,
clear_body=clear_body,
)
if updated_task:
self.task = updated_task
Expand Down Expand Up @@ -351,6 +395,9 @@ async def _on_save_clicked(self, interaction: discord.Interaction) -> None:
keep_archived = self.task.status == TaskStatus.COMPLETED or self.task.is_archived
async with unarchive_thread_if_needed(thread, keep_archived=keep_archived):
updated_task = self.task
title_changed = self.staged_title != updated_task.title
body_changed = self.staged_body != updated_task.body

if self.staged_priority != updated_task.priority:
updated_task = await self.task_service.update_priority(
task_id=self.task.id,
Expand All @@ -359,17 +406,29 @@ async def _on_save_clicked(self, interaction: discord.Interaction) -> None:
)

details_changed = (
self.staged_due_at != updated_task.due_at
title_changed
or body_changed
or self.staged_due_at != updated_task.due_at
or self.staged_clear_due
or set(self.staged_watchers) != set(updated_task.watchers)
)
if details_changed:
details_kwargs: dict[str, Any] = {
"due_at": self.staged_due_at,
"clear_due_at": self.staged_clear_due,
"watchers": self.staged_watchers,
}
if title_changed:
details_kwargs["title"] = self.staged_title
if body_changed:
details_kwargs["body"] = self.staged_body
if not self.staged_body:
details_kwargs["clear_body"] = True

updated_task = await self.task_service.update_details(
task_id=self.task.id,
actor_discord_id=interaction.user.id,
due_at=self.staged_due_at,
clear_due_at=self.staged_clear_due,
watchers=self.staged_watchers,
**details_kwargs,
)

if self.staged_assignee_id != updated_task.assignee_discord_id:
Expand All @@ -392,7 +451,7 @@ async def _on_save_clicked(self, interaction: discord.Interaction) -> None:
await interaction.response.edit_message(embed=embed, view=None)
if self.bot and hasattr(self.bot, "sync_root_task_message"):
await self.bot.sync_root_task_message(updated_task)
await self.bot.sync_task_thread(updated_task, sync_archive=False)
await self.bot.sync_task_thread(updated_task, sync_title=title_changed, sync_archive=False)

menu_manager.unregister_menu(interaction)
menu_manager.schedule_toast_dismissal(interaction, delay=3.0)
Expand Down Expand Up @@ -500,9 +559,9 @@ def __init__(
)
self.add_item(self.note_btn)

# Row 1: Advanced Actions / Tools (Edit Details in first position)
# Row 1: Advanced Actions / Tools (Consolidated Edit Task in first position)
self.edit_btn = discord.ui.Button(
label="Edit Details",
label="Edit Task",
style=discord.ButtonStyle.secondary,
custom_id=f"task:edit:{task_id}",
row=1,
Expand All @@ -517,14 +576,6 @@ def __init__(
)
self.add_item(self.deps_btn)

self.controls_btn = discord.ui.Button(
label="Quick Controls",
style=discord.ButtonStyle.secondary,
custom_id=f"task:controls:{task_id}",
row=1,
)
self.add_item(self.controls_btn)


class TaskLinkButtonView(BaseView):
"""View containing a 1-click link button to open the task in Discord."""
Expand Down
47 changes: 46 additions & 1 deletion src/adapters/discord_bot/views/task_modals.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import logging
import re
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Any
from uuid import UUID

import discord
Expand Down Expand Up @@ -228,3 +228,48 @@ async def on_submit(self, interaction: discord.Interaction) -> None:
await send_interaction_error(
interaction, e, f"updating details for task '{self.short_id}'", logger, ephemeral=True
)


class TaskQuickEditTitleModal(BaseModal):
"""Modal to edit title and description inside TaskQuickControlsView with staged preview."""

def __init__(self, target_view: Any):
super().__init__(title="Edit Task Details")
self.target_view = target_view

current_title = getattr(target_view, "staged_title", "")
current_body = getattr(target_view, "staged_body", "") or ""

self.title_input = discord.ui.TextInput(
label="Task Title",
default=current_title,
required=True,
max_length=100,
)
self.add_item(self.title_input)

self.desc_input = discord.ui.TextInput(
label="Description / Body (Optional)",
style=discord.TextStyle.paragraph,
default=current_body,
placeholder="Detailed requirements or instructions...",
required=False,
max_length=1500,
)
self.add_item(self.desc_input)

async def on_submit(self, interaction: discord.Interaction) -> None:
title = self.title_input.value.strip()
if not title:
await interaction.response.send_message("❌ Task title cannot be empty.", ephemeral=True)
return

body = self.desc_input.value.strip() or None
if hasattr(self.target_view, "update_text_content"):
await self.target_view.update_text_content(interaction, title=title, body=body)
else:
self.target_view.staged_title = title
self.target_view.staged_body = body
self.target_view._rebuild_items()
embed = self.target_view._build_embed()
await interaction.response.edit_message(embed=embed, view=self.target_view)
3 changes: 3 additions & 0 deletions src/adapters/discord_bot/workspace_protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,9 @@ async def save_task_controls(
due_at: Any = _UNSET,
clear_due_at: bool = False,
watchers: list[int] | None = None,
title: str | None = None,
body: str | None = None,
clear_body: bool = False,
) -> Task | None:
"""Applies staged task control adjustments atomically, syncing thread tags and action card."""
...
Expand Down
6 changes: 3 additions & 3 deletions tests/test_cogs.py
Original file line number Diff line number Diff line change
Expand Up @@ -237,14 +237,14 @@ async def test_task_action_view_and_modals(services):
task_service=task_srv,
)

# Check children: action buttons, note, edit, deps, controls (no inline dropdown clutter)
# Check children: action buttons, note, edit, deps (no separate controls button or inline dropdown clutter)
custom_ids = [item.custom_id for item in view.children if hasattr(item, "custom_id")]
assert f"task:start:{task.id}" in custom_ids
assert f"task:complete:{task.id}" in custom_ids
assert f"task:note:{task.id}" in custom_ids
assert f"task:edit:{task.id}" in custom_ids
assert f"task:deps:{task.id}" in custom_ids
assert f"task:controls:{task.id}" in custom_ids
assert f"task:controls:{task.id}" not in custom_ids
assert f"task:priority:{task.id}" not in custom_ids
assert f"task:assignee:{task.id}" not in custom_ids
assert f"task:due:{task.id}" not in custom_ids
Expand Down Expand Up @@ -833,7 +833,7 @@ async def test_task_quick_controls_view_callbacks(services):
from src.adapters.discord_bot.views.task_buttons import TaskQuickControlsView, build_task_controls_embed

ctrl_embed = build_task_controls_embed(task)
assert "Quick Controls" in ctrl_embed.title
assert "Edit Draft" in ctrl_embed.title
assert "Priority**: Low" in ctrl_embed.description

mock_bot = MagicMock()
Expand Down
Loading
Loading