feat: project dropdown in sidebar, resizable AI sidebar with snap, ctx & t/s telemetry, live line-number re-anchoring, and thinking indicator
This commit is contained in:
@@ -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 },
|
||||
|
||||
@@ -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 (
|
||||
<aside className={`ps-sidebar${navCollapsed ? " is-collapsed" : ""}`}>
|
||||
<div className="ps-sidebar-header">
|
||||
@@ -76,22 +87,47 @@ export function Sidebar({ nav }: SidebarProps) {
|
||||
</div>
|
||||
|
||||
{!navCollapsed && <div className="ps-nav-section-title">Project</div>}
|
||||
<button
|
||||
type="button"
|
||||
className="ps-nav-item"
|
||||
title={`${title} (${chapters.length} chapters · ${wordCount.toLocaleString()} words)`}
|
||||
onClick={() => navigate("/projects")}
|
||||
>
|
||||
<span className="ps-nav-icon">📖</span>
|
||||
{!navCollapsed && (
|
||||
<span className="ps-nav-label">
|
||||
<div style={{ fontWeight: 600 }}>{title}</div>
|
||||
<div style={{ fontSize: 12, opacity: 0.65 }}>
|
||||
{chapters.length} chapter{chapters.length === 1 ? "" : "s"} · {wordCount.toLocaleString()} words
|
||||
</div>
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{navCollapsed ? (
|
||||
<label
|
||||
className="ps-nav-item ps-project-switcher-collapsed"
|
||||
title={`Select Project — Current: ${title} (${chapters.length} chapters · ${wordCount.toLocaleString()} words)`}
|
||||
>
|
||||
<span className="ps-nav-icon">📖</span>
|
||||
<select
|
||||
aria-label="Select Project"
|
||||
className="ps-project-switcher-native-overlay"
|
||||
value={project?.id ?? ""}
|
||||
onChange={(e) => void handleSelectProject(e.target.value)}
|
||||
>
|
||||
{projects.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
) : (
|
||||
<div className="ps-sidebar-project-box">
|
||||
<div className="ps-sidebar-project-row">
|
||||
<span className="ps-nav-icon">📖</span>
|
||||
<select
|
||||
aria-label="Select Project"
|
||||
className="ps-select ps-sidebar-project-select"
|
||||
value={project?.id ?? ""}
|
||||
onChange={(e) => void handleSelectProject(e.target.value)}
|
||||
>
|
||||
{projects.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="ps-sidebar-project-meta">
|
||||
{chapters.length} chapter{chapters.length === 1 ? "" : "s"} · {wordCount.toLocaleString()} words
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!navCollapsed && (
|
||||
<div className="ps-nav-section-title" style={{ marginTop: 12 }}>
|
||||
@@ -99,34 +135,18 @@ export function Sidebar({ nav }: SidebarProps) {
|
||||
</div>
|
||||
)}
|
||||
<nav className="ps-sidebar-nav">
|
||||
{nav.map((item) => {
|
||||
if (item.to === "/ai") {
|
||||
return (
|
||||
<button
|
||||
key={item.to}
|
||||
type="button"
|
||||
title={`${item.label} (Toggle AI Sidebar)`}
|
||||
className={`ps-nav-item${aiSidebarOpen ? " is-active" : ""}`}
|
||||
onClick={() => setSettings({ aiSidebarOpen: !aiSidebarOpen })}
|
||||
>
|
||||
<span className="ps-nav-icon">{item.icon}</span>
|
||||
{!navCollapsed && <span className="ps-nav-label">{item.label}</span>}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.end ?? false}
|
||||
title={item.label}
|
||||
className={({ isActive }) => `ps-nav-item${isActive ? " is-active" : ""}`}
|
||||
>
|
||||
<span className="ps-nav-icon">{item.icon}</span>
|
||||
{!navCollapsed && <span className="ps-nav-label">{item.label}</span>}
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
{nav.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.end ?? false}
|
||||
title={item.label}
|
||||
className={({ isActive }) => `ps-nav-item${isActive ? " is-active" : ""}`}
|
||||
>
|
||||
<span className="ps-nav-icon">{item.icon}</span>
|
||||
{!navCollapsed && <span className="ps-nav-label">{item.label}</span>}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
);
|
||||
|
||||
@@ -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<Record<string, string>>({});
|
||||
const [input, setInput] = useState("");
|
||||
const [sending, setSending] = useState(false);
|
||||
const [thinkingStage, setThinkingStage] = useState<string>("");
|
||||
const [thinkingTrace, setThinkingTrace] = useState<string>("");
|
||||
const [thinkingElapsedMs, setThinkingElapsedMs] = useState<number>(0);
|
||||
const [reloading, setReloading] = useState(false);
|
||||
const [newNoteKind, setNewNoteKind] = useState<NoteKind>("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<number>(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<number | null>(null);
|
||||
const [lastOutputTokens, setLastOutputTokens] = useState<number>(0);
|
||||
|
||||
const scrollRef = useRef<HTMLDivElement>(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<HTMLDivElement>) => {
|
||||
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 <<<SUGGESTION blocks, still
|
||||
// generate concrete line-numbered suggestions for any [Line N] it cited!
|
||||
if (parsedSuggestions.length === 0 && chapterLines.length > 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 (
|
||||
<aside className={fullPage ? "ps-ai-fullpage" : "ps-ai-sidebar"} aria-label="AI Co-Writing Sidebar">
|
||||
<aside
|
||||
className={fullPage ? "ps-ai-fullpage" : `ps-ai-sidebar${isResizing ? " is-resizing" : ""}`}
|
||||
style={!fullPage ? { width: `${sidebarWidth}px` } : undefined}
|
||||
aria-label="AI Co-Writing Sidebar"
|
||||
>
|
||||
{/* Left-edge drag handle with snap-to-default (370px) */}
|
||||
{!fullPage && (
|
||||
<div
|
||||
className={`ps-ai-resize-handle${isSnapped ? " is-snapped" : ""}`}
|
||||
title={`Drag to resize AI sidebar · Snaps to ${DEFAULT_AI_SIDEBAR_WIDTH}px default · Double-click to reset`}
|
||||
onMouseDown={handleStartResize}
|
||||
onDoubleClick={handleResetWidth}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<div className="ps-ai-sidebar-head">
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, minWidth: 0 }}>
|
||||
<span style={{ fontSize: 16 }}>✨</span>
|
||||
<strong style={{ fontSize: 14 }}>AI Studio</strong>
|
||||
<Badge variant="accent">{activeAgent.name}</Badge>
|
||||
@@ -980,7 +1248,7 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
|
||||
{activeTab === "chat" && (
|
||||
<div className="ps-ai-chat-body">
|
||||
<div ref={scrollRef} className="ps-ai-messages">
|
||||
{messages.length === 0 ? (
|
||||
{messages.length === 0 && !sending ? (
|
||||
<div className="ps-ai-empty">
|
||||
<div style={{ fontSize: 24, marginBottom: 6 }}>✨</div>
|
||||
<div style={{ fontWeight: 600, color: "var(--ps-text)" }}>
|
||||
@@ -1013,6 +1281,29 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
||||
{/* Live Thinking Indicator */}
|
||||
{sending && (
|
||||
<div className="ps-ai-thinking" role="status" aria-live="polite">
|
||||
<div className="ps-ai-thinking-head">
|
||||
<span className="ps-ai-thinking-dots" aria-hidden="true">
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
</span>
|
||||
<strong>{activeAgent.name} is thinking…</strong>
|
||||
<span className="ps-ai-thinking-timer">
|
||||
{(thinkingElapsedMs / 1000).toFixed(1)}s
|
||||
</span>
|
||||
</div>
|
||||
<div className="ps-ai-thinking-stage">
|
||||
{thinkingStage || "Working through prompt…"}
|
||||
</div>
|
||||
{thinkingTrace && (
|
||||
<div className="ps-ai-thinking-trace">{thinkingTrace}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<form onSubmit={(e) => void handleSendMessage(e)} className="ps-ai-composer">
|
||||
@@ -1064,7 +1355,7 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* SUGGESTIONS TAB: Step through each line suggestion with Accept / Deny / Copy to Note */}
|
||||
{/* SUGGESTIONS TAB */}
|
||||
{activeTab === "suggestions" && (
|
||||
<div className="ps-ai-chat-body">
|
||||
{pendingSuggestions.length > 0 && (
|
||||
@@ -1103,7 +1394,26 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
|
||||
)}
|
||||
|
||||
<div className="ps-ai-messages">
|
||||
{pendingSuggestions.length === 0 ? (
|
||||
{sending && (
|
||||
<div className="ps-ai-thinking" role="status" aria-live="polite">
|
||||
<div className="ps-ai-thinking-head">
|
||||
<span className="ps-ai-thinking-dots" aria-hidden="true">
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
</span>
|
||||
<strong>{activeAgent.name} is thinking…</strong>
|
||||
<span className="ps-ai-thinking-timer">
|
||||
{(thinkingElapsedMs / 1000).toFixed(1)}s
|
||||
</span>
|
||||
</div>
|
||||
<div className="ps-ai-thinking-stage">
|
||||
{thinkingStage || "Generating line-by-line suggestions…"}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pendingSuggestions.length === 0 && !sending ? (
|
||||
<div className="ps-ai-empty">
|
||||
<div style={{ marginBottom: 8 }}>No pending line suggestions yet.</div>
|
||||
<Button
|
||||
@@ -1112,7 +1422,7 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
|
||||
onClick={() => void handleGenerateLineSuggestions()}
|
||||
disabled={sending}
|
||||
>
|
||||
{sending ? "Analyzing Lines…" : "✨ Generate Line Suggestions"}
|
||||
✨ Generate Line Suggestions
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
@@ -1270,6 +1580,38 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Non-intrusive Context & t/s Telemetry Footer */}
|
||||
<div className="ps-ai-telemetry-bar">
|
||||
<div
|
||||
className="ps-ai-telemetry-ctx"
|
||||
title={`Context usage: ${contextStats.used.toLocaleString()} of ${contextStats.total.toLocaleString()} tokens (${contextStats.pct.toFixed(1)}%)`}
|
||||
>
|
||||
<span>
|
||||
ctx {(contextStats.used / 1000).toFixed(1)}k / {(contextStats.total / 1024).toFixed(0)}k ({contextStats.pct.toFixed(0)}%)
|
||||
</span>
|
||||
<span className="ps-ai-telemetry-meter">
|
||||
<span
|
||||
className="ps-ai-telemetry-meter-fill"
|
||||
style={{ width: `${Math.max(3, contextStats.pct)}%` }}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="ps-ai-telemetry-speed"
|
||||
title={
|
||||
tokensPerSec != null
|
||||
? `Throughput: ${tokensPerSec} tokens/sec (${lastOutputTokens} output tokens)`
|
||||
: "Throughput (tokens/sec) measured on generation"
|
||||
}
|
||||
>
|
||||
{isResizing
|
||||
? `${sidebarWidth}px${isSnapped ? " · snapped" : ""}`
|
||||
: tokensPerSec != null
|
||||
? `${tokensPerSec} t/s · ${lastOutputTokens} tok`
|
||||
: "— t/s"}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -250,6 +250,7 @@ export interface AppSettings {
|
||||
// Navigation & AI Sidebars
|
||||
navCollapsed: boolean;
|
||||
aiSidebarOpen: boolean;
|
||||
aiSidebarWidth: number;
|
||||
// Spell & grammar (Chunk #7)
|
||||
enableSpellcheck: boolean;
|
||||
enableGrammarCheck: boolean;
|
||||
@@ -278,6 +279,7 @@ export const DEFAULT_SETTINGS: AppSettings = {
|
||||
focusMode: false,
|
||||
navCollapsed: false,
|
||||
aiSidebarOpen: false,
|
||||
aiSidebarWidth: 370,
|
||||
// Spell & grammar (Chunk #7)
|
||||
enableSpellcheck: true,
|
||||
enableGrammarCheck: false,
|
||||
|
||||
@@ -328,6 +328,25 @@ export class AIStore {
|
||||
});
|
||||
}
|
||||
|
||||
/** Update a suggestion's line number or text anchor when editor blocks shift. */
|
||||
async updateSuggestion(
|
||||
sessionId: string,
|
||||
suggestionId: string,
|
||||
patch: Partial<Pick<AISuggestion, "lineNumber" | "originalText" | "newText" | "text">>,
|
||||
): Promise<AISession> {
|
||||
return this.withTransaction(async () => {
|
||||
const session = this.require(sessionId);
|
||||
const suggestion = session.suggestions.find((s) => s.id === suggestionId);
|
||||
if (!suggestion) return this.clone(session);
|
||||
if (patch.lineNumber !== undefined) suggestion.lineNumber = patch.lineNumber;
|
||||
if (patch.originalText !== undefined) suggestion.originalText = patch.originalText;
|
||||
if (patch.newText !== undefined) suggestion.newText = patch.newText;
|
||||
if (patch.text !== undefined) suggestion.text = patch.text;
|
||||
session.updatedAt = nowIso();
|
||||
return this.clone(session);
|
||||
});
|
||||
}
|
||||
|
||||
/** Add a note to a session and persist it. */
|
||||
async addNote(sessionId: string, note: Omit<AINote, "id" | "createdAt" | "updatedAt">): Promise<AISession> {
|
||||
return this.withTransaction(async () => {
|
||||
|
||||
@@ -184,6 +184,47 @@ body {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Sidebar Project Switcher Dropdown */
|
||||
.ps-sidebar-project-box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 6px 8px;
|
||||
background: var(--ps-surface-2);
|
||||
border: 1px solid var(--ps-border);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.ps-sidebar-project-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.ps-sidebar-project-select {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 5px 8px !important;
|
||||
font-size: 13px !important;
|
||||
font-weight: 600;
|
||||
background: var(--ps-surface) !important;
|
||||
}
|
||||
.ps-sidebar-project-meta {
|
||||
font-size: 11.5px;
|
||||
color: var(--ps-text-muted);
|
||||
padding-left: 26px;
|
||||
}
|
||||
.ps-project-switcher-collapsed {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.ps-project-switcher-native-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ps-topbar {
|
||||
grid-area: topbar;
|
||||
display: flex;
|
||||
@@ -648,8 +689,9 @@ body {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ---- AI Sidebar (Toggleable Right Rail) ---- */
|
||||
/* ---- AI Sidebar (Toggleable & Resizable Right Rail) ---- */
|
||||
.ps-ai-sidebar {
|
||||
position: relative;
|
||||
width: 370px;
|
||||
flex-shrink: 0;
|
||||
height: 100%;
|
||||
@@ -660,6 +702,28 @@ body {
|
||||
font-family: var(--ps-font-ui);
|
||||
overflow: hidden;
|
||||
}
|
||||
.ps-ai-sidebar.is-resizing {
|
||||
user-select: none;
|
||||
}
|
||||
.ps-ai-resize-handle {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: -3px;
|
||||
width: 7px;
|
||||
cursor: col-resize;
|
||||
z-index: 25;
|
||||
background: transparent;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
.ps-ai-resize-handle:hover,
|
||||
.ps-ai-sidebar.is-resizing .ps-ai-resize-handle {
|
||||
background: color-mix(in srgb, var(--ps-accent) 55%, transparent);
|
||||
}
|
||||
.ps-ai-sidebar.is-resizing .ps-ai-resize-handle.is-snapped {
|
||||
background: var(--ps-success);
|
||||
box-shadow: 0 0 8px var(--ps-success);
|
||||
}
|
||||
.ps-ai-fullpage {
|
||||
width: 100%;
|
||||
max-width: 900px;
|
||||
@@ -909,6 +973,99 @@ body {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* AI Thinking Indicator */
|
||||
.ps-ai-thinking {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
background: color-mix(in srgb, var(--ps-accent) 10%, var(--ps-surface-2));
|
||||
border: 1px dashed color-mix(in srgb, var(--ps-accent) 55%, var(--ps-border));
|
||||
font-size: 12.5px;
|
||||
}
|
||||
.ps-ai-thinking-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--ps-accent);
|
||||
}
|
||||
.ps-ai-thinking-timer {
|
||||
margin-left: auto;
|
||||
font-family: var(--ps-font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--ps-text-muted);
|
||||
}
|
||||
.ps-ai-thinking-stage {
|
||||
font-size: 12px;
|
||||
color: var(--ps-text);
|
||||
}
|
||||
.ps-ai-thinking-trace {
|
||||
font-size: 11.5px;
|
||||
font-style: italic;
|
||||
color: var(--ps-text-muted);
|
||||
border-left: 2px solid var(--ps-border);
|
||||
padding-left: 8px;
|
||||
}
|
||||
.ps-ai-thinking-dots {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
}
|
||||
.ps-ai-thinking-dots > span {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--ps-accent);
|
||||
animation: ps-thinking-bounce 1.2s infinite ease-in-out both;
|
||||
}
|
||||
.ps-ai-thinking-dots > span:nth-child(1) { animation-delay: -0.24s; }
|
||||
.ps-ai-thinking-dots > span:nth-child(2) { animation-delay: -0.12s; }
|
||||
@keyframes ps-thinking-bounce {
|
||||
0%, 80%, 100% { transform: scale(0.45); opacity: 0.4; }
|
||||
40% { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
|
||||
/* Non-intrusive AI Telemetry Bar (Context & t/s) */
|
||||
.ps-ai-telemetry-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 5px 12px;
|
||||
border-top: 1px solid var(--ps-border);
|
||||
background: var(--ps-surface-2);
|
||||
font-family: var(--ps-font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--ps-text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ps-ai-telemetry-ctx {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
.ps-ai-telemetry-meter {
|
||||
width: 44px;
|
||||
height: 4px;
|
||||
border-radius: 999px;
|
||||
background: var(--ps-border);
|
||||
overflow: hidden;
|
||||
display: inline-block;
|
||||
}
|
||||
.ps-ai-telemetry-meter-fill {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: var(--ps-accent);
|
||||
border-radius: 999px;
|
||||
transition: width 0.2s ease;
|
||||
}
|
||||
.ps-ai-telemetry-speed {
|
||||
white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* ---- Projects Page & Template Wizard ---- */
|
||||
.ps-projects-grid {
|
||||
display: grid;
|
||||
|
||||
Reference in New Issue
Block a user