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:
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user