user comments

This commit is contained in:
2026-03-03 10:35:24 +10:00
parent aafebbc9fb
commit d15b134d43
9 changed files with 262 additions and 14 deletions

View File

@@ -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<Record<string, string[]>> {
}
export async function saveOrderFile(order: Record<string, string[]>): Promise<void> {
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<Record<string, unknown>> {
}
export async function writeSession(data: Record<string, unknown>): Promise<void> {
await mkdir(HOHOFF_DIR, { recursive: true })
await writeFile(SESSION_FILE, JSON.stringify(data), 'utf-8')
}

View File

@@ -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)'
}
}

View File

@@ -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;
}

View File

@@ -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<typeof EditorVi
backgroundColor: 'rgba(30, 200, 150, 0.15)',
borderBottom: '2px solid rgba(30, 200, 150, 0.7)',
borderRadius: '2px'
},
'.annotation-user_comment': {
backgroundColor: 'rgba(240, 100, 180, 0.15)',
borderBottom: '2px solid rgba(240, 100, 180, 0.65)',
borderRadius: '2px'
}
},
{ dark }
@@ -391,8 +420,28 @@ export function MarkdownEditor(): JSX.Element {
const viewRef = useRef<EditorView | null>(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 && (
<div className="comment-modal-overlay" onClick={() => setPendingComment(null)}>
<div className="comment-modal" onClick={e => e.stopPropagation()}>
<div className="comment-modal-excerpt">
&ldquo;{pendingComment.text.length > 80
? pendingComment.text.slice(0, 80) + '…'
: pendingComment.text}&rdquo;
</div>
<textarea
className="comment-modal-input"
placeholder="Add a comment…"
value={commentDraft}
onChange={e => setCommentDraft(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); saveComment() }
if (e.key === 'Escape') setPendingComment(null)
}}
rows={3}
autoFocus
/>
<div className="comment-modal-actions">
<button className="comment-modal-cancel" onClick={() => setPendingComment(null)}>Cancel</button>
<button className="comment-modal-save" onClick={saveComment} disabled={!commentDraft.trim()}>Save</button>
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -408,3 +408,22 @@
color: var(--text-secondary);
line-height: 1.5;
}
/* ── User comment card body ──────────────────────────── */
.fb-card-user-comment-body {
padding: 4px 10px 10px;
font-size: 12.5px;
line-height: 1.6;
color: var(--text-primary);
font-family: var(--font-sans);
white-space: pre-wrap;
word-break: break-word;
}
.fb-archive-card-comment {
font-size: 11px;
color: var(--text-secondary);
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
}

View File

@@ -25,6 +25,7 @@ function badgeColor(type: TextAnnotation['type']): string {
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)'
}
}
@@ -165,6 +166,37 @@ function FeedbackCard({ ann, autoAnalyse, onDismiss }: FeedbackCardProps): JSX.E
)
}
interface UserCommentCardProps {
ann: TextAnnotation
onDismiss: () => void
}
function UserCommentCard({ ann, onDismiss }: UserCommentCardProps): JSX.Element {
return (
<div
className="fb-card fb-card-user_comment"
style={{ '--badge-color': 'rgba(240, 100, 180, 0.85)' } as React.CSSProperties}
>
<div className="fb-card-header" onClick={() => scrollToAnnotation(ann)} title="Jump to passage">
<div className="fb-card-header-top">
<div className="fb-card-header-badges">
<span className="fb-card-badge">Your comment</span>
</div>
<button
className="fb-card-dismiss"
onClick={(e) => { e.stopPropagation(); onDismiss() }}
title="Dismiss"
>
×
</button>
</div>
<span className="fb-card-excerpt">"{ann.matchedText}"</span>
</div>
<div className="fb-card-user-comment-body">{ann.comment}</div>
</div>
)
}
interface ArchiveCardProps {
ann: TextAnnotation
onRemove?: () => void
@@ -191,6 +223,9 @@ function ArchiveCard({ ann, onRemove }: ArchiveCardProps): JSX.Element {
)}
</div>
<span className="fb-archive-card-excerpt">"{ann.matchedText}"</span>
{ann.comment && (
<span className="fb-archive-card-comment">{ann.comment}</span>
)}
{ann.suggestion && (
<span className="fb-archive-card-suggestion"> {ann.suggestion}</span>
)}
@@ -267,14 +302,20 @@ export function FeedbackPanel(): JSX.Element {
</div>
<div className="fb-list">
{[...annotations].sort((a, b) => a.from - b.from).map(ann => (
<FeedbackCard
key={ann.id}
ann={ann}
autoAnalyse={analyseAll || ann.autoAnalyse === true}
onDismiss={() => { cancelPendingDismiss(ann.id); removeAnnotation(ann.id); tooltipAnalysisCache.delete(ann.id) }}
/>
))}
{[...annotations].sort((a, b) => a.from - b.from).map(ann =>
ann.type === 'user_comment'
? <UserCommentCard
key={ann.id}
ann={ann}
onDismiss={() => { cancelPendingDismiss(ann.id); removeAnnotation(ann.id) }}
/>
: <FeedbackCard
key={ann.id}
ann={ann}
autoAnalyse={analyseAll || ann.autoAnalyse === true}
onDismiss={() => { cancelPendingDismiss(ann.id); removeAnnotation(ann.id); tooltipAnalysisCache.delete(ann.id) }}
/>
)}
</div>
</>
)}

View File

@@ -29,7 +29,7 @@ export interface ChatSession {
messages: ChatMessage[]
}
export type AnnotationType = 'passive_voice' | 'consistency' | 'style' | 'show_tell' | 'critique' | 'custom'
export type AnnotationType = 'passive_voice' | 'consistency' | 'style' | 'show_tell' | 'critique' | 'custom' | 'user_comment'
export interface TextAnnotation {
id: string
@@ -43,6 +43,7 @@ export interface TextAnnotation {
dismissed?: boolean // true when the user dismissed this annotation (archived)
autoAnalyse?: boolean // true when created via context menu — FeedbackCard starts AI analysis immediately
analysisCache?: { text: string; suggestion: string | null } // persisted AI analysis result
comment?: string // user-written note text (only set for user_comment type)
}
export type AnalysisMode = 'none' | 'passive_voice' | 'consistency' | 'style' | 'show_tell' | 'critique'