Files
hohoff/src/renderer/components/Toolbar/AnalysisToolbar.tsx
2026-06-14 18:48:53 +10:00

718 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { useEditorStore } from '../../store/editorStore'
import { detectPassiveVoice } from '../../utils/passiveVoice'
import { detectPastProgressive } from '../../utils/pastProgressive'
import { parseAnnotationsFromAIResponse } from '../../utils/annotationParser'
import { tooltipAnalysisCache } from '../Editor/MarkdownEditor'
import '../FileTree/ContextMenu.css'
import './Toolbar.css'
const BIBLE_PROMPTS = {
full: `Read the full manuscript and generate comprehensive story bible content as markdown. Include:
## Characters
[Detailed profile for every named character: role, physical description, personality, key relationships, arc]
## World & Setting
[Geography, historical period, Basque Country cultural details, atmosphere, key locations]
## Timeline
[Key events in chronological order with chapter references]
## Themes & Motifs
[Recurring symbols, imagery, thematic concerns]
## Continuity Rules
[Facts that must stay consistent: character details, established plot points, internal logic]
Base everything strictly on what is in the manuscript text.`,
characters: `Read the full manuscript and write character profiles for the Story Bible. Begin your response with the heading \`## Characters\` followed by a blank line. For each named character include: role, physical description, personality traits, key relationships, and arc. Format each character as a ### subsection.`,
timeline: `Read the full manuscript and extract all significant events in chronological order for the Story Bible. Begin your response with the heading \`## Timeline\` followed by a blank line. Reference chapters where helpful. Format as a numbered list.`,
world: `Read the full manuscript and write a World & Setting section for the Story Bible. Begin your response with the heading \`## World & Setting\` followed by a blank line. Cover: the Basque Country geography and atmosphere, the historical period and cultural context, and key locations described in the text.`
}
function countWords(text: string): number {
return text.trim() === '' ? 0 : text.trim().split(/\s+/).length
}
function formatWordCount(n: number): string {
if (n >= 1000) return `${(n / 1000).toFixed(1)}k`
return String(n)
}
interface ParagraphInfo {
text: string
sentences: number
words: number
}
function computeParagraphRhythm(text: string): ParagraphInfo[] {
const blocks = text.split(/\n+/)
const result: ParagraphInfo[] = []
for (const block of blocks) {
const trimmed = block.trim()
if (!trimmed || !/\w/.test(trimmed)) continue
if (/^#{1,6}\s/.test(trimmed) || /^-{3,}$/.test(trimmed) || /^={3,}$/.test(trimmed)) continue
const words = countWords(trimmed)
if (words < 3) continue
const sentences = trimmed
.split(/[.!?]+/)
.map(s => s.trim())
.filter(s => s.length > 0 && /\w/.test(s)).length
result.push({ text: trimmed, sentences, words })
}
return result
}
function sentenceCountColor(n: number): string {
if (n === 1) return '#5b8dd9'
if (n === 2) return '#6db8a0'
if (n === 3) return '#7dc76e'
if (n === 4) return '#b5c45a'
if (n === 5) return '#d4a83a'
if (n === 6) return '#d47830'
if (n === 7) return '#c45040'
return '#b03030'
}
interface SentenceStats {
avg: number
stdDev: number
totalSentences: number
/** bins[i] = count of sentences with (i+1) words; bins[30] = sentences with 31+ words */
bins: number[]
outliers: Array<{ text: string; wordCount: number }>
}
function computeSentenceStats(text: string): SentenceStats | null {
const sentences = text
.split(/[.!?]+/)
.map(s => s.trim())
.filter(s => s.length > 0 && /\w/.test(s))
if (sentences.length === 0) return null
const lengths = sentences.map(s => countWords(s))
const avg = lengths.reduce((a, b) => a + b, 0) / lengths.length
const variance = lengths.reduce((a, b) => a + (b - avg) ** 2, 0) / lengths.length
const stdDev = Math.sqrt(variance)
const bins = new Array(31).fill(0)
const outliers: Array<{ text: string; wordCount: number }> = []
for (let i = 0; i < sentences.length; i++) {
const wc = lengths[i]
bins[Math.min(wc - 1, 30)]++
if (wc <= 4 || wc >= 26) outliers.push({ text: sentences[i], wordCount: wc })
}
return { avg, stdDev, totalSentences: sentences.length, bins, outliers }
}
export function AnalysisToolbar(): JSX.Element {
const {
activeFilePath,
activeFileContent,
isDirty,
analysisMode,
annotations,
isAILoading,
setAnnotations,
clearAnnotations,
setAnalysisMode,
addUserMessage,
finalizeAssistantMessage,
setStreamingContent,
setAILoading,
setAIError,
linkAnnotationsToMessage,
chatHistory,
projectWordCount,
setProjectWordCount,
setRightPanelTab,
outlineOpen,
toggleOutline,
revisionPanelOpen,
toggleRevisionPanel,
selectionWordCount
} = useEditorStore()
const streamingContentRef = useRef('')
const streamingRafRef = useRef<number | null>(null)
const [analyzeOpen, setAnalyzeOpen] = useState(false)
const analyzeButtonRef = useRef<HTMLButtonElement>(null)
const analyzeMenuRef = useRef<HTMLDivElement>(null)
const [statsOpen, setStatsOpen] = useState(false)
const statsButtonRef = useRef<HTMLButtonElement>(null)
const statsMenuRef = useRef<HTMLDivElement>(null)
const [rhythmOpen, setRhythmOpen] = useState(false)
const rhythmButtonRef = useRef<HTMLButtonElement>(null)
const rhythmMenuRef = useRef<HTMLDivElement>(null)
useEffect(() => {
window.api.getProjectWordCount().then(setProjectWordCount).catch(() => {})
}, [])
// Refresh project count after a save (isDirty transitions from true → false)
useEffect(() => {
if (!isDirty) {
window.api.getProjectWordCount().then(setProjectWordCount).catch(() => {})
}
}, [isDirty])
// Close dropdowns on file change
useEffect(() => { setAnalyzeOpen(false); setStatsOpen(false); setRhythmOpen(false) }, [activeFilePath])
// Click-outside closes dropdown
useEffect(() => {
if (!analyzeOpen) return
const handler = (e: MouseEvent): void => {
if (
!analyzeButtonRef.current?.contains(e.target as Node) &&
!analyzeMenuRef.current?.contains(e.target as Node)
) {
setAnalyzeOpen(false)
}
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [analyzeOpen])
// Escape closes dropdowns
useEffect(() => {
if (!analyzeOpen && !statsOpen && !rhythmOpen) return
const handler = (e: KeyboardEvent): void => {
if (e.key === 'Escape') { setAnalyzeOpen(false); setStatsOpen(false); setRhythmOpen(false) }
}
document.addEventListener('keydown', handler)
return () => document.removeEventListener('keydown', handler)
}, [analyzeOpen, statsOpen, rhythmOpen])
// Click-outside closes rhythm popover
useEffect(() => {
if (!rhythmOpen) return
const handler = (e: MouseEvent): void => {
if (
!rhythmButtonRef.current?.contains(e.target as Node) &&
!rhythmMenuRef.current?.contains(e.target as Node)
) setRhythmOpen(false)
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [rhythmOpen])
// Click-outside closes stats popover
useEffect(() => {
if (!statsOpen) return
const handler = (e: MouseEvent): void => {
if (
!statsButtonRef.current?.contains(e.target as Node) &&
!statsMenuRef.current?.contains(e.target as Node)
) setStatsOpen(false)
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [statsOpen])
const hasFile = Boolean(activeFilePath)
const isStoryBible = activeFilePath?.endsWith('Story Bible.md') ?? false
const runBibleGeneration = async (prompt: string): Promise<void> => {
if (!activeFilePath || isAILoading) return
setAIError(null)
setRightPanelTab('chat')
streamingContentRef.current = ''
addUserMessage(prompt)
setAILoading(true)
try {
await window.api.streamAIMessage(
{
mode: 'chat',
documentContent: activeFileContent,
documentPath: activeFilePath,
conversationHistory: chatHistory
.slice(-10)
.map((m) => ({ role: m.role, content: m.content })),
userMessage: prompt
},
(chunk: string) => {
streamingContentRef.current += chunk
if (streamingRafRef.current === null) {
streamingRafRef.current = requestAnimationFrame(() => {
streamingRafRef.current = null
setStreamingContent(streamingContentRef.current)
})
}
}
)
if (streamingRafRef.current !== null) {
cancelAnimationFrame(streamingRafRef.current)
streamingRafRef.current = null
}
const finalContent = streamingContentRef.current
streamingContentRef.current = ''
if (finalContent.length > 0) {
finalizeAssistantMessage(finalContent, { bibleGeneration: true })
}
} catch (err) {
setAIError(err instanceof Error ? err.message : 'Generation failed')
} finally {
setAILoading(false)
}
}
const runPassiveVoice = (): void => {
if (!hasFile) return
const found = detectPassiveVoice(activeFileContent)
const existing = useEditorStore.getState().annotations.filter((a) => a.type !== 'passive_voice')
setAnnotations([...existing, ...found])
setAnalysisMode('passive_voice')
}
const runPastProgressive = (): void => {
if (!hasFile) return
const found = detectPastProgressive(activeFileContent)
const existing = useEditorStore.getState().annotations.filter((a) => a.type !== 'past_progressive')
setAnnotations([...existing, ...found])
setAnalysisMode('past_progressive')
}
const runAIAnalysis = async (mode: 'consistency' | 'style' | 'show_tell' | 'critique' | 'weak_verbs' | 'cliches' | 'past_progressive'): Promise<void> => {
if (!activeFilePath || isAILoading) return
setAnalysisMode(mode)
setAIError(null)
const prompt =
mode === 'consistency'
? 'Please check this chapter for consistency issues (character names, timeline, repeated phrases).'
: mode === 'style'
? 'Please analyze the style and pacing of this chapter and suggest improvements.'
: mode === 'show_tell'
? 'Please identify every passage in this chapter where I am telling rather than showing.'
: mode === 'weak_verbs'
? 'Please identify sentences in this chapter that use weak verbs (was, were, had, got, seemed, appeared, looked, felt) as the main predicate.'
: mode === 'cliches'
? 'Please identify every cliché and overused phrase in this chapter.'
: mode === 'past_progressive'
? 'Please identify every past progressive construction (was/were + verb-ing) in this chapter that would be stronger in simple past.'
: 'Please give me an honest critique of this chapter.'
streamingContentRef.current = ''
addUserMessage(prompt)
setAILoading(true)
try {
await window.api.streamAIMessage(
{
mode,
documentContent: activeFileContent,
documentPath: activeFilePath,
conversationHistory: chatHistory
.slice(-10)
.map((m) => ({ role: m.role, content: m.content })),
userMessage: prompt
},
(chunk: string) => {
streamingContentRef.current += chunk
if (streamingRafRef.current === null) {
streamingRafRef.current = requestAnimationFrame(() => {
streamingRafRef.current = null
setStreamingContent(streamingContentRef.current)
})
}
}
)
if (streamingRafRef.current !== null) {
cancelAnimationFrame(streamingRafRef.current)
streamingRafRef.current = null
}
const finalContent = streamingContentRef.current
streamingContentRef.current = ''
if (finalContent.length > 0) {
finalizeAssistantMessage(finalContent)
// Parse annotations from response
const overrideType =
mode === 'show_tell' ? 'show_tell' :
mode === 'weak_verbs' ? 'weak_verbs' :
mode === 'cliches' ? 'cliches' :
mode === 'past_progressive' ? 'past_progressive' :
undefined
const { annotations: newAnnotations } = parseAnnotationsFromAIResponse(finalContent, activeFileContent, overrideType)
if (newAnnotations.length > 0) {
const currentHistory = useEditorStore.getState().chatHistory
const lastMsg = currentHistory[currentHistory.length - 1]
const existing = useEditorStore.getState().annotations.filter((a) => a.type !== mode)
setAnnotations([...existing, ...newAnnotations])
if (lastMsg?.role === 'assistant') {
linkAnnotationsToMessage(lastMsg.id, newAnnotations.map(a => a.id))
}
}
}
} catch (err) {
setAIError(err instanceof Error ? err.message : 'Analysis failed')
} finally {
setAILoading(false)
}
}
const passiveCount = annotations.filter((a) => a.type === 'passive_voice').length
const pastProgressiveCount = annotations.filter((a) => a.type === 'past_progressive').length
const weakVerbsCount = annotations.filter((a) => a.type === 'weak_verbs').length
const clichesCount = annotations.filter((a) => a.type === 'cliches').length
const consistencyCount = annotations.filter((a) => a.type === 'consistency').length
const styleCount = annotations.filter((a) => a.type === 'style').length
const showTellCount = annotations.filter((a) => a.type === 'show_tell').length
const critiqueCount = annotations.filter((a) => a.type === 'critique').length
const totalCount = passiveCount + pastProgressiveCount + weakVerbsCount + clichesCount + consistencyCount + styleCount + showTellCount + critiqueCount
const anyActive = Boolean(analysisMode)
const docWordCount = countWords(activeFileContent)
const sentenceStats = activeFileContent ? computeSentenceStats(activeFileContent) : null
const avgSentenceLen = sentenceStats ? Math.round(sentenceStats.avg * 10) / 10 : null
const paragraphRhythm = activeFileContent ? computeParagraphRhythm(activeFileContent) : []
const maxParaWords = paragraphRhythm.length > 0 ? Math.max(...paragraphRhythm.map(p => p.words)) : 1
return (
<div className="toolbar">
<div className="toolbar-left">
{isStoryBible ? (
<>
<span className="toolbar-bible-label">Generate from manuscript:</span>
<button
className="toolbar-btn toolbar-btn-bible"
onClick={() => runBibleGeneration(BIBLE_PROMPTS.full)}
disabled={isAILoading}
title="Generate all story bible sections from the full manuscript"
>
{isAILoading ? 'Generating…' : 'Full bible'}
</button>
<button
className="toolbar-btn toolbar-btn-bible"
onClick={() => runBibleGeneration(BIBLE_PROMPTS.characters)}
disabled={isAILoading}
title="Extract character profiles from the manuscript"
>
Characters
</button>
<button
className="toolbar-btn toolbar-btn-bible"
onClick={() => runBibleGeneration(BIBLE_PROMPTS.timeline)}
disabled={isAILoading}
title="Extract timeline of events from the manuscript"
>
Timeline
</button>
<button
className="toolbar-btn toolbar-btn-bible"
onClick={() => runBibleGeneration(BIBLE_PROMPTS.world)}
disabled={isAILoading}
title="Extract world & setting details from the manuscript"
>
World
</button>
</>
) : (
<>
<button
ref={analyzeButtonRef}
className={`toolbar-btn toolbar-analyze-btn${anyActive ? ' active' : ''}`}
onClick={() => setAnalyzeOpen((v) => !v)}
disabled={!hasFile || isAILoading}
title="Run analysis on this chapter"
>
{isAILoading ? 'Analyzing…' : `Analyze ${analyzeOpen ? '▴' : '▾'}`}
{totalCount > 0 && !isAILoading && (
<span className="toolbar-analyze-badge">{totalCount}</span>
)}
</button>
{annotations.length > 0 && (
<button
className="toolbar-btn toolbar-btn-clear"
onClick={() => { clearAnnotations(); tooltipAnalysisCache.clear() }}
title="Remove all highlights"
>
Clear
</button>
)}
{analyzeOpen && analyzeButtonRef.current && createPortal(
(() => {
const rect = analyzeButtonRef.current!.getBoundingClientRect()
return (
<div
ref={analyzeMenuRef}
className="context-menu toolbar-analyze-menu"
style={{ top: rect.bottom + 4, left: rect.left }}
>
<button
className={`context-menu-item${analysisMode === 'passive_voice' ? ' active' : ''}`}
onClick={() => { setAnalyzeOpen(false); runPassiveVoice() }}
>
<span>Passive Voice</span>
{passiveCount > 0 && <span className="toolbar-analyze-count">{passiveCount}</span>}
</button>
<button
className={`context-menu-item${analysisMode === 'past_progressive' ? ' active' : ''}`}
onClick={() => { setAnalyzeOpen(false); runPastProgressive() }}
>
<span>Past Progressive</span>
{pastProgressiveCount > 0 && <span className="toolbar-analyze-count">{pastProgressiveCount}</span>}
</button>
<button
className={`context-menu-item${analysisMode === 'weak_verbs' ? ' active' : ''}`}
onClick={() => { setAnalyzeOpen(false); void runAIAnalysis('weak_verbs') }}
>
<span>Weak Verbs</span>
{weakVerbsCount > 0 && <span className="toolbar-analyze-count">{weakVerbsCount}</span>}
</button>
<button
className={`context-menu-item${analysisMode === 'cliches' ? ' active' : ''}`}
onClick={() => { setAnalyzeOpen(false); void runAIAnalysis('cliches') }}
>
<span>Clichés</span>
{clichesCount > 0 && <span className="toolbar-analyze-count">{clichesCount}</span>}
</button>
<button
className={`context-menu-item${analysisMode === 'consistency' ? ' active' : ''}`}
onClick={() => { setAnalyzeOpen(false); void runAIAnalysis('consistency') }}
>
<span>Consistency</span>
{consistencyCount > 0 && <span className="toolbar-analyze-count">{consistencyCount}</span>}
</button>
<button
className={`context-menu-item${analysisMode === 'style' ? ' active' : ''}`}
onClick={() => { setAnalyzeOpen(false); void runAIAnalysis('style') }}
>
<span>Style</span>
{styleCount > 0 && <span className="toolbar-analyze-count">{styleCount}</span>}
</button>
<button
className={`context-menu-item${analysisMode === 'show_tell' ? ' active' : ''}`}
onClick={() => { setAnalyzeOpen(false); void runAIAnalysis('show_tell') }}
>
<span>Show vs Tell</span>
{showTellCount > 0 && <span className="toolbar-analyze-count">{showTellCount}</span>}
</button>
<button
className={`context-menu-item${analysisMode === 'critique' ? ' active' : ''}`}
onClick={() => { setAnalyzeOpen(false); void runAIAnalysis('critique') }}
>
<span>Critique</span>
{critiqueCount > 0 && <span className="toolbar-analyze-count">{critiqueCount}</span>}
</button>
</div>
)
})(),
document.body
)}
</>
)}
</div>
<div className="toolbar-right">
<button
className={`toolbar-btn toolbar-btn-outline${revisionPanelOpen ? ' active' : ''}`}
onClick={toggleRevisionPanel}
title="Revision history"
>
</button>
<button
className={`toolbar-btn toolbar-btn-outline${outlineOpen ? ' active' : ''}`}
onClick={toggleOutline}
title={outlineOpen ? 'Hide document outline' : 'Show document outline'}
>
</button>
{selectionWordCount !== null && (
<span className="toolbar-selection-wordcount" title={`${selectionWordCount} words selected`}>
{formatWordCount(selectionWordCount)} sel
</span>
)}
{activeFilePath && paragraphRhythm.length > 0 && (
<>
<button
ref={rhythmButtonRef}
className={`toolbar-btn toolbar-btn-outline${rhythmOpen ? ' active' : ''}`}
onClick={() => setRhythmOpen(v => !v)}
title="Paragraph rhythm — sentence count per paragraph"
>
</button>
{rhythmOpen && rhythmButtonRef.current && createPortal(
(() => {
const rect = rhythmButtonRef.current!.getBoundingClientRect()
return (
<div
ref={rhythmMenuRef}
className="toolbar-rhythm-popover"
style={{ top: rect.bottom + 4, right: window.innerWidth - rect.right }}
>
<div className="toolbar-stats-header">
<span className="toolbar-stats-title">Paragraph Rhythm</span>
<button className="toolbar-stats-close" onClick={() => setRhythmOpen(false)}>×</button>
</div>
<div className="toolbar-stats-summary">
<span><strong>{paragraphRhythm.length}</strong> paragraphs</span>
<span>avg <strong>{Math.round(paragraphRhythm.reduce((s, p) => s + p.sentences, 0) / paragraphRhythm.length * 10) / 10}</strong> sentences</span>
</div>
<div className="toolbar-rhythm-list">
{paragraphRhythm.map((p, i) => {
const barW = Math.max(12, Math.round((p.words / maxParaWords) * 180))
const color = sentenceCountColor(p.sentences)
const preview = p.text.length > 80 ? p.text.slice(0, 80) + '…' : p.text
return (
<div
key={i}
className="toolbar-rhythm-row"
title={`${p.sentences} sentence${p.sentences !== 1 ? 's' : ''} · ${p.words} words\n${preview}`}
>
<div
className="toolbar-rhythm-bar"
style={{ width: barW, background: color }}
/>
<span className="toolbar-rhythm-count" style={{ color }}>{p.sentences}</span>
</div>
)
})}
</div>
<div className="toolbar-rhythm-legend">
{[1, 2, 3, 4, 5, 6, 7, 8].map(n => (
<div key={n} className="toolbar-rhythm-legend-item" title={`${n}${n === 8 ? '+' : ''} sentence${n !== 1 ? 's' : ''}`}>
<div className="toolbar-rhythm-legend-swatch" style={{ background: sentenceCountColor(n) }} />
<span>{n}{n === 8 ? '+' : ''}</span>
</div>
))}
</div>
</div>
)
})(),
document.body
)}
</>
)}
{activeFilePath && sentenceStats && (
<>
<button
ref={statsButtonRef}
className={`toolbar-btn toolbar-btn-stats${statsOpen ? ' active' : ''}`}
onClick={() => setStatsOpen(v => !v)}
title="Sentence length histogram"
>
</button>
{statsOpen && statsButtonRef.current && createPortal(
(() => {
const rect = statsButtonRef.current!.getBoundingClientRect()
const maxBin = Math.max(...sentenceStats.bins, 1)
const CHART_H = 60
return (
<div
ref={statsMenuRef}
className="toolbar-stats-popover"
style={{ top: rect.bottom + 4, right: window.innerWidth - rect.right }}
>
<div className="toolbar-stats-header">
<span className="toolbar-stats-title">Sentence Lengths</span>
<button className="toolbar-stats-close" onClick={() => setStatsOpen(false)}>×</button>
</div>
<div className="toolbar-stats-summary">
<span>avg <strong>{Math.round(sentenceStats.avg * 10) / 10}w</strong></span>
<span>σ <strong>{Math.round(sentenceStats.stdDev * 10) / 10}</strong></span>
<span><strong>{sentenceStats.totalSentences}</strong> sentences</span>
</div>
<div className="toolbar-stats-chart" style={{ height: CHART_H }}>
{sentenceStats.bins.map((count, i) => {
const wordCount = i + 1
const isTarget = wordCount >= 11 && wordCount <= 16
const barH = count === 0 ? 0 : Math.max(2, Math.round((count / maxBin) * CHART_H))
return (
<div
key={i}
className={`toolbar-stats-bar${isTarget ? ' target' : ''}`}
style={{ height: barH }}
title={`${wordCount === 31 ? '31+' : wordCount}w: ${count} sentence${count !== 1 ? 's' : ''}`}
/>
)
})}
</div>
<div className="toolbar-stats-axis">
<span>1</span>
<span>8</span>
<span className="toolbar-stats-axis-target">1116 </span>
<span>22</span>
<span>31+</span>
</div>
{sentenceStats.outliers.length > 0 && (
<div className="toolbar-stats-outliers">
<div className="toolbar-stats-outliers-title">
Flagged (4 or 26 words)
</div>
<div className="toolbar-stats-outliers-list">
{sentenceStats.outliers.slice(0, 8).map((o, i) => (
<div key={i} className="toolbar-stats-outlier">
<span className="toolbar-stats-outlier-wc">{o.wordCount}w</span>
<span className={`toolbar-stats-outlier-text${o.wordCount <= 4 ? ' short' : ' long'}`}>
{o.text.length > 55 ? o.text.slice(0, 55) + '…' : o.text}
</span>
</div>
))}
{sentenceStats.outliers.length > 8 && (
<div className="toolbar-stats-outlier-more">
+{sentenceStats.outliers.length - 8} more
</div>
)}
</div>
</div>
)}
</div>
)
})(),
document.body
)}
</>
)}
{activeFilePath && (
<span
className="toolbar-wordcount"
title={`This document: ${docWordCount.toLocaleString()} words · Entire project: ${projectWordCount.toLocaleString()} words${avgSentenceLen !== null ? ` · Avg sentence: ${avgSentenceLen} words` : ''}`}
>
{formatWordCount(docWordCount)}
<span className="toolbar-wordcount-sep">/</span>
{formatWordCount(projectWordCount)}
{avgSentenceLen !== null && (
<>
<span className="toolbar-wordcount-sep">·</span>
{avgSentenceLen}w
</>
)}
</span>
)}
{isDirty && (
<span className="toolbar-dirty" title="Unsaved changes — press Cmd+S to save">
</span>
)}
{activeFilePath && (
<span className="toolbar-filename" title={activeFilePath.split('/').pop()?.replace(/\.md$/, '')}>
{(() => {
const name = activeFilePath.split('/').pop()?.replace(/\.md$/, '') ?? ''
return name.length > 30 ? name.slice(0, 30) + '…' : name
})()}
</span>
)}
</div>
</div>
)
}