From d15b134d43fd14c648fd5e2741a4de5afb95ef0b Mon Sep 17 00:00:00 2001 From: Alex Hernandez Date: Tue, 3 Mar 2026 10:35:24 +1000 Subject: [PATCH] :sparkles: user comments --- src/main/fileSystem.ts | 8 +- .../components/AIChat/ChatMessageItem.tsx | 2 + src/renderer/components/Editor/Editor.css | 94 +++++++++++++++++++ .../components/Editor/MarkdownEditor.tsx | 89 ++++++++++++++++++ .../components/Feedback/FeedbackPanel.css | 19 ++++ .../components/Feedback/FeedbackPanel.tsx | 57 +++++++++-- src/renderer/types/editor.ts | 3 +- tsconfig.node.tsbuildinfo | 2 +- tsconfig.web.tsbuildinfo | 2 +- 9 files changed, 262 insertions(+), 14 deletions(-) diff --git a/src/main/fileSystem.ts b/src/main/fileSystem.ts index ffd9185..77c0794 100644 --- a/src/main/fileSystem.ts +++ b/src/main/fileSystem.ts @@ -5,10 +5,10 @@ import type { FileNode, RevisionMeta } from '../renderer/types/editor' const DRAFT_ROOT = process.env.DRAFT_PATH ?? '/Users/pori/WebstormProjects/hohoff/draft' -const ORDER_FILE = join(DRAFT_ROOT, '.order.json') -const SESSION_FILE = join(DRAFT_ROOT, '.session.json') -const REVISIONS_DIR = join(DRAFT_ROOT, '.revisions') const HOHOFF_DIR = join(DRAFT_ROOT, '.hohoff') +const ORDER_FILE = join(HOHOFF_DIR, 'order.json') +const SESSION_FILE = join(HOHOFF_DIR, 'session.json') +const REVISIONS_DIR = join(HOHOFF_DIR, 'revisions') export const STORY_BIBLE_PATH = join(HOHOFF_DIR, 'Story Bible.md') const STORY_BIBLE_TEMPLATE = `# Story Bible @@ -56,6 +56,7 @@ async function readOrderFile(): Promise> { } export async function saveOrderFile(order: Record): Promise { + await mkdir(HOHOFF_DIR, { recursive: true }) await writeFile(ORDER_FILE, JSON.stringify(order, null, 2), 'utf-8') } @@ -68,6 +69,7 @@ export async function readSession(): Promise> { } export async function writeSession(data: Record): Promise { + await mkdir(HOHOFF_DIR, { recursive: true }) await writeFile(SESSION_FILE, JSON.stringify(data), 'utf-8') } diff --git a/src/renderer/components/AIChat/ChatMessageItem.tsx b/src/renderer/components/AIChat/ChatMessageItem.tsx index e974dec..41f825d 100644 --- a/src/renderer/components/AIChat/ChatMessageItem.tsx +++ b/src/renderer/components/AIChat/ChatMessageItem.tsx @@ -18,8 +18,10 @@ function badgeColor(type: TextAnnotation['type']): string { case 'passive_voice': return 'rgba(255, 200, 0, 0.75)' case 'consistency': return 'rgba(220, 80, 80, 0.75)' case 'style': return 'rgba(80, 160, 255, 0.75)' + case 'show_tell': return 'rgba(255, 140, 30, 0.75)' case 'critique': return 'rgba(160, 80, 220, 0.75)' case 'custom': return 'rgba(30, 200, 150, 0.8)' + case 'user_comment': return 'rgba(240, 100, 180, 0.85)' } } diff --git a/src/renderer/components/Editor/Editor.css b/src/renderer/components/Editor/Editor.css index a62bdc7..6a0265b 100644 --- a/src/renderer/components/Editor/Editor.css +++ b/src/renderer/components/Editor/Editor.css @@ -263,3 +263,97 @@ background: var(--accent); color: var(--bg-base); } + +/* ── Comment input modal ───────────────────────────────── */ +.comment-modal-overlay { + position: absolute; + inset: 0; + z-index: 100; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.35); +} + +.comment-modal { + background: var(--message-bg); + border: 1px solid var(--border); + border-radius: 8px; + padding: 14px 16px; + width: 340px; + display: flex; + flex-direction: column; + gap: 10px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6); +} + +.comment-modal-excerpt { + font-family: var(--font-serif); + font-size: 12px; + font-style: italic; + color: var(--text-secondary); + line-height: 1.5; + border-left: 2px solid rgba(240, 100, 180, 0.65); + padding-left: 8px; +} + +.comment-modal-input { + background: var(--input-bg, rgba(255, 255, 255, 0.05)); + border: 1px solid var(--border); + border-radius: 5px; + color: var(--text-primary); + font-family: var(--font-sans); + font-size: 13px; + line-height: 1.55; + padding: 7px 10px; + resize: vertical; + outline: none; + width: 100%; + box-sizing: border-box; +} + +.comment-modal-input:focus { + border-color: rgba(240, 100, 180, 0.65); +} + +.comment-modal-actions { + display: flex; + justify-content: flex-end; + gap: 8px; +} + +.comment-modal-cancel { + background: none; + border: 1px solid var(--border); + border-radius: 4px; + color: var(--text-muted); + font-size: 12px; + padding: 4px 12px; + cursor: pointer; + transition: color 0.15s; +} + +.comment-modal-cancel:hover { + color: var(--text-primary); +} + +.comment-modal-save { + background: rgba(240, 100, 180, 0.85); + border: none; + border-radius: 4px; + color: #1a0a12; + font-size: 12px; + font-weight: 700; + padding: 4px 14px; + cursor: pointer; + transition: opacity 0.15s; +} + +.comment-modal-save:hover:not(:disabled) { + opacity: 0.85; +} + +.comment-modal-save:disabled { + opacity: 0.35; + cursor: default; +} diff --git a/src/renderer/components/Editor/MarkdownEditor.tsx b/src/renderer/components/Editor/MarkdownEditor.tsx index 5a0d2bf..2a2ede5 100644 --- a/src/renderer/components/Editor/MarkdownEditor.tsx +++ b/src/renderer/components/Editor/MarkdownEditor.tsx @@ -205,6 +205,30 @@ const annotationHoverTooltip = hoverTooltip( // across the nested create() closure without requiring non-null assertions. const ann: TextAnnotation = found + // User comments: show static tooltip with the comment text — no AI streaming + if (ann.type === 'user_comment') { + return { + pos, + above: true, + create() { + const dom = document.createElement('div') + dom.className = 'annotation-tooltip' + const label = document.createElement('span') + label.className = 'annotation-tooltip-label' + label.textContent = 'Your comment' + dom.appendChild(label) + const divider = document.createElement('div') + divider.className = 'annotation-tooltip-divider' + dom.appendChild(divider) + const body = document.createElement('div') + body.className = 'annotation-tooltip-body' + body.textContent = ann.comment ?? ann.message + dom.appendChild(body) + return { dom, destroy() {} } + } + } + } + return { pos, above: true, @@ -380,6 +404,11 @@ function buildTheme(fontSize: number, dark: boolean): ReturnType(null) const [menuPos, setMenuPos] = useState<{ x: number; y: number } | null>(null) const [hasSelection, setHasSelection] = useState(false) + const [pendingComment, setPendingComment] = useState<{ from: number; to: number; text: string } | null>(null) + const [commentDraft, setCommentDraft] = useState('') const { activeFilePath, activeFileContent, setContent, annotations, fontSize, theme, scrollPositions } = useEditorStore() + function saveComment(): void { + if (!pendingComment || !commentDraft.trim()) return + const annotation: TextAnnotation = { + id: `user-comment-${Date.now()}`, + type: 'user_comment', + from: pendingComment.from, + to: pendingComment.to, + matchedText: pendingComment.text, + message: commentDraft.trim(), + comment: commentDraft.trim(), + } + const store = useEditorStore.getState() + store.setAnnotations([...store.annotations, annotation]) + store.setRightPanelTab('feedback') + setPendingComment(null) + setCommentDraft('') + } + // Initialize CodeMirror once useEffect(() => { if (!containerRef.current) return @@ -648,6 +697,19 @@ export function MarkdownEditor(): JSX.Element { store.setAnnotations([...store.annotations, annotation]) store.setRightPanelTab('feedback') } + }, + { + label: 'Add comment', + action: () => { + const view = viewRef.current + if (!view) return + const { from, to } = view.state.selection.main + const text = view.state.sliceDoc(from, to) + if (!text.trim()) return + setMenuPos(null) + setPendingComment({ from, to, text }) + setCommentDraft('') + } } ] : []) ] @@ -669,6 +731,33 @@ export function MarkdownEditor(): JSX.Element { onClose={() => setMenuPos(null)} /> )} + {pendingComment && ( +
setPendingComment(null)}> +
e.stopPropagation()}> +
+ “{pendingComment.text.length > 80 + ? pendingComment.text.slice(0, 80) + '…' + : pendingComment.text}” +
+