CI & Build / TypeScript typecheck (push) Successful in 39s
CI & Build / Plugin hooks (push) Successful in 11s
CI & Build / Python lint (push) Successful in 2s
CI & Build / Python tests (push) Successful in 1m12s
CI & Build / integration (push) Successful in 1m41s
CI & Build / Build & push image (push) Successful in 1m1s
Rule 27: no UI, no ship. Three surfaces. THE EDITOR ASKS, but only where the answer can be saved: the fields appear for a plain note and not for a task or a snippet, matching the service gate from step 2 so the form never offers a write the save would reject. The labels are phrased as the QUESTION rather than the field name — "how would someone check this is still true?" and, underneath, "could this become false without anyone editing it?". "Verify with" gets filled in on every note; the question gets filled in on the few that can go stale. `expires_when` appears only once a check exists, and asks for a state rather than a date in the placeholder itself. THE NOTE SHOWS ITS AGE beside the field — "checked 2026-08-28" or "never checked", italic, and nothing at all when no check exists. No red/amber ramp, matching RuleSweepPane: a colour scale would restate the sweep's ordering and force an invented staleness threshold. "Never" is marked because it is categorically different from a date, not a worse one. THE SWEEP is a pane in the Knowledge view, not beside the rules sweep — operator's call, taken over a unified "everything due" surface and over a second pane under /rules. Notes stay where notes live. The cost, accepted knowingly: no single screen shows every unconfirmed record. It REPLACES the feed rather than filtering it, because a facet answers "show me this kind" and this answers "show me what nobody has confirmed" — a question the type chips cannot narrow without under-reporting. Two REST routes for it, since step 3 built only the service and the MCP door. Along the way: NoteEditorView spelled its write payload out at three call sites (save, create, auto-save), so every new field had to be added three times — which is how one of them ends up not carrying it. Now one `payload()` and one `snapshot()`. Known and filed, not fixed: NoteSweepPane copies ~12 scoped CSS rules from RuleSweepPane (#3207). The clean extraction needs prefixed names, because `.age`, `.row-title`, `.lede` and `.actions` all exist scoped in other components and an unscoped global would leak into them — which means editing the shipped rules sweep, blind, inside a step whose acceptance is the operator looking at a different surface.
870 lines
28 KiB
Vue
870 lines
28 KiB
Vue
<script setup lang="ts">
|
||
import { ref, onMounted, onUnmounted, computed, nextTick, watch } from "vue";
|
||
import { useRoute, useRouter } from "vue-router";
|
||
import type { NoteType } from "@/types/note";
|
||
import { useNotesStore } from "@/stores/notes";
|
||
import { useToastStore } from "@/stores/toast";
|
||
import { renderMarkdown } from "@/utils/markdown";
|
||
import { useAssist, type NoteDraft } from "@/composables/useAssist";
|
||
import { useAutoSave } from "@/composables/useAutoSave";
|
||
import { useEditorGuards } from "@/composables/useEditorGuards";
|
||
import { useTagSuggestions } from "@/composables/useTagSuggestions";
|
||
import { useFloatingAssist } from "@/composables/useFloatingAssist";
|
||
import { apiGet, apiPost } from "@/api/client";
|
||
import type { Editor } from "@tiptap/vue-3";
|
||
import MarkdownToolbar from "@/components/MarkdownToolbar.vue";
|
||
import TiptapEditor from "@/components/TiptapEditor.vue";
|
||
import WordCount from "@/components/WordCount.vue";
|
||
import TagInput from "@/components/TagInput.vue";
|
||
import ProjectSelector from "@/components/ProjectSelector.vue";
|
||
import MilestoneSelector from "@/components/MilestoneSelector.vue";
|
||
import DiffView from "@/components/DiffView.vue";
|
||
import VersionHistorySection from "@/components/VersionHistorySection.vue";
|
||
import ConfirmDialog from "@/components/ConfirmDialog.vue";
|
||
import { Trash2 } from "lucide-vue-next";
|
||
|
||
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 noteType = ref<NoteType>("note");
|
||
|
||
// The note's own check (milestone 317). Offered only for a plain note: a
|
||
// task's decay is its status, and a snippet has verify_snippet — the service
|
||
// refuses both, so the form must not ask for what the save would reject.
|
||
const verifyWith = ref("");
|
||
const expiresWhen = ref("");
|
||
const verifiedAt = ref<string | null>(null);
|
||
const canCarryCheck = computed(() => noteType.value === "note");
|
||
const dirty = ref(false);
|
||
const saving = ref(false);
|
||
const showPreview = ref(false);
|
||
const sidebarOpen = ref(true);
|
||
|
||
const editorRef = ref<InstanceType<typeof TiptapEditor> | null>(null);
|
||
const titleRef = ref<HTMLInputElement | 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 titlePlaceholder = computed(() => {
|
||
switch (noteType.value) {
|
||
case 'process': return 'Process name';
|
||
default: return 'Title';
|
||
}
|
||
});
|
||
|
||
const renderedPreview = computed(() => renderMarkdown(body.value));
|
||
|
||
// AI Assist — pass noteId for draft persistence
|
||
const assist = useAssist(body, noteId, projectId);
|
||
|
||
// Scope selector options: "Whole document" + one per section
|
||
const scopeOptions = computed(() => {
|
||
const opts: Array<{ value: string; label: string }> = [
|
||
{ value: '__document__', label: 'Whole document' },
|
||
];
|
||
for (const section of assist.sections.value) {
|
||
opts.push({
|
||
value: String(assist.sections.value.indexOf(section)),
|
||
label: section.heading || '(preamble)',
|
||
});
|
||
}
|
||
return opts;
|
||
});
|
||
|
||
const scopeSelectValue = computed({
|
||
get() {
|
||
if (assist.scopeMode.value === 'document') return '__document__';
|
||
if (assist.selectedSection.value) {
|
||
const idx = assist.sections.value.indexOf(assist.selectedSection.value);
|
||
return idx >= 0 ? String(idx) : '__document__';
|
||
}
|
||
return '__document__';
|
||
},
|
||
set(val: string) {
|
||
if (val === '__document__') {
|
||
assist.scopeMode.value = 'document';
|
||
assist.selectedSection.value = null;
|
||
} else {
|
||
const idx = Number(val);
|
||
const section = assist.sections.value[idx];
|
||
if (section) {
|
||
assist.scopeMode.value = 'section';
|
||
assist.selectSection(section);
|
||
}
|
||
}
|
||
},
|
||
});
|
||
|
||
// Floating inline assist button
|
||
const instructionRef = ref<HTMLTextAreaElement | null>(null);
|
||
const { floatingAssist, onSelectionChange, handleInlineAssist } = useFloatingAssist(
|
||
({ start, end }) => {
|
||
assist.scopeMode.value = 'section';
|
||
assist.selectTextRange(start, end);
|
||
nextTick(() => instructionRef.value?.focus());
|
||
}
|
||
);
|
||
|
||
function handleAssistAccept() {
|
||
const newBody = assist.accept();
|
||
body.value = newBody;
|
||
markDirty();
|
||
toast.show("Document updated");
|
||
}
|
||
|
||
// Tag suggestions
|
||
const { suggestedTags, appliedTags, suggestingTags, fetchTagSuggestions, applyTagSuggestion, dismissTagSuggestions } =
|
||
useTagSuggestions(title, body, tags, markDirty);
|
||
|
||
// ── Link Suggestions ────────────────────────────────────────────────────────
|
||
|
||
interface LinkSuggestion {
|
||
note_id: number;
|
||
title: string;
|
||
count: number;
|
||
}
|
||
|
||
const linkSuggestions = ref<LinkSuggestion[]>([]);
|
||
let linkCheckTimer: ReturnType<typeof setTimeout> | null = null;
|
||
|
||
async function fetchLinkSuggestions() {
|
||
if (!projectId.value || !body.value || !noteId.value) {
|
||
linkSuggestions.value = [];
|
||
return;
|
||
}
|
||
try {
|
||
const res = await apiPost<{ suggestions: LinkSuggestion[] }>('/api/notes/link-suggestions', {
|
||
body: body.value,
|
||
project_id: projectId.value,
|
||
exclude_note_id: noteId.value,
|
||
});
|
||
linkSuggestions.value = res.suggestions;
|
||
} catch {
|
||
linkSuggestions.value = [];
|
||
}
|
||
}
|
||
|
||
function scheduleLinkCheck() {
|
||
if (linkCheckTimer) clearTimeout(linkCheckTimer);
|
||
linkCheckTimer = setTimeout(fetchLinkSuggestions, 2000);
|
||
}
|
||
|
||
watch(body, () => {
|
||
if (projectId.value) scheduleLinkCheck();
|
||
});
|
||
|
||
watch(noteId, () => { showPreview.value = false; });
|
||
|
||
watch(projectId, (pid) => {
|
||
if (pid) fetchLinkSuggestions();
|
||
else linkSuggestions.value = [];
|
||
});
|
||
|
||
/** Replace unlinked occurrences of title in body with [[title]]. */
|
||
function applyWikilink(text: string, title: string): string {
|
||
const escaped = title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||
const parts = text.split(/(\[\[[^\]]*\]\])/);
|
||
const pattern = new RegExp(`\\b${escaped}\\b`, 'gi');
|
||
return parts.map((part, i) =>
|
||
i % 2 === 0 ? part.replace(pattern, `[[${title}]]`) : part
|
||
).join('');
|
||
}
|
||
|
||
function applyLink(title: string) {
|
||
body.value = applyWikilink(body.value, title);
|
||
markDirty();
|
||
linkSuggestions.value = linkSuggestions.value.filter(s => s.title !== title);
|
||
}
|
||
|
||
function applyAllLinks() {
|
||
let text = body.value;
|
||
for (const s of linkSuggestions.value) {
|
||
text = applyWikilink(text, s.title);
|
||
}
|
||
body.value = text;
|
||
markDirty();
|
||
linkSuggestions.value = [];
|
||
}
|
||
|
||
// Track saved state for dirty detection
|
||
let savedTitle = "";
|
||
let savedBody = "";
|
||
let savedTags: string[] = [];
|
||
let savedProjectId: number | null = null;
|
||
let savedMilestoneId: number | null = null;
|
||
let savedNoteType: NoteType = "note";
|
||
let savedVerifyWith = "";
|
||
let savedExpiresWhen = "";
|
||
|
||
/** The write, in one place. Three call sites (save, create, auto-save) each
|
||
* spelled this out, so every new field had to be added three times — which is
|
||
* how one of them ends up not carrying it. */
|
||
function payload() {
|
||
return {
|
||
title: title.value,
|
||
body: body.value,
|
||
tags: tags.value,
|
||
project_id: projectId.value,
|
||
milestone_id: milestoneId.value,
|
||
note_type: noteType.value,
|
||
// "" clears the check: the REST door reads an empty string as NULL
|
||
// (NULLABLE_NOTE_TEXT), which is how a cleared form input says "remove
|
||
// this" without needing the MCP door's explicit `clear` list.
|
||
verify_with: canCarryCheck.value ? verifyWith.value : "",
|
||
expires_when: canCarryCheck.value ? expiresWhen.value : "",
|
||
};
|
||
}
|
||
|
||
/** What the form last agreed with the server about — the other half of the
|
||
* same list, and for the same reason. */
|
||
function snapshot() {
|
||
savedTitle = title.value;
|
||
savedBody = body.value;
|
||
savedTags = [...tags.value];
|
||
savedProjectId = projectId.value;
|
||
savedMilestoneId = milestoneId.value;
|
||
savedNoteType = noteType.value;
|
||
savedVerifyWith = verifyWith.value;
|
||
savedExpiresWhen = expiresWhen.value;
|
||
dirty.value = false;
|
||
}
|
||
|
||
function markDirty() {
|
||
dirty.value =
|
||
title.value !== savedTitle ||
|
||
body.value !== savedBody ||
|
||
JSON.stringify(tags.value) !== JSON.stringify(savedTags) ||
|
||
projectId.value !== savedProjectId ||
|
||
milestoneId.value !== savedMilestoneId ||
|
||
noteType.value !== savedNoteType ||
|
||
verifyWith.value !== savedVerifyWith ||
|
||
expiresWhen.value !== savedExpiresWhen;
|
||
}
|
||
|
||
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;
|
||
noteType.value = (store.currentNote.note_type as NoteType) || "note";
|
||
verifyWith.value = store.currentNote.verify_with || "";
|
||
expiresWhen.value = store.currentNote.expires_when || "";
|
||
verifiedAt.value = store.currentNote.verified_at ?? null;
|
||
snapshot();
|
||
}
|
||
} else {
|
||
// New note: read type from query param
|
||
const qt = route.query.type as string | undefined;
|
||
if (qt && ["note", "process"].includes(qt)) {
|
||
noteType.value = qt as NoteType;
|
||
}
|
||
}
|
||
|
||
// Restore pending draft if any
|
||
try {
|
||
const draft = await apiGet<NoteDraft>(`/api/notes/${noteId.value}/draft`);
|
||
if (draft) assist.loadDraft(draft);
|
||
} catch {
|
||
// No draft — normal
|
||
}
|
||
|
||
await nextTick();
|
||
titleRef.value?.focus();
|
||
|
||
// Initial link suggestions
|
||
fetchLinkSuggestions();
|
||
});
|
||
|
||
async function save() {
|
||
if (saving.value) return;
|
||
saving.value = true;
|
||
const finalBody = body.value;
|
||
try {
|
||
if (isEditing.value) {
|
||
await store.updateNote(noteId.value!, { ...payload(), body: finalBody });
|
||
snapshot();
|
||
toast.show("Note saved");
|
||
} else {
|
||
const note = await store.createNote({ ...payload(), body: finalBody });
|
||
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(projectId.value ? `/projects/${projectId.value}` : "/");
|
||
} catch {
|
||
toast.show("Failed to delete note", "error");
|
||
}
|
||
}
|
||
|
||
// Auto-save every 5 minutes
|
||
async function doAutoSave() {
|
||
if (!isEditing.value || saving.value) return;
|
||
saving.value = true;
|
||
const finalBody = body.value;
|
||
try {
|
||
await store.updateNote(noteId.value!, { ...payload(), body: finalBody });
|
||
snapshot();
|
||
toast.show("Auto-saved");
|
||
} catch {
|
||
// Silent
|
||
} finally {
|
||
saving.value = false;
|
||
}
|
||
}
|
||
useAutoSave(dirty, saving, doAutoSave);
|
||
useEditorGuards(dirty, save);
|
||
|
||
// Close any in-flight assist SSE stream when navigating away
|
||
onUnmounted(() => assist.clearSelection());
|
||
</script>
|
||
|
||
<template>
|
||
<main class="editor-page note-editor-page">
|
||
<div class="editor-header">
|
||
<div class="toolbar">
|
||
<router-link :to="projectId ? `/projects/${projectId}` : '/'" class="btn-back">← {{ projectId ? 'Project' : 'Knowledge' }}</router-link>
|
||
<button class="btn-save" @click="save" :disabled="saving">
|
||
{{ saving ? "Saving..." : "Save" }}
|
||
</button>
|
||
<button v-if="isEditing" class="btn-delete" @click="remove">
|
||
<Trash2 :size="16" /> Delete
|
||
</button>
|
||
<WordCount :body="body" />
|
||
</div>
|
||
<input
|
||
ref="titleRef"
|
||
v-model="title"
|
||
type="text"
|
||
:placeholder="titlePlaceholder"
|
||
class="title-input"
|
||
@input="markDirty"
|
||
@keydown.ctrl.s.prevent="save"
|
||
@keydown.ctrl.e.prevent="tiptapEditor?.commands.focus()"
|
||
/>
|
||
</div>
|
||
|
||
<!-- Two-column body: main | sidebar -->
|
||
<div class="note-body">
|
||
|
||
<!-- ── Main column ────────────────────────────────────────── -->
|
||
<div class="note-main" @keydown.ctrl.e.prevent="tiptapEditor?.commands.focus()">
|
||
<!-- ── Process (prompt) editor ──────────────────────────── -->
|
||
<template v-if="noteType === 'process'">
|
||
<label class="ef-label">Prompt</label>
|
||
<textarea
|
||
class="prompt-editor"
|
||
:value="body"
|
||
@input="onBodyUpdate(($event.target as HTMLTextAreaElement).value)"
|
||
placeholder="The prompt to run when this process is fired (markdown). If it needs inputs, instruct Claude to ask up front."
|
||
spellcheck="false"
|
||
></textarea>
|
||
</template>
|
||
|
||
<!-- ── Generic note editor ──────────────────────────────── -->
|
||
<template v-else>
|
||
<div class="body-tabs-row">
|
||
<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 && assist.state.value === 'idle'" :editor="tiptapEditor" />
|
||
</div>
|
||
|
||
<!-- Streaming preview -->
|
||
<template v-if="assist.state.value === 'streaming'">
|
||
<div class="stream-label">Generating...</div>
|
||
<div class="stream-preview prose" v-html="renderMarkdown(assist.streamingText.value)" />
|
||
</template>
|
||
|
||
<!-- Review: full-document diff -->
|
||
<template v-else-if="assist.state.value === 'review'">
|
||
<DiffView :diff="assist.diff.value" class="main-diff" />
|
||
</template>
|
||
|
||
<!-- Normal editor -->
|
||
<template v-else>
|
||
<Transition name="tab-fade" mode="out-in">
|
||
<div v-if="!showPreview" class="body-editor-wrap">
|
||
<TiptapEditor
|
||
ref="editorRef"
|
||
:modelValue="body"
|
||
placeholder="Write your note in Markdown..."
|
||
@update:modelValue="onBodyUpdate"
|
||
@selectionChange="onSelectionChange"
|
||
@escape="titleRef?.focus()"
|
||
/>
|
||
</div>
|
||
<div
|
||
v-else
|
||
class="preview-pane prose"
|
||
v-html="renderedPreview"
|
||
/>
|
||
</Transition>
|
||
</template>
|
||
|
||
<!-- Error from assist -->
|
||
<div v-if="assist.error.value" class="assist-error">{{ assist.error.value }}</div>
|
||
</template>
|
||
</div>
|
||
|
||
<!-- ── Sidebar ──────────────────────────────────────────── -->
|
||
<aside class="note-sidebar">
|
||
<button class="sidebar-toggle" @click="sidebarOpen = !sidebarOpen">
|
||
Details {{ sidebarOpen ? '▴' : '▾' }}
|
||
</button>
|
||
|
||
<div :class="['sidebar-content', { 'sidebar-open': sidebarOpen }]">
|
||
|
||
<!-- Project / Milestone / Tags -->
|
||
<div class="sb-field">
|
||
<label class="sb-label">Project</label>
|
||
<ProjectSelector
|
||
v-model="projectId"
|
||
@update:modelValue="milestoneId = null; markDirty()"
|
||
/>
|
||
</div>
|
||
<div v-if="projectId" class="sb-field">
|
||
<label class="sb-label">Milestone</label>
|
||
<MilestoneSelector
|
||
:projectId="projectId"
|
||
v-model="milestoneId"
|
||
@update:modelValue="markDirty"
|
||
/>
|
||
</div>
|
||
<div class="sb-field">
|
||
<label class="sb-label">Tags</label>
|
||
<TagInput
|
||
v-model="tags"
|
||
:fetchTags="(q: string) => store.fetchAllTags(q)"
|
||
@update:modelValue="markDirty"
|
||
/>
|
||
</div>
|
||
<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">✓</span>
|
||
</button>
|
||
<button class="btn-dismiss-tags" aria-label="Dismiss tag suggestions" @click="dismissTagSuggestions">×</button>
|
||
</template>
|
||
</div>
|
||
|
||
<!-- Note type -->
|
||
<div class="sb-field">
|
||
<label class="sb-label">Type</label>
|
||
<select v-model="noteType" class="sb-select" @change="markDirty">
|
||
<option value="note">Note</option>
|
||
<option value="process">Process</option>
|
||
</select>
|
||
</div>
|
||
|
||
<!-- The note's own check (milestone 317). Shown only for a plain
|
||
note: the service refuses a check on a task or a snippet, so
|
||
offering the fields there would be a form whose save fails. -->
|
||
<template v-if="canCarryCheck">
|
||
<div class="sb-field">
|
||
<div class="sb-label-row">
|
||
<span class="sb-label">Check</span>
|
||
<span v-if="verifyWith" class="check-age" :class="{ unchecked: !verifiedAt }">
|
||
{{ verifiedAt ? `checked ${verifiedAt.slice(0, 10)}` : "never checked" }}
|
||
</span>
|
||
</div>
|
||
<!-- Phrased as the question that decides, not as a field name.
|
||
"Verify with" would get filled in on every note; "could this
|
||
become false without anyone editing it?" gets filled in on
|
||
the few that can. -->
|
||
<textarea
|
||
v-model="verifyWith"
|
||
class="sb-textarea"
|
||
rows="2"
|
||
placeholder="How would someone check this is still true? Leave empty unless this note could become false without anyone editing it."
|
||
@input="markDirty"
|
||
></textarea>
|
||
</div>
|
||
<div v-if="verifyWith" class="sb-field">
|
||
<label class="sb-label">Ends when</label>
|
||
<textarea
|
||
v-model="expiresWhen"
|
||
class="sb-textarea"
|
||
rows="2"
|
||
placeholder="What state ends it? A state, not a date — “when the forge numbers runs per workflow”, not “in six months”."
|
||
@input="markDirty"
|
||
></textarea>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- Link Suggestions -->
|
||
<div v-if="linkSuggestions.length > 0" class="sb-field link-suggest-field">
|
||
<div class="sb-label-row">
|
||
<span class="sb-label">Link Suggestions</span>
|
||
<button class="btn-link-all" @click="applyAllLinks">Link all</button>
|
||
</div>
|
||
<div class="link-suggest-list">
|
||
<div v-for="s in linkSuggestions" :key="s.note_id" class="link-suggest-item">
|
||
<span class="link-suggest-title">[[{{ s.title }}]]</span>
|
||
<span class="link-suggest-count">×{{ s.count }}</span>
|
||
<button class="btn-apply-link" @click="applyLink(s.title)">Link</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="sb-divider"></div>
|
||
|
||
<!-- Writing Assistant (always visible) -->
|
||
<div class="assist-section">
|
||
<div class="assist-section-title">✨ Writing Assistant</div>
|
||
|
||
<!-- Scope selector -->
|
||
<div class="sb-field">
|
||
<label class="sb-label">Scope</label>
|
||
<select v-model="scopeSelectValue" class="sb-select" :disabled="assist.state.value === 'streaming'">
|
||
<option
|
||
v-for="opt in scopeOptions"
|
||
:key="opt.value"
|
||
:value="opt.value"
|
||
>{{ opt.label }}</option>
|
||
</select>
|
||
</div>
|
||
|
||
<!-- Idle: instruction + buttons -->
|
||
<template v-if="assist.state.value === 'idle'">
|
||
<textarea
|
||
ref="instructionRef"
|
||
v-model="assist.instruction.value"
|
||
placeholder="What should I do?"
|
||
class="assist-instruction"
|
||
rows="3"
|
||
@keydown.enter.exact.prevent="assist.canSubmit.value && assist.submit()"
|
||
></textarea>
|
||
<div class="assist-input-actions">
|
||
<button
|
||
class="btn-generate"
|
||
@click="assist.submit()"
|
||
:disabled="!assist.canSubmit.value"
|
||
>Generate</button>
|
||
<button
|
||
class="btn-proofread"
|
||
@click="assist.proofread()"
|
||
>Proofread</button>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- Streaming: cancel -->
|
||
<template v-else-if="assist.state.value === 'streaming'">
|
||
<div class="assist-active-hint">Generating… see main area</div>
|
||
<button class="btn-clear" @click="assist.clearSelection()">Cancel</button>
|
||
</template>
|
||
|
||
<!-- Review: accept / reject -->
|
||
<template v-else-if="assist.state.value === 'review'">
|
||
<div class="assist-active-hint">Review the diff in the main area</div>
|
||
<div class="assist-actions">
|
||
<button class="btn-accept" @click="handleAssistAccept">Accept</button>
|
||
<button class="btn-reject" @click="assist.reject()">Reject</button>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
|
||
<VersionHistorySection
|
||
v-if="noteId"
|
||
:note-id="noteId"
|
||
:current-body="body"
|
||
@restore="(b, t) => { body = b; tags = t; markDirty(); }"
|
||
/>
|
||
|
||
</div><!-- /sidebar-content -->
|
||
</aside>
|
||
</div><!-- /note-body -->
|
||
|
||
<!-- Floating inline assist button -->
|
||
<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 -->
|
||
<ConfirmDialog
|
||
v-if="showDeleteConfirm"
|
||
title="Delete Note"
|
||
message="Are you sure you want to delete this note? This cannot be undone."
|
||
@confirm="confirmDelete"
|
||
@cancel="showDeleteConfirm = false"
|
||
/>
|
||
</main>
|
||
</template>
|
||
|
||
<style src="@/assets/editor-shared.css" />
|
||
<style scoped>
|
||
/* ── Two-column note layout ──────────────────────────────────── */
|
||
.note-editor-page {
|
||
max-width: 1600px;
|
||
}
|
||
|
||
.note-body {
|
||
flex: 1;
|
||
min-height: 0;
|
||
display: flex;
|
||
overflow: hidden;
|
||
position: relative;
|
||
}
|
||
|
||
/* Main column */
|
||
.note-main {
|
||
flex: 1;
|
||
min-width: 0;
|
||
overflow-y: auto;
|
||
padding: 0.75rem 1.25rem 2rem;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.75rem;
|
||
}
|
||
|
||
.editor-tabs {
|
||
display: inline-flex;
|
||
background: var(--fs-surface-page);
|
||
border: 1px solid var(--fs-border-color);
|
||
border-radius: 8px;
|
||
padding: 2px;
|
||
gap: 2px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.tab {
|
||
background: none;
|
||
border: none;
|
||
border-radius: 6px;
|
||
padding: 0.22rem 0.75rem;
|
||
font-size: 0.78rem;
|
||
font-weight: 500;
|
||
color: var(--fs-text-tertiary);
|
||
cursor: pointer;
|
||
transition: background 0.15s, color 0.15s;
|
||
}
|
||
.tab:hover { color: var(--fs-text-primary); }
|
||
.tab.active {
|
||
background: var(--fs-surface-hover);
|
||
color: var(--fs-text-primary);
|
||
box-shadow: 0 1px 3px rgba(0,0,0,0.12);
|
||
}
|
||
|
||
.tab-fade-enter-active,
|
||
.tab-fade-leave-active {
|
||
transition: opacity 0.12s ease;
|
||
}
|
||
.tab-fade-enter-from,
|
||
.tab-fade-leave-to {
|
||
opacity: 0;
|
||
}
|
||
|
||
.stream-label {
|
||
font-size: 0.8rem;
|
||
color: var(--fs-text-tertiary);
|
||
}
|
||
|
||
/* Right sidebar */
|
||
.note-sidebar {
|
||
width: 280px;
|
||
flex-shrink: 0;
|
||
border-left: 1px solid var(--fs-border-color);
|
||
overflow-y: auto;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.sb-select, .sb-input, .sb-textarea {
|
||
width: 100%;
|
||
padding: 5px 8px;
|
||
border-radius: var(--fs-radius-sm);
|
||
border: 1px solid var(--fs-border-color);
|
||
background: var(--fs-surface-hover);
|
||
color: var(--fs-text-primary);
|
||
font-size: 0.82rem;
|
||
font-family: inherit;
|
||
outline: none;
|
||
transition: border-color 0.15s;
|
||
}
|
||
.sb-select:focus, .sb-input:focus, .sb-textarea:focus {
|
||
border-color: var(--fs-accent);
|
||
}
|
||
.sb-textarea {
|
||
resize: vertical;
|
||
line-height: 1.4;
|
||
box-sizing: border-box;
|
||
}
|
||
/* No red/amber ramp, matching RuleSweepPane: a colour scale would restate the
|
||
sweep's ordering and force an invented "stale after N days" threshold.
|
||
"Never" is marked because it is categorically different from a date, not a
|
||
worse one — it means nobody has ever confirmed the claim. */
|
||
.check-age {
|
||
font-size: 0.7rem;
|
||
color: var(--fs-text-secondary);
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
.check-age.unchecked {
|
||
font-style: italic;
|
||
color: var(--fs-text-tertiary);
|
||
}
|
||
|
||
/* Link Suggestions */
|
||
.link-suggest-field { gap: 0.4rem; }
|
||
|
||
.sb-label-row {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
}
|
||
|
||
.btn-link-all {
|
||
font-size: 0.72rem;
|
||
padding: 0.15rem 0.5rem;
|
||
background: none;
|
||
border: 1px solid var(--fs-accent);
|
||
border-radius: var(--fs-radius-sm);
|
||
color: var(--fs-accent);
|
||
cursor: pointer;
|
||
font-family: inherit;
|
||
}
|
||
.btn-link-all:hover { background: var(--fs-action-primary); color: var(--fs-text-on-action); }
|
||
|
||
.link-suggest-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.25rem;
|
||
}
|
||
|
||
.link-suggest-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.35rem;
|
||
font-size: 0.78rem;
|
||
}
|
||
|
||
.link-suggest-title {
|
||
flex: 1;
|
||
font-family: monospace;
|
||
color: var(--fs-accent);
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.link-suggest-count {
|
||
color: var(--fs-text-tertiary);
|
||
font-size: 0.72rem;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.btn-apply-link {
|
||
flex-shrink: 0;
|
||
font-size: 0.72rem;
|
||
padding: 0.1rem 0.4rem;
|
||
background: none;
|
||
border: 1px solid var(--fs-border-color);
|
||
border-radius: var(--fs-radius-sm);
|
||
cursor: pointer;
|
||
font-family: inherit;
|
||
color: var(--fs-text-secondary);
|
||
}
|
||
.btn-apply-link:hover { border-color: var(--fs-accent); color: var(--fs-accent); }
|
||
|
||
/* Writing Assistant section */
|
||
.assist-section {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.5rem;
|
||
}
|
||
|
||
/* ── Process editor ─────────────────────────────────────── */
|
||
.ef-label {
|
||
font-family: 'Fraunces', Georgia, serif;
|
||
font-size: 0.92rem;
|
||
color: var(--fs-accent);
|
||
}
|
||
.prompt-editor {
|
||
width: 100%;
|
||
min-height: 60vh;
|
||
margin-top: 8px;
|
||
padding: 14px 16px;
|
||
border: 1px solid var(--fs-border-color);
|
||
border-radius: 8px;
|
||
background: var(--fs-surface-hover);
|
||
color: var(--fs-text-primary);
|
||
/* Prompts are plain markdown — a code-style editor, not rich text. */
|
||
font-family: var(--fs-font-mono);
|
||
font-size: 0.88rem;
|
||
line-height: 1.55;
|
||
tab-size: 2;
|
||
resize: vertical;
|
||
outline: none;
|
||
}
|
||
.prompt-editor:focus {
|
||
border-color: var(--fs-accent);
|
||
}
|
||
/* Narrow screen: sidebar collapses */
|
||
@media (max-width: 720px) {
|
||
.note-body { flex-direction: column; overflow-y: auto; overflow-x: hidden; }
|
||
.note-main { padding: 0.75rem 1rem 1rem; overflow-y: visible; }
|
||
.note-sidebar {
|
||
width: 100%;
|
||
border-left: none;
|
||
border-top: 1px solid var(--fs-border-color);
|
||
overflow-y: visible;
|
||
}
|
||
}
|
||
</style>
|