From 74ebb8a87fcbbd48d54d2e6f3f91b39aa9b010bc Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 7 Mar 2026 11:34:06 -0500 Subject: [PATCH] Project Workspace view, abort button, session invalidation, workspace fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- frontend/src/App.vue | 3 + .../src/components/WorkspaceNoteEditor.vue | 571 ++++++++++++++++ .../src/components/WorkspaceTaskPanel.vue | 579 +++++++++++++++++ frontend/src/router/index.ts | 5 + frontend/src/stores/chat.ts | 2 + frontend/src/views/ChatView.vue | 24 + frontend/src/views/HomeView.vue | 2 +- frontend/src/views/ProjectView.vue | 46 +- frontend/src/views/SettingsView.vue | 44 ++ frontend/src/views/WorkspaceView.vue | 607 ++++++++++++++++++ src/fabledassistant/routes/auth.py | 16 + src/fabledassistant/routes/chat.py | 5 +- src/fabledassistant/services/auth.py | 13 + .../services/generation_task.py | 2 + src/fabledassistant/services/llm.py | 17 + 15 files changed, 1927 insertions(+), 9 deletions(-) create mode 100644 frontend/src/components/WorkspaceNoteEditor.vue create mode 100644 frontend/src/components/WorkspaceTaskPanel.vue create mode 100644 frontend/src/views/WorkspaceView.vue diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 093b35a..0ff01fb 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -277,6 +277,9 @@ onUnmounted(() => { min-height: 0; overflow-y: auto; } +.app-content:has(.workspace-root) { + overflow: hidden; +} /* Shortcuts overlay */ .shortcuts-overlay { diff --git a/frontend/src/components/WorkspaceNoteEditor.vue b/frontend/src/components/WorkspaceNoteEditor.vue new file mode 100644 index 0000000..922d9ca --- /dev/null +++ b/frontend/src/components/WorkspaceNoteEditor.vue @@ -0,0 +1,571 @@ + + + + + diff --git a/frontend/src/components/WorkspaceTaskPanel.vue b/frontend/src/components/WorkspaceTaskPanel.vue new file mode 100644 index 0000000..b17308f --- /dev/null +++ b/frontend/src/components/WorkspaceTaskPanel.vue @@ -0,0 +1,579 @@ + + + + + diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 4b672ed..bff27a4 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -75,6 +75,11 @@ const router = createRouter({ name: "project-view", component: () => import("@/views/ProjectView.vue"), }, + { + path: "/workspace/:projectId", + name: "workspace", + component: () => import("@/views/WorkspaceView.vue"), + }, { path: "/tasks", name: "tasks", diff --git a/frontend/src/stores/chat.ts b/frontend/src/stores/chat.ts index f9fb6b8..15c8c11 100644 --- a/frontend/src/stores/chat.ts +++ b/frontend/src/stores/chat.ts @@ -156,6 +156,7 @@ export const useChatStore = defineStore("chat", () => { contextNoteTitle?: string, excludeNoteIds?: number[], ragProjectId?: number | null, + workspaceProjectId?: number | null, ) { if (!currentConversation.value) return; @@ -206,6 +207,7 @@ export const useChatStore = defineStore("chat", () => { excluded_note_ids: excludeNoteIds?.length ? excludeNoteIds : undefined, think, rag_project_id: ragProjectId ?? undefined, + workspace_project_id: workspaceProjectId ?? undefined, }, ); assistantMessageId = resp.assistant_message_id; diff --git a/frontend/src/views/ChatView.vue b/frontend/src/views/ChatView.vue index c9ff665..82aa006 100644 --- a/frontend/src/views/ChatView.vue +++ b/frontend/src/views/ChatView.vue @@ -458,6 +458,14 @@ onUnmounted(() => {

{{ store.currentConversation.title || "New Chat" }}

+ +
+ + Open Workspace + + +

Loading...

@@ -612,6 +621,12 @@ function formatDate(dateStr?: string | null): string { margin-bottom: 1.25rem; } +.page-header-actions { + display: flex; + gap: 0.5rem; + align-items: center; +} + .btn-back { color: var(--color-primary); text-decoration: none; @@ -621,6 +636,23 @@ function formatDate(dateStr?: string | null): string { text-decoration: underline; } +.btn-outline { + padding: 0.35rem 0.8rem; + background: none; + border: 1px solid var(--color-primary); + color: var(--color-primary); + border-radius: 6px; + font-size: 0.875rem; + text-decoration: none; + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 0.3rem; +} +.btn-outline:hover { + background: color-mix(in srgb, var(--color-primary) 10%, transparent); +} + .btn-danger-outline { padding: 0.35rem 0.8rem; background: none; diff --git a/frontend/src/views/SettingsView.vue b/frontend/src/views/SettingsView.vue index 0a4da62..bb0089d 100644 --- a/frontend/src/views/SettingsView.vue +++ b/frontend/src/views/SettingsView.vue @@ -21,6 +21,7 @@ const currentPassword = ref(""); const newPassword = ref(""); const confirmNewPassword = ref(""); const changingPassword = ref(false); +const invalidatingSessions = ref(false); const saving = ref(false); const saved = ref(false); const exporting = ref(false); @@ -155,6 +156,18 @@ async function changeEmail() { } } +async function invalidateSessions() { + invalidatingSessions.value = true; + try { + await apiPost("/api/auth/invalidate-sessions", {}); + toastStore.show("All other sessions have been invalidated"); + } catch { + toastStore.show("Failed to invalidate sessions", "error"); + } finally { + invalidatingSessions.value = false; + } +} + async function changePassword() { if (newPassword.value !== confirmNewPassword.value) { toastStore.show("New passwords do not match", "error"); @@ -457,6 +470,20 @@ function hostname(url: string): string { + +
+

Active Sessions

+

+ Sign out all other devices and sessions. Use this after an SSO password change + to ensure stale sessions are revoked. +

+
+ +
+
+

Change Password

@@ -882,6 +909,23 @@ function hostname(url: string): string { .btn-save:disabled { opacity: 0.6; cursor: default; } .btn-save:hover:not(:disabled) { opacity: 0.9; } +.btn-danger-outline { + padding: 0.4rem 0.9rem; + background: none; + color: var(--color-danger, #e74c3c); + border: 1px solid var(--color-danger, #e74c3c); + border-radius: var(--radius-sm); + cursor: pointer; + font-size: 0.875rem; + font-family: inherit; + white-space: nowrap; +} +.btn-danger-outline:hover:not(:disabled) { + background: var(--color-danger, #e74c3c); + color: #fff; +} +.btn-danger-outline:disabled { opacity: 0.5; cursor: default; } + .btn-secondary { padding: 0.4rem 0.9rem; background: var(--color-bg-secondary); diff --git a/frontend/src/views/WorkspaceView.vue b/frontend/src/views/WorkspaceView.vue new file mode 100644 index 0000000..a99438d --- /dev/null +++ b/frontend/src/views/WorkspaceView.vue @@ -0,0 +1,607 @@ + + + + + diff --git a/src/fabledassistant/routes/auth.py b/src/fabledassistant/routes/auth.py index 434e991..8d4c040 100644 --- a/src/fabledassistant/routes/auth.py +++ b/src/fabledassistant/routes/auth.py @@ -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(): diff --git a/src/fabledassistant/routes/chat.py b/src/fabledassistant/routes/chat.py index 352019d..7ac9aa0 100644 --- a/src/fabledassistant/routes/chat.py +++ b/src/fabledassistant/routes/chat.py @@ -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({ diff --git a/src/fabledassistant/services/auth.py b/src/fabledassistant/services/auth.py index 57f8beb..c203e83 100644 --- a/src/fabledassistant/services/auth.py +++ b/src/fabledassistant/services/auth.py @@ -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. diff --git a/src/fabledassistant/services/generation_task.py b/src/fabledassistant/services/generation_task.py index 1ec227c..7a16c29 100644 --- a/src/fabledassistant/services/generation_task.py +++ b/src/fabledassistant/services/generation_task.py @@ -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 diff --git a/src/fabledassistant/services/llm.py b/src/fabledassistant/services/llm.py index 6752c52..5fdd5c8 100644 --- a/src/fabledassistant/services/llm.py +++ b/src/fabledassistant/services/llm.py @@ -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(