Files
FabledScribe/frontend/src/views/NoteEditorView.vue
T
bvandeusen 9bf047ec45 Task work log, inline writing assistant, task editor sidebar layout
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/<id>/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 <noreply@anthropic.com>
2026-03-05 13:05:26 -05:00

519 lines
16 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { ref, onMounted, onUnmounted, computed, nextTick } from "vue";
import { useRoute, useRouter, onBeforeRouteLeave } from "vue-router";
import { useNotesStore } from "@/stores/notes";
import { useToastStore } from "@/stores/toast";
import { renderMarkdown } from "@/utils/markdown";
import { useAssist } from "@/composables/useAssist";
import { apiPost } from "@/api/client";
import type { Editor } from "@tiptap/vue-3";
import MarkdownToolbar from "@/components/MarkdownToolbar.vue";
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();
const store = useNotesStore();
const toast = useToastStore();
const title = ref("");
const body = ref("");
const tags = ref<string[]>([]);
const projectId = ref<number | null>(null);
const milestoneId = ref<number | null>(null);
const dirty = ref(false);
const saving = ref(false);
const showPreview = ref(false);
const editorRef = ref<InstanceType<typeof TiptapEditor> | null>(null);
const tiptapEditor = computed<Editor | null>(() => {
return (editorRef.value?.editor as Editor | undefined) ?? null;
});
const noteId = computed(() =>
route.params.id ? Number(route.params.id) : null
);
const isEditing = computed(() => noteId.value !== null);
const renderedPreview = computed(() => renderMarkdown(body.value));
// AI Assist
const assist = useAssist(body);
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';
const assistOpen = ref(localStorage.getItem(ASSIST_KEY) !== 'false');
function toggleAssist() {
assistOpen.value = !assistOpen.value;
localStorage.setItem(ASSIST_KEY, String(assistOpen.value));
}
// Floating inline assist button
const floatingAssist = ref({ show: false, top: 0, left: 0 });
const pendingSelection = ref<{ start: number; end: number } | null>(null);
const instructionRef = ref<HTMLTextAreaElement | null>(null);
function onSelectionChange(payload: { text: string; start: number; end: number }) {
if (payload.text.trim().length > 0) {
const sel = window.getSelection();
if (sel && sel.rangeCount > 0) {
const rect = sel.getRangeAt(0).getBoundingClientRect();
floatingAssist.value = { show: true, top: rect.top - 44, left: rect.left + rect.width / 2 };
pendingSelection.value = { start: payload.start, end: payload.end };
}
} else {
floatingAssist.value.show = false;
pendingSelection.value = null;
}
}
function handleInlineAssist() {
if (!pendingSelection.value) return;
assist.selectTextRange(pendingSelection.value.start, pendingSelection.value.end);
assistOpen.value = true;
localStorage.setItem(ASSIST_KEY, 'true');
floatingAssist.value.show = false;
nextTick(() => instructionRef.value?.focus());
}
function handleAssistAccept() {
const newBody = assist.accept();
if (newBody !== body.value) {
body.value = newBody;
markDirty();
toast.show("Section updated");
}
}
function truncateTarget(text: string, max = 60): string {
const first = text.split("\n")[0];
return first.length > max ? first.slice(0, max) + "..." : first;
}
// Tag suggestions
const suggestedTags = ref<string[]>([]);
const appliedTags = ref<Set<string>>(new Set());
const suggestingTags = ref(false);
async function fetchTagSuggestions() {
if (suggestingTags.value) return;
suggestingTags.value = true;
suggestedTags.value = [];
appliedTags.value = new Set();
try {
const res = await apiPost<{ suggested_tags: string[] }>("/api/notes/suggest-tags", {
title: title.value,
body: body.value,
current_tags: tags.value,
});
suggestedTags.value = res.suggested_tags;
} catch {
toast.show("Failed to get tag suggestions", "error");
} finally {
suggestingTags.value = false;
}
}
function applyTagSuggestion(tag: string) {
if (appliedTags.value.has(tag)) return;
if (!tags.value.includes(tag)) {
tags.value = [...tags.value, tag];
}
appliedTags.value.add(tag);
markDirty();
}
function dismissTagSuggestions() {
suggestedTags.value = [];
appliedTags.value = new Set();
}
// Track saved state for dirty detection
let savedTitle = "";
let savedBody = "";
let savedTags: string[] = [];
let savedProjectId: number | null = null;
let savedMilestoneId: number | null = null;
function markDirty() {
dirty.value =
title.value !== savedTitle ||
body.value !== savedBody ||
JSON.stringify(tags.value) !== JSON.stringify(savedTags) ||
projectId.value !== savedProjectId ||
milestoneId.value !== savedMilestoneId;
}
function onBodyUpdate(newVal: string) {
body.value = newVal;
markDirty();
}
onMounted(async () => {
if (noteId.value) {
await store.fetchNote(noteId.value);
if (store.currentNote) {
title.value = store.currentNote.title;
body.value = store.currentNote.body;
tags.value = [...(store.currentNote.tags || [])];
projectId.value = store.currentNote.project_id ?? null;
milestoneId.value = store.currentNote.milestone_id ?? null;
savedTitle = title.value;
savedBody = body.value;
savedTags = [...tags.value];
savedProjectId = projectId.value;
savedMilestoneId = milestoneId.value;
}
}
});
async function save() {
if (saving.value) return;
saving.value = true;
try {
if (isEditing.value) {
await store.updateNote(noteId.value!, {
title: title.value,
body: body.value,
tags: tags.value,
project_id: projectId.value,
milestone_id: milestoneId.value,
});
savedTitle = title.value;
savedBody = body.value;
savedTags = [...tags.value];
savedProjectId = projectId.value;
savedMilestoneId = milestoneId.value;
dirty.value = false;
toast.show("Note saved");
} else {
const note = await store.createNote({
title: title.value,
body: body.value,
tags: tags.value,
project_id: projectId.value,
milestone_id: milestoneId.value,
});
dirty.value = false;
toast.show("Note created");
router.push(`/notes/${note.id}`);
}
} catch {
toast.show("Failed to save note", "error");
} finally {
saving.value = false;
}
}
const showDeleteConfirm = ref(false);
function remove() {
if (noteId.value) {
showDeleteConfirm.value = true;
}
}
async function confirmDelete() {
showDeleteConfirm.value = false;
if (!noteId.value) return;
try {
await store.deleteNote(noteId.value);
dirty.value = false;
toast.show("Note deleted");
router.push("/notes");
} catch {
toast.show("Failed to delete note", "error");
}
}
// Auto-save every 5 minutes when editing an existing note
let autoSaveTimer: ReturnType<typeof setInterval> | null = null;
async function autoSave() {
if (!isEditing.value || !dirty.value || saving.value) return;
saving.value = true;
try {
await store.updateNote(noteId.value!, { title: title.value, body: body.value, tags: tags.value, project_id: projectId.value, milestone_id: milestoneId.value } as Record<string, unknown>);
savedTitle = title.value;
savedBody = body.value;
savedTags = [...tags.value];
savedProjectId = projectId.value;
savedMilestoneId = milestoneId.value;
dirty.value = false;
toast.show("Auto-saved");
} catch {
// Silent — user can still save manually
} finally {
saving.value = false;
}
}
onMounted(() => { autoSaveTimer = setInterval(autoSave, 5 * 60 * 1000); });
onUnmounted(() => { if (autoSaveTimer !== null) clearInterval(autoSaveTimer); });
// Ctrl+S handler
function onKeydown(e: KeyboardEvent) {
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
e.preventDefault();
save();
}
}
onMounted(() => document.addEventListener("keydown", onKeydown));
onUnmounted(() => document.removeEventListener("keydown", onKeydown));
// Unsaved changes guard — in-app navigation
onBeforeRouteLeave(() => {
if (dirty.value) {
return confirm("You have unsaved changes. Leave anyway?");
}
});
// Unsaved changes guard — browser close/reload
function onBeforeUnload(e: BeforeUnloadEvent) {
if (dirty.value) {
e.preventDefault();
}
}
onMounted(() => window.addEventListener("beforeunload", onBeforeUnload));
onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
</script>
<template>
<main class="editor-page">
<div class="editor-header">
<div class="toolbar">
<router-link to="/notes" class="btn-back">Back</router-link>
<button class="btn-save" @click="save" :disabled="saving">
{{ saving ? "Saving..." : "Save" }}
</button>
<button v-if="isEditing" class="btn-delete" @click="remove">
Delete
</button>
<button class="btn-assist-toggle" :class="{ active: assistOpen }" @click="toggleAssist">
Assist
</button>
</div>
<input
v-model="title"
type="text"
placeholder="Title"
class="title-input"
@input="markDirty"
/>
<div class="field-row-meta">
<div class="meta-field">
<label class="meta-label">Project</label>
<ProjectSelector v-model="projectId" @update:modelValue="milestoneId = null; markDirty()" />
</div>
<div class="meta-field">
<label class="meta-label">Milestone</label>
<MilestoneSelector :projectId="projectId" v-model="milestoneId" @update:modelValue="markDirty" />
</div>
</div>
<TagInput
v-model="tags"
:fetchTags="(q: string) => store.fetchAllTags(q)"
@update:modelValue="markDirty"
/>
<div class="tag-suggest-row">
<button class="btn-suggest-tags" @click="fetchTagSuggestions" :disabled="suggestingTags">
{{ suggestingTags ? "Suggesting..." : "Suggest tags" }}
</button>
<template v-if="suggestedTags.length > 0">
<button
v-for="tag in suggestedTags"
:key="tag"
:class="['tag-pill', { applied: appliedTags.has(tag) }]"
:disabled="appliedTags.has(tag)"
@click="applyTagSuggestion(tag)"
>
#{{ tag }}
<span v-if="appliedTags.has(tag)" class="tag-check">&#10003;</span>
</button>
<button class="btn-dismiss-tags" @click="dismissTagSuggestions">&times;</button>
</template>
</div>
<div class="editor-tabs">
<button
:class="['tab', { active: !showPreview }]"
@click="showPreview = false"
>
Write
</button>
<button
:class="['tab', { active: showPreview }]"
@click="showPreview = true"
>
Preview
</button>
</div>
<MarkdownToolbar v-show="!showPreview" :editor="tiptapEditor" />
</div>
<div class="editor-body">
<div class="editor-main">
<!-- Inline assist output: streaming preview + review diff -->
<InlineAssistPanel
v-if="assist.state.value !== 'idle'"
:phase="assist.state.value as 'streaming' | 'review'"
:label="assistLabel"
:streaming-text="assist.streamingText.value"
:diff="assist.diff.value"
:proposed-text="assist.proposedText.value"
@accept="handleAssistAccept"
@reject="assist.reject()"
@cancel="assist.clearSelection()"
/>
<!-- Editor hidden during review so diff takes full column -->
<div v-show="!showPreview && assist.state.value !== 'review'">
<TiptapEditor
ref="editorRef"
:modelValue="body"
placeholder="Write your note in Markdown..."
@update:modelValue="onBodyUpdate"
@selectionChange="onSelectionChange"
/>
</div>
<div
v-show="showPreview && assist.state.value !== 'review'"
class="preview-pane prose"
v-html="renderedPreview"
></div>
</div>
<aside v-if="assistOpen" class="assist-panel">
<!-- Panel header -->
<div class="assist-panel-header">
<span class="assist-panel-title"> AI Assist</span>
<button
class="btn-proofread"
@click="assist.proofread()"
:disabled="assist.state.value === 'streaming'"
>Proofread</button>
<button class="btn-close-assist" @click="toggleAssist">×</button>
</div>
<!-- Panel body -->
<div class="assist-panel-body">
<!-- IDLE -->
<div v-if="assist.state.value === 'idle'" class="assist-idle">
<div class="assist-sections-label">Sections</div>
<div class="assist-sections">
<div
v-for="(section, i) in assist.sections.value"
:key="i"
:class="['assist-section-item', { selected: assist.selectedSection.value === section }]"
@click="assist.selectSection(section)"
>
{{ section.heading || '(preamble)' }}
</div>
<div v-if="!assist.sections.value.length" class="assist-empty">
Write some content to get started.
</div>
</div>
<template v-if="assist.target.value">
<div class="assist-target-preview">
Editing: <em>{{ truncateTarget(assist.target.value.text) }}</em>
</div>
<textarea
ref="instructionRef"
v-model="assist.instruction.value"
placeholder="What should I do with this section?"
class="assist-instruction"
rows="3"
></textarea>
<div class="assist-input-actions">
<button
class="btn-generate"
@click="assist.submit()"
:disabled="!assist.canSubmit.value"
>Generate</button>
<button class="btn-clear" @click="assist.clearSelection()">Clear</button>
</div>
</template>
<div v-else class="assist-hint">
Select a section above or highlight text in the editor.
</div>
</div>
<!-- ERROR -->
<div v-if="assist.error.value" class="assist-error">{{ assist.error.value }}</div>
<!-- Hint when active (output shown inline in editor area) -->
<div v-if="assist.state.value !== 'idle'" class="assist-active-hint">
{{ assist.state.value === 'streaming' ? 'Generating in editor…' : 'Review diff in editor ↑' }}
</div>
</div>
</aside>
</div>
<!-- Floating inline assist button (teleported to body) -->
<teleport to="body">
<button
v-if="floatingAssist.show"
class="inline-assist-btn"
:style="{ top: floatingAssist.top + 'px', left: floatingAssist.left + 'px' }"
@mousedown.prevent="handleInlineAssist"
>
Assist
</button>
</teleport>
<!-- Delete confirmation -->
<teleport to="body">
<div v-if="showDeleteConfirm" class="modal-overlay" @click="showDeleteConfirm = false">
<div class="modal-card" @click.stop>
<h3 class="modal-title">Delete Note</h3>
<p class="modal-message">Are you sure you want to delete this note? This cannot be undone.</p>
<div class="modal-actions">
<button class="modal-btn" @click="showDeleteConfirm = false">Cancel</button>
<button class="modal-btn modal-btn-danger" @click="confirmDelete">Delete</button>
</div>
</div>
</div>
</teleport>
</main>
</template>
<style src="@/assets/editor-shared.css" />
<style scoped>
.field-row-meta {
display: flex;
gap: 1rem;
flex-wrap: wrap;
margin-bottom: 0.25rem;
}
.meta-field {
display: flex;
flex-direction: column;
gap: 0.25rem;
min-width: 180px;
flex: 1;
max-width: 300px;
}
.meta-label {
font-size: 0.8rem;
color: var(--color-text-secondary);
font-weight: 500;
}
</style>