From 9bf047ec45e732c889c708ff771ea7b058f96253 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 5 Mar 2026 13:05:26 -0500 Subject: [PATCH] Task work log, inline writing assistant, task editor sidebar layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - Migration 0021: task_logs table (FK → notes + users, CASCADE, indexed) - models/task_log.py: SQLAlchemy model with to_dict() - services/task_logs.py: CRUD with ownership checks, _UNSET sentinel for optional duration clear - routes/task_logs.py: GET/POST/PATCH/DELETE /api/tasks//logs - services/tools.py: log_work LLM tool (resolves task by title, creates log entry) - services/generation_task.py: retry assist generation up to 3× on HTTP 500 Frontend: - types/task.ts: TaskLog interface - TaskLogSection.vue: chronological work log with date+time timestamps, duration badge, inline edit, autofocus - InlineAssistPanel.vue: streaming preview + diff review rendered inline in editor column - useAssist.ts: removed chatStore.chatReady gate; toast notifications for errors - NoteEditorView.vue + TaskEditorView.vue: inline assist panel, aside restricted to idle state - TaskEditorView.vue: two-column layout (editor+log left, metadata sidebar right), body defaults to Preview, sidebarOpen accordion for mobile - editor-shared.css: .assist-active-hint style Co-Authored-By: Claude Sonnet 4.6 --- alembic/versions/0021_add_task_logs.py | 28 + frontend/src/assets/editor-shared.css | 9 + frontend/src/components/InlineAssistPanel.vue | 259 ++++++++ frontend/src/components/TaskLogSection.vue | 338 ++++++++++ frontend/src/composables/useAssist.ts | 8 +- frontend/src/types/task.ts | 9 + frontend/src/views/NoteEditorView.vue | 70 +- frontend/src/views/TaskEditorView.vue | 620 ++++++++++-------- src/fabledassistant/app.py | 2 + src/fabledassistant/models/__init__.py | 1 + src/fabledassistant/models/task_log.py | 35 + src/fabledassistant/routes/task_logs.py | 68 ++ .../services/generation_task.py | 48 +- src/fabledassistant/services/task_logs.py | 84 +++ src/fabledassistant/services/tools.py | 55 ++ summary.md | 14 +- 16 files changed, 1309 insertions(+), 339 deletions(-) create mode 100644 alembic/versions/0021_add_task_logs.py create mode 100644 frontend/src/components/InlineAssistPanel.vue create mode 100644 frontend/src/components/TaskLogSection.vue create mode 100644 src/fabledassistant/models/task_log.py create mode 100644 src/fabledassistant/routes/task_logs.py create mode 100644 src/fabledassistant/services/task_logs.py diff --git a/alembic/versions/0021_add_task_logs.py b/alembic/versions/0021_add_task_logs.py new file mode 100644 index 0000000..ee11a4f --- /dev/null +++ b/alembic/versions/0021_add_task_logs.py @@ -0,0 +1,28 @@ +"""Add task_logs table.""" + +from alembic import op + +revision = "0021" +down_revision = "0020" + + +def upgrade() -> None: + op.execute(""" + CREATE TABLE IF NOT EXISTS task_logs ( + id SERIAL PRIMARY KEY, + task_id INTEGER NOT NULL REFERENCES notes(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + content TEXT NOT NULL, + duration_minutes INTEGER, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW() + ) + """) + op.execute("CREATE INDEX IF NOT EXISTS ix_task_logs_task_id ON task_logs(task_id)") + op.execute("CREATE INDEX IF NOT EXISTS ix_task_logs_user_id ON task_logs(user_id)") + + +def downgrade() -> None: + op.execute("DROP INDEX IF EXISTS ix_task_logs_user_id") + op.execute("DROP INDEX IF EXISTS ix_task_logs_task_id") + op.execute("DROP TABLE IF EXISTS task_logs") diff --git a/frontend/src/assets/editor-shared.css b/frontend/src/assets/editor-shared.css index 3041264..769f498 100644 --- a/frontend/src/assets/editor-shared.css +++ b/frontend/src/assets/editor-shared.css @@ -365,6 +365,15 @@ 50% { opacity: 0; } } +/* Active hint shown in the panel while output is inline */ +.assist-active-hint { + padding: 0.5rem 0.75rem; + font-size: 0.8rem; + color: var(--color-text-muted); + font-style: italic; + text-align: center; +} + /* Error */ .assist-error { padding: 0.5rem 0.75rem; diff --git a/frontend/src/components/InlineAssistPanel.vue b/frontend/src/components/InlineAssistPanel.vue new file mode 100644 index 0000000..33956be --- /dev/null +++ b/frontend/src/components/InlineAssistPanel.vue @@ -0,0 +1,259 @@ + + + + + diff --git a/frontend/src/components/TaskLogSection.vue b/frontend/src/components/TaskLogSection.vue new file mode 100644 index 0000000..6730802 --- /dev/null +++ b/frontend/src/components/TaskLogSection.vue @@ -0,0 +1,338 @@ + + + + + diff --git a/frontend/src/composables/useAssist.ts b/frontend/src/composables/useAssist.ts index d6a67a4..cd77b87 100644 --- a/frontend/src/composables/useAssist.ts +++ b/frontend/src/composables/useAssist.ts @@ -1,6 +1,6 @@ import { ref, computed, watch, type Ref } from "vue"; import { apiPost, apiSSEStream, type SSEStreamHandle } from "@/api/client"; -import { useChatStore } from "@/stores/chat"; +import { useToastStore } from "@/stores/toast"; import { parseMarkdownSections, type MarkdownSection, @@ -42,7 +42,7 @@ function computeDiff(a: string, b: string): DiffLine[] { } export function useAssist(body: Ref) { - const chatStore = useChatStore(); + const toast = useToastStore(); const state = ref("idle"); const sections = ref([]); @@ -80,7 +80,6 @@ export function useAssist(body: Ref) { () => target.value !== null && instruction.value.trim().length > 0 && - chatStore.chatReady && state.value !== "streaming" ); @@ -154,6 +153,7 @@ export function useAssist(body: Ref) { } if (evt.event === "error") { error.value = evt.data.error as string; + toast.show("Assist failed: " + error.value, "error"); state.value = "idle"; streamHandle = null; } @@ -173,6 +173,7 @@ export function useAssist(body: Ref) { } } catch (e) { error.value = e instanceof Error ? e.message : "Request failed"; + toast.show("Assist failed: " + error.value, "error"); state.value = "idle"; streamHandle = null; } @@ -202,6 +203,7 @@ export function useAssist(body: Ref) { const currentSlice = body.value.slice(t.startOffset, t.endOffset); if (currentSlice !== t.text) { error.value = "The document changed since this suggestion was made. Please clear and try again."; + toast.show(error.value, "error"); state.value = "idle"; return body.value; } diff --git a/frontend/src/types/task.ts b/frontend/src/types/task.ts index 919f476..f4899b7 100644 --- a/frontend/src/types/task.ts +++ b/frontend/src/types/task.ts @@ -4,3 +4,12 @@ export interface TaskListResponse { tasks: import("./note").Note[]; total: number; } + +export interface TaskLog { + id: number; + task_id: number; + content: string; + duration_minutes: number | null; + created_at: string; + updated_at: string; +} diff --git a/frontend/src/views/NoteEditorView.vue b/frontend/src/views/NoteEditorView.vue index 8e53ab0..052eaf9 100644 --- a/frontend/src/views/NoteEditorView.vue +++ b/frontend/src/views/NoteEditorView.vue @@ -12,6 +12,7 @@ import TiptapEditor from "@/components/TiptapEditor.vue"; import TagInput from "@/components/TagInput.vue"; import ProjectSelector from "@/components/ProjectSelector.vue"; import MilestoneSelector from "@/components/MilestoneSelector.vue"; +import InlineAssistPanel from "@/components/InlineAssistPanel.vue"; const route = useRoute(); const router = useRouter(); @@ -41,8 +42,13 @@ const renderedPreview = computed(() => renderMarkdown(body.value)); // AI Assist const assist = useAssist(body); -const renderedStreaming = computed(() => renderMarkdown(assist.streamingText.value)); -const renderedProposal = computed(() => renderMarkdown(assist.proposedText.value)); +const assistLabel = computed(() => { + if (assist.isProofreading.value) return "Proofreading document..."; + const t = assist.target.value; + if (!t) return "Generating..."; + const heading = t.text.split("\n")[0]; + return `Revising: "${heading.length > 50 ? heading.slice(0, 50) + "..." : heading}"`; +}); // Assist panel toggle (persisted) const ASSIST_KEY = 'fa-assist-open'; @@ -80,9 +86,6 @@ function handleInlineAssist() { nextTick(() => instructionRef.value?.focus()); } -// Diff view toggle -const showFullProposed = ref(false); - function handleAssistAccept() { const newBody = assist.accept(); if (newBody !== body.value) { @@ -90,7 +93,6 @@ function handleAssistAccept() { markDirty(); toast.show("Section updated"); } - showFullProposed.value = false; } function truncateTarget(text: string, max = 60): string { @@ -366,7 +368,21 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
-
+ + + + +
window.removeEventListener("beforeunload", onBeforeUnload));
@@ -442,41 +458,9 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
{{ assist.error.value }}
- -
-
- {{ assist.isProofreading.value - ? 'Proofreading document...' - : `Revising: "${truncateTarget(assist.target.value?.text ?? '')}"` }} -
-
- ●●● -
- - -
-
- {{ assist.isProofreading.value ? 'Document proofread' : 'Proposed changes' }} - -
-
-
- {{ line.type === 'delete' ? '−' : line.type === 'insert' ? '+' : ' ' }} - {{ line.text }} -
-
No changes.
-
-
-
- - -
+ +
+ {{ assist.state.value === 'streaming' ? 'Generating in editor…' : 'Review diff in editor ↑' }}
diff --git a/frontend/src/views/TaskEditorView.vue b/frontend/src/views/TaskEditorView.vue index 6217974..e2ef5e5 100644 --- a/frontend/src/views/TaskEditorView.vue +++ b/frontend/src/views/TaskEditorView.vue @@ -14,6 +14,8 @@ import TiptapEditor from "@/components/TiptapEditor.vue"; import TagInput from "@/components/TagInput.vue"; import ProjectSelector from "@/components/ProjectSelector.vue"; import MilestoneSelector from "@/components/MilestoneSelector.vue"; +import TaskLogSection from "@/components/TaskLogSection.vue"; +import InlineAssistPanel from "@/components/InlineAssistPanel.vue"; const route = useRoute(); const router = useRouter(); @@ -90,7 +92,8 @@ async function toggleSubTask(sub: SubTask) { toast.show("Failed to update sub-task", "error"); } } -const showPreview = ref(false); +const showPreview = ref(true); +const sidebarOpen = ref(true); const editorRef = ref | null>(null); const tiptapEditor = computed(() => { return (editorRef.value?.editor as Editor | undefined) ?? null; @@ -106,8 +109,13 @@ const renderedPreview = computed(() => renderMarkdown(body.value)); // AI Assist const assist = useAssist(body); -const renderedStreaming = computed(() => renderMarkdown(assist.streamingText.value)); -const renderedProposal = computed(() => renderMarkdown(assist.proposedText.value)); +const assistLabel = computed(() => { + if (assist.isProofreading.value) return "Proofreading document..."; + const t = assist.target.value; + if (!t) return "Generating..."; + const heading = t.text.split("\n")[0]; + return `Revising: "${heading.length > 50 ? heading.slice(0, 50) + "..." : heading}"`; +}); // Assist panel toggle (persisted) const ASSIST_KEY = 'fa-assist-open'; @@ -145,9 +153,6 @@ function handleInlineAssist() { nextTick(() => instructionRef.value?.focus()); } -// Diff view toggle -const showFullProposed = ref(false); - function handleAssistAccept() { const newBody = assist.accept(); if (newBody !== body.value) { @@ -155,7 +160,6 @@ function handleAssistAccept() { markDirty(); toast.show("Section updated"); } - showFullProposed.value = false; } function truncateTarget(text: string, max = 60): string { @@ -443,7 +447,7 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));