✨ hover highlighted sections for feedback
This commit is contained in:
14
demo/index.html
Normal file
14
demo/index.html
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<title>Annotation Tooltip Demo</title>
|
||||||
|
<style>
|
||||||
|
html, body, #root { height: 100%; margin: 0; padding: 0; overflow: hidden; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="./main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
68
demo/main.tsx
Normal file
68
demo/main.tsx
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import { useEditorStore } from '@renderer/store/editorStore'
|
||||||
|
import { MarkdownEditor } from '@renderer/components/Editor/MarkdownEditor'
|
||||||
|
import '@renderer/styles/global.css'
|
||||||
|
|
||||||
|
// Mock window.api so the renderer doesn't crash in a plain browser context
|
||||||
|
;(window as unknown as Record<string, unknown>).api = {
|
||||||
|
listFiles: () => Promise.resolve([]),
|
||||||
|
readFile: () => Promise.resolve(''),
|
||||||
|
writeFile: () => Promise.resolve(),
|
||||||
|
streamAIMessage: () => Promise.resolve(),
|
||||||
|
removeAIListener: () => {},
|
||||||
|
getProjectWordCount: () => Promise.resolve(0),
|
||||||
|
saveOrder: () => Promise.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEMO_TEXT =
|
||||||
|
`She was seen by him walking down the cobblestone path.\n\n` +
|
||||||
|
`The old manuscript was written by an unknown author many centuries ago.\n\n` +
|
||||||
|
`The message had been delivered by the courier at dawn.`
|
||||||
|
|
||||||
|
// Compute annotation character ranges directly from the text
|
||||||
|
const a1s = DEMO_TEXT.indexOf('She was seen')
|
||||||
|
const a1e = DEMO_TEXT.indexOf('.', a1s) + 1
|
||||||
|
const a2s = DEMO_TEXT.indexOf('The old manuscript')
|
||||||
|
const a2e = DEMO_TEXT.indexOf('.', a2s) + 1
|
||||||
|
const a3s = DEMO_TEXT.indexOf('The message')
|
||||||
|
const a3e = DEMO_TEXT.indexOf('.', a3s) + 1
|
||||||
|
|
||||||
|
// Pre-seed store before React renders so all three effects fire correctly on mount
|
||||||
|
const store = useEditorStore.getState()
|
||||||
|
store.setActiveFile('demo.md', DEMO_TEXT)
|
||||||
|
store.setAnnotations([
|
||||||
|
{
|
||||||
|
id: 'ann-1',
|
||||||
|
type: 'passive_voice',
|
||||||
|
from: a1s,
|
||||||
|
to: a1e,
|
||||||
|
matchedText: 'was seen by',
|
||||||
|
message: 'Passive voice weakens the narrative tension here — placing the subject in an active role would make the sentence more immediate and visceral, which is essential for gothic prose where atmosphere must feel alive and urgent\u2026',
|
||||||
|
suggestion: 'He saw her walking down the cobblestone path.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ann-2',
|
||||||
|
type: 'passive_voice',
|
||||||
|
from: a2s,
|
||||||
|
to: a2e,
|
||||||
|
matchedText: 'was written by',
|
||||||
|
message: 'Passive construction distances the reader from the action; consider foregrounding the actor to strengthen the historical atmosphere.',
|
||||||
|
suggestion: 'An unknown author wrote the old manuscript many centuries ago.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ann-3',
|
||||||
|
type: 'style',
|
||||||
|
from: a3s,
|
||||||
|
to: a3e,
|
||||||
|
matchedText: 'had been delivered by',
|
||||||
|
message: 'Wordy passive construction: "had been delivered by"',
|
||||||
|
suggestion: 'The courier delivered the message at dawn.'
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<MarkdownEditor />
|
||||||
|
</StrictMode>
|
||||||
|
)
|
||||||
@@ -66,7 +66,27 @@ For each suggestion:
|
|||||||
ISSUE: [type: Pacing / Sentence Variety / Show-Don't-Tell / Atmosphere / Dialogue]
|
ISSUE: [type: Pacing / Sentence Variety / Show-Don't-Tell / Atmosphere / Dialogue]
|
||||||
PASSAGE: "[exact quoted text]"
|
PASSAGE: "[exact quoted text]"
|
||||||
PROBLEM: [specific explanation]
|
PROBLEM: [specific explanation]
|
||||||
SUGGESTION: [concrete rewrite or approach]`
|
SUGGESTION: [concrete rewrite or approach]`,
|
||||||
|
|
||||||
|
critique: `Give an honest, detailed critique of this chapter as a whole. Structure your response as follows:
|
||||||
|
|
||||||
|
**Overall impression** (2–3 sentences on what the chapter achieves and its most significant weakness)
|
||||||
|
|
||||||
|
**What works well**
|
||||||
|
Identify 2–4 specific strengths — scenes, lines, or moments that land effectively. Quote the passage and explain why it works.
|
||||||
|
|
||||||
|
STRENGTH: "[exact quoted passage]"
|
||||||
|
WHY: [explanation]
|
||||||
|
|
||||||
|
**What needs work**
|
||||||
|
Identify 2–4 areas where the chapter falls short. Be direct. Quote the passage and give a concrete direction for improvement.
|
||||||
|
|
||||||
|
ISSUE: "[exact quoted passage]"
|
||||||
|
PROBLEM: [explanation]
|
||||||
|
SUGGESTION: [concrete direction]
|
||||||
|
|
||||||
|
**One priority**
|
||||||
|
Name the single most important thing to fix in a revision of this chapter.`
|
||||||
}
|
}
|
||||||
|
|
||||||
return `${chapterContext}\n\n${modeInstructions[payload.mode]}`
|
return `${chapterContext}\n\n${modeInstructions[payload.mode]}`
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { FileTree } from './components/FileTree/FileTree'
|
import { FileTree } from './components/FileTree/FileTree'
|
||||||
import { MarkdownEditor } from './components/Editor/MarkdownEditor'
|
import { MarkdownEditor } from './components/Editor/MarkdownEditor'
|
||||||
import { ChatPanel } from './components/AIChat/ChatPanel'
|
import { ChatPanel } from './components/AIChat/ChatPanel'
|
||||||
@@ -9,6 +9,8 @@ import './styles/app.css'
|
|||||||
export default function App(): JSX.Element {
|
export default function App(): JSX.Element {
|
||||||
const { setFileTree, activeFilePath, isDirty, markSaved, activeFileContent, theme, toggleTheme } =
|
const { setFileTree, activeFilePath, isDirty, markSaved, activeFileContent, theme, toggleTheme } =
|
||||||
useEditorStore()
|
useEditorStore()
|
||||||
|
const [sidebarOpen, setSidebarOpen] = useState(true)
|
||||||
|
const [chatOpen, setChatOpen] = useState(true)
|
||||||
|
|
||||||
// Load file tree on mount and apply persisted theme
|
// Load file tree on mount and apply persisted theme
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -32,16 +34,35 @@ export default function App(): JSX.Element {
|
|||||||
}, [activeFilePath, isDirty, activeFileContent])
|
}, [activeFilePath, isDirty, activeFileContent])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app-layout">
|
<div
|
||||||
|
className="app-layout"
|
||||||
|
data-sidebar={sidebarOpen ? 'open' : 'closed'}
|
||||||
|
data-chat={chatOpen ? 'open' : 'closed'}
|
||||||
|
>
|
||||||
<div className="app-titlebar">
|
<div className="app-titlebar">
|
||||||
<span className="app-titlebar-title">Hohoff Editor</span>
|
<span className="app-titlebar-title">Hohoff Editor</span>
|
||||||
<button
|
<div className="app-titlebar-right">
|
||||||
className="app-titlebar-theme-btn"
|
<div className="app-layout-toggle">
|
||||||
onClick={toggleTheme}
|
<button
|
||||||
title={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
|
className={`app-layout-toggle-seg${sidebarOpen ? ' active' : ''}`}
|
||||||
>
|
onClick={() => setSidebarOpen((v) => !v)}
|
||||||
{theme === 'dark' ? '☀' : '☾'}
|
title={sidebarOpen ? 'Hide file tree' : 'Show file tree'}
|
||||||
</button>
|
/>
|
||||||
|
<div className="app-layout-toggle-seg app-layout-toggle-seg--mid" />
|
||||||
|
<button
|
||||||
|
className={`app-layout-toggle-seg${chatOpen ? ' active' : ''}`}
|
||||||
|
onClick={() => setChatOpen((v) => !v)}
|
||||||
|
title={chatOpen ? 'Hide AI chat' : 'Show AI chat'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="app-titlebar-theme-btn"
|
||||||
|
onClick={toggleTheme}
|
||||||
|
title={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
|
||||||
|
>
|
||||||
|
{theme === 'dark' ? '☀' : '☾'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<aside className="sidebar">
|
<aside className="sidebar">
|
||||||
<FileTree />
|
<FileTree />
|
||||||
|
|||||||
@@ -36,3 +36,85 @@
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Annotation hover tooltip ─────────────────────────────────── */
|
||||||
|
|
||||||
|
/* Strip CodeMirror's default tooltip chrome */
|
||||||
|
.cm-tooltip.cm-tooltip-hover {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
padding: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotation-tooltip {
|
||||||
|
background: var(--message-bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
max-width: 320px;
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotation-tooltip-divider {
|
||||||
|
height: 1px;
|
||||||
|
background: var(--border);
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotation-tooltip-label {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotation-tooltip-body {
|
||||||
|
color: var(--text-primary);
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotation-tooltip-body p {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
}
|
||||||
|
.annotation-tooltip-body p:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
.annotation-tooltip-body strong {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.annotation-tooltip-body em {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.annotation-tooltip-body code {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
background: rgba(255, 255, 255, 0.07);
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 1px 4px;
|
||||||
|
}
|
||||||
|
.annotation-tooltip-body blockquote {
|
||||||
|
border-left: 2px solid var(--accent);
|
||||||
|
margin: 4px 0;
|
||||||
|
padding: 1px 8px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
.annotation-tooltip-body ul,
|
||||||
|
.annotation-tooltip-body ol {
|
||||||
|
margin: 2px 0 4px;
|
||||||
|
padding-left: 16px;
|
||||||
|
}
|
||||||
|
.annotation-tooltip-body li {
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotation-tooltip-loading {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { useEffect, useRef } from 'react'
|
import { useEffect, useRef } from 'react'
|
||||||
import { EditorView, Decoration, type DecorationSet } from '@codemirror/view'
|
import { EditorView, Decoration, type DecorationSet, hoverTooltip, keymap } from '@codemirror/view'
|
||||||
import { EditorState, StateField, StateEffect, RangeSetBuilder, Compartment } from '@codemirror/state'
|
import { EditorState, StateField, StateEffect, RangeSetBuilder, Compartment } from '@codemirror/state'
|
||||||
import { markdown } from '@codemirror/lang-markdown'
|
import { markdown } from '@codemirror/lang-markdown'
|
||||||
import { syntaxHighlighting, defaultHighlightStyle } from '@codemirror/language'
|
import { syntaxHighlighting, defaultHighlightStyle } from '@codemirror/language'
|
||||||
import { history, defaultKeymap, historyKeymap } from '@codemirror/commands'
|
import { history, defaultKeymap, historyKeymap } from '@codemirror/commands'
|
||||||
import { keymap } from '@codemirror/view'
|
import { marked } from 'marked'
|
||||||
|
import DOMPurify from 'dompurify'
|
||||||
import { useEditorStore } from '../../store/editorStore'
|
import { useEditorStore } from '../../store/editorStore'
|
||||||
import type { TextAnnotation } from '../../types/editor'
|
import type { TextAnnotation } from '../../types/editor'
|
||||||
import './Editor.css'
|
import './Editor.css'
|
||||||
@@ -12,6 +13,102 @@ import './Editor.css'
|
|||||||
// StateEffect to push new annotations into the editor
|
// StateEffect to push new annotations into the editor
|
||||||
export const setAnnotationsEffect = StateEffect.define<TextAnnotation[]>()
|
export const setAnnotationsEffect = StateEffect.define<TextAnnotation[]>()
|
||||||
|
|
||||||
|
// StateField stores the raw annotation array for hover lookup
|
||||||
|
const rawAnnotationsField = StateField.define<TextAnnotation[]>({
|
||||||
|
create: () => [],
|
||||||
|
update(annotations, tr) {
|
||||||
|
for (const effect of tr.effects) {
|
||||||
|
if (effect.is(setAnnotationsEffect)) return effect.value
|
||||||
|
}
|
||||||
|
return annotations
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Per-session cache: annotation id → lazily generated analysis text
|
||||||
|
const tooltipAnalysisCache = new Map<string, string>()
|
||||||
|
|
||||||
|
// Hover tooltip — lazily streams a specific AI analysis for the hovered passage
|
||||||
|
const annotationHoverTooltip = hoverTooltip(
|
||||||
|
(view, pos) => {
|
||||||
|
const annotations = view.state.field(rawAnnotationsField)
|
||||||
|
const ann = annotations.find(a => pos >= a.from && pos <= a.to)
|
||||||
|
if (!ann) return null
|
||||||
|
|
||||||
|
return {
|
||||||
|
pos: ann.from,
|
||||||
|
end: ann.to,
|
||||||
|
above: true,
|
||||||
|
create() {
|
||||||
|
const dom = document.createElement('div')
|
||||||
|
dom.className = 'annotation-tooltip'
|
||||||
|
|
||||||
|
const label = document.createElement('span')
|
||||||
|
label.className = 'annotation-tooltip-label'
|
||||||
|
label.textContent = ann.type.replace(/_/g, ' ')
|
||||||
|
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'
|
||||||
|
dom.appendChild(body)
|
||||||
|
|
||||||
|
let destroyed = false
|
||||||
|
|
||||||
|
function showText(text: string, streaming = false): void {
|
||||||
|
body.classList.remove('annotation-tooltip-loading')
|
||||||
|
const raw = marked.parse(streaming ? text + ' ▋' : text) as string
|
||||||
|
body.innerHTML = DOMPurify.sanitize(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
const cached = tooltipAnalysisCache.get(ann.id)
|
||||||
|
if (cached) {
|
||||||
|
showText(cached)
|
||||||
|
} else {
|
||||||
|
body.classList.add('annotation-tooltip-loading')
|
||||||
|
body.innerHTML = 'Analysing…'
|
||||||
|
|
||||||
|
const { activeFilePath, activeFileContent } = useEditorStore.getState()
|
||||||
|
const typeName = ann.type.replace(/_/g, ' ')
|
||||||
|
let accumulated = ''
|
||||||
|
|
||||||
|
const api = (window as unknown as { api?: { streamAIMessage: (p: unknown, cb: (c: string) => void) => Promise<void> } }).api
|
||||||
|
if (api?.streamAIMessage) {
|
||||||
|
api.streamAIMessage(
|
||||||
|
{
|
||||||
|
mode: 'chat',
|
||||||
|
documentContent: activeFileContent,
|
||||||
|
documentPath: activeFilePath ?? '',
|
||||||
|
conversationHistory: [],
|
||||||
|
userMessage: `This passage was flagged for ${typeName}: "${ann.matchedText}"\n\nIn 1–2 sentences explain specifically what the issue is in this exact passage, then give a direct rewrite of just this passage. Be concise and specific—no generic advice.`
|
||||||
|
},
|
||||||
|
(chunk: string) => {
|
||||||
|
if (destroyed) return
|
||||||
|
accumulated += chunk
|
||||||
|
showText(accumulated, true)
|
||||||
|
}
|
||||||
|
).then(() => {
|
||||||
|
if (destroyed) return
|
||||||
|
const result = accumulated || ann.message
|
||||||
|
tooltipAnalysisCache.set(ann.id, result)
|
||||||
|
showText(result)
|
||||||
|
}).catch(() => {
|
||||||
|
if (!destroyed) showText(ann.message)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
showText(ann.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { dom, destroy() { destroyed = true } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ hoverTime: 500 }
|
||||||
|
)
|
||||||
|
|
||||||
// StateField tracks the decoration set derived from annotations
|
// StateField tracks the decoration set derived from annotations
|
||||||
const annotationField = StateField.define<DecorationSet>({
|
const annotationField = StateField.define<DecorationSet>({
|
||||||
create: () => Decoration.none,
|
create: () => Decoration.none,
|
||||||
@@ -31,7 +128,7 @@ const annotationField = StateField.define<DecorationSet>({
|
|||||||
to,
|
to,
|
||||||
Decoration.mark({
|
Decoration.mark({
|
||||||
class: `annotation annotation-${ann.type}`,
|
class: `annotation annotation-${ann.type}`,
|
||||||
attributes: { 'data-id': ann.id, title: ann.message }
|
attributes: { 'data-id': ann.id }
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -96,6 +193,11 @@ function buildTheme(fontSize: number, dark: boolean): ReturnType<typeof EditorVi
|
|||||||
backgroundColor: 'rgba(80, 160, 255, 0.18)',
|
backgroundColor: 'rgba(80, 160, 255, 0.18)',
|
||||||
borderBottom: '2px solid rgba(80, 160, 255, 0.7)',
|
borderBottom: '2px solid rgba(80, 160, 255, 0.7)',
|
||||||
borderRadius: '2px'
|
borderRadius: '2px'
|
||||||
|
},
|
||||||
|
'.annotation-critique': {
|
||||||
|
backgroundColor: 'rgba(160, 80, 220, 0.18)',
|
||||||
|
borderBottom: '2px solid rgba(160, 80, 220, 0.7)',
|
||||||
|
borderRadius: '2px'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ dark }
|
{ dark }
|
||||||
@@ -119,7 +221,9 @@ export function MarkdownEditor(): JSX.Element {
|
|||||||
keymap.of([...defaultKeymap, ...historyKeymap]),
|
keymap.of([...defaultKeymap, ...historyKeymap]),
|
||||||
markdown(),
|
markdown(),
|
||||||
syntaxHighlighting(defaultHighlightStyle),
|
syntaxHighlighting(defaultHighlightStyle),
|
||||||
|
rawAnnotationsField,
|
||||||
annotationField,
|
annotationField,
|
||||||
|
annotationHoverTooltip,
|
||||||
themeCompartment.of(buildTheme(fontSize, theme === 'dark')),
|
themeCompartment.of(buildTheme(fontSize, theme === 'dark')),
|
||||||
EditorView.lineWrapping,
|
EditorView.lineWrapping,
|
||||||
EditorView.updateListener.of((update) => {
|
EditorView.updateListener.of((update) => {
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ export function AnalysisToolbar(): JSX.Element {
|
|||||||
setAnalysisMode('passive_voice')
|
setAnalysisMode('passive_voice')
|
||||||
}
|
}
|
||||||
|
|
||||||
const runAIAnalysis = async (mode: 'consistency' | 'style'): Promise<void> => {
|
const runAIAnalysis = async (mode: 'consistency' | 'style' | 'critique'): Promise<void> => {
|
||||||
if (!activeFilePath || isAILoading) return
|
if (!activeFilePath || isAILoading) return
|
||||||
|
|
||||||
setAnalysisMode(mode)
|
setAnalysisMode(mode)
|
||||||
@@ -65,7 +65,9 @@ export function AnalysisToolbar(): JSX.Element {
|
|||||||
const prompt =
|
const prompt =
|
||||||
mode === 'consistency'
|
mode === 'consistency'
|
||||||
? 'Please check this chapter for consistency issues (character names, timeline, repeated phrases).'
|
? 'Please check this chapter for consistency issues (character names, timeline, repeated phrases).'
|
||||||
: 'Please analyze the style and pacing of this chapter and suggest improvements.'
|
: mode === 'style'
|
||||||
|
? 'Please analyze the style and pacing of this chapter and suggest improvements.'
|
||||||
|
: 'Please give me an honest critique of this chapter.'
|
||||||
|
|
||||||
addUserMessage(prompt)
|
addUserMessage(prompt)
|
||||||
startAssistantMessage()
|
startAssistantMessage()
|
||||||
@@ -144,6 +146,18 @@ export function AnalysisToolbar(): JSX.Element {
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className={`toolbar-btn${analysisMode === 'critique' ? ' active' : ''}`}
|
||||||
|
onClick={() => runAIAnalysis('critique')}
|
||||||
|
disabled={!hasFile || isAILoading}
|
||||||
|
title="Honest overall critique of this chapter via AI"
|
||||||
|
>
|
||||||
|
{isAILoading && analysisMode === 'critique' ? 'Reading…' : 'Critique'}
|
||||||
|
{analysisMode === 'critique' && otherCount > 0 && (
|
||||||
|
<span className="toolbar-badge">{otherCount}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
{annotations.length > 0 && (
|
{annotations.length > 0 && (
|
||||||
<button
|
<button
|
||||||
className="toolbar-btn toolbar-btn-clear"
|
className="toolbar-btn toolbar-btn-clear"
|
||||||
|
|||||||
@@ -4,6 +4,19 @@
|
|||||||
grid-template-rows: 38px calc(100vh - 38px);
|
grid-template-rows: 38px calc(100vh - 38px);
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
transition: grid-template-columns 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-layout[data-sidebar='closed'] {
|
||||||
|
grid-template-columns: 0 1fr 340px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-layout[data-chat='closed'] {
|
||||||
|
grid-template-columns: 220px 1fr 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-layout[data-sidebar='closed'][data-chat='closed'] {
|
||||||
|
grid-template-columns: 0 1fr 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-titlebar {
|
.app-titlebar {
|
||||||
@@ -11,9 +24,9 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
background: var(--sidebar-bg);
|
background: var(--sidebar-bg);
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
/* Makes the entire bar draggable so the window can be moved */
|
|
||||||
-webkit-app-region: drag;
|
-webkit-app-region: drag;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -28,9 +41,55 @@
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-titlebar-theme-btn {
|
/* ─── Three-segment layout toggle ─────────────────────────────── */
|
||||||
|
.app-layout-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
-webkit-app-region: no-drag;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-layout-toggle-seg {
|
||||||
|
width: 16px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 2px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
transition: background 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-layout-toggle-seg:hover {
|
||||||
|
border-color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-layout-toggle-seg.active {
|
||||||
|
background: var(--text-muted);
|
||||||
|
border-color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Middle segment: wider, non-interactive, always "on" */
|
||||||
|
.app-layout-toggle-seg--mid {
|
||||||
|
width: 22px;
|
||||||
|
cursor: default;
|
||||||
|
background: var(--text-muted);
|
||||||
|
border-color: var(--text-muted);
|
||||||
|
opacity: 0.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Right-side controls cluster ─────────────────────────────── */
|
||||||
|
.app-titlebar-right {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 12px;
|
right: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
-webkit-app-region: no-drag;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Theme toggle button ──────────────────────────────────────── */
|
||||||
|
.app-titlebar-theme-btn {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export interface ChatMessage {
|
|||||||
content: string
|
content: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AnnotationType = 'passive_voice' | 'consistency' | 'style'
|
export type AnnotationType = 'passive_voice' | 'consistency' | 'style' | 'critique'
|
||||||
|
|
||||||
export interface TextAnnotation {
|
export interface TextAnnotation {
|
||||||
id: string
|
id: string
|
||||||
@@ -23,9 +23,9 @@ export interface TextAnnotation {
|
|||||||
suggestion?: string
|
suggestion?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AnalysisMode = 'none' | 'passive_voice' | 'consistency' | 'style'
|
export type AnalysisMode = 'none' | 'passive_voice' | 'consistency' | 'style' | 'critique'
|
||||||
|
|
||||||
export type AIMode = 'chat' | 'passive_voice' | 'consistency' | 'style'
|
export type AIMode = 'chat' | 'passive_voice' | 'consistency' | 'style' | 'critique'
|
||||||
|
|
||||||
export interface AIPayload {
|
export interface AIPayload {
|
||||||
mode: AIMode
|
mode: AIMode
|
||||||
|
|||||||
@@ -74,23 +74,24 @@ function classifyType(contextBefore: string): AnnotationType {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function extractSuggestion(text: string): string | undefined {
|
function extractSuggestion(text: string): string | undefined {
|
||||||
// Look for SUGGESTION: "..." pattern
|
// Look for SUGGESTION: "..." pattern — allow up to 400 chars to capture full rewrites
|
||||||
const m = text.match(/SUGGESTION:\s*["""'](.{5,200}?)["""']/i)
|
const m = text.match(/SUGGESTION:\s*["""'](.{5,400}?)["""']/i)
|
||||||
return m?.[1]?.trim()
|
return m?.[1]?.trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractMessage(response: string, quoteIndex: number): string {
|
function extractMessage(response: string, quoteIndex: number): string {
|
||||||
// Look back for ISSUE: or PROBLEM: line
|
// Look back for ISSUE: or PROBLEM: line
|
||||||
const before = response.slice(Math.max(0, quoteIndex - 300), quoteIndex)
|
const before = response.slice(Math.max(0, quoteIndex - 400), quoteIndex)
|
||||||
const issueMatch = before.match(/(?:ISSUE|PROBLEM|WHY):\s*(.+?)(?:\n|$)/gi)
|
const issueMatch = before.match(/(?:ISSUE|PROBLEM|WHY):\s*(.+?)(?:\n|$)/gi)
|
||||||
if (issueMatch) {
|
if (issueMatch) {
|
||||||
const last = issueMatch[issueMatch.length - 1]
|
const last = issueMatch[issueMatch.length - 1]
|
||||||
return last.replace(/^(?:ISSUE|PROBLEM|WHY):\s*/i, '').trim().slice(0, 120)
|
const text = last.replace(/^(?:ISSUE|PROBLEM|WHY):\s*/i, '').trim()
|
||||||
|
return text.length > 200 ? text.slice(0, 200) + '…' : text
|
||||||
}
|
}
|
||||||
// Fall back to the last sentence before the quote
|
// Fall back to the last sentence before the quote
|
||||||
const sentences = before.split(/[.!?]\s+/)
|
const sentences = before.split(/[.!?]\s+/)
|
||||||
const last = sentences[sentences.length - 1]?.trim()
|
const last = sentences[sentences.length - 1]?.trim() ?? ''
|
||||||
return last?.slice(0, 120) ?? ''
|
return last.length > 200 ? last.slice(0, 200) + '…' : last
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find a normalized string in a document (ignores whitespace differences)
|
// Find a normalized string in a document (ignores whitespace differences)
|
||||||
|
|||||||
1
tsconfig.node.tsbuildinfo
Normal file
1
tsconfig.node.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
1
tsconfig.web.tsbuildinfo
Normal file
1
tsconfig.web.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
17
vite.demo.config.ts
Normal file
17
vite.demo.config.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { resolve } from 'path'
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
root: resolve(__dirname, 'demo'),
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@renderer': resolve(__dirname, 'src/renderer')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
port: 5174,
|
||||||
|
open: false
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user