feat: ship v0.6.0 with cockpit, knowledge base, and chapter bridge
Deliver P5 writer workflow: writing cockpit, manual knowledge CRUD with review, chapter bridge with optional AI, publish status, and stock buffer reminders. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,11 +1,14 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import type { CockpitSummary, PublishStatus } from '@shared/types'
|
||||
import { useAppStore } from '@renderer/stores/useAppStore'
|
||||
import { useBookStore } from '@renderer/stores/useBookStore'
|
||||
import { useEditStore } from '@renderer/stores/useEditStore'
|
||||
import { useOutlineStore } from '@renderer/stores/useOutlineStore'
|
||||
import { useReferenceStore } from '@renderer/stores/useReferenceStore'
|
||||
import { useSettingsStore } from '@renderer/stores/useSettingsStore'
|
||||
import { useCockpitStore } from '@renderer/stores/useCockpitStore'
|
||||
import { ipcCall } from '@renderer/lib/ipc-client'
|
||||
import { TipTapEditor, flushEditorSave } from '@renderer/components/editor/TipTapEditor'
|
||||
import { insertLandmarkInEditor } from '@renderer/lib/editor-commands'
|
||||
@@ -17,6 +20,8 @@ import { SettingList } from '@renderer/components/setting/SettingList'
|
||||
import { InspirationList } from '@renderer/components/inspiration/InspirationList'
|
||||
import { VersionModal } from '@renderer/components/version/VersionModal'
|
||||
import { LandmarkModal } from '@renderer/components/landmark/LandmarkModal'
|
||||
import { CockpitModal } from '@renderer/components/cockpit/CockpitModal'
|
||||
import { ChapterBridgeModal } from '@renderer/components/bridge/ChapterBridgeModal'
|
||||
import {
|
||||
editorDirtyAtom,
|
||||
editorSavingAtom,
|
||||
@@ -24,6 +29,19 @@ import {
|
||||
wordCountAtom
|
||||
} from '@renderer/atoms/editorAtoms'
|
||||
|
||||
const PUBLISH_CYCLE: PublishStatus[] = ['draft', 'ready', 'published']
|
||||
|
||||
function nextPublishStatus(current: PublishStatus | undefined): PublishStatus {
|
||||
const idx = PUBLISH_CYCLE.indexOf(current ?? 'draft')
|
||||
return PUBLISH_CYCLE[(idx + 1) % PUBLISH_CYCLE.length]
|
||||
}
|
||||
|
||||
function publishIcon(status: PublishStatus | undefined): string {
|
||||
if (status === 'ready') return '📦'
|
||||
if (status === 'published') return '✅'
|
||||
return '📝'
|
||||
}
|
||||
|
||||
function useDocumentTitle(): string {
|
||||
const target = useEditStore((s) => s.target)
|
||||
const chapters = useBookStore((s) => s.chapters)
|
||||
@@ -71,6 +89,10 @@ export function EditorLayout(): React.JSX.Element {
|
||||
const target = useEditStore((s) => s.target)
|
||||
const switchTarget = useEditStore((s) => s.switchTarget)
|
||||
const setTarget = useEditStore((s) => s.setTarget)
|
||||
const updateSchedule = useSettingsStore((s) => s.updateSchedule)
|
||||
const bridgeChapterId = useCockpitStore((s) => s.bridgeChapterId)
|
||||
const clearBridgeChapter = useCockpitStore((s) => s.clearBridgeChapter)
|
||||
const updateChapterLocal = useBookStore((s) => s.updateChapterLocal)
|
||||
|
||||
const dirty = useAtomValue(editorDirtyAtom)
|
||||
const saving = useAtomValue(editorSavingAtom)
|
||||
@@ -78,6 +100,9 @@ export function EditorLayout(): React.JSX.Element {
|
||||
const wordCount = useAtomValue(wordCountAtom)
|
||||
const documentTitle = useDocumentTitle()
|
||||
|
||||
const [bridgeOpen, setBridgeOpen] = useState(false)
|
||||
const [cockpitSummary, setCockpitSummary] = useState<CockpitSummary | null>(null)
|
||||
|
||||
const bookMeta = useBookStore.getState().books.find((b) => b.id === currentBookId)
|
||||
const isChapterTarget = target?.kind === 'chapter'
|
||||
const chapterId = target?.kind === 'chapter' ? target.id : selectedChapterId
|
||||
@@ -97,6 +122,47 @@ export function EditorLayout(): React.JSX.Element {
|
||||
}
|
||||
}, [currentBookId, target?.kind, target?.id])
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentBookId) return
|
||||
void ipcCall(() =>
|
||||
window.electronAPI.cockpit.getSummary(currentBookId, activeVolumeId ?? undefined)
|
||||
).then(setCockpitSummary)
|
||||
}, [currentBookId, activeVolumeId, chapters])
|
||||
|
||||
useEffect(() => {
|
||||
if (bridgeChapterId) setBridgeOpen(true)
|
||||
}, [bridgeChapterId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentBookId || target?.kind !== 'chapter') return
|
||||
const chapterId = target.id
|
||||
void ipcCall(() => window.electronAPI.bridge.shouldPrompt(currentBookId, chapterId)).then(
|
||||
(should) => {
|
||||
if (should) setTimeout(() => setBridgeOpen(true), 500)
|
||||
}
|
||||
)
|
||||
}, [currentBookId, target?.kind, target?.id])
|
||||
|
||||
const activeBridgeChapterId =
|
||||
bridgeChapterId ?? (target?.kind === 'chapter' ? target.id : selectedChapterId)
|
||||
|
||||
const showStockWarning =
|
||||
updateSchedule !== 'none' &&
|
||||
cockpitSummary != null &&
|
||||
cockpitSummary.stockReadyCount < cockpitSummary.stockThreshold
|
||||
|
||||
const handlePublishCycle = async (chapterId: string, e: React.MouseEvent): Promise<void> => {
|
||||
e.stopPropagation()
|
||||
if (!currentBookId) return
|
||||
const ch = chapters.find((c) => c.id === chapterId)
|
||||
if (!ch) return
|
||||
const next = nextPublishStatus(ch.publishStatus)
|
||||
const updated = await ipcCall(() =>
|
||||
window.electronAPI.chapter.setPublishStatus(currentBookId, chapterId, next)
|
||||
)
|
||||
updateChapterLocal(updated)
|
||||
}
|
||||
|
||||
const handleSelectChapter = async (chapterId: string): Promise<void> => {
|
||||
setSelectedChapter(chapterId)
|
||||
await switchTarget({ kind: 'chapter', id: chapterId })
|
||||
@@ -178,11 +244,19 @@ export function EditorLayout(): React.JSX.Element {
|
||||
}
|
||||
const label = prompt(t('landmark.prompt'))
|
||||
if (!label?.trim()) return
|
||||
insertLandmarkInEditor({ label: label.trim(), landmarkType: 'todo' })
|
||||
await ipcCall(() =>
|
||||
window.electronAPI.bookmark.create(currentBookId, target.id, 0, label.trim(), 'todo')
|
||||
const isForeshadow = confirm(t('landmark.foreshadowConfirm'))
|
||||
const landmarkType = isForeshadow ? 'foreshadow' : 'todo'
|
||||
const syncKnowledge = isForeshadow && confirm(t('knowledge.syncFromLandmark'))
|
||||
insertLandmarkInEditor({ label: label.trim(), landmarkType })
|
||||
const bm = await ipcCall(() =>
|
||||
window.electronAPI.bookmark.create(currentBookId, target.id, 0, label.trim(), landmarkType)
|
||||
)
|
||||
showToast(t('landmark.created'))
|
||||
if (syncKnowledge) {
|
||||
await ipcCall(() => window.electronAPI.knowledge.createFromBookmark(currentBookId, bm.id))
|
||||
showToast(t('knowledge.syncedFromLandmark'))
|
||||
} else {
|
||||
showToast(t('landmark.created'))
|
||||
}
|
||||
}
|
||||
|
||||
const saveStatus = saving ? t('editor.saving') : dirty ? t('editor.unsaved') : t('editor.saved')
|
||||
@@ -254,6 +328,15 @@ export function EditorLayout(): React.JSX.Element {
|
||||
>
|
||||
<span>{idx + 1}.</span>
|
||||
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis' }}>{ch.title}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="ch-publish-btn"
|
||||
title={t(`publish.status.${ch.publishStatus ?? 'draft'}`)}
|
||||
data-testid={`chapter-publish-${ch.id}`}
|
||||
onClick={(e) => void handlePublishCycle(ch.id, e)}
|
||||
>
|
||||
{publishIcon(ch.publishStatus)}
|
||||
</button>
|
||||
<span className={`ch-badge ${ch.status === 'done' ? 'done' : 'draft'}`}>{ch.status}</span>
|
||||
</div>
|
||||
))}
|
||||
@@ -322,6 +405,14 @@ export function EditorLayout(): React.JSX.Element {
|
||||
/>
|
||||
<div id="editor-statusbar">
|
||||
<span>{saveStatus}{lastSaved ? ` · ${lastSaved.toLocaleTimeString()}` : ''}</span>
|
||||
{showStockWarning && (
|
||||
<span className="status-stock-warning" data-testid="status-stock-warning">
|
||||
{t('stock.warning', {
|
||||
count: cockpitSummary!.stockReadyCount,
|
||||
threshold: cockpitSummary!.stockThreshold
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
{isChapterTarget ? (
|
||||
<>
|
||||
<span>{t('status.chapter')}: {t('editor.words', { count: wordCount.chapter })}</span>
|
||||
@@ -343,6 +434,20 @@ export function EditorLayout(): React.JSX.Element {
|
||||
chapterId={chapterId}
|
||||
/>
|
||||
<LandmarkModal open={landmarksModalOpen} onClose={() => setLandmarksModalOpen(false)} />
|
||||
<CockpitModal
|
||||
onOpenBridge={(chapterId) => {
|
||||
useCockpitStore.getState().openBridgeForChapter(chapterId)
|
||||
setBridgeOpen(true)
|
||||
}}
|
||||
/>
|
||||
<ChapterBridgeModal
|
||||
open={bridgeOpen}
|
||||
chapterId={activeBridgeChapterId}
|
||||
onClose={() => {
|
||||
setBridgeOpen(false)
|
||||
clearBridgeChapter()
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user