Project Workspace view, abort button, session invalidation, workspace fixes

Workspace (/workspace/:projectId):
- Three-panel layout (tasks / chat / notes) with CSS grid collapse toggles
- WorkspaceTaskPanel: tasks grouped by milestone, collapsible groups, task
  detail slide-over with status cycling, TaskLogSection work log, and
  inline-confirm delete
- WorkspaceNoteEditor: list view (sorted by updated_at, inline tag pills,
  inline-confirm delete) with editor view (TipTap, TagInput, Suggest tags,
  60s autosave)
- Persistent workspace conversation stored in localStorage per project;
  reused on return visits
- Thinking enabled (think: true) with Reasoning block in streaming bubble
- workspace_project_id backend pipeline: chat.py → generation_task.py →
  llm.py; system prompt uses project title so agent passes project="Title"
  to tools (fixes create_note failing with numeric project string)
- SSE tool-call watcher bridges agent actions to panel updates
- Height fix: workspace-root uses height 100%; app-content switches to
  overflow hidden via :has() selector
- Entry point: "Open Workspace" button on ProjectView

Abort button:
- Stop button in ChatView header and WorkspaceView input bar
- Calls existing cancelGeneration() / POST .../generation/cancel

Session invalidation:
- POST /api/auth/invalidate-sessions bumps session_version, keeps current
  session alive; useful after SSO/OAuth password rotation
- Button in Settings → Active Sessions section

Other:
- Dashboard recent notes limit increased from 8 to 16
- Workspace chat abort replaces Send button while streaming

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-07 11:34:06 -05:00
parent a8bb687349
commit 74ebb8a87f
15 changed files with 1927 additions and 9 deletions
+16
View File
@@ -18,6 +18,7 @@ from fabledassistant.services.auth import (
get_user_by_id,
get_user_by_username,
get_user_count,
invalidate_other_sessions,
is_registration_open,
register_with_invitation,
reset_password_with_token,
@@ -166,6 +167,21 @@ async def update_password():
return jsonify({"status": "ok"})
@auth_bp.route("/invalidate-sessions", methods=["POST"])
@login_required
async def invalidate_sessions_route():
"""Invalidate all other active sessions by bumping the session version.
The current session is kept alive. Useful after an SSO/OAuth password change
where the app has no visibility into credential rotation.
"""
uid = get_current_user_id()
new_version = await invalidate_other_sessions(uid)
session["session_version"] = new_version
await log_audit("sessions_invalidated", user_id=uid, username=g.user.username, ip_address=_client_ip())
return jsonify({"status": "ok"})
@auth_bp.route("/email", methods=["PUT"])
@login_required
async def update_email():
+4 -1
View File
@@ -118,6 +118,8 @@ async def send_message_route(conv_id: int):
excluded_note_ids = data.get("excluded_note_ids") or []
think = bool(data.get("think", False))
rag_project_id = data.get("rag_project_id") or None
workspace_project_id = data.get("workspace_project_id") or None
effective_rag_project_id = workspace_project_id or rag_project_id
# Reject if generation already running for this conversation
existing = get_buffer(conv_id)
@@ -151,7 +153,8 @@ async def send_message_route(conv_id: int):
include_note_ids=include_note_ids,
excluded_note_ids=excluded_note_ids,
think=think,
rag_project_id=rag_project_id,
rag_project_id=effective_rag_project_id,
workspace_project_id=workspace_project_id,
))
return jsonify({
+13
View File
@@ -124,6 +124,19 @@ async def change_password(user_id: int, current_password: str, new_password: str
return user.session_version
async def invalidate_other_sessions(user_id: int) -> int:
"""Bump session_version so all sessions except the current one are invalidated.
Returns the new session_version so the caller can update the active session cookie.
"""
async with async_session() as session:
user = await session.get(User, user_id)
user.session_version += 1
await session.commit()
logger.info("Sessions invalidated for user %d (%s)", user_id, user.username)
return user.session_version
async def is_registration_open() -> bool:
"""Check if new user registration is allowed.
@@ -150,6 +150,7 @@ async def run_generation(
excluded_note_ids: list[int] | None = None,
think: bool = False,
rag_project_id: int | None = None,
workspace_project_id: int | None = None,
) -> None:
"""Stream LLM response into buffer with periodic DB flushes."""
MAX_TOOL_ROUNDS = 5
@@ -181,6 +182,7 @@ async def run_generation(
include_note_ids=include_note_ids,
excluded_note_ids=excluded_note_ids,
rag_project_id=rag_project_id,
workspace_project_id=workspace_project_id,
))
messages, context_meta = await context_task
+17
View File
@@ -451,6 +451,7 @@ async def build_context(
include_note_ids: list[int] | None = None,
excluded_note_ids: list[int] | None = None,
rag_project_id: int | None = None,
workspace_project_id: int | None = None,
) -> tuple[list[dict], dict]:
"""Build messages array for Ollama with system prompt and context.
@@ -637,6 +638,22 @@ async def build_context(
f"\n\n--- Content from {url} ---\n{content}\n--- End URL Content ---"
)
# Inject workspace context when user is in a project workspace
if workspace_project_id is not None:
from fabledassistant.services.projects import get_project
try:
wp = await get_project(user_id, workspace_project_id)
if wp:
system_parts.append(
f"\n\n--- Active Workspace ---\n"
f"You are in the \"{wp.title}\" project workspace.\n"
f"All notes and tasks you create or update MUST belong to this project.\n"
f"Always pass project=\"{wp.title}\" when calling create_note or create_task.\n"
f"--- End Active Workspace ---"
)
except Exception:
logger.warning("Failed to fetch workspace project %d", workspace_project_id)
# Inject compressed summary of older exchanges when history has been trimmed
if history_summary:
system_parts.append(