Add Tiptap inline-preview editor, layout improvements, and README

Replace plain textarea with Tiptap (ProseMirror) WYSIWYG editor for notes
and tasks. Headings, bold, lists, and code blocks now render inline while
editing. Tags and wikilinks get visual highlighting via decoration plugins,
and autocomplete uses @tiptap/suggestion with heading disambiguation.

Layout: pin navbar with app-shell flex column (100dvh), sticky editor
toolbar above scrolling content, AI Assist panel fixed at bottom 1/3 with
full-height section selector and prompt. Increase max-width to 960px
across all views. Hide assist controls during streaming/review.

Other fixes: markdown paste handling, auto-syncing assist sections via
body watcher, trailing newline on AI accept, ChatView height fix.

Add README.md describing the app, usage guide, and technical overview.
Update summary.md with Phase 5.2 roadmap and current status.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-11 21:24:35 -05:00
parent cbfdf5289e
commit 586026bff6
29 changed files with 2088 additions and 438 deletions
+204
View File
@@ -0,0 +1,204 @@
import { ref, computed, watch, type Ref } from "vue";
import { apiStreamPost } from "@/api/client";
import { useChatStore } from "@/stores/chat";
import {
parseMarkdownSections,
type MarkdownSection,
} from "@/utils/sectionParser";
export type AssistState = "idle" | "streaming" | "review";
export interface AssistTarget {
text: string;
startOffset: number;
endOffset: number;
}
export function useAssist(body: Ref<string>) {
const chatStore = useChatStore();
const state = ref<AssistState>("idle");
const sections = ref<MarkdownSection[]>([]);
const selectedSection = ref<MarkdownSection | null>(null);
const customSelection = ref<{ start: number; end: number; text: string } | null>(null);
const instruction = ref("");
const streamingText = ref("");
const proposedText = ref("");
const error = ref("");
// Snapshot of body at the time a section/selection was chosen
let bodySnapshot = "";
const target = computed<AssistTarget | null>(() => {
if (customSelection.value) {
return {
text: customSelection.value.text,
startOffset: customSelection.value.start,
endOffset: customSelection.value.end,
};
}
if (selectedSection.value) {
return {
text: selectedSection.value.content,
startOffset: selectedSection.value.startOffset,
endOffset: selectedSection.value.endOffset,
};
}
return null;
});
const canSubmit = computed(
() =>
target.value !== null &&
instruction.value.trim().length > 0 &&
chatStore.chatReady &&
state.value !== "streaming"
);
function refreshSections() {
sections.value = parseMarkdownSections(body.value);
}
function selectSection(section: MarkdownSection) {
customSelection.value = null;
selectedSection.value = section;
bodySnapshot = body.value;
error.value = "";
}
function selectTextRange(start: number, end: number) {
if (start === end) return;
selectedSection.value = null;
customSelection.value = {
start,
end,
text: body.value.slice(start, end),
};
bodySnapshot = body.value;
error.value = "";
}
function clearSelection() {
selectedSection.value = null;
customSelection.value = null;
instruction.value = "";
streamingText.value = "";
proposedText.value = "";
error.value = "";
state.value = "idle";
}
async function submit() {
if (!canSubmit.value || !target.value) return;
state.value = "streaming";
streamingText.value = "";
proposedText.value = "";
error.value = "";
try {
await apiStreamPost(
"/api/notes/assist",
{
body: body.value,
target_section: target.value.text,
instruction: instruction.value,
},
(data) => {
if (data.chunk) {
streamingText.value += data.chunk as string;
}
if (data.done) {
proposedText.value = (data.full_text as string) || streamingText.value;
state.value = "review";
}
if (data.error) {
error.value = data.error as string;
state.value = "idle";
}
}
);
// If stream ended without a done event, use accumulated text
if (state.value === "streaming") {
if (streamingText.value) {
proposedText.value = streamingText.value;
state.value = "review";
} else {
state.value = "idle";
}
}
} catch (e) {
error.value = e instanceof Error ? e.message : "Request failed";
state.value = "idle";
}
}
function accept(): string {
if (!target.value || !proposedText.value) return body.value;
const t = target.value;
// Validate that the body hasn't changed at the target offsets
if (body.value !== bodySnapshot) {
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.";
return body.value;
}
}
// Ensure proposed text ends with a newline for clean separation
let text = proposedText.value;
if (!text.endsWith("\n")) {
text += "\n";
}
const newBody =
body.value.slice(0, t.startOffset) +
text +
body.value.slice(t.endOffset);
// Reset state
selectedSection.value = null;
customSelection.value = null;
streamingText.value = "";
proposedText.value = "";
error.value = "";
state.value = "idle";
return newBody;
}
function reject() {
proposedText.value = "";
streamingText.value = "";
error.value = "";
state.value = "idle";
// Keep selection + instruction for retry
}
// Keep sections in sync with body automatically
watch(body, () => {
refreshSections();
}, { immediate: true });
return {
state,
sections,
selectedSection,
customSelection,
target,
instruction,
streamingText,
proposedText,
error,
canSubmit,
refreshSections,
selectSection,
selectTextRange,
clearSelection,
submit,
accept,
reject,
};
}