diff --git a/core/database.py b/core/database.py
index 65ad40316..cb644d946 100644
--- a/core/database.py
+++ b/core/database.py
@@ -251,6 +251,16 @@ def to_dict(self):
'crew_member_id': self.crew_member_id,
}
+
+class GroupChatState(TimestampMixin, Base):
+ __tablename__ = "group_chat_states"
+
+ parent_session_id = Column(String, ForeignKey("sessions.id", ondelete="CASCADE"), primary_key=True, index=True)
+ owner = Column(String, nullable=True, index=True)
+ mode = Column(String, nullable=False, default="parallel")
+ state = Column(JSON, nullable=False, default=dict)
+
+
class ChatMessage(Base):
"""
SQLAlchemy model for ChatMessage table.
diff --git a/core/session_manager.py b/core/session_manager.py
index eeb9c2a16..3d3c11c5e 100644
--- a/core/session_manager.py
+++ b/core/session_manager.py
@@ -587,39 +587,15 @@ def create_session(
def delete_session(self, session_id: str) -> bool:
"""Permanently delete a session and all its messages."""
db = SessionLocal()
+ image_paths = []
try:
- try:
- from src.session_image_cleanup import cleanup_session_images
- cleanup_session_images(session_id, db=db)
- except Exception as e:
- logger.warning(f"Image cleanup failed while deleting session {session_id}: {e}")
-
- # Detach documents so they survive as orphans in the library
- db.query(DbDocument).filter(DbDocument.session_id == session_id).update(
- {DbDocument.session_id: None}, synchronize_session=False
- )
-
- # Delete messages
- db.query(DbChatMessage).filter(DbChatMessage.session_id == session_id).delete()
-
- # Delete session
- db_session = db.query(DbSession).filter(DbSession.id == session_id).first()
- if db_session:
- db.delete(db_session)
-
- # Drop the in-memory copy even when there is no DB row. A "ghost"
- # session lives only here (never persisted, or its row was removed
- # out-of-band); without this it can never be cleared and keeps
- # 404ing on every operation (issue #1044).
- removed_in_memory = self.sessions.pop(session_id, None) is not None
-
- if db_session or removed_in_memory:
- # Commit the document-detach / message-delete above (a no-op when
- # the ghost had no rows) together with the session delete.
- db.commit()
- logger.info(f"Deleted session {session_id}")
- return True
- return False
+ deleted = self._stage_session_deletion(db, session_id, image_paths)
+ if not deleted:
+ return False
+ db.commit()
+ self._finalize_session_deletions([session_id], image_paths)
+ logger.info(f"Deleted session {session_id}")
+ return True
except Exception as e:
logger.error(f"Error deleting session: {e}")
@@ -628,6 +604,41 @@ def delete_session(self, session_id: str) -> bool:
finally:
db.close()
+ def _stage_session_deletion(self, db, session_id: str, image_paths: list) -> bool:
+ """Stage one session deletion in a caller-owned transaction.
+
+ This method does not commit, evict in-memory state, or unlink files.
+ Errors intentionally propagate so callers deleting related sessions can
+ roll the entire unit of work back.
+ """
+ from src.session_image_cleanup import prepare_session_image_cleanup
+
+ _, pending_paths = prepare_session_image_cleanup(session_id, db)
+ image_paths.extend(pending_paths)
+
+ db.query(DbDocument).filter(DbDocument.session_id == session_id).update(
+ {DbDocument.session_id: None}, synchronize_session=False
+ )
+ db.query(DbChatMessage).filter(DbChatMessage.session_id == session_id).delete(
+ synchronize_session=False
+ )
+
+ db_session = db.query(DbSession).filter(DbSession.id == session_id).first()
+ if db_session:
+ db.delete(db_session)
+
+ # A ghost session may exist only in memory. Keep it there until commit
+ # succeeds, but still report it as a deletable target.
+ return bool(db_session or session_id in self.sessions)
+
+ def _finalize_session_deletions(self, session_ids, image_paths: list) -> None:
+ """Apply non-transactional cleanup after the DB commit succeeds."""
+ from src.session_image_cleanup import unlink_session_image_paths
+
+ for session_id in session_ids:
+ self.sessions.pop(session_id, None)
+ unlink_session_image_paths(image_paths, ",".join(str(sid) for sid in session_ids))
+
# ------------------------------------------------------------------
# Session updates
# ------------------------------------------------------------------
diff --git a/routes/chat_routes.py b/routes/chat_routes.py
index 0b181796f..cf3d19b0a 100644
--- a/routes/chat_routes.py
+++ b/routes/chat_routes.py
@@ -40,10 +40,11 @@
from src.session_search import search_session_messages
from src.prompt_security import untrusted_context_message
from core.exceptions import SessionNotFoundError
-from src.auth_helpers import effective_user, get_current_user
+from src.auth_helpers import effective_user, get_current_user, owner_filter
from routes.session_routes import _verify_session_owner
from routes.document_helpers import _owner_session_filter
from core.database import SessionLocal, get_session_mode, set_session_mode
+from core.database import GroupChatState
from core.database import Session as DBSession, ChatMessage as DBChatMessage
from core.database import Document as DBDocument, ModelEndpoint
from core.log_safety import redact_url
@@ -75,6 +76,112 @@
_active_streams: Dict[str, dict] = {}
+def _group_value(value: Any, max_len: int = 1024) -> str:
+ if value is None:
+ return ""
+ if not isinstance(value, str):
+ value = str(value)
+ return value.strip()[:max_len]
+
+
+def _group_participant_label(model: dict | None, idx: int) -> str:
+ if not isinstance(model, dict):
+ return f"Participant {idx + 1}"
+ character = model.get("character")
+ if not isinstance(character, dict):
+ character = {}
+ for value in (
+ model.get("_groupName"),
+ character.get("characterName"),
+ model.get("display"),
+ model.get("mid"),
+ ):
+ text = _group_value(value)
+ if text:
+ return text
+ return f"Participant {idx + 1}"
+
+
+def _group_child_whisper_context(session_id: str, owner: str | None) -> dict | None:
+ """Return parent metadata when a direct chat targets a group child session."""
+ if not session_id:
+ return None
+ target_id = str(session_id)
+ db = SessionLocal()
+ try:
+ q = db.query(GroupChatState)
+ q = owner_filter(q, GroupChatState, owner)
+ for row in q.all():
+ state = row.state if isinstance(row.state, dict) else {}
+ participant_ids = state.get("participantSessions")
+ if not isinstance(participant_ids, list):
+ continue
+ models = state.get("models")
+ if not isinstance(models, list):
+ models = []
+ for idx, participant_id in enumerate(participant_ids):
+ if not participant_id or str(participant_id) != target_id:
+ continue
+ model = models[idx] if idx < len(models) and isinstance(models[idx], dict) else {}
+ return {
+ "parent_session_id": row.parent_session_id,
+ "participant_session_id": target_id,
+ "participant_index": idx,
+ "participant_name": _group_participant_label(model, idx),
+ "participant_model": _group_value(model.get("mid") or model.get("display")),
+ }
+ finally:
+ db.close()
+ return None
+
+
+def _group_parent_add_message(session_manager, ctx: dict | None, role: str, content: Any, metadata: dict | None) -> None:
+ if not ctx or not ctx.get("parent_session_id"):
+ return
+ try:
+ parent = session_manager.get_session(ctx["parent_session_id"])
+ except KeyError:
+ return
+ parent.add_message(ChatMessage(role, content, metadata=metadata))
+ session_manager.save_sessions()
+
+
+def _mirror_group_child_user_message(session_manager, ctx: dict | None, content: Any) -> None:
+ if not ctx:
+ return
+ _group_parent_add_message(
+ session_manager,
+ ctx,
+ "user",
+ content,
+ {
+ "group_whisper": True,
+ "whisper_to": ctx["participant_name"],
+ "whisper_to_session": ctx["participant_session_id"],
+ "whisper_to_model": ctx.get("participant_model", ""),
+ },
+ )
+
+
+def _mirror_group_child_assistant_message(
+ session_manager,
+ ctx: dict | None,
+ full_response: str,
+ model: str,
+ metrics: dict | None,
+) -> None:
+ if not ctx or not full_response:
+ return
+ metadata = dict(metrics) if metrics else {}
+ metadata.update({
+ "group_model": ctx["participant_name"],
+ "model": model,
+ "group_whisper": True,
+ "whisper_from": ctx["participant_name"],
+ "whisper_from_session": ctx["participant_session_id"],
+ })
+ content, metadata = clean_thinking_for_save(full_response, metadata)
+ _group_parent_add_message(session_manager, ctx, "assistant", content, metadata)
def _stream_failure_status(chunk: str) -> Optional[int]:
"""Extract a provider status without retaining provider-supplied detail."""
@@ -745,6 +852,22 @@ async def chat_endpoint(request: Request, chat_request: ChatRequest) -> Dict[str
defer_context_shaping=foreground_policy.enabled,
)
+ # Direct sends to a hidden group participant are whispers. Mirror the
+ # visible user turn into the parent transcript, just like the streaming
+ # route does. Internal group orchestration already writes to the parent
+ # and must opt out to avoid duplicate turns.
+ group_child_whisper = (
+ None
+ if chat_request.group_internal
+ else _group_child_whisper_context(session, ctx.user)
+ )
+ if group_child_whisper:
+ _mirror_group_child_user_message(
+ session_manager,
+ group_child_whisper,
+ ctx.preprocessed.user_content,
+ )
+
# Research injection
research_blocked_by_policy = (
tool_policy.blocks("trigger_research")
@@ -839,6 +962,14 @@ async def chat_endpoint(request: Request, chat_request: ChatRequest) -> Dict[str
},
)
sess.add_message(ChatMessage("assistant", _clean_reply, metadata=_clean_md))
+ if group_child_whisper:
+ _mirror_group_child_assistant_message(
+ session_manager,
+ group_child_whisper,
+ reply,
+ sess.model,
+ None,
+ )
from core.database import update_session_last_accessed
update_session_last_accessed(session)
@@ -904,6 +1035,7 @@ async def chat_stream(request: Request) -> StreamingResponse:
search_context = form_data.get("search_context") # pre-fetched web search results (compare mode)
compare_mode = str(form_data.get("compare_mode", "")).lower() == "true"
incognito = str(form_data.get("incognito", "")).lower() == "true"
+ group_internal = str(form_data.get("group_internal", "")).lower() == "true"
plan_mode = str(form_data.get("plan_mode") or (body or {}).get("plan_mode") or "").lower() == "true"
chat_mode = str(form_data.get("mode", "")).lower() # 'chat' or 'agent'
tool_approval_id = (
@@ -1418,6 +1550,17 @@ async def chat_stream(request: Request) -> StreamingResponse:
# Enforce per-user privileges
_privs = {}
_user = ctx.user
+ group_child_whisper = None if group_internal else _group_child_whisper_context(session, _user)
+ if group_child_whisper and not incognito and not compare_mode:
+ try:
+ mirrored_user_content = message
+ if sess.history:
+ last_msg = sess.history[-1]
+ if getattr(last_msg, "role", None) == "user":
+ mirrored_user_content = getattr(last_msg, "content", message)
+ _mirror_group_child_user_message(session_manager, group_child_whisper, mirrored_user_content)
+ except Exception:
+ logger.exception("Failed to mirror group child user message for session %s", session)
if _user and hasattr(request.app.state, 'auth_manager') and request.app.state.auth_manager:
_privs = request.app.state.auth_manager.get_privileges(_user)
if _privs:
@@ -2128,6 +2271,17 @@ def _commit_chat_compaction(candidate_index: int) -> bool:
)
if _saved_id:
yield f'data: {json.dumps({"type": "message_saved", "id": _saved_id})}\n\n'
+ if group_child_whisper and not incognito and not compare_mode:
+ try:
+ _mirror_group_child_assistant_message(
+ session_manager,
+ group_child_whisper,
+ full_response,
+ last_metrics.get("model") if last_metrics else sess.model,
+ last_metrics,
+ )
+ except Exception:
+ logger.exception("Failed to mirror group child assistant message for session %s", session)
run_post_response_tasks(
sess, session_manager, session, message, full_response,
_metrics_to_save, ctx.uprefs, memory_manager, memory_vector, webhook_manager,
@@ -2392,6 +2546,17 @@ def _commit_chat_compaction(candidate_index: int) -> bool:
)
if _saved_id:
yield f'data: {json.dumps({"type": "message_saved", "id": _saved_id})}\n\n'
+ if group_child_whisper and not incognito and not compare_mode:
+ try:
+ _mirror_group_child_assistant_message(
+ session_manager,
+ group_child_whisper,
+ full_response,
+ last_metrics.get("model") if last_metrics else sess.model,
+ last_metrics,
+ )
+ except Exception:
+ logger.exception("Failed to mirror group child assistant message for session %s", session)
run_post_response_tasks(
sess, session_manager, session, message, _response_to_save,
_metrics_to_save, ctx.uprefs, memory_manager, memory_vector, webhook_manager,
diff --git a/routes/session_routes.py b/routes/session_routes.py
index b1d79f7fe..2e92e366f 100644
--- a/routes/session_routes.py
+++ b/routes/session_routes.py
@@ -10,8 +10,8 @@
from core.session_manager import SessionManager
from core.models import ChatMessage
from src.request_models import SessionResponse
-from core.database import Session as DbSession, SessionLocal, Document, GalleryImage, utcnow_naive
-from src.auth_helpers import effective_user, _auth_disabled, owner_filter
+from core.database import Session as DbSession, GroupChatState, SessionLocal, Document, GalleryImage, utcnow_naive
+from src.auth_helpers import get_current_user, effective_user, _auth_disabled, owner_filter
from src.session_image_cleanup import _generated_image_path_for_cleanup, session_image_refs
from src.session_actions import is_session_recently_active
from src.upload_handler import reserve_message_upload_references
@@ -173,6 +173,241 @@ def _persist_session_headers(session_id: str, headers: dict | None) -> None:
db.close()
+_GROUP_CHAT_MODES = {"parallel", "round-robin"}
+_GROUP_CHAT_MAX_PARTICIPANTS = 8
+
+
+def _group_state_str(value, max_len: int = 4096) -> str | None:
+ if value is None:
+ return None
+ text = str(value)
+ return text[:max_len]
+
+
+def _normalize_group_character(value) -> dict | None:
+ if not isinstance(value, dict):
+ return None
+ normalized = {}
+ character_id = _group_state_str(value.get("characterId"), 256)
+ character_name = _group_state_str(value.get("characterName"), 1024)
+ character_prompt = _group_state_str(value.get("characterPrompt"), 50000)
+ if character_id:
+ normalized["characterId"] = character_id
+ if character_name:
+ normalized["characterName"] = character_name
+ if character_prompt:
+ normalized["characterPrompt"] = character_prompt
+ return normalized or None
+
+
+def _normalize_group_model(value) -> dict:
+ if not isinstance(value, dict):
+ raise HTTPException(400, "Invalid group model")
+ mid = _group_state_str(value.get("mid"), 1024)
+ url = _group_state_str(value.get("url"), 2048)
+ if not mid or not url:
+ raise HTTPException(400, "Group model is missing its model id or endpoint URL")
+
+ normalized = {
+ "mid": mid,
+ "url": url,
+ "display": _group_state_str(value.get("display") or mid, 1024),
+ }
+ endpoint_id = _group_state_str(value.get("endpointId"), 256)
+ endpoint_name = _group_state_str(value.get("epName"), 1024)
+ group_name = _group_state_str(value.get("_groupName"), 1024)
+ character = _normalize_group_character(value.get("character"))
+ if endpoint_id:
+ normalized["endpointId"] = endpoint_id
+ if endpoint_name:
+ normalized["epName"] = endpoint_name
+ if group_name:
+ normalized["_groupName"] = group_name
+ if character:
+ normalized["character"] = character
+ return normalized
+
+
+def _normalize_group_state(raw_state, parent_session_id: str) -> dict:
+ if isinstance(raw_state, dict) and isinstance(raw_state.get("group_state"), dict):
+ raw_state = raw_state["group_state"]
+ if not isinstance(raw_state, dict):
+ raise HTTPException(400, "Invalid group chat state")
+
+ mode = raw_state.get("mode")
+ if mode not in _GROUP_CHAT_MODES:
+ mode = "parallel"
+
+ raw_models = raw_state.get("models")
+ if not isinstance(raw_models, list):
+ raise HTTPException(400, "Group chat state is missing models")
+ models = [
+ _normalize_group_model(model)
+ for model in raw_models[:_GROUP_CHAT_MAX_PARTICIPANTS]
+ ]
+ if len(models) < 2:
+ raise HTTPException(400, "Group chat state must include at least two models")
+
+ raw_participants = raw_state.get("participantSessions", [])
+ if not isinstance(raw_participants, list):
+ raise HTTPException(400, "Invalid group participant session list")
+ participant_sessions = []
+ for value in raw_participants[:len(models)]:
+ participant_sessions.append(_group_state_str(value, 256) if value else None)
+ while len(participant_sessions) < len(models):
+ participant_sessions.append(None)
+
+ try:
+ round_robin_idx = max(0, int(raw_state.get("roundRobinIdx", 0)))
+ except (TypeError, ValueError):
+ round_robin_idx = 0
+
+ return {
+ "active": True,
+ "mode": mode,
+ "models": models,
+ "participantSessions": participant_sessions,
+ "parentSessionId": parent_session_id,
+ "roundRobinIdx": round_robin_idx,
+ }
+
+
+def _group_participant_ids_from_state(state) -> set[str]:
+ if not isinstance(state, dict):
+ return set()
+ raw_participants = state.get("participantSessions")
+ if not isinstance(raw_participants, list):
+ return set()
+ return {str(session_id) for session_id in raw_participants if session_id}
+
+
+def _group_state_query_for_user(db, user):
+ q = db.query(GroupChatState.parent_session_id, GroupChatState.state)
+ return owner_filter(q, GroupChatState, user)
+
+
+def _group_session_links_for_user(db, user) -> tuple[set[str], set[str]]:
+ parent_ids: set[str] = set()
+ participant_ids: set[str] = set()
+ for parent_id, state in _group_state_query_for_user(db, user).all():
+ if parent_id:
+ parent_ids.add(parent_id)
+ participant_ids.update(_group_participant_ids_from_state(state))
+ return parent_ids, participant_ids
+
+
+def _group_participant_label(model: dict | None, idx: int) -> str:
+ if not isinstance(model, dict):
+ return f"Participant {idx + 1}"
+ character = model.get("character")
+ if not isinstance(character, dict):
+ character = {}
+ for value in (
+ model.get("_groupName"),
+ character.get("characterName"),
+ model.get("display"),
+ model.get("mid"),
+ ):
+ text = _group_state_str(value, 1024)
+ if text:
+ return text
+ return f"Participant {idx + 1}"
+
+
+def _group_participants_for_user(db, user) -> dict[str, list[dict]]:
+ participants_by_parent: dict[str, list[dict]] = {}
+ for parent_id, state in _group_state_query_for_user(db, user).all():
+ if not parent_id or not isinstance(state, dict):
+ continue
+ raw_participants = state.get("participantSessions")
+ if not isinstance(raw_participants, list):
+ continue
+ models = state.get("models")
+ if not isinstance(models, list):
+ models = []
+
+ participants = []
+ for idx, session_id in enumerate(raw_participants):
+ if not session_id:
+ continue
+ model = models[idx] if idx < len(models) and isinstance(models[idx], dict) else {}
+ participants.append({
+ "id": str(session_id),
+ "index": idx,
+ "name": _group_participant_label(model, idx),
+ "model": _group_state_str(model.get("display") or model.get("mid"), 1024),
+ "model_id": _group_state_str(model.get("mid"), 1024),
+ "endpoint_url": _group_state_str(model.get("url"), 2048),
+ "endpoint_id": _group_state_str(model.get("endpointId"), 1024),
+ })
+ if participants:
+ participants_by_parent[str(parent_id)] = participants
+ return participants_by_parent
+
+
+def _group_parent_for_participant(db, session_id: str, user) -> str | None:
+ for parent_id, state in _group_state_query_for_user(db, user).all():
+ if session_id in _group_participant_ids_from_state(state):
+ return parent_id
+ return None
+
+
+def _reject_group_participant_direct_action(db, session_id: str, user, action: str) -> None:
+ if _group_parent_for_participant(db, session_id, user):
+ raise HTTPException(403, f"{action} the parent group chat instead")
+
+
+def _set_group_participant_folders(db, participant_ids: set[str], folder: str | None, user) -> None:
+ if not participant_ids:
+ return
+ q = db.query(DbSession).filter(DbSession.id.in_(participant_ids))
+ q = owner_filter(q, DbSession, user)
+ now = utcnow_naive()
+ for participant in q.all():
+ participant.folder = folder
+ participant.updated_at = now
+
+
+def _sync_group_participant_folder(db, parent_session_id: str, folder: str | None, user) -> None:
+ row = db.query(GroupChatState.state).filter(GroupChatState.parent_session_id == parent_session_id).first()
+ if row is None:
+ return
+ _set_group_participant_folders(db, _group_participant_ids_from_state(row.state), folder, user)
+
+
+def _delete_session_with_group_children(session_manager, session_id: str, user) -> bool:
+ db = SessionLocal()
+ target_ids: list[str] = []
+ image_paths = []
+ try:
+ q = db.query(GroupChatState).filter(GroupChatState.parent_session_id == session_id)
+ q = owner_filter(q, GroupChatState, user)
+ group_state = q.first()
+ participant_ids = (
+ sorted(_group_participant_ids_from_state(group_state.state))
+ if group_state
+ else []
+ )
+ target_ids = list(dict.fromkeys([*participant_ids, session_id]))
+
+ for target_id in target_ids:
+ if not session_manager._stage_session_deletion(db, target_id, image_paths):
+ raise RuntimeError(f"Session deletion target not found: {target_id}")
+
+ if group_state:
+ db.delete(group_state)
+ db.commit()
+ except Exception:
+ db.rollback()
+ logger.exception("Failed atomic deletion of session group rooted at %s", session_id)
+ raise
+ finally:
+ db.close()
+
+ session_manager._finalize_session_deletions(target_ids, image_paths)
+ return True
+
+
_HIDDEN_SYSTEM_SESSION_NAMES = {
"[Task] Chat Sessions Tidy",
"[Task] Documents Tidy",
@@ -269,6 +504,8 @@ def list_sessions(request: Request):
last_msg_map = {}
mode_map = {}
msg_count_map = {}
+ group_parent_ids, group_participant_ids = _group_session_links_for_user(db, user)
+ group_participants_map = _group_participants_for_user(db, user)
q = db.query(DbSession.id, DbSession.folder, DbSession.total_input_tokens, DbSession.total_output_tokens, DbSession.is_important, DbSession.created_at, DbSession.updated_at, DbSession.last_message_at, DbSession.mode, DbSession.message_count).filter(DbSession.archived == False)
q = owner_filter(q, DbSession, user)
rows = q.all()
@@ -319,9 +556,12 @@ def list_sessions(request: Request):
"has_documents": s.id in doc_session_ids,
"has_images": s.id in img_session_ids,
"mode": mode_map.get(s.id),
- "message_count": msg_count_map.get(s.id, 0)}
+ "message_count": msg_count_map.get(s.id, 0),
+ "is_group_parent": s.id in group_parent_ids,
+ "group_participants": group_participants_map.get(s.id, [])}
for s in user_sessions.values()
if not s.archived
+ and s.id not in group_participant_ids
and (s.name or "").strip() not in ("Nobody", "Incognito")
and (s.name or "").strip() not in _HIDDEN_SYSTEM_SESSION_NAMES]
@@ -478,12 +718,24 @@ def rename_session(
if folder is not None:
db = SessionLocal()
try:
+ user = effective_user(request)
+ parent_id = _group_parent_for_participant(db, sid, user)
+ if parent_id:
+ raise HTTPException(403, "Move the parent group chat instead")
db_session = db.query(DbSession).filter(DbSession.id == sid).first()
if db_session:
- db_session.folder = folder if folder else None
+ folder_value = folder if folder else None
+ db_session.folder = folder_value
db_session.updated_at = utcnow_naive()
+ _sync_group_participant_folder(db, sid, folder_value, user)
db.commit()
- result["folder"] = folder if folder else None
+ result["folder"] = folder_value
+ except HTTPException:
+ db.rollback()
+ raise
+ except Exception:
+ db.rollback()
+ raise
finally:
db.close()
# Switch model/endpoint mid-session
@@ -568,6 +820,74 @@ async def inject_messages(request: Request, sid: str):
session_manager.save_sessions()
return {"ok": True, "count": len(messages)}
+ @router.put("/session/{sid}/group_state")
+ async def save_group_state(request: Request, sid: str):
+ """Persist the group-chat participant/model state for the parent session."""
+ _verify_session_owner(request, sid)
+ state = _normalize_group_state(await request.json(), sid)
+ user = effective_user(request)
+ participant_ids = {
+ participant_id
+ for participant_id in state["participantSessions"]
+ if participant_id
+ }
+
+ db = SessionLocal()
+ try:
+ if participant_ids:
+ rows = db.query(DbSession.id, DbSession.owner).filter(DbSession.id.in_(participant_ids)).all()
+ owner_by_id = {row.id: row.owner for row in rows}
+ if set(owner_by_id) != participant_ids:
+ raise HTTPException(400, "Group participant session not found")
+ if user and any(owner not in (user, None) for owner in owner_by_id.values()):
+ raise HTTPException(404, "Group participant session not found")
+
+ group_state = db.query(GroupChatState).filter(GroupChatState.parent_session_id == sid).first()
+ if group_state is None:
+ group_state = GroupChatState(parent_session_id=sid)
+ db.add(group_state)
+ group_state.owner = user
+ group_state.mode = state["mode"]
+ group_state.state = state
+ group_state.updated_at = utcnow_naive()
+ parent_folder = db.query(DbSession.folder).filter(DbSession.id == sid).first()
+ _set_group_participant_folders(
+ db,
+ participant_ids,
+ parent_folder.folder if parent_folder else None,
+ user,
+ )
+ db.commit()
+ return {"ok": True, "group_state": state}
+ except HTTPException:
+ db.rollback()
+ raise
+ except Exception:
+ db.rollback()
+ raise
+ finally:
+ db.close()
+
+ @router.get("/session/{sid}/group_state")
+ def get_group_state(request: Request, sid: str):
+ """Return persisted group-chat state for a parent session, if one exists."""
+ _verify_session_owner(request, sid)
+ user = effective_user(request)
+ db = SessionLocal()
+ try:
+ group_state = db.query(GroupChatState).filter(GroupChatState.parent_session_id == sid).first()
+ if group_state is None:
+ return {"ok": False, "group_state": None}
+ if user and group_state.owner and group_state.owner != user:
+ raise HTTPException(404, "Group chat state not found")
+ try:
+ state = _normalize_group_state(group_state.state, sid)
+ except HTTPException:
+ return {"ok": False, "group_state": None}
+ return {"ok": True, "group_state": state}
+ finally:
+ db.close()
+
@router.post("/session/{sid}/delete")
def delete_session_beacon(request: Request, sid: str):
"""Delete session via POST (for navigator.sendBeacon on page close)."""
@@ -586,17 +906,19 @@ async def bulk_delete_sessions(request: Request):
for sid in ids:
try:
_verify_session_owner(request, sid, session_manager)
+ user = effective_user(request)
# Enforce "starred" protection consistent with single-session delete
db = SessionLocal()
try:
+ _reject_group_participant_direct_action(db, sid, user, "Delete")
db_sess = db.query(DbSession).filter(DbSession.id == sid).first()
if db_sess and db_sess.is_important:
continue
finally:
db.close()
- if session_manager.delete_session(sid):
+ if _delete_session_with_group_children(session_manager, sid, user):
deleted_count += 1
except Exception:
pass
@@ -607,9 +929,11 @@ def delete_session(request: Request, sid: str):
"""Permanently delete a session and all its messages."""
_verify_session_owner(request, sid, session_manager)
try:
+ user = effective_user(request)
# Block deletion of starred/favorited sessions
db = SessionLocal()
try:
+ _reject_group_participant_direct_action(db, sid, user, "Delete")
db_sess = db.query(DbSession).filter(DbSession.id == sid).first()
if db_sess and db_sess.is_important:
raise HTTPException(
@@ -620,7 +944,7 @@ def delete_session(request: Request, sid: str):
db.close()
# Delete the session and all its messages
- if session_manager.delete_session(sid):
+ if _delete_session_with_group_children(session_manager, sid, user):
return {"status": "deleted"}
else:
raise HTTPException(404, "Session not found")
@@ -698,8 +1022,10 @@ def archive_session(request: Request, sid: str):
session_manager.get_session(sid)
# Archive the session
+ user = effective_user(request)
db = SessionLocal()
try:
+ _reject_group_participant_direct_action(db, sid, user, "Archive")
db_session = db.query(DbSession).filter(DbSession.id == sid).first()
if db_session:
db_session.archived = True
@@ -731,8 +1057,10 @@ def archive_session(request: Request, sid: str):
def unarchive_session(request: Request, sid: str):
"""Restore an archived session back to the active session list."""
_verify_session_owner(request, sid)
+ user = effective_user(request)
db = SessionLocal()
try:
+ _reject_group_participant_direct_action(db, sid, user, "Restore")
db_session = db.query(DbSession).filter(DbSession.id == sid).first()
if not db_session:
raise HTTPException(404, f"Session {sid} not found")
@@ -767,6 +1095,9 @@ def list_archived_sessions(request: Request, search: str = "", offset: int = 0,
if not user:
raise HTTPException(403, "Authentication required")
q = q.filter(DbSession.owner == user)
+ _, group_participant_ids = _group_session_links_for_user(db, user)
+ if group_participant_ids:
+ q = q.filter(~DbSession.id.in_(group_participant_ids))
if search:
safe_search = search.replace('%', r'\%').replace('_', r'\_')
q = q.filter(DbSession.name.ilike(f"%{safe_search}%", escape='\\'))
@@ -1060,6 +1391,8 @@ def auto_sort_sessions(request: Request, skip_llm: bool = False):
db = SessionLocal()
deleted_empty = 0
deleted_throwaway = 0
+ group_parent_ids: set[str] = set()
+ group_participant_ids: set[str] = set()
# Names that indicate a throwaway/test session (case-insensitive exact or prefix match)
_THROWAWAY_NAMES = {
"test", "testing", "asdf", "asd", "hello", "hi", "hey",
@@ -1077,6 +1410,8 @@ def auto_sort_sessions(request: Request, skip_llm: bool = False):
elif not single_user_mode:
rows_q = rows_q.filter(DbSession.owner == user)
rows = rows_q.limit(2000).all()
+ group_parent_ids, group_participant_ids = _group_session_links_for_user(db, user)
+ protected_group_ids = group_parent_ids | group_participant_ids
folder_map = {r.id: r.folder for r in rows}
# Precompute per-session message counts in TWO aggregate queries
# instead of 1–3 queries PER session — with many chats the per-row
@@ -1089,6 +1424,8 @@ def auto_sort_sessions(request: Request, skip_llm: bool = False):
)
cleanup_now = utcnow_naive()
for row in rows:
+ if row.id in protected_group_ids:
+ continue
# Never delete important sessions
if getattr(row, 'is_important', False):
continue
@@ -1167,6 +1504,8 @@ def auto_sort_sessions(request: Request, skip_llm: bool = False):
for s in user_sessions.values():
if s.archived or s.name == "Incognito":
continue
+ if s.id in group_participant_ids:
+ continue
if folder_map.get(s.id):
# Already in a folder — skip on this pass.
continue
@@ -1305,6 +1644,7 @@ def _loads_lenient(s):
if db_session:
db_session.folder = folder_name
db_session.updated_at = utcnow_naive()
+ _sync_group_participant_folder(db, sid, folder_name, user)
updated += 1
db.commit()
except Exception as e:
diff --git a/src/request_models.py b/src/request_models.py
index f29e9fbab..6d5a42e10 100644
--- a/src/request_models.py
+++ b/src/request_models.py
@@ -12,6 +12,10 @@ class ChatRequest(BaseModel):
use_research: Optional[bool] = Field(default=False, description="Enable deep research")
time_filter: Optional[str] = Field(default=None, description="Time filter for search")
preset_id: Optional[str] = Field(default=None, description="Preset identifier")
+ group_internal: Optional[bool] = Field(
+ default=False,
+ description="Internal group-chat turn; suppress parent whisper mirroring",
+ )
selected_endpoint_id: Optional[str] = Field(default=None, description="Selected model endpoint ID")
@field_validator('message')
diff --git a/src/session_actions.py b/src/session_actions.py
index 072bb4c06..301aa928c 100644
--- a/src/session_actions.py
+++ b/src/session_actions.py
@@ -53,6 +53,15 @@ def is_session_recently_active(row, now=None, grace=_FRESH_SESSION_GRACE) -> boo
return False
+def _group_participant_ids_from_state(state) -> set[str]:
+ if not isinstance(state, dict):
+ return set()
+ participants = state.get("participantSessions")
+ if not isinstance(participants, list):
+ return set()
+ return {str(session_id) for session_id in participants if session_id}
+
+
async def run_auto_sort(owner: str, skip_llm: bool = False, delete_throwaway: bool = True) -> str:
"""Run session cleanup + (optional) AI folder sort for the given owner.
@@ -65,7 +74,8 @@ async def run_auto_sort(owner: str, skip_llm: bool = False, delete_throwaway: bo
Returns a human-readable summary of what was done.
"""
- from core.database import SessionLocal, Session as DbSession, ChatMessage as DbMsg
+ from core.database import SessionLocal, Session as DbSession, ChatMessage as DbMsg, GroupChatState
+ from src.auth_helpers import owner_filter
from src.llm_core import llm_call_async
from src.task_endpoint import resolve_task_endpoint
@@ -75,13 +85,25 @@ async def run_auto_sort(owner: str, skip_llm: bool = False, delete_throwaway: bo
deleted_empty = 0
deleted_throwaway = 0
- rows = db.query(DbSession).filter(
- DbSession.archived == False,
- *([DbSession.owner == owner] if owner else []),
+ rows = owner_filter(
+ db.query(DbSession).filter(DbSession.archived == False),
+ DbSession,
+ owner,
).all()
+ group_query = db.query(GroupChatState.parent_session_id, GroupChatState.state)
+ group_query = owner_filter(group_query, GroupChatState, owner)
+ group_parent_to_participants = {}
+ group_participant_ids: set[str] = set()
+ for parent_id, state in group_query.all():
+ participants = _group_participant_ids_from_state(state)
+ group_parent_to_participants[parent_id] = participants
+ group_participant_ids.update(participants)
+ protected_group_ids = set(group_parent_to_participants) | group_participant_ids
cleanup_now = _utcnow_naive()
for row in rows:
+ if row.id in protected_group_ids:
+ continue
if getattr(row, 'is_important', False):
continue
created_at = _as_naive_utc(row.created_at or row.updated_at) or _utcnow_naive()
@@ -141,15 +163,18 @@ async def run_auto_sort(owner: str, skip_llm: bool = False, delete_throwaway: bo
logger.info(f"Auto-sort: deleted {deleted_empty} empty + {deleted_throwaway} throwaway sessions")
# ── Phase 2: AI folder assignment ──
- remaining = db.query(DbSession).filter(
- DbSession.archived == False,
- *([DbSession.owner == owner] if owner else []),
+ remaining = owner_filter(
+ db.query(DbSession).filter(DbSession.archived == False),
+ DbSession,
+ owner,
).all()
session_list = []
for row in remaining:
if row.name == "Incognito":
continue
+ if row.id in group_participant_ids:
+ continue
session_list.append({
"id": row.id,
"name": row.name or "(unnamed)",
@@ -240,6 +265,13 @@ async def run_auto_sort(owner: str, skip_llm: bool = False, delete_throwaway: bo
if db_sess:
db_sess.folder = folder_name
db_sess.updated_at = _utcnow_naive()
+ participant_ids = group_parent_to_participants.get(full_id, set())
+ if participant_ids:
+ child_query = db.query(DbSession).filter(DbSession.id.in_(participant_ids))
+ child_query = owner_filter(child_query, DbSession, owner)
+ for child in child_query.all():
+ child.folder = folder_name
+ child.updated_at = _utcnow_naive()
updated += 1
db.commit()
diff --git a/src/session_image_cleanup.py b/src/session_image_cleanup.py
index 280169c75..8cb499983 100644
--- a/src/session_image_cleanup.py
+++ b/src/session_image_cleanup.py
@@ -81,44 +81,66 @@ def session_image_refs(db, session_id: str) -> tuple[set[str], set[str]]:
return image_ids, filenames
+def prepare_session_image_cleanup(session_id: str, db) -> tuple[int, list[Path]]:
+ """Stage gallery soft-deletes and return files to unlink after commit.
+
+ Database work deliberately raises to the caller so a larger session-delete
+ transaction can roll back as one unit. Filesystem deletion is deferred
+ because it cannot be rolled back with the database transaction.
+ """
+ _, GalleryImage, _ = _database_models()
+ image_ids, filenames = session_image_refs(db, session_id)
+ query = db.query(GalleryImage).filter(GalleryImage.session_id == session_id)
+ if image_ids or filenames:
+ from sqlalchemy import or_
+
+ clauses = [GalleryImage.session_id == session_id]
+ if image_ids:
+ clauses.append(GalleryImage.id.in_(list(image_ids)))
+ if filenames:
+ clauses.append(GalleryImage.filename.in_(list(filenames)))
+ query = db.query(GalleryImage).filter(or_(*clauses))
+
+ images = query.all()
+ paths: list[Path] = []
+ for img in images:
+ img.is_active = False
+ if img.filename:
+ path = _generated_image_path_for_cleanup(img.filename)
+ if path:
+ paths.append(path)
+ return len(images), paths
+
+
+def unlink_session_image_paths(paths: list[Path], session_id: str) -> None:
+ """Best-effort removal of generated files after a successful DB commit."""
+ for path in dict.fromkeys(paths):
+ if not path.exists():
+ continue
+ try:
+ path.unlink()
+ except Exception as exc:
+ logger.warning(
+ "Could not remove generated image %s for deleted session %s: %s",
+ path.name,
+ session_id,
+ exc,
+ )
+
+
def cleanup_session_images(session_id: str, db=None) -> int:
"""Soft-delete Gallery rows and unlink generated files owned by a chat."""
- _, GalleryImage, SessionLocal = _database_models()
+ _, _, SessionLocal = _database_models()
owns_db = db is None
db = db or SessionLocal()
try:
- image_ids, filenames = session_image_refs(db, session_id)
- query = db.query(GalleryImage).filter(GalleryImage.session_id == session_id)
- if image_ids or filenames:
- from sqlalchemy import or_
-
- clauses = [GalleryImage.session_id == session_id]
- if image_ids:
- clauses.append(GalleryImage.id.in_(list(image_ids)))
- if filenames:
- clauses.append(GalleryImage.filename.in_(list(filenames)))
- query = db.query(GalleryImage).filter(or_(*clauses))
-
- images = query.all()
- removed = 0
- for img in images:
- img.is_active = False
- if img.filename:
- path = _generated_image_path_for_cleanup(img.filename)
- if path and path.exists():
- try:
- path.unlink()
- except Exception as exc:
- logger.warning(
- "Could not remove generated image %s for deleted session %s: %s",
- img.filename,
- session_id,
- exc,
- )
- removed += 1
-
- if owns_db and images:
+ removed, paths = prepare_session_image_cleanup(session_id, db)
+ if owns_db and removed:
db.commit()
+ # Retain the standalone helper's historical behavior for callers that
+ # supply a transaction. Atomic session deletion uses the prepare/unlink
+ # pair directly so it can defer this irreversible step until commit.
+ unlink_session_image_paths(paths, session_id)
return removed
except Exception as exc:
if owns_db:
diff --git a/src/session_search.py b/src/session_search.py
index d8b994fa8..a17a1d840 100644
--- a/src/session_search.py
+++ b/src/session_search.py
@@ -11,6 +11,7 @@
from sqlalchemy import text
from core.database import ChatMessage as DBChatMessage
+from core.database import GroupChatState
from core.database import Session as DBSession
from core.database import SessionLocal
@@ -134,6 +135,30 @@ def _owner_filter(query, owner: str | None, include_legacy_owner: bool):
return query.filter((DBSession.owner == owner) | (DBSession.owner.is_(None)))
+def _group_participant_ids_from_state(state) -> set[str]:
+ if not isinstance(state, dict):
+ return set()
+ participants = state.get("participantSessions")
+ if not isinstance(participants, list):
+ return set()
+ return {str(session_id) for session_id in participants if session_id}
+
+
+def _hidden_group_participant_ids(db, owner: str | None, restrict_owner: bool, include_legacy_owner: bool) -> set[str]:
+ q = db.query(GroupChatState.state)
+ if restrict_owner:
+ if owner is None:
+ q = q.filter(GroupChatState.owner.is_(None))
+ elif include_legacy_owner:
+ q = q.filter((GroupChatState.owner == owner) | (GroupChatState.owner.is_(None)))
+ else:
+ q = q.filter(GroupChatState.owner == owner)
+ participant_ids: set[str] = set()
+ for (state,) in q.all():
+ participant_ids.update(_group_participant_ids_from_state(state))
+ return participant_ids
+
+
def _context_for_message(db, msg: DBChatMessage, count: int) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
if count <= 0 or not msg.timestamp:
return [], []
@@ -210,6 +235,9 @@ def _search_like(
q = q.filter(~DBSession.name.like("SFT trace batch%"))
if restrict_owner:
q = _owner_filter(q, owner, include_legacy_owner)
+ hidden_participant_ids = _hidden_group_participant_ids(db, owner, restrict_owner, include_legacy_owner)
+ if hidden_participant_ids:
+ q = q.filter(~DBChatMessage.session_id.in_(hidden_participant_ids))
rows = q.order_by(DBChatMessage.timestamp.desc()).limit(limit).all()
shaped = ((msg, session_name, _snippet(msg.content or "", query)) for msg, session_name in rows)
return _rows_to_results(db, shaped, query, context_messages)
@@ -278,6 +306,8 @@ def _search_fts(
"""
)
+ hidden_participant_ids = _hidden_group_participant_ids(db, owner, restrict_owner, include_legacy_owner)
+
try:
hits = db.execute(sql, params).fetchall()
except Exception as e:
@@ -293,6 +323,8 @@ def _search_fts(
found = by_id.get(hit[0])
if found:
msg, session_name = found
+ if msg.session_id in hidden_participant_ids:
+ continue
rows.append((msg, session_name, hit[1] or ""))
return _rows_to_results(db, rows, query, context_messages)
diff --git a/static/app.js b/static/app.js
index 426be5f66..aae437676 100644
--- a/static/app.js
+++ b/static/app.js
@@ -29,6 +29,7 @@ import voiceRecorderModule from './js/voiceRecorder.js';
import censorModule from './js/censor.js';
import galleryModule from './js/gallery.js';
import { UI_VIS_DEFAULT_OFF, resolveVisibility } from './js/ui_visibility.js';
+import { syncWhisperIndicatorAccessibility } from './js/whisperIndicator.js';
import tasksModule from './js/tasks.js?v=20260723tasksbulkfeedback1';
import calendarModule from './js/calendar.js';
import notesModule from './js/notes.js';
@@ -908,7 +909,8 @@ function initializeEventListeners() {
if (chk) chk.checked = active;
// Hide/show model picker
const _mpw = el('model-picker-wrap');
- if (_mpw) _mpw.style.display = active ? 'none' : '';
+ const whisperActive = !!(sessionModule && sessionModule.isCurrentGroupChild && sessionModule.isCurrentGroupChild());
+ if (_mpw) _mpw.style.display = (active || whisperActive) ? 'none' : '';
// Mutual exclusion: group disables research + web search
if (active) {
_syncResearchIndicator(false);
@@ -974,6 +976,34 @@ function initializeEventListeners() {
if (ws) { ws.style.animation = 'none'; ws.offsetHeight; ws.style.animation = 'welcome-enter 0.3s ease-out both'; }
}
+ /** Sync raw participant whisper indicator. */
+ function _syncWhisperIndicator(active, target = null) {
+ const btn = el('whisper-toggle-btn');
+ const label = el('whisper-toggle-label');
+ const name = target && target.name ? String(target.name) : '';
+ if (btn) {
+ btn.style.display = active ? '' : 'none';
+ btn.classList.toggle('active', active);
+ syncWhisperIndicatorAccessibility(btn, active, target);
+ btn.title = active
+ ? `Whisper to ${name || 'group participant'} - click to open group`
+ : 'Whisper mode active';
+ if (target && target.parent_session_id) {
+ btn.dataset.parentSessionId = String(target.parent_session_id);
+ } else {
+ delete btn.dataset.parentSessionId;
+ }
+ }
+ if (label) {
+ label.textContent = 'Whisper';
+ }
+ const _mpw = el('model-picker-wrap');
+ if (_mpw) {
+ const groupActive = !!(groupModule && groupModule.isActive && groupModule.isActive());
+ _mpw.style.display = (active || groupActive) ? 'none' : '';
+ }
+ }
+
// ── Close compare if active (used by all tool/sidebar activations) ──
// Returns true if compare was active (page will reload), caller should return early
function _closeCompareIfActive() {
@@ -2044,6 +2074,7 @@ function initializeEventListeners() {
// run locally — finds it instead of silently no-op'ing (the "group indicator
// sometimes doesn't appear" bug).
window._syncGroupIndicator = _syncGroupIndicator;
+ window._syncWhisperIndicator = _syncWhisperIndicator;
// Init RAG state on load
{
const st = loadToggleState();
@@ -2575,6 +2606,19 @@ function initializeEventListeners() {
}
// ── Incognito mode toggle (on welcome screen) ──
+ const whisperToggleBtn = el('whisper-toggle-btn');
+ if (whisperToggleBtn) {
+ whisperToggleBtn.addEventListener('click', () => {
+ const child = sessionModule && sessionModule.getCurrentGroupChildInfo
+ ? sessionModule.getCurrentGroupChildInfo()
+ : null;
+ const parentId = (child && child.parent_session_id) || whisperToggleBtn.dataset.parentSessionId;
+ if (parentId && sessionModule && sessionModule.selectSession) {
+ sessionModule.selectSession(parentId, { keepSidebar: true });
+ }
+ });
+ }
+
const incognitoBtn = el('incognito-btn');
const INCOGNITO_EYE_OPEN = '';
const INCOGNITO_EYE_CLOSED = '';
@@ -3866,7 +3910,8 @@ function startOdysseusApp() {
if (!msg) { console.log('[group] Empty message, skipping'); return; }
console.log('[group] Sending:', msg);
chatRenderer.hideWelcomeScreen();
- chatRenderer.addMessage('user', msg);
+ const userMetadata = groupModule.getWhisperUserMetadata ? groupModule.getWhisperUserMetadata() : null;
+ chatRenderer.addMessage('user', msg, null, userMetadata);
msgInput.value = '';
groupModule.sendMessage(msg);
return;
@@ -3921,6 +3966,16 @@ function startOdysseusApp() {
return true;
}
+ function _currentWhisperChildInfo() {
+ try {
+ return sessionModule && sessionModule.getCurrentGroupChildInfo
+ ? sessionModule.getCurrentGroupChildInfo()
+ : null;
+ } catch (_) {
+ return null;
+ }
+ }
+
function _updateSendBtnIcon() {
if (!sendBtn) return;
if (sendBtn.dataset.mode === 'streaming') {
@@ -3943,9 +3998,13 @@ function startOdysseusApp() {
} else if (!hasText && !hasFiles && !_isSttEnabled()) {
clearTimeout(sendBtn._collapseTimer);
// Group chat: always show send button, never newchat mode
- if (groupModule && groupModule.isActive()) {
+ const whisperChild = _currentWhisperChildInfo();
+ if ((groupModule && groupModule.isActive()) || whisperChild) {
+ const whisperTarget = groupModule.getWhisperTarget ? groupModule.getWhisperTarget() : null;
sendBtn.innerHTML = _sendIcon;
- sendBtn.title = 'Send to group';
+ sendBtn.title = whisperChild
+ ? `Send whisper to ${whisperChild.name || 'participant'}`
+ : (whisperTarget ? `Whisper to ${whisperTarget.name}` : 'Send to group');
newMode = 'idle';
sendBtn.classList.remove('mic-mode', 'newchat-mode', 'newchat-expanded');
} else {
@@ -3983,15 +4042,25 @@ function startOdysseusApp() {
const delay = wasExpanded ? 300 : 0;
setTimeout(() => {
if (sendBtn.dataset.mode !== 'send') return;
+ const groupActive = groupModule && groupModule.isActive && groupModule.isActive();
+ const whisperTarget = groupActive && groupModule.getWhisperTarget ? groupModule.getWhisperTarget() : null;
+ const whisperChild = _currentWhisperChildInfo();
sendBtn.innerHTML = _sendIcon;
- sendBtn.title = 'Send message';
+ sendBtn.title = whisperChild
+ ? `Send whisper to ${whisperChild.name || 'participant'}`
+ : (whisperTarget ? `Whisper to ${whisperTarget.name}` : (groupActive ? 'Send to group' : 'Send message'));
sendBtn.classList.remove('mic-mode', 'newchat-mode', 'anim-spin-swap');
sendBtn.classList.add('anim-spin');
sendBtn.addEventListener('animationend', () => sendBtn.classList.remove('anim-spin'), { once: true });
}, delay);
} else {
+ const groupActive = groupModule && groupModule.isActive && groupModule.isActive();
+ const whisperTarget = groupActive && groupModule.getWhisperTarget ? groupModule.getWhisperTarget() : null;
+ const whisperChild = _currentWhisperChildInfo();
sendBtn.innerHTML = _sendIcon;
- sendBtn.title = 'Send message';
+ sendBtn.title = whisperChild
+ ? `Send whisper to ${whisperChild.name || 'participant'}`
+ : (whisperTarget ? `Whisper to ${whisperTarget.name}` : (groupActive ? 'Send to group' : 'Send message'));
sendBtn.classList.remove('mic-mode', 'newchat-mode', 'newchat-expanded', 'anim-spin', 'anim-launch', 'anim-land');
}
}
diff --git a/static/index.html b/static/index.html
index 4dd4c6795..62d389976 100644
--- a/static/index.html
+++ b/static/index.html
@@ -1180,6 +1180,10 @@
Odysseus
Group
+