fix: resolve Gitea issues #1–#5 (mobile layout, suggestion refresh, import/export icons, context rail default/icon, manual save)
CI / build (push) Waiting to run

Closes #1
Closes #2
Closes #3
Closes #4
Closes #5
This commit is contained in:
Schwitzz
2026-11-02 14:00:48 -06:00
parent 020563ed85
commit b01073eac0
11 changed files with 799 additions and 152 deletions
+74 -3
View File
@@ -1,5 +1,5 @@
import { useState, useEffect } from "react";
import { Routes, Route } from "react-router-dom";
import { Routes, Route, useLocation } from "react-router-dom";
import { Sidebar, Topbar, CommandPalette, SplashScreen } from "./components/index.js";
import { AuthGate } from "./components/AuthGate.js";
import { ExportDialog } from "./features/export/ExportDialog.js";
@@ -29,12 +29,49 @@ const NAV = [
];
export default function App() {
const location = useLocation();
const [paletteOpen, setPaletteOpen] = useState(false);
const [exportOpen, setExportOpen] = useState(false);
const [importOpen, setImportOpen] = useState(false);
const [mobileNavOpen, setMobileNavOpen] = useState(false);
const [isMobile, setIsMobile] = useState<boolean>(() => {
if (typeof window === "undefined") return false;
const cookieMobile =
typeof document !== "undefined" && document.cookie.includes("ps_client_device=mobile");
const uaMobile =
typeof navigator !== "undefined" &&
/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile/i.test(
navigator.userAgent,
);
return cookieMobile || uaMobile || window.innerWidth <= 768;
});
const navCollapsed = useSettings((s) => s.navCollapsed);
const aiSidebarOpen = useSettings((s) => s.aiSidebarOpen);
const focusMode = useSettings((s) => s.focusMode);
const setSettings = useSettings((s) => s.set);
// Detect mobile viewport changes + server ps_client_device cookie
useEffect(() => {
const updateMobile = () => {
const cookieMobile =
typeof document !== "undefined" && document.cookie.includes("ps_client_device=mobile");
const uaMobile =
typeof navigator !== "undefined" &&
/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile/i.test(
navigator.userAgent,
);
setIsMobile(cookieMobile || uaMobile || window.innerWidth <= 768);
};
updateMobile();
window.addEventListener("resize", updateMobile);
return () => window.removeEventListener("resize", updateMobile);
}, []);
// Automatically close the mobile navigation drawer when navigating to a route
useEffect(() => {
setMobileNavOpen(false);
}, [location.pathname]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
@@ -49,13 +86,24 @@ export default function App() {
return (
<AuthGate>
<div className={`ps-app${navCollapsed ? " is-nav-collapsed" : ""}${aiSidebarOpen ? " is-ai-open" : ""}${focusMode ? " is-focus-mode" : ""}`}>
<div
className={`ps-app${navCollapsed ? " is-nav-collapsed" : ""}${aiSidebarOpen ? " is-ai-open" : ""}${focusMode ? " is-focus-mode" : ""}${isMobile ? " is-mobile" : ""}${mobileNavOpen ? " is-mobile-nav-open" : ""}`}
data-mobile={isMobile ? "true" : "false"}
>
<SplashScreen />
{mobileNavOpen && (
<div
className="ps-mobile-nav-backdrop"
onClick={() => setMobileNavOpen(false)}
aria-hidden="true"
/>
)}
<Sidebar nav={NAV} />
<Topbar
onOpenPalette={() => setPaletteOpen(true)}
onOpenExport={() => setExportOpen(true)}
onOpenImport={() => setImportOpen(true)}
onToggleMobileNav={() => setMobileNavOpen((o) => !o)}
/>
<div className="ps-workspace-row">
<main className="ps-main">
@@ -72,9 +120,32 @@ export default function App() {
<Route path="*" element={<Write />} />
</Routes>
</main>
{aiSidebarOpen && <AISidebar />}
{aiSidebarOpen && (
<>
<div
className="ps-mobile-ai-backdrop"
onClick={() => setSettings({ aiSidebarOpen: false })}
aria-hidden="true"
/>
<AISidebar />
</>
)}
</div>
{/* Dedicated bottom-of-screen AI home bar for mobile devices */}
{isMobile && !aiSidebarOpen && (
<div className="ps-mobile-ai-bar">
<button
type="button"
className="ps-mobile-ai-bar-btn"
onClick={() => setSettings({ aiSidebarOpen: true })}
>
<span>✨ AI Studio · Chat, Inline Edits &amp; Notes</span>
<span style={{ fontSize: 11, opacity: 0.85 }}>▲ Open</span>
</button>
</div>
)}
<CommandPalette open={paletteOpen} onClose={() => setPaletteOpen(false)} nav={NAV} />
<ImportDialog open={importOpen} onClose={() => setImportOpen(false)} />
<ExportDialog open={exportOpen} onClose={() => setExportOpen(false)} />
+70 -9
View File
@@ -3,7 +3,7 @@ import { useLocation, NavLink, useNavigate } from "react-router-dom";
import { useSettings } from "../store/settings.js";
import { useProjectStore } from "../store/project-store.js";
import { useAuth } from "./AuthGate.js";
import { Badge } from "@pubscript/ui";
import { Badge, toast } from "@pubscript/ui";
import type { Entity } from "@pubscript/core";
const TITLES: Record<string, string> = {
@@ -22,6 +22,7 @@ export interface TopbarProps {
onOpenPalette?: () => void;
onOpenExport: () => void;
onOpenImport?: () => void;
onToggleMobileNav?: () => void;
}
function entityName(e: Entity): string {
@@ -29,7 +30,7 @@ function entityName(e: Entity): string {
return (e.body || "Untitled note").slice(0, 48);
}
export function Topbar({ onOpenExport, onOpenImport }: TopbarProps) {
export function Topbar({ onOpenExport, onOpenImport, onToggleMobileNav }: TopbarProps) {
const location = useLocation();
const navigate = useNavigate();
const { user, logout } = useAuth();
@@ -41,6 +42,8 @@ export function Topbar({ onOpenExport, onOpenImport }: TopbarProps) {
const selectChapter = useProjectStore((s) => s.selectChapter);
const entities = useProjectStore((s) => s.entities);
const plotBeats = useProjectStore((s) => s.plotBeats);
const autosave = useProjectStore((s) => s.autosave);
const saveChapterNow = useProjectStore((s) => s.saveChapterNow);
const [searchQuery, setSearchQuery] = useState("");
const [searchFocused, setSearchFocused] = useState(false);
@@ -109,13 +112,64 @@ export function Topbar({ onOpenExport, onOpenImport }: TopbarProps) {
return items.slice(0, 10);
}, [searchQuery, chapters, entities, plotBeats, selectChapter, navigate]);
const handleManualSave = async () => {
await saveChapterNow();
toast.success("Chapter saved");
};
return (
<header className="ps-topbar">
{onToggleMobileNav && (
<button
type="button"
className="ps-icon-btn ps-mobile-nav-btn"
onClick={onToggleMobileNav}
aria-label="Toggle Navigation Menu"
title="Navigation Menu"
>
<svg viewBox="0 0 18 18" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="1.8">
<line x1="2.5" y1="4.5" x2="15.5" y2="4.5" strokeLinecap="round" />
<line x1="2.5" y1="9" x2="15.5" y2="9" strokeLinecap="round" />
<line x1="2.5" y1="13.5" x2="15.5" y2="13.5" strokeLinecap="round" />
</svg>
</button>
)}
<h1 style={{ margin: 0, fontSize: 15, fontWeight: 600, display: "inline-flex", verticalAlign: "middle" }}>
{title}
</h1>
{autoSave && <Badge variant="success">✓ autosave on</Badge>}
{autoSave ? (
<Badge variant="success">✓ autosave on</Badge>
) : (
<div style={{ display: "inline-flex", alignItems: "center", gap: 6 }}>
<button
type="button"
className={`ps-nav-item${autosave.dirty ? " is-active" : ""}`}
onClick={() => void handleManualSave()}
disabled={autosave.saving}
style={{
width: "auto",
padding: "4px 10px",
fontSize: 12.5,
fontWeight: 600,
display: "inline-flex",
alignItems: "center",
gap: 5,
}}
title="Manual Save (Ctrl+S)"
>
<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.6">
<path d="M3 2.5h8.2L13.5 4.8v8.2a.5.5 0 0 1-.5.5H3a.5.5 0 0 1-.5-.5V3a.5.5 0 0 1 .5-.5z" strokeLinejoin="round" />
<path d="M5 2.5v3.5h5V2.5M4.5 13.5V9h7v4.5" strokeLinejoin="round" />
</svg>
<span>{autosave.saving ? "Saving…" : autosave.dirty ? "Save *" : "Save"}</span>
</button>
<Badge variant={autosave.dirty ? "warning" : "neutral"}>
{autosave.dirty ? "unsaved" : "saved"}
</Badge>
</div>
)}
<div style={{ flex: 1 }} />
@@ -180,22 +234,29 @@ export function Topbar({ onOpenExport, onOpenImport }: TopbarProps) {
{onOpenImport && (
<button
type="button"
className="ps-nav-item"
className="ps-icon-btn"
onClick={onOpenImport}
style={{ width: "auto", padding: "6px 10px" }}
aria-label="Import manuscript"
title="Import manuscript (.docx, .odt, .epub, .md, .txt, .html, .rtf, .json)"
>
<span>Import</span>
<svg viewBox="0 0 18 18" width="17" height="17" fill="none" stroke="currentColor" strokeWidth="1.65">
<path d="M9 2.5v8.5M5.8 7.8L9 11l3.2-3.2" strokeLinecap="round" strokeLinejoin="round" />
<path d="M3 11.5v2.2a1.3 1.3 0 0 0 1.3 1.3h9.4a1.3 1.3 0 0 0 1.3-1.3v-2.2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
)}
<button
type="button"
className="ps-nav-item"
className="ps-icon-btn"
onClick={onOpenExport}
style={{ width: "auto", padding: "6px 10px" }}
aria-label="Export manuscript"
title="Export manuscript (.epub, .docx, .odt, .pdf, .html, .md)"
>
<span>Export</span>
<svg viewBox="0 0 18 18" width="17" height="17" fill="none" stroke="currentColor" strokeWidth="1.65">
<path d="M9 11V2.5M5.8 5.7L9 2.5l3.2 3.2" strokeLinecap="round" strokeLinejoin="round" />
<path d="M3 11.5v2.2a1.3 1.3 0 0 0 1.3 1.3h9.4a1.3 1.3 0 0 0 1.3-1.3v-2.2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
<NavLink to="/settings" className="ps-icon-btn" style={{ fontSize: 18 }} aria-label="Settings" title="Settings">
+75 -19
View File
@@ -76,6 +76,7 @@ export function WritingView({ onChapterChange }: WritingViewProps) {
const renameChapter = useProjectStore((s) => s.renameChapter);
const deleteChapter = useProjectStore((s) => s.deleteChapter);
const scheduleSave = useProjectStore((s) => s.scheduleSave);
const saveChapterNow = useProjectStore((s) => s.saveChapterNow);
const flushAutosave = useProjectStore((s) => s.flushAutosave);
const autosave = useProjectStore((s) => s.autosave);
const entities = useProjectStore((s) => s.entities);
@@ -84,6 +85,7 @@ export function WritingView({ onChapterChange }: WritingViewProps) {
const highlightSeq = useProjectStore((s) => s.highlightSeq);
const docExternalRev = useProjectStore((s) => s.docExternalRev);
const autoSave = useSettings((st) => st.autoSave);
const fontFamily = useSettings((st) => st.fontFamily);
const fontSize = useSettings((st) => st.fontSize);
const leading = useSettings((st) => st.leading);
@@ -93,7 +95,10 @@ export function WritingView({ onChapterChange }: WritingViewProps) {
const setSettings = useSettings((st) => st.set);
const [firstLineIndent, setFirstLineIndent] = useState(false);
const [showContextRail, setShowContextRail] = useState(true);
const [showContextRail, setShowContextRail] = useState(false);
const [formattingOpen, setFormattingOpen] = useState(() =>
typeof window !== "undefined" ? window.innerWidth > 768 : true,
);
const [renamingChapter, setRenamingChapter] = useState(false);
const [chapterTitleDraft, setChapterTitleDraft] = useState("");
const [resolvedDomChildIdx, setResolvedDomChildIdx] = useState<number | null>(null);
@@ -253,19 +258,22 @@ export function WritingView({ onChapterChange }: WritingViewProps) {
return clearCustomHighlight;
}, [editor, highlightedText, highlightedLine, highlightSeq, docExternalRev, chapter?.id]);
// Keyboard shortcut: F2 toggles focus mode.
// Keyboard shortcuts: F2 toggles focus mode; Ctrl+S / Cmd+S saves chapter immediately.
useEffect(() => {
const dom = editorDomRef.current;
if (!dom) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "F2") {
e.preventDefault();
setSettings({ focusMode: !focusMode });
} else if ((e.metaKey || e.ctrlKey) && typeof e.key === "string" && e.key.toLowerCase() === "s") {
e.preventDefault();
void saveChapterNow().then(() => {
toast.success("Chapter saved");
});
}
};
dom.addEventListener("keydown", onKey);
return () => dom.removeEventListener("keydown", onKey);
}, [focusMode, setSettings]);
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [focusMode, setSettings, saveChapterNow]);
// Flush pending edits when the user closes or navigates away.
useEffect(() => {
@@ -421,12 +429,41 @@ export function WritingView({ onChapterChange }: WritingViewProps) {
</svg>
</button>
)}
<button
type="button"
className={`ps-tool-icon${!autoSave && autosave.dirty ? " is-active" : ""}`}
title="Save Chapter Now (Ctrl+S)"
aria-label="Save Chapter Now"
onClick={() => {
void saveChapterNow().then(() => {
toast.success("Chapter saved");
});
}}
>
<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M3 2.5h8.2L13.5 4.8v8.2a.5.5 0 0 1-.5.5H3a.5.5 0 0 1-.5-.5V3a.5.5 0 0 1 .5-.5z" strokeLinejoin="round" />
<path d="M5 2.5v3.5h5V2.5M4.5 13.5V9h7v4.5" strokeLinejoin="round" />
</svg>
</button>
<button
type="button"
className={`ps-tool-icon ps-formatting-toggle-btn${formattingOpen ? " is-active" : ""}`}
title={formattingOpen ? "Minimize Formatting Tools" : "Show Formatting Tools"}
aria-label="Toggle Formatting Tools"
aria-expanded={formattingOpen}
onClick={() => setFormattingOpen((o) => !o)}
>
<span style={{ fontSize: 11.5, fontWeight: 700, letterSpacing: "-0.02em" }}>
Aa {formattingOpen ? "▴" : "▾"}
</span>
</button>
</div>
<span className="ps-toolbar-sep" />
<div className={`ps-toolbar-collapsible${formattingOpen ? " is-open" : " is-minimized"}`}>
<span className="ps-toolbar-sep" />
{/* History: Undo / Redo */}
<div className="ps-toolbar-group">
{/* History: Undo / Redo */}
<div className="ps-toolbar-group">
<button
type="button"
className="ps-tool-icon"
@@ -861,17 +898,22 @@ export function WritingView({ onChapterChange }: WritingViewProps) {
<path d="M3 10l2.5-6L8 10M3.8 8.2h3.4M9.5 10.5l2 2 3-3.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
</div>
</div>
<div className="ps-toolbar-group" style={{ marginLeft: "auto" }}>
<button
type="button"
className={`ps-tool-icon${showContextRail ? " is-active" : ""}`}
title="Toggle Story Context Rail"
title="Toggle Story Context Rail (Info)"
aria-label="Toggle Story Context Rail"
aria-pressed={showContextRail}
onClick={() => setShowContextRail((r) => !r)}
>
<svg viewBox="0 0 16 16" width="15" height="15" fill="none" stroke="currentColor" strokeWidth="1.5">
<rect x="2" y="2.5" width="12" height="11" rx="1.5" />
<line x1="10" y1="2.5" x2="10" y2="13.5" />
<circle cx="8" cy="8" r="6" />
<line x1="8" y1="7.2" x2="8" y2="11.2" strokeLinecap="round" />
<circle cx="8" cy="4.8" r="0.85" fill="currentColor" stroke="none" />
</svg>
</button>
</div>
@@ -879,9 +921,16 @@ export function WritingView({ onChapterChange }: WritingViewProps) {
<div
className="ps-autosave-indicator"
data-status={autosave.saving ? "saving" : autosave.dirty ? "dirty" : "saved"}
onClick={() => {
if (!autoSave && autosave.dirty) {
void saveChapterNow().then(() => toast.success("Chapter saved"));
}
}}
style={!autoSave && autosave.dirty ? { cursor: "pointer" } : undefined}
title={!autoSave ? "Click to save manually (Ctrl+S)" : undefined}
>
<span className="ps-autosave-dot" />
<span>{autosaveLabel(autosave)}</span>
<span>{!autoSave && autosave.dirty ? "Unsaved · Click to save" : autosaveLabel(autosave)}</span>
</div>
</div>
@@ -924,11 +973,18 @@ export function WritingView({ onChapterChange }: WritingViewProps) {
</div>
{showContextRail && (
<ContextRail
chapter={chapter}
entities={entities}
onOpenEntity={(id) => navigate(`/characters?entity=${encodeURIComponent(id)}`)}
/>
<>
<div
className="ps-mobile-rail-backdrop"
onClick={() => setShowContextRail(false)}
aria-hidden="true"
/>
<ContextRail
chapter={chapter}
entities={entities}
onOpenEntity={(id) => navigate(`/characters?entity=${encodeURIComponent(id)}`)}
/>
</>
)}
{check.open && (
+185 -118
View File
@@ -113,55 +113,90 @@ function parseSuggestionBlocks(
chapterLines: ChapterLine[],
): { cleanText: string; parsed: ParsedLineSuggestion[] } {
const parsed: ParsedLineSuggestion[] = [];
const blockRegex = /<<<SUGGESTION([\s\S]*?)SUGGESTION>>>/gi;
const norm = (s: string) =>
s.replace(/[\u201C\u201D]/g, '"').replace(/[\u2018\u2019]/g, "'").toLowerCase();
const cleanText = rawText
const addParsed = (rawOrig: string, newText: string, title?: string, summary?: string) => {
const cleanOrig = rawOrig.replace(/^["“'`*]+|["”'`*]+$/g, "").trim();
const cleanNew = newText.replace(/^["“'`*]+|["”'`*]+$/g, "").trim();
if (!cleanOrig || !cleanNew || cleanOrig === cleanNew) return;
if (parsed.some((p) => norm(p.originalText) === norm(cleanOrig))) return;
const matchedLine =
chapterLines.find((l) => norm(l.text).includes(norm(cleanOrig))) ??
chapterLines[0];
const lineNumber = matchedLine?.lineNumber ?? 1;
parsed.push({
lineNumber,
kind: "review",
title: title?.replace(/^\*+|\*+$/g, "").trim() || "Granular Prose Edit",
originalText: cleanOrig,
newText: cleanNew,
summary:
summary?.replace(/^\*+|\*+$/g, "").trim() ||
"Targeted phrase-level revision pegged to manuscript text.",
});
};
// 1. Standard <<<SUGGESTION ... SUGGESTION>>> or <<<SUGGESTION ... >>> blocks
const blockRegex = /<<<SUGGESTION\b([\s\S]*?)(?:SUGGESTION>>>|>>>)/gi;
let cleanText = rawText
.replace(blockRegex, (_match, body: string) => {
const getField = (name: string): string => {
const m = body.match(new RegExp(`^\\s*${name}\\s*:\\s*(.+)$`, "im"));
const m = body.match(new RegExp(`^\\s*[*_]*${name}[*_]*\\s*:\\s*(.+)$`, "im"));
return m?.[1]?.trim() ?? "";
};
const rawOrig = getField("ORIGINAL").replace(/^["“]|["”]$/g, "").trim();
const newText = (getField("REPLACEMENT") || getField("NEW"))
.replace(/^["“]|["”]$/g, "")
.trim();
if (!rawOrig || !newText) return "";
const matchedLine =
chapterLines.find((l) => norm(l.text).includes(norm(rawOrig))) ??
chapterLines[0];
const lineNumber = matchedLine?.lineNumber ?? 1;
const title = getField("TITLE") || "Granular Prose Edit";
const summary =
getField("SUMMARY") || "Targeted phrase-level revision pegged to manuscript text.";
parsed.push({
lineNumber,
kind: "review",
title,
originalText: rawOrig,
newText,
summary,
});
const rawOrig = getField("ORIGINAL");
const newText = getField("REPLACEMENT") || getField("NEW") || getField("SUGGESTED");
const title = getField("TITLE");
const summary = getField("SUMMARY") || getField("RATIONALE") || getField("REASON");
addParsed(rawOrig, newText, title, summary);
return "";
})
.trim();
// 2. Fallback parser for models that emit ORIGINAL: ... / REPLACEMENT: ... without <<<SUGGESTION delimiters
if (parsed.length === 0) {
const looseRegex =
/(?:^|\n)\s*(?:[-*•]|\d+\.)?\s*[*_]*(?:TITLE\s*:\s*([^\n]+)\n\s*)?[*_]*ORIGINAL[*_]*\s*:\s*["“]?([^\n"”]+)["”]?\s*\n\s*[*_]*(?:REPLACEMENT|NEW|SUGGESTED)[*_]*\s*:\s*["“]?([^\n"”]+)["”]?(?:\s*\n\s*[*_]*(?:SUMMARY|RATIONALE|REASON)[*_]*\s*:\s*([^\n]+))?/gi;
let m: RegExpExecArray | null;
while ((m = looseRegex.exec(rawText)) !== null) {
addParsed(m[2] ?? "", m[3] ?? "", m[1], m[4]);
}
}
// 3. Fallback parser for models that emit `"original phrase" -> "replacement phrase"`
if (parsed.length === 0) {
const arrowRegex = /["“]([^"”\n]{10,160})["”]\s*(?:->|→|=>)\s*["“]([^"”\n]{4,200})["”]/g;
let m: RegExpExecArray | null;
while ((m = arrowRegex.exec(rawText)) !== null) {
const candidateOrig = (m[1] ?? "").trim();
if (chapterLines.some((l) => norm(l.text).includes(norm(candidateOrig)))) {
addParsed(candidateOrig, m[2] ?? "", "Inline Phrase Revision");
}
}
}
if (!cleanText && parsed.length > 0) {
cleanText = `Generated ${parsed.length} inline edit suggestion${parsed.length === 1 ? "" : "s"} in the Suggestions tab.`;
}
return { cleanText, parsed };
}
/**
* Extract granular phrase, clause, and single-sentence edit candidates from
* the chapter's non-empty paragraphs so suggestions are surgical and never
* overwrite entire multi-sentence paragraphs.
* the chapter's non-empty paragraphs so offline/test suggestions are surgical,
* prompt-sensitive, and never repeat static clichés.
*/
function buildGranularSuggestionsFromLines(
nonEmptyLines: ChapterLine[],
role: string,
userPrompt = "",
): ParsedLineSuggestion[] {
const results: ParsedLineSuggestion[] = [];
const promptLower = userPrompt.toLowerCase();
const rules: Array<{
pattern: RegExp;
@@ -172,7 +207,10 @@ function buildGranularSuggestionsFromLines(
{
pattern: /left the mainland before dawn,\s*its iron hull shuddering against the black chop/i,
title: "Tighten Opening Clause",
replace: () => "cleared the mainland before dawn, iron hull shuddering through the black chop",
replace: () =>
promptLower.includes("pacing") || role === "publisher"
? "cleared the mainland before dawn, iron hull driving hard into the black chop"
: "cleared the mainland before dawn, iron hull shuddering through the black chop",
summary: "Replace generic 'left' with active 'cleared' and tighten the participial phrase.",
},
{
@@ -227,56 +265,46 @@ function buildGranularSuggestionsFromLines(
}
}
if (results.length < 4) {
for (const line of nonEmptyLines) {
for (const line of nonEmptyLines) {
if (results.length >= 5) break;
const sentences = line.text
.split(/(?<=[.!?])\s+/)
.map((s) => s.trim())
.filter((s) => s.length >= 12);
for (const sentence of sentences) {
if (results.length >= 5) break;
const sentences = line.text
.split(/(?<=[.!?])\s+/)
.map((s) => s.trim())
.filter((s) => s.length >= 12);
if (results.some((r) => r.originalText.includes(sentence) || sentence.includes(r.originalText))) {
continue;
}
for (const sentence of sentences) {
if (results.length >= 5) break;
if (results.some((r) => r.originalText.includes(sentence) || sentence.includes(r.originalText))) {
continue;
}
const clauseMatch = sentence.match(/^([^,]{18,85}),\s+(.{12,})$/);
const targetSpan = clauseMatch ? clauseMatch[1]!.trim() : sentence;
const clauseMatch = sentence.match(/^([^,]{18,85}),\s+(.{12,})$/);
const targetSpan = clauseMatch ? clauseMatch[1]!.trim() : sentence;
let revisedSpan = targetSpan
.replace(/\bseemed to\s+/gi, "")
.replace(/\bstarted to\s+/gi, "")
.replace(/\bbegan to\s+/gi, "")
.replace(/\bwatched the\b/gi, "saw the")
.replace(/\bfelt the\b/gi, "knew the")
.replace(/\bvery\s+/gi, "")
.trim();
if (revisedSpan === targetSpan) {
if (targetSpan.endsWith(".")) {
revisedSpan =
role === "reader"
? `${targetSpan.slice(0, -1)}—and the silence that followed held its breath.`
: role === "publisher"
? `${targetSpan.slice(0, -1)}, raising the stakes of what waited ahead.`
: `${targetSpan.slice(0, -1)}—sharp and unmistakable.`;
} else {
revisedSpan = `${targetSpan}—clear and unsparing`;
}
}
const revisedSpan = targetSpan
.replace(/\bseemed to\s+/gi, "")
.replace(/\bstarted to\s+/gi, "")
.replace(/\bbegan to\s+/gi, "")
.replace(/\bwatched the\b/gi, "saw the")
.replace(/\bfelt the\b/gi, "knew the")
.replace(/\bvery\s+/gi, "")
.replace(/\breally\s+/gi, "")
.replace(/\bjust\s+/gi, "")
.replace(/\bsuddenly\s+/gi, "")
.trim();
if (revisedSpan !== targetSpan && revisedSpan.length > 0) {
results.push({
lineNumber: line.lineNumber,
kind: "review",
title:
role === "editor"
? "Tighten Clause & Cadence"
? "Tighten Filter & Qualifier Words"
: role === "reader"
? "Deepen Reader Immersion"
? "Deepen Reader Immediacy"
: role === "publisher"
? "Reinforce Scene Hook"
: "Enhance Sensory Beat",
? "Accelerate Narrative Pacing"
: "Refine Clause Rhythm",
originalText: targetSpan,
newText: revisedSpan,
summary: `Surgical edit pegged to "${targetSpan.slice(0, 42)}${targetSpan.length > 42 ? "…" : ""}" (preserves surrounding sentences).`,
@@ -308,7 +336,7 @@ function buildLineBasedFallback(opts: {
} = opts;
const nonEmptyLines = chapterLines.filter((l) => l.text.length > 0);
const suggestions = buildGranularSuggestionsFromLines(nonEmptyLines, agentRole);
const suggestions = buildGranularSuggestionsFromLines(nonEmptyLines, agentRole, userPrompt);
const cast = characterNames.length > 0 ? characterNames.slice(0, 4).join(", ") : "your cast";
const thinkingText = `Scanning non-empty paragraphs in "${chapterTitle}" → isolating individual clauses & phrases → cross-checking entities (${cast}) → building ${suggestions.length} text-anchored edits.`;
@@ -770,63 +798,96 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
);
try {
const controller = new AgentController({
id: activeProvider.id,
kind: activeProvider.kind,
baseUrl: activeProvider.baseUrl,
...(activeProvider.apiKey ? { apiKey: activeProvider.apiKey } : {}),
model: activeModel,
temperature: activeAgent.temperature,
maxTokens: activeAgent.maxTokens,
contextWindow: activeAgent.contextWindow,
...(activeAgent.thinkingEnabled
? {
thinking: {
enabled: true,
effort: activeAgent.thinkingEffort,
},
}
: {}),
});
const historyMsgs = (await aiStore.listMessages(sessionId)).map((m) => ({
role: m.role,
content: m.content,
}));
const { result } = await controller.collect(
[
{
role: "system",
content: `${systemPromptWithLines}\n\n--- LINE-NUMBERED MANUSCRIPT CONTEXT ---\n${contextText}`,
},
...historyMsgs,
],
{
const messagesWithContext = historyMsgs.map((m, idx) =>
idx === historyMsgs.length - 1 && m.role === "user"
? {
role: "user" as const,
content: `--- MANUSCRIPT CONTEXT ---\n${contextText}\n\n--- AUTHOR PROMPT ---\n${m.content}`,
}
: m,
);
let rawModelText = "";
let rawReasoning: string | undefined;
try {
const controller = new AgentController({
id: activeProvider.id,
kind: activeProvider.kind,
baseUrl: activeProvider.baseUrl,
...(activeProvider.apiKey ? { apiKey: activeProvider.apiKey } : {}),
model: activeModel,
temperature: activeAgent.temperature,
maxTokens: activeAgent.maxTokens,
contextText,
},
);
if (result.reasoning) {
setThinkingTrace(result.reasoning);
}
const extracted = parseSuggestionBlocks(result.text.trim(), chapterLines);
assistantReply = extracted.cleanText;
parsedSuggestions = extracted.parsed;
if (parsedSuggestions.length === 0 && chapterLines.length > 0) {
const fallback = buildLineBasedFallback({
agentName: activeAgent.name,
agentRole: activeAgent.role,
userPrompt: promptText,
projectTitle: project?.title ?? "Untitled Project",
chapterTitle: activeChapter?.title ?? "Chapter 1",
chapterLines,
characterNames,
contextWindow: Math.max(activeAgent.contextWindow || 32768, 32768),
...(activeAgent.thinkingEnabled
? {
thinking: {
enabled: true,
effort: activeAgent.thinkingEffort,
},
}
: {}),
});
parsedSuggestions = fallback.suggestions;
const { result } = await controller.collect(
[
{
role: "system",
content: systemPromptWithLines,
},
...messagesWithContext,
],
{
temperature: activeAgent.temperature,
maxTokens: activeAgent.maxTokens,
},
);
rawModelText = result.text.trim();
rawReasoning = result.reasoning;
} catch {
// Fallback to Host Server AI Proxy (/api/ai/chat) so providers running on
// the Host Machine (e.g. Ollama/llama.cpp on localhost) work from remote
// browsers and mobile devices without CORS or localhost errors.
const proxyRes = await fetch("/api/ai/chat", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
kind: activeProvider.kind,
baseUrl: activeProvider.baseUrl,
apiKey: activeProvider.apiKey,
model: activeModel,
messages: [
{ role: "system", content: systemPromptWithLines },
...messagesWithContext,
],
temperature: activeAgent.temperature,
maxTokens: activeAgent.maxTokens,
}),
});
if (!proxyRes.ok) {
throw new Error(`AI proxy returned ${proxyRes.status}`);
}
const proxyJson = (await proxyRes.json()) as {
text?: string;
reasoning?: string;
};
rawModelText = (proxyJson.text ?? "").trim();
rawReasoning = proxyJson.reasoning;
}
if (rawReasoning) {
setThinkingTrace(rawReasoning);
}
const extracted = parseSuggestionBlocks(rawModelText, chapterLines);
assistantReply = extracted.cleanText || rawModelText;
parsedSuggestions = extracted.parsed;
} catch {
const fallback = buildLineBasedFallback({
agentName: activeAgent.name,
@@ -839,12 +900,18 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
});
setThinkingTrace(fallback.thinkingText);
setThinkingStage("Formulating line-numbered suggestions…");
// Ensure a visible thinking phase so the user sees the model working through the prompt
await new Promise((r) => setTimeout(r, 650));
await new Promise((r) => setTimeout(r, 400));
assistantReply = fallback.replyText;
parsedSuggestions = fallback.suggestions;
}
// Clear previous pending suggestions in this session before adding fresh ones
// so suggestions always refresh cleanly on every new prompt and never duplicate.
if (typeof aiStore.clearPendingSuggestions === "function") {
await aiStore.clearPendingSuggestions(sessionId);
}
setFocusedIndex(0);
for (const item of parsedSuggestions) {
await aiStore.addSuggestion(sessionId, {
sessionId,
+64 -2
View File
@@ -132,6 +132,8 @@ interface ProjectStore {
deleteChapter: (id: string) => Promise<void>;
/** Queue a debounced save of a chapter document. */
scheduleSave: (ref: DocumentRef) => void;
/** Manually save the active (or specified) chapter immediately (used when autosave is off or on Ctrl+S). */
saveChapterNow: (chapterId?: string) => Promise<void>;
/** Flush any pending save immediately (beforeunload / chapter switch). */
flushAutosave: () => void;
/** Highlight a 1-based line number on the writing surface (or null to clear). */
@@ -831,6 +833,7 @@ export const useProjectStore = create<ProjectStore>((set, get) => {
scheduleSave: (ref) => {
lastScheduledChapterId = ref.id;
const autoSaveEnabled = useSettings.getState().autoSave;
// Keep the in-memory chapter doc updated so switching tabs or counting words stays live.
set((state) => {
const nextChapters = state.chapters.map((c) =>
@@ -846,13 +849,72 @@ export const useProjectStore = create<ProjectStore>((set, get) => {
},
}
: state.projectStats;
return { chapters: nextChapters, projectStats: nextStats };
return {
chapters: nextChapters,
projectStats: nextStats,
...(!autoSaveEnabled
? {
autosave: {
...state.autosave,
dirty: true,
saving: false,
status: "unsaved" as const,
},
}
: {}),
};
});
if (autosave) {
if (autoSaveEnabled && autosave) {
autosave.save(ref);
}
},
saveChapterNow: async (chapterId) => {
const st = get();
const adapter = st.adapter ?? storageLocal;
if (!st.project) return;
const targetId = chapterId ?? st.activeChapterId;
const targetCh = st.chapters.find((c) => c.id === targetId) ?? st.chapters[0];
if (!targetCh) return;
set({
autosave: {
...st.autosave,
dirty: true,
saving: true,
status: "saving",
},
});
if (autosave) {
await autosave.flush();
}
await adapter.saveDocument({
id: targetCh.id,
projectId: st.project.id,
bookId: targetCh.bookId,
title: targetCh.title,
doc: targetCh.doc,
rev: targetCh.rev,
});
const { nextMap, nextRev } = recordChapterRevision(
st.project.id,
st.revisionsByChapter,
targetCh,
"Manual save",
);
set({
revisionsByChapter: nextMap,
chapters: st.chapters.map((c) => (c.id === targetCh.id ? { ...c, rev: nextRev } : c)),
autosave: {
dirty: false,
saving: false,
lastSavedAt: new Date().toISOString(),
status: "saved",
},
});
},
flushAutosave: () => {
if (autosave) {
void autosave.flush();
+44
View File
@@ -48,6 +48,17 @@
gap: 2px;
}
.ps-toolbar-collapsible.is-open {
display: contents;
}
.ps-toolbar-collapsible.is-minimized {
display: none !important;
}
.ps-formatting-toggle-btn {
width: auto !important;
padding: 0 8px !important;
}
.ps-toolbar-sep {
width: 1px;
height: 20px;
@@ -317,3 +328,36 @@
text-decoration-thickness: 1.5px;
text-underline-offset: 3px;
}
/* Mobile-fixed writing pane for standard phone screens (Issue #1) */
@media (max-width: 768px) {
.ps-writing-slot,
.ps-writing,
.ps-writing-body {
width: 100vw;
max-width: 100vw;
overflow-x: hidden;
}
.ps-editor-scroll {
padding: 14px 16px 68px !important;
width: 100% !important;
max-width: 100vw !important;
overflow-x: hidden !important;
box-sizing: border-box;
}
.ps-editor-writing {
width: 100% !important;
max-width: 100% !important;
margin: 0 !important;
}
.ps-editor .ProseMirror {
word-break: break-word;
overflow-wrap: anywhere;
}
.ps-writing-status {
padding: 6px 12px 50px;
font-size: 11px;
flex-wrap: wrap;
gap: 8px;
}
}
+16 -1
View File
@@ -112,6 +112,11 @@ export class AIStore {
if (this.loaded) return;
const rows = await this.persistence.list(this.projectId);
for (const row of rows) {
const cleanedSuggestions = (row.suggestions ?? []).filter(
(sg) =>
!String(sg.newText ?? sg.text ?? "").includes("—clear and unsparing") &&
!String(sg.newText ?? sg.text ?? "").includes("—sharp and unmistakable"),
);
this.sessions.set(row.id, {
id: row.id,
projectId: row.projectId,
@@ -122,7 +127,7 @@ export class AIStore {
providerId: row.providerId,
model: row.model,
messages: row.messages ?? [],
suggestions: row.suggestions ?? [],
suggestions: cleanedSuggestions,
notes: row.notes ?? [],
createdAt: row.createdAt,
updatedAt: row.updatedAt,
@@ -328,6 +333,16 @@ export class AIStore {
});
}
/** Clear all pending suggestions in a session before populating fresh suggestions from a new prompt. */
async clearPendingSuggestions(sessionId: string): Promise<AISession> {
return this.withTransaction(async () => {
const session = this.require(sessionId);
session.suggestions = session.suggestions.filter((s) => s.status !== "pending");
session.updatedAt = nowIso();
return this.clone(session);
});
}
/** Update a suggestion's line number or text anchor when editor blocks shift. */
async updateSuggestion(
sessionId: string,
+135
View File
@@ -1392,3 +1392,138 @@ body {
/* ---- Focus visible ---- */
:focus-visible { outline: 2px solid var(--ps-accent); outline-offset: 2px; }
/* ---- Mobile Helpers (Hidden on Desktop by Default) ---- */
.ps-mobile-nav-btn,
.ps-mobile-nav-backdrop,
.ps-mobile-rail-backdrop,
.ps-mobile-ai-backdrop,
.ps-mobile-ai-bar {
display: none;
}
/* ---- Mobile Friendly Layout (Issue #1) ---- */
@media (max-width: 768px) {
.ps-app,
.ps-app.is-nav-collapsed {
grid-template-columns: 1fr !important;
grid-template-rows: var(--ps-topbar-h) 1fr !important;
grid-template-areas:
"topbar"
"main" !important;
width: 100vw;
max-width: 100vw;
height: 100dvh;
overflow: hidden;
}
.ps-mobile-nav-btn {
display: inline-flex;
}
/* 1. Side panels hide completely when closed and dismiss when tapping outside */
.ps-sidebar {
display: none !important;
}
.ps-app.is-mobile-nav-open .ps-sidebar {
display: flex !important;
position: fixed;
top: 0;
bottom: 0;
left: 0;
width: min(270px, 82vw) !important;
z-index: 210;
box-shadow: 12px 0 32px rgba(0, 0, 0, 0.5);
}
.ps-app.is-mobile-nav-open .ps-mobile-nav-backdrop {
display: block;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.52);
z-index: 205;
}
/* Story Context Rail overlays on mobile and hides completely when tapping outside */
.ps-context-rail {
position: fixed !important;
top: var(--ps-topbar-h);
bottom: 44px;
right: 0;
width: min(300px, 86vw) !important;
z-index: 210;
box-shadow: -10px 0 28px rgba(0, 0, 0, 0.45);
}
.ps-mobile-rail-backdrop {
display: block;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.48);
z-index: 205;
}
/* 4. AI features docked at bottom of phone screen */
.ps-ai-sidebar {
position: fixed !important;
left: 0 !important;
right: 0 !important;
bottom: 0 !important;
top: auto !important;
width: 100vw !important;
height: 74dvh !important;
border-left: none !important;
border-top: 2px solid var(--ps-accent) !important;
border-radius: 16px 16px 0 0;
z-index: 220;
box-shadow: 0 -12px 36px rgba(0, 0, 0, 0.55);
}
.ps-ai-resize-handle {
display: none !important;
}
.ps-mobile-ai-backdrop {
display: block;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.48);
z-index: 215;
}
.ps-mobile-ai-bar {
display: flex;
position: fixed;
left: 0;
right: 0;
bottom: 0;
height: 44px;
padding: 5px 12px;
background: var(--ps-surface);
border-top: 1px solid var(--ps-border);
z-index: 150;
align-items: center;
}
.ps-mobile-ai-bar-btn {
width: 100%;
height: 34px;
border-radius: 999px;
border: 1px solid color-mix(in srgb, var(--ps-accent) 55%, var(--ps-border));
background: color-mix(in srgb, var(--ps-accent) 16%, var(--ps-surface-2));
color: var(--ps-text);
font-family: var(--ps-font-ui);
font-size: 12.5px;
font-weight: 600;
padding: 0 14px;
display: flex;
align-items: center;
justify-content: space-between;
cursor: pointer;
}
.ps-topbar {
padding: 0 10px;
gap: 6px;
overflow-x: auto;
}
.ps-topbar-search {
max-width: 150px;
min-width: 100px;
}
}
+6
View File
@@ -198,10 +198,16 @@ export function registerAuthRoutes(app: FastifyInstance): void {
const count = (
app.db.prepare("SELECT COUNT(*) AS n FROM users").get() as { n: number }
).n;
const ua = String(req.headers["user-agent"] ?? "");
const chMobile = String(req.headers["sec-ch-ua-mobile"] ?? "");
const isMobileClient =
chMobile === "?1" ||
/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile/i.test(ua);
return {
initialized: count > 0,
authenticated: Boolean(req.user),
user: req.user ?? null,
isMobileClient,
};
});
+121
View File
@@ -212,6 +212,18 @@ export async function buildApp() {
reply.header("Referrer-Policy", "strict-origin-when-cross-origin");
reply.header("Permissions-Policy", "camera=(), microphone=(), geolocation=()");
const ua = String(req.headers["user-agent"] ?? "");
const chMobile = String(req.headers["sec-ch-ua-mobile"] ?? "");
const isMobile =
chMobile === "?1" ||
/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile/i.test(ua);
reply.header("X-PubScript-Client", isMobile ? "mobile" : "desktop");
reply.setCookie("ps_client_device", isMobile ? "mobile" : "desktop", {
path: "/",
httpOnly: false,
sameSite: "lax",
});
const cfg = loadPersistedServerSysConfig();
const forwardedHost = (req.headers["x-forwarded-host"] as string | undefined) || req.headers.host;
if (!isRequestHostAllowed(forwardedHost, cfg.allowedHosts)) {
@@ -336,6 +348,11 @@ export async function buildApp() {
app.get("/api/system/server-config", async (req) => {
requireAuth(req);
const persisted = loadPersistedServerSysConfig();
const isMobileClient =
String(req.headers["sec-ch-ua-mobile"] ?? "") === "?1" ||
/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile/i.test(
String(req.headers["user-agent"] ?? ""),
);
return {
ok: true,
enabled: persisted.enabled,
@@ -350,9 +367,113 @@ export async function buildApp() {
projectDirectory: persisted.projectDirectory,
autostartPath: autostartDesktopPath,
systemdServicePath,
isMobileClient,
};
});
// Host-side AI proxy so remote/mobile browsers and local sessions can talk to
// Ollama / llama.cpp / LM Studio / OpenAI-compatible / Anthropic / Google
// endpoints running on or reachable from the Host Machine without CORS or
// remote-localhost errors.
app.post("/api/ai/chat", async (req, reply) => {
requireAuth(req);
const body = (req.body ?? {}) as {
kind?: string;
baseUrl?: string;
apiKey?: string;
model?: string;
messages?: Array<{ role: string; content: string }>;
temperature?: number;
maxTokens?: number;
};
const baseUrl = (body.baseUrl ?? "http://localhost:11434/v1").replace(/\/+$/, "");
const model = body.model || "llama3.2";
const messages = body.messages ?? [];
const temperature = body.temperature ?? 0.7;
const maxTokens = body.maxTokens ?? 2048;
try {
if (body.kind === "anthropic") {
const systemMsg = messages
.filter((m) => m.role === "system")
.map((m) => m.content)
.join("\n\n");
const chatMsgs = messages
.filter((m) => m.role !== "system")
.map((m) => ({
role: m.role === "assistant" ? "assistant" : "user",
content: m.content,
}));
const res = await fetch(`${baseUrl}/messages`, {
method: "POST",
headers: {
"content-type": "application/json",
...(body.apiKey ? { "x-api-key": body.apiKey } : {}),
"anthropic-version": "2023-06-01",
},
body: JSON.stringify({
model,
max_tokens: maxTokens,
temperature,
...(systemMsg ? { system: systemMsg } : {}),
messages: chatMsgs,
}),
});
if (!res.ok) {
const errText = await res.text().catch(() => "");
return reply.code(res.status).send({ error: `Provider HTTP ${res.status}: ${errText}` });
}
const json = (await res.json()) as {
content?: Array<{ type?: string; text?: string; thinking?: string }>;
};
const text = (json.content ?? [])
.filter((c) => c.type === "text" || (!c.type && c.text))
.map((c) => c.text ?? "")
.join("");
const reasoning = (json.content ?? [])
.filter((c) => c.type === "thinking")
.map((c) => c.thinking ?? "")
.join("");
return { ok: true, text, ...(reasoning ? { reasoning } : {}) };
}
const headers: Record<string, string> = {
"content-type": "application/json",
};
if (body.apiKey) {
headers.authorization = `Bearer ${body.apiKey}`;
}
const res = await fetch(`${baseUrl}/chat/completions`, {
method: "POST",
headers,
body: JSON.stringify({
model,
messages,
stream: false,
temperature,
max_tokens: maxTokens,
}),
});
if (!res.ok) {
const errText = await res.text().catch(() => "");
return reply.code(res.status).send({ error: `Provider HTTP ${res.status}: ${errText}` });
}
const json = (await res.json()) as {
choices?: Array<{
message?: { content?: string; reasoning_content?: string; reasoning?: string };
}>;
};
const msg = json.choices?.[0]?.message;
const text = msg?.content ?? "";
const reasoning = msg?.reasoning_content ?? msg?.reasoning;
return { ok: true, text, ...(reasoning ? { reasoning } : {}) };
} catch (err) {
return reply.code(502).send({
error: err instanceof Error ? err.message : "Failed to reach AI provider from host",
});
}
});
app.post("/api/system/server-config", async (req, reply) => {
requireAdmin(req);
const body = (req.body ?? {}) as Partial<PersistedServerSysConfig>;
+9
View File
@@ -117,8 +117,17 @@ describe("admin endpoints", () => {
initialized: false,
authenticated: false,
user: null,
isMobileClient: false,
});
const mobileStatus = await app.inject({
method: "GET",
url: "/api/auth/status",
headers: { "user-agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) Mobile/15E148" },
});
expect(mobileStatus.headers["x-pubscript-client"]).toBe("mobile");
expect((mobileStatus.json() as { isMobileClient: boolean }).isMobileClient).toBe(true);
const setupRes = await app.inject({
method: "POST",
url: "/api/auth/setup",