From b01073eac0872fdd361589340271b55cc3e76f49 Mon Sep 17 00:00:00 2001 From: Schwitzz Date: Mon, 2 Nov 2026 14:00:48 -0600 Subject: [PATCH] =?UTF-8?q?fix:=20resolve=20Gitea=20issues=20#1=E2=80=93#5?= =?UTF-8?q?=20(mobile=20layout,=20suggestion=20refresh,=20import/export=20?= =?UTF-8?q?icons,=20context=20rail=20default/icon,=20manual=20save)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #1 Closes #2 Closes #3 Closes #4 Closes #5 --- apps/web/src/App.tsx | 77 +++++- apps/web/src/components/Topbar.tsx | 79 +++++- apps/web/src/components/WritingView.tsx | 94 ++++++-- apps/web/src/features/ai/AISidebar.tsx | 303 +++++++++++++++--------- apps/web/src/store/project-store.ts | 66 +++++- apps/web/src/styles/writing.css | 44 ++++ packages/ai/src/ai-session.ts | 17 +- packages/ui/src/AppShell.css | 135 +++++++++++ server/src/auth.ts | 6 + server/src/index.ts | 121 ++++++++++ server/src/server.test.ts | 9 + 11 files changed, 799 insertions(+), 152 deletions(-) diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 928a780..c4df879 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -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(() => { + 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 ( -
+
+ {mobileNavOpen && ( +
setMobileNavOpen(false)} + aria-hidden="true" + /> + )} setPaletteOpen(true)} onOpenExport={() => setExportOpen(true)} onOpenImport={() => setImportOpen(true)} + onToggleMobileNav={() => setMobileNavOpen((o) => !o)} />
@@ -72,9 +120,32 @@ export default function App() { } />
- {aiSidebarOpen && } + {aiSidebarOpen && ( + <> +
setSettings({ aiSidebarOpen: false })} + aria-hidden="true" + /> + + + )}
+ {/* Dedicated bottom-of-screen AI home bar for mobile devices */} + {isMobile && !aiSidebarOpen && ( +
+ +
+ )} + setPaletteOpen(false)} nav={NAV} /> setImportOpen(false)} /> setExportOpen(false)} /> diff --git a/apps/web/src/components/Topbar.tsx b/apps/web/src/components/Topbar.tsx index 01ecd79..eb8da72 100644 --- a/apps/web/src/components/Topbar.tsx +++ b/apps/web/src/components/Topbar.tsx @@ -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 = { @@ -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 (
+ {onToggleMobileNav && ( + + )} +

{title}

- {autoSave && ✓ autosave on} + {autoSave ? ( + ✓ autosave on + ) : ( +
+ + + {autosave.dirty ? "unsaved" : "saved"} + +
+ )}
@@ -180,22 +234,29 @@ export function Topbar({ onOpenExport, onOpenImport }: TopbarProps) { {onOpenImport && ( )} diff --git a/apps/web/src/components/WritingView.tsx b/apps/web/src/components/WritingView.tsx index 5f13ff3..763f064 100644 --- a/apps/web/src/components/WritingView.tsx +++ b/apps/web/src/components/WritingView.tsx @@ -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(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) { )} + +
- +
+ - {/* History: Undo / Redo */} -
+ {/* History: Undo / Redo */} +
+
+
+
@@ -879,9 +921,16 @@ export function WritingView({ onChapterChange }: WritingViewProps) {
{ + 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} > - {autosaveLabel(autosave)} + {!autoSave && autosave.dirty ? "Unsaved · Click to save" : autosaveLabel(autosave)}
@@ -924,11 +973,18 @@ export function WritingView({ onChapterChange }: WritingViewProps) {
{showContextRail && ( - navigate(`/characters?entity=${encodeURIComponent(id)}`)} - /> + <> +
setShowContextRail(false)} + aria-hidden="true" + /> + navigate(`/characters?entity=${encodeURIComponent(id)}`)} + /> + )} {check.open && ( diff --git a/apps/web/src/features/ai/AISidebar.tsx b/apps/web/src/features/ai/AISidebar.tsx index f395e38..44c1bb8 100644 --- a/apps/web/src/features/ai/AISidebar.tsx +++ b/apps/web/src/features/ai/AISidebar.tsx @@ -113,55 +113,90 @@ function parseSuggestionBlocks( chapterLines: ChapterLine[], ): { cleanText: string; parsed: ParsedLineSuggestion[] } { const parsed: ParsedLineSuggestion[] = []; - const blockRegex = /<<>>/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 <<>> or <<>> blocks + const blockRegex = /<<>>|>>>)/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 << "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, diff --git a/apps/web/src/store/project-store.ts b/apps/web/src/store/project-store.ts index a2fd2ed..57d3bfc 100644 --- a/apps/web/src/store/project-store.ts +++ b/apps/web/src/store/project-store.ts @@ -132,6 +132,8 @@ interface ProjectStore { deleteChapter: (id: string) => Promise; /** 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; /** 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((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((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(); diff --git a/apps/web/src/styles/writing.css b/apps/web/src/styles/writing.css index 04e8730..229d000 100644 --- a/apps/web/src/styles/writing.css +++ b/apps/web/src/styles/writing.css @@ -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; + } +} diff --git a/packages/ai/src/ai-session.ts b/packages/ai/src/ai-session.ts index 0141939..3e42ec5 100644 --- a/packages/ai/src/ai-session.ts +++ b/packages/ai/src/ai-session.ts @@ -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 { + 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, diff --git a/packages/ui/src/AppShell.css b/packages/ui/src/AppShell.css index b8ebdff..733ec27 100644 --- a/packages/ui/src/AppShell.css +++ b/packages/ui/src/AppShell.css @@ -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; + } +} diff --git a/server/src/auth.ts b/server/src/auth.ts index b1381b6..646e2b1 100644 --- a/server/src/auth.ts +++ b/server/src/auth.ts @@ -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, }; }); diff --git a/server/src/index.ts b/server/src/index.ts index 3698ec5..06384f8 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -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 = { + "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; diff --git a/server/src/server.test.ts b/server/src/server.test.ts index 01223e2..4f1c334 100644 --- a/server/src/server.test.ts +++ b/server/src/server.test.ts @@ -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",