From 271d1e2c79110869b3162c768cb4459333d5ca6c Mon Sep 17 00:00:00 2001 From: Schwitzz Date: Fri, 30 Oct 2026 11:53:32 -0500 Subject: [PATCH] feat: project dropdown in sidebar, resizable AI sidebar with snap, ctx & t/s telemetry, live line-number re-anchoring, and thinking indicator --- apps/web/src/App.tsx | 1 - apps/web/src/components/Sidebar.tsx | 110 ++++--- apps/web/src/features/ai/AISidebar.tsx | 404 +++++++++++++++++++++++-- apps/web/src/store/settings.ts | 2 + packages/ai/src/ai-session.ts | 19 ++ packages/ui/src/AppShell.css | 159 +++++++++- 6 files changed, 617 insertions(+), 78 deletions(-) diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 3dadd1d..b21137d 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -21,7 +21,6 @@ const NAV = [ { to: "/plot", label: "Plot", icon: "🗺", end: false }, { to: "/timeline", label: "Timeline", icon: "📅", end: false }, { to: "/whiteboard", label: "Whiteboard", icon: "🧩", end: false }, - { to: "/ai", label: "AI", icon: "✨", end: false }, { to: "/projects", label: "Projects", icon: "📁", end: false }, { to: "/settings", label: "Settings", icon: "⚙", end: false }, { to: "/admin", label: "Admin", icon: "🛠", end: false }, diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 85317de..41b1dea 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1,4 +1,5 @@ import { NavLink, useNavigate } from "react-router-dom"; +import { toast } from "@pubscript/ui"; import { useProjectStore } from "../store/project-store.js"; import { useSettings } from "../store/settings.js"; @@ -39,15 +40,25 @@ function totalWords(chapters: ChapterDoc[]): number { export function Sidebar({ nav }: SidebarProps) { const navigate = useNavigate(); + const projects = useProjectStore((s) => s.projects); const project = useProjectStore((s) => s.project); + const openProject = useProjectStore((s) => s.openProject); const chapters = useProjectStore((s) => s.chapters); const navCollapsed = useSettings((s) => s.navCollapsed); - const aiSidebarOpen = useSettings((s) => s.aiSidebarOpen); const setSettings = useSettings((s) => s.set); const title = project?.title ?? "Untitled project"; const wordCount = totalWords(chapters); + const handleSelectProject = async (id: string) => { + if (!id || id === project?.id) return; + await openProject(id); + const next = useProjectStore.getState().project; + if (next) { + toast.info(`Switched to "${next.title}"`); + } + }; + return ( ); diff --git a/apps/web/src/features/ai/AISidebar.tsx b/apps/web/src/features/ai/AISidebar.tsx index 2450e72..8dfe5bf 100644 --- a/apps/web/src/features/ai/AISidebar.tsx +++ b/apps/web/src/features/ai/AISidebar.tsx @@ -1,13 +1,14 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useNavigate } from "react-router-dom"; import { AIStore, AgentController, createIndexedDBPersistence } from "@pubscript/ai"; -import type { - AIMessage, - AINote, - AISession, - AISuggestion, - NoteKind, - SuggestionKind, +import { + countTokens, + type AIMessage, + type AINote, + type AISession, + type AISuggestion, + type NoteKind, + type SuggestionKind, } from "@pubscript/core"; import { Badge, Button, toast } from "@pubscript/ui"; import { useProjectStore } from "../../store/project-store.js"; @@ -15,6 +16,11 @@ import { useSettings, LINE_SUGGESTION_PROMPT_RULES } from "../../store/settings. type SidebarTab = "chat" | "suggestions" | "notes"; +export const DEFAULT_AI_SIDEBAR_WIDTH = 370; +const MIN_AI_SIDEBAR_WIDTH = 280; +const MAX_AI_SIDEBAR_WIDTH = 760; +const SNAP_THRESHOLD_PX = 18; + const NOTE_KINDS: { value: NoteKind; label: string }[] = [ { value: "character", label: "Character" }, { value: "world", label: "World" }, @@ -50,6 +56,105 @@ function extractChapterLines( }); } +/** Word-level similarity (0..1) to match an edited block back to a suggestion. */ +function wordSimilarity(a: string, b: string): number { + const wordsA = a + .toLowerCase() + .split(/\s+/) + .map((w) => w.replace(/[^a-z0-9]/g, "")) + .filter((w) => w.length > 2); + const wordsB = b + .toLowerCase() + .split(/\s+/) + .map((w) => w.replace(/[^a-z0-9]/g, "")) + .filter((w) => w.length > 2); + if (wordsA.length === 0 || wordsB.length === 0) return 0; + const setB = new Set(wordsB); + let shared = 0; + for (const w of wordsA) { + if (setB.has(w)) shared += 1; + } + return shared / Math.max(wordsA.length, wordsB.length); +} + +/** + * Re-anchor a suggestion's 1-based lineNumber when blocks are inserted, + * deleted, or edited in the inline editor. + */ +function resolveAdjustedLine( + sg: AISuggestion, + currentLines: ChapterLine[], + prevLines: ChapterLine[], +): { lineNumber: number; originalText?: string } | null { + if (currentLines.length === 0) return null; + const oldLine = sg.lineNumber ?? 1; + const orig = (sg.originalText ?? "").trim(); + + if (orig) { + // 1. Exact match closest to oldLine + const exactMatches = currentLines.filter((l) => l.text === orig); + if (exactMatches.length > 0) { + exactMatches.sort( + (a, b) => Math.abs(a.lineNumber - oldLine) - Math.abs(b.lineNumber - oldLine), + ); + const best = exactMatches[0]!; + if (best.lineNumber !== oldLine) { + return { lineNumber: best.lineNumber }; + } + return null; + } + + // 2. Substring or high word-similarity match (if the user edited the line itself or shifted it) + let bestCandidate: { line: ChapterLine; score: number } | null = null; + for (const l of currentLines) { + if (!l.text) continue; + const contains = + (orig.length >= 12 && l.text.includes(orig)) || + (l.text.length >= 12 && orig.includes(l.text)); + const sim = contains ? 0.92 : wordSimilarity(orig, l.text); + if (sim >= 0.6) { + const distancePenalty = Math.min(0.15, Math.abs(l.lineNumber - oldLine) * 0.02); + const score = sim - distancePenalty; + if (!bestCandidate || score > bestCandidate.score) { + bestCandidate = { line: l, score }; + } + } + } + if (bestCandidate) { + const changedLine = bestCandidate.line.lineNumber !== oldLine; + const changedText = bestCandidate.line.text !== orig; + if (changedLine || changedText) { + return { + lineNumber: bestCandidate.line.lineNumber, + originalText: bestCandidate.line.text, + }; + } + return null; + } + } + + // 3. Fallback: if block count changed above oldLine, shift by delta + const delta = currentLines.length - prevLines.length; + if (delta !== 0 && prevLines.length > 0) { + let firstDiffLine = prevLines.length + 1; + const minLen = Math.min(currentLines.length, prevLines.length); + for (let i = 0; i < minLen; i++) { + if (currentLines[i]!.text !== prevLines[i]!.text) { + firstDiffLine = i + 1; + break; + } + } + if (oldLine >= firstDiffLine) { + const shifted = Math.max(1, Math.min(currentLines.length, oldLine + delta)); + if (shifted !== oldLine) { + return { lineNumber: shifted }; + } + } + } + + return null; +} + interface ParsedLineSuggestion { lineNumber: number; kind: SuggestionKind; @@ -100,10 +205,6 @@ function parseSuggestionBlocks( return { cleanText, parsed }; } -/** - * Polish a line of prose according to the agent role so every generated line - * suggestion offers a concrete, meaningful edit for that exact line number. - */ function craftRevisedLine(original: string, role: string, lineNumber: number): { title: string; newText: string; @@ -118,7 +219,6 @@ function craftRevisedLine(original: string, role: string, lineNumber: number): { }; } - // Tighten common filter phrases if present let tightened = trimmed .replace(/\bShe pressed her palm against the cold window and watched the lighthouse appear out of the mist\b/i, "Her palm met the icy glass as the lighthouse cut through the mist") .replace(/\bit seemed to have shrunk, or perhaps she had simply grown too large for the memory of it\b/i, "the tower had shrunk—or she had finally outgrown the memory of it") @@ -181,7 +281,7 @@ function buildLineBasedFallback(opts: { chapterTitle: string; chapterLines: ChapterLine[]; characterNames: string[]; -}): { replyText: string; suggestions: ParsedLineSuggestion[] } { +}): { replyText: string; suggestions: ParsedLineSuggestion[]; thinkingText: string } { const { agentName, agentRole, @@ -193,7 +293,6 @@ function buildLineBasedFallback(opts: { } = opts; const nonEmptyLines = chapterLines.filter((l) => l.text.length > 0); - // Check if user asked about a specific line number, e.g. "line 3" const requestedLineMatch = userPrompt.match(/\bline\s*(\d+)\b/i); const requestedLineNum = requestedLineMatch ? parseInt(requestedLineMatch[1]!, 10) : null; @@ -219,6 +318,8 @@ function buildLineBasedFallback(opts: { }); const cast = characterNames.length > 0 ? characterNames.slice(0, 4).join(", ") : "your cast"; + const thinkingText = `Scanning ${chapterLines.length} numbered lines in "${chapterTitle}" → cross-checking entities (${cast}) → evaluating ${targetLines.map((l) => `[Line ${l.lineNumber}]`).join(", ")} for ${agentRole} improvements.`; + const bulletLines = suggestions .map( (s) => @@ -237,7 +338,7 @@ function buildLineBasedFallback(opts: { .filter(Boolean) .join("\n\n"); - return { replyText, suggestions }; + return { replyText, suggestions, thinkingText }; } export interface AISidebarProps { @@ -285,10 +386,25 @@ export function AISidebar({ fullPage = false }: AISidebarProps) { const [draftEdits, setDraftEdits] = useState>({}); const [input, setInput] = useState(""); const [sending, setSending] = useState(false); + const [thinkingStage, setThinkingStage] = useState(""); + const [thinkingTrace, setThinkingTrace] = useState(""); + const [thinkingElapsedMs, setThinkingElapsedMs] = useState(0); const [reloading, setReloading] = useState(false); const [newNoteKind, setNewNoteKind] = useState("general"); const [newNoteBody, setNewNoteBody] = useState(""); + // Resizable sidebar state with snap-to-default (370px) + const storedWidth = s.aiSidebarWidth || DEFAULT_AI_SIDEBAR_WIDTH; + const [sidebarWidth, setSidebarWidth] = useState(storedWidth); + const [isResizing, setIsResizing] = useState(false); + const [isSnapped, setIsSnapped] = useState( + Math.abs(storedWidth - DEFAULT_AI_SIDEBAR_WIDTH) <= 1, + ); + + // Telemetry stats: tokens/sec (t/s) & last output token count + const [tokensPerSec, setTokensPerSec] = useState(null); + const [lastOutputTokens, setLastOutputTokens] = useState(0); + const scrollRef = useRef(null); const activeChapter = useMemo( @@ -301,6 +417,95 @@ export function AISidebar({ fullPage = false }: AISidebarProps) { [activeChapter?.doc], ); + // Keep previous lines snapshot per chapter to adjust suggestion line numbers when inline edits happen + const prevChapterLinesRef = useRef<{ chapterId: string | null; lines: ChapterLine[] }>({ + chapterId: null, + lines: [], + }); + + useEffect(() => { + const currentChapterId = activeChapter?.id ?? null; + const prev = prevChapterLinesRef.current; + + if (prev.chapterId === currentChapterId && prev.lines.length > 0 && suggestions.length > 0) { + let anyChanged = false; + const updatedList = suggestions.map((sg) => { + if (sg.status !== "pending") return sg; + if (sg.chapterId && sg.chapterId !== currentChapterId) return sg; + const adjusted = resolveAdjustedLine(sg, chapterLines, prev.lines); + if (!adjusted) return sg; + anyChanged = true; + const nextSg: AISuggestion = { + ...sg, + lineNumber: adjusted.lineNumber, + ...(adjusted.originalText ? { originalText: adjusted.originalText } : {}), + }; + if (aiStore && activeSessionId) { + void aiStore.updateSuggestion(activeSessionId, sg.id, { + lineNumber: adjusted.lineNumber, + ...(adjusted.originalText ? { originalText: adjusted.originalText } : {}), + }); + } + if (highlightedLine != null && highlightedLine === sg.lineNumber) { + setHighlightedLine(adjusted.lineNumber); + } + return nextSg; + }); + + if (anyChanged) { + setSuggestions(updatedList); + } + } + + prevChapterLinesRef.current = { + chapterId: currentChapterId, + lines: chapterLines, + }; + }, [chapterLines, activeChapter?.id, suggestions, aiStore, activeSessionId, highlightedLine, setHighlightedLine]); + + // Sidebar drag-resize handler with snap-to-default (370px) + const handleStartResize = (e: React.MouseEvent) => { + if (fullPage) return; + e.preventDefault(); + const startX = e.clientX; + const startWidth = sidebarWidth; + setIsResizing(true); + + let latestWidth = startWidth; + const onMouseMove = (moveEvt: MouseEvent) => { + const deltaX = startX - moveEvt.clientX; + const rawWidth = Math.max( + MIN_AI_SIDEBAR_WIDTH, + Math.min(MAX_AI_SIDEBAR_WIDTH, startWidth + deltaX), + ); + if (Math.abs(rawWidth - DEFAULT_AI_SIDEBAR_WIDTH) <= SNAP_THRESHOLD_PX) { + latestWidth = DEFAULT_AI_SIDEBAR_WIDTH; + setIsSnapped(true); + } else { + latestWidth = rawWidth; + setIsSnapped(false); + } + setSidebarWidth(latestWidth); + }; + + const onMouseUp = () => { + setIsResizing(false); + setSettings({ aiSidebarWidth: latestWidth }); + window.removeEventListener("mousemove", onMouseMove); + window.removeEventListener("mouseup", onMouseUp); + }; + + window.addEventListener("mousemove", onMouseMove); + window.addEventListener("mouseup", onMouseUp); + }; + + const handleResetWidth = () => { + setSidebarWidth(DEFAULT_AI_SIDEBAR_WIDTH); + setIsSnapped(true); + setSettings({ aiSidebarWidth: DEFAULT_AI_SIDEBAR_WIDTH }); + toast.info(`Snapped AI sidebar to default width (${DEFAULT_AI_SIDEBAR_WIDTH}px)`); + }; + const activeProvider = useMemo( () => s.aiProviders.find((p) => p.id === s.activeProviderId) ?? @@ -318,6 +523,38 @@ export function AISidebar({ fullPage = false }: AISidebarProps) { const activeModel = s.activeModel || activeProvider?.defaultModel || "llama3.2"; + // Live context token calculation + const contextStats = useMemo(() => { + const sysTokens = countTokens(activeAgent.systemPrompt); + const chapterTokens = countTokens( + chapterLines.map((l) => `[Line ${l.lineNumber}] ${l.text}`).join("\n"), + ); + const entitiesTokens = countTokens( + entities.map((ent) => ("name" in ent ? ent.name : ent.body)).join(", "), + ); + const historyTokens = countTokens( + messages.map((m) => m.content).join("\n"), + ); + const draftInputTokens = countTokens(input); + const used = sysTokens + chapterTokens + entitiesTokens + historyTokens + draftInputTokens; + const total = activeAgent.contextWindow || 32768; + const pct = Math.min(100, (used / total) * 100); + return { used, total, pct }; + }, [activeAgent.systemPrompt, activeAgent.contextWindow, chapterLines, entities, messages, input]); + + // Thinking elapsed timer while sending + useEffect(() => { + if (!sending) { + setThinkingElapsedMs(0); + return undefined; + } + const started = performance.now(); + const timer = window.setInterval(() => { + setThinkingElapsedMs(Math.round(performance.now() - started)); + }, 100); + return () => window.clearInterval(timer); + }, [sending]); + const visibleSessions = useMemo( () => sessions.filter((sess) => @@ -344,7 +581,6 @@ export function AISidebar({ fullPage = false }: AISidebarProps) { [suggestions], ); - // Keep focusedIndex in bounds of pendingSuggestions useEffect(() => { if (focusedIndex >= pendingSuggestions.length) { setFocusedIndex(Math.max(0, pendingSuggestions.length - 1)); @@ -353,14 +589,12 @@ export function AISidebar({ fullPage = false }: AISidebarProps) { const focusedSuggestion = pendingSuggestions[focusedIndex] ?? null; - // Automatically highlight the focused suggestion's line when viewing the Suggestions tab useEffect(() => { if (activeTab === "suggestions" && focusedSuggestion?.lineNumber) { setHighlightedLine(focusedSuggestion.lineNumber); } }, [activeTab, focusedSuggestion?.id, focusedSuggestion?.lineNumber, setHighlightedLine]); - // Clear highlight when unmounting useEffect(() => { return () => setHighlightedLine(null); }, [setHighlightedLine]); @@ -402,7 +636,7 @@ export function AISidebar({ fullPage = false }: AISidebarProps) { top: scrollRef.current.scrollHeight, behavior: "smooth", }); - }, [messages]); + }, [messages, sending, thinkingStage]); const handleLoadReload = async () => { setReloading(true); @@ -474,7 +708,13 @@ export function AISidebar({ fullPage = false }: AISidebarProps) { setActiveSessionId(sessionId); } + const startTime = performance.now(); setSending(true); + setThinkingStage( + `Reading ${chapterLines.length} numbered line${chapterLines.length === 1 ? "" : "s"} in "${activeChapter?.title ?? "Chapter"}"…`, + ); + setThinkingTrace(""); + try { await aiStore.addMessage(sessionId, { role: "user", @@ -509,6 +749,10 @@ export function AISidebar({ fullPage = false }: AISidebarProps) { let assistantReply = ""; let parsedSuggestions: ParsedLineSuggestion[] = []; + setThinkingStage( + `${activeAgent.name} (${activeModel}) is analyzing lines & reasoning through prompt…`, + ); + try { const controller = new AgentController({ id: activeProvider.id, @@ -548,12 +792,13 @@ export function AISidebar({ fullPage = false }: AISidebarProps) { contextText, }, ); + if (result.reasoning) { + setThinkingTrace(result.reasoning); + } const extracted = parseSuggestionBlocks(result.text.trim(), chapterLines); assistantReply = extracted.cleanText; parsedSuggestions = extracted.parsed; - // If the model responded without explicit << 0) { const fallback = buildLineBasedFallback({ agentName: activeAgent.name, @@ -576,6 +821,10 @@ export function AISidebar({ fullPage = false }: AISidebarProps) { chapterLines, characterNames, }); + 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)); assistantReply = fallback.replyText; parsedSuggestions = fallback.suggestions; } @@ -602,6 +851,14 @@ export function AISidebar({ fullPage = false }: AISidebarProps) { }); } + const elapsedSec = Math.max(0.08, (performance.now() - startTime) / 1000); + const outTok = countTokens( + assistantReply + + parsedSuggestions.map((s) => `${s.title} ${s.newText} ${s.summary}`).join(" "), + ); + setLastOutputTokens(outTok); + setTokensPerSec(Number((outTok / elapsedSec).toFixed(1))); + setMessages(await aiStore.listMessages(sessionId)); const updatedSuggestions = await aiStore.listSuggestions(sessionId); setSuggestions(updatedSuggestions); @@ -612,6 +869,7 @@ export function AISidebar({ fullPage = false }: AISidebarProps) { } } finally { setSending(false); + setThinkingStage(""); } }; @@ -631,7 +889,6 @@ export function AISidebar({ fullPage = false }: AISidebarProps) { setFocusedIndex(0); }; - // Accept: applies the edit to the line on the writing screen and removes the suggestion from pending const handleAcceptSuggestion = async (sg: AISuggestion) => { if (!aiStore || !activeSessionId) return; const replacement = (draftEdits[sg.id] ?? sg.newText ?? sg.text).trim(); @@ -655,7 +912,6 @@ export function AISidebar({ fullPage = false }: AISidebarProps) { } }; - // Deny: removes the suggestion from the suggestions list const handleDenySuggestion = async (sg: AISuggestion) => { if (!aiStore || !activeSessionId) return; await aiStore.deleteSuggestion(activeSessionId, sg.id); @@ -667,7 +923,6 @@ export function AISidebar({ fullPage = false }: AISidebarProps) { toast.info(`Denied suggestion for Line ${sg.lineNumber ?? "?"}`); }; - // Copy to Note: places the suggestion into the Notes section of the chat const handleCopySuggestionToNote = async (sg: AISuggestion) => { if (!aiStore || !activeSessionId) return; const replacement = (draftEdits[sg.id] ?? sg.newText ?? sg.text).trim(); @@ -712,7 +967,6 @@ export function AISidebar({ fullPage = false }: AISidebarProps) { } }; - // Render message text with clickable [Line N] badges that highlight that line in the editor const renderMessageWithLineLinks = (content: string) => { const parts = content.split(/(\[Line\s+\d+\])/gi); return parts.map((part, i) => { @@ -737,10 +991,24 @@ export function AISidebar({ fullPage = false }: AISidebarProps) { }; return ( -