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: "/plot", label: "Plot", icon: "🗺", end: false },
|
||||||
{ to: "/timeline", label: "Timeline", icon: "📅", end: false },
|
{ to: "/timeline", label: "Timeline", icon: "📅", end: false },
|
||||||
{ to: "/whiteboard", label: "Whiteboard", 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: "/projects", label: "Projects", icon: "📁", end: false },
|
||||||
{ to: "/settings", label: "Settings", icon: "⚙", end: false },
|
{ to: "/settings", label: "Settings", icon: "⚙", end: false },
|
||||||
{ to: "/admin", label: "Admin", icon: "🛠", end: false },
|
{ to: "/admin", label: "Admin", icon: "🛠", end: false },
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { NavLink, useNavigate } from "react-router-dom";
|
import { NavLink, useNavigate } from "react-router-dom";
|
||||||
|
import { toast } from "@pubscript/ui";
|
||||||
import { useProjectStore } from "../store/project-store.js";
|
import { useProjectStore } from "../store/project-store.js";
|
||||||
import { useSettings } from "../store/settings.js";
|
import { useSettings } from "../store/settings.js";
|
||||||
|
|
||||||
@@ -39,15 +40,25 @@ function totalWords(chapters: ChapterDoc[]): number {
|
|||||||
|
|
||||||
export function Sidebar({ nav }: SidebarProps) {
|
export function Sidebar({ nav }: SidebarProps) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const projects = useProjectStore((s) => s.projects);
|
||||||
const project = useProjectStore((s) => s.project);
|
const project = useProjectStore((s) => s.project);
|
||||||
|
const openProject = useProjectStore((s) => s.openProject);
|
||||||
const chapters = useProjectStore((s) => s.chapters);
|
const chapters = useProjectStore((s) => s.chapters);
|
||||||
const navCollapsed = useSettings((s) => s.navCollapsed);
|
const navCollapsed = useSettings((s) => s.navCollapsed);
|
||||||
const aiSidebarOpen = useSettings((s) => s.aiSidebarOpen);
|
|
||||||
const setSettings = useSettings((s) => s.set);
|
const setSettings = useSettings((s) => s.set);
|
||||||
|
|
||||||
const title = project?.title ?? "Untitled project";
|
const title = project?.title ?? "Untitled project";
|
||||||
const wordCount = totalWords(chapters);
|
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 (
|
return (
|
||||||
<aside className={`ps-sidebar${navCollapsed ? " is-collapsed" : ""}`}>
|
<aside className={`ps-sidebar${navCollapsed ? " is-collapsed" : ""}`}>
|
||||||
<div className="ps-sidebar-header">
|
<div className="ps-sidebar-header">
|
||||||
@@ -76,22 +87,47 @@ export function Sidebar({ nav }: SidebarProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!navCollapsed && <div className="ps-nav-section-title">Project</div>}
|
{!navCollapsed && <div className="ps-nav-section-title">Project</div>}
|
||||||
<button
|
{navCollapsed ? (
|
||||||
type="button"
|
<label
|
||||||
className="ps-nav-item"
|
className="ps-nav-item ps-project-switcher-collapsed"
|
||||||
title={`${title} (${chapters.length} chapters · ${wordCount.toLocaleString()} words)`}
|
title={`Select Project — Current: ${title} (${chapters.length} chapters · ${wordCount.toLocaleString()} words)`}
|
||||||
onClick={() => navigate("/projects")}
|
>
|
||||||
>
|
<span className="ps-nav-icon">📖</span>
|
||||||
<span className="ps-nav-icon">📖</span>
|
<select
|
||||||
{!navCollapsed && (
|
aria-label="Select Project"
|
||||||
<span className="ps-nav-label">
|
className="ps-project-switcher-native-overlay"
|
||||||
<div style={{ fontWeight: 600 }}>{title}</div>
|
value={project?.id ?? ""}
|
||||||
<div style={{ fontSize: 12, opacity: 0.65 }}>
|
onChange={(e) => void handleSelectProject(e.target.value)}
|
||||||
{chapters.length} chapter{chapters.length === 1 ? "" : "s"} · {wordCount.toLocaleString()} words
|
>
|
||||||
</div>
|
{projects.map((p) => (
|
||||||
</span>
|
<option key={p.id} value={p.id}>
|
||||||
)}
|
{p.title}
|
||||||
</button>
|
</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 && (
|
{!navCollapsed && (
|
||||||
<div className="ps-nav-section-title" style={{ marginTop: 12 }}>
|
<div className="ps-nav-section-title" style={{ marginTop: 12 }}>
|
||||||
@@ -99,34 +135,18 @@ export function Sidebar({ nav }: SidebarProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<nav className="ps-sidebar-nav">
|
<nav className="ps-sidebar-nav">
|
||||||
{nav.map((item) => {
|
{nav.map((item) => (
|
||||||
if (item.to === "/ai") {
|
<NavLink
|
||||||
return (
|
key={item.to}
|
||||||
<button
|
to={item.to}
|
||||||
key={item.to}
|
end={item.end ?? false}
|
||||||
type="button"
|
title={item.label}
|
||||||
title={`${item.label} (Toggle AI Sidebar)`}
|
className={({ isActive }) => `ps-nav-item${isActive ? " is-active" : ""}`}
|
||||||
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>}
|
||||||
<span className="ps-nav-icon">{item.icon}</span>
|
</NavLink>
|
||||||
{!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>
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { AIStore, AgentController, createIndexedDBPersistence } from "@pubscript/ai";
|
import { AIStore, AgentController, createIndexedDBPersistence } from "@pubscript/ai";
|
||||||
import type {
|
import {
|
||||||
AIMessage,
|
countTokens,
|
||||||
AINote,
|
type AIMessage,
|
||||||
AISession,
|
type AINote,
|
||||||
AISuggestion,
|
type AISession,
|
||||||
NoteKind,
|
type AISuggestion,
|
||||||
SuggestionKind,
|
type NoteKind,
|
||||||
|
type SuggestionKind,
|
||||||
} from "@pubscript/core";
|
} from "@pubscript/core";
|
||||||
import { Badge, Button, toast } from "@pubscript/ui";
|
import { Badge, Button, toast } from "@pubscript/ui";
|
||||||
import { useProjectStore } from "../../store/project-store.js";
|
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";
|
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 }[] = [
|
const NOTE_KINDS: { value: NoteKind; label: string }[] = [
|
||||||
{ value: "character", label: "Character" },
|
{ value: "character", label: "Character" },
|
||||||
{ value: "world", label: "World" },
|
{ 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 {
|
interface ParsedLineSuggestion {
|
||||||
lineNumber: number;
|
lineNumber: number;
|
||||||
kind: SuggestionKind;
|
kind: SuggestionKind;
|
||||||
@@ -100,10 +205,6 @@ function parseSuggestionBlocks(
|
|||||||
return { cleanText, parsed };
|
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): {
|
function craftRevisedLine(original: string, role: string, lineNumber: number): {
|
||||||
title: string;
|
title: string;
|
||||||
newText: string;
|
newText: string;
|
||||||
@@ -118,7 +219,6 @@ function craftRevisedLine(original: string, role: string, lineNumber: number): {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tighten common filter phrases if present
|
|
||||||
let tightened = trimmed
|
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(/\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")
|
.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;
|
chapterTitle: string;
|
||||||
chapterLines: ChapterLine[];
|
chapterLines: ChapterLine[];
|
||||||
characterNames: string[];
|
characterNames: string[];
|
||||||
}): { replyText: string; suggestions: ParsedLineSuggestion[] } {
|
}): { replyText: string; suggestions: ParsedLineSuggestion[]; thinkingText: string } {
|
||||||
const {
|
const {
|
||||||
agentName,
|
agentName,
|
||||||
agentRole,
|
agentRole,
|
||||||
@@ -193,7 +293,6 @@ function buildLineBasedFallback(opts: {
|
|||||||
} = opts;
|
} = opts;
|
||||||
|
|
||||||
const nonEmptyLines = chapterLines.filter((l) => l.text.length > 0);
|
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 requestedLineMatch = userPrompt.match(/\bline\s*(\d+)\b/i);
|
||||||
const requestedLineNum = requestedLineMatch ? parseInt(requestedLineMatch[1]!, 10) : null;
|
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 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
|
const bulletLines = suggestions
|
||||||
.map(
|
.map(
|
||||||
(s) =>
|
(s) =>
|
||||||
@@ -237,7 +338,7 @@ function buildLineBasedFallback(opts: {
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join("\n\n");
|
.join("\n\n");
|
||||||
|
|
||||||
return { replyText, suggestions };
|
return { replyText, suggestions, thinkingText };
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AISidebarProps {
|
export interface AISidebarProps {
|
||||||
@@ -285,10 +386,25 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
|
|||||||
const [draftEdits, setDraftEdits] = useState<Record<string, string>>({});
|
const [draftEdits, setDraftEdits] = useState<Record<string, string>>({});
|
||||||
const [input, setInput] = useState("");
|
const [input, setInput] = useState("");
|
||||||
const [sending, setSending] = useState(false);
|
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 [reloading, setReloading] = useState(false);
|
||||||
const [newNoteKind, setNewNoteKind] = useState<NoteKind>("general");
|
const [newNoteKind, setNewNoteKind] = useState<NoteKind>("general");
|
||||||
const [newNoteBody, setNewNoteBody] = useState("");
|
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 scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const activeChapter = useMemo(
|
const activeChapter = useMemo(
|
||||||
@@ -301,6 +417,95 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
|
|||||||
[activeChapter?.doc],
|
[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(
|
const activeProvider = useMemo(
|
||||||
() =>
|
() =>
|
||||||
s.aiProviders.find((p) => p.id === s.activeProviderId) ??
|
s.aiProviders.find((p) => p.id === s.activeProviderId) ??
|
||||||
@@ -318,6 +523,38 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
|
|||||||
const activeModel =
|
const activeModel =
|
||||||
s.activeModel || activeProvider?.defaultModel || "llama3.2";
|
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(
|
const visibleSessions = useMemo(
|
||||||
() =>
|
() =>
|
||||||
sessions.filter((sess) =>
|
sessions.filter((sess) =>
|
||||||
@@ -344,7 +581,6 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
|
|||||||
[suggestions],
|
[suggestions],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Keep focusedIndex in bounds of pendingSuggestions
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (focusedIndex >= pendingSuggestions.length) {
|
if (focusedIndex >= pendingSuggestions.length) {
|
||||||
setFocusedIndex(Math.max(0, pendingSuggestions.length - 1));
|
setFocusedIndex(Math.max(0, pendingSuggestions.length - 1));
|
||||||
@@ -353,14 +589,12 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
|
|||||||
|
|
||||||
const focusedSuggestion = pendingSuggestions[focusedIndex] ?? null;
|
const focusedSuggestion = pendingSuggestions[focusedIndex] ?? null;
|
||||||
|
|
||||||
// Automatically highlight the focused suggestion's line when viewing the Suggestions tab
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeTab === "suggestions" && focusedSuggestion?.lineNumber) {
|
if (activeTab === "suggestions" && focusedSuggestion?.lineNumber) {
|
||||||
setHighlightedLine(focusedSuggestion.lineNumber);
|
setHighlightedLine(focusedSuggestion.lineNumber);
|
||||||
}
|
}
|
||||||
}, [activeTab, focusedSuggestion?.id, focusedSuggestion?.lineNumber, setHighlightedLine]);
|
}, [activeTab, focusedSuggestion?.id, focusedSuggestion?.lineNumber, setHighlightedLine]);
|
||||||
|
|
||||||
// Clear highlight when unmounting
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => setHighlightedLine(null);
|
return () => setHighlightedLine(null);
|
||||||
}, [setHighlightedLine]);
|
}, [setHighlightedLine]);
|
||||||
@@ -402,7 +636,7 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
|
|||||||
top: scrollRef.current.scrollHeight,
|
top: scrollRef.current.scrollHeight,
|
||||||
behavior: "smooth",
|
behavior: "smooth",
|
||||||
});
|
});
|
||||||
}, [messages]);
|
}, [messages, sending, thinkingStage]);
|
||||||
|
|
||||||
const handleLoadReload = async () => {
|
const handleLoadReload = async () => {
|
||||||
setReloading(true);
|
setReloading(true);
|
||||||
@@ -474,7 +708,13 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
|
|||||||
setActiveSessionId(sessionId);
|
setActiveSessionId(sessionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const startTime = performance.now();
|
||||||
setSending(true);
|
setSending(true);
|
||||||
|
setThinkingStage(
|
||||||
|
`Reading ${chapterLines.length} numbered line${chapterLines.length === 1 ? "" : "s"} in "${activeChapter?.title ?? "Chapter"}"…`,
|
||||||
|
);
|
||||||
|
setThinkingTrace("");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await aiStore.addMessage(sessionId, {
|
await aiStore.addMessage(sessionId, {
|
||||||
role: "user",
|
role: "user",
|
||||||
@@ -509,6 +749,10 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
|
|||||||
let assistantReply = "";
|
let assistantReply = "";
|
||||||
let parsedSuggestions: ParsedLineSuggestion[] = [];
|
let parsedSuggestions: ParsedLineSuggestion[] = [];
|
||||||
|
|
||||||
|
setThinkingStage(
|
||||||
|
`${activeAgent.name} (${activeModel}) is analyzing lines & reasoning through prompt…`,
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const controller = new AgentController({
|
const controller = new AgentController({
|
||||||
id: activeProvider.id,
|
id: activeProvider.id,
|
||||||
@@ -548,12 +792,13 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
|
|||||||
contextText,
|
contextText,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
if (result.reasoning) {
|
||||||
|
setThinkingTrace(result.reasoning);
|
||||||
|
}
|
||||||
const extracted = parseSuggestionBlocks(result.text.trim(), chapterLines);
|
const extracted = parseSuggestionBlocks(result.text.trim(), chapterLines);
|
||||||
assistantReply = extracted.cleanText;
|
assistantReply = extracted.cleanText;
|
||||||
parsedSuggestions = extracted.parsed;
|
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) {
|
if (parsedSuggestions.length === 0 && chapterLines.length > 0) {
|
||||||
const fallback = buildLineBasedFallback({
|
const fallback = buildLineBasedFallback({
|
||||||
agentName: activeAgent.name,
|
agentName: activeAgent.name,
|
||||||
@@ -576,6 +821,10 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
|
|||||||
chapterLines,
|
chapterLines,
|
||||||
characterNames,
|
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;
|
assistantReply = fallback.replyText;
|
||||||
parsedSuggestions = fallback.suggestions;
|
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));
|
setMessages(await aiStore.listMessages(sessionId));
|
||||||
const updatedSuggestions = await aiStore.listSuggestions(sessionId);
|
const updatedSuggestions = await aiStore.listSuggestions(sessionId);
|
||||||
setSuggestions(updatedSuggestions);
|
setSuggestions(updatedSuggestions);
|
||||||
@@ -612,6 +869,7 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
|
|||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setSending(false);
|
setSending(false);
|
||||||
|
setThinkingStage("");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -631,7 +889,6 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
|
|||||||
setFocusedIndex(0);
|
setFocusedIndex(0);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Accept: applies the edit to the line on the writing screen and removes the suggestion from pending
|
|
||||||
const handleAcceptSuggestion = async (sg: AISuggestion) => {
|
const handleAcceptSuggestion = async (sg: AISuggestion) => {
|
||||||
if (!aiStore || !activeSessionId) return;
|
if (!aiStore || !activeSessionId) return;
|
||||||
const replacement = (draftEdits[sg.id] ?? sg.newText ?? sg.text).trim();
|
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) => {
|
const handleDenySuggestion = async (sg: AISuggestion) => {
|
||||||
if (!aiStore || !activeSessionId) return;
|
if (!aiStore || !activeSessionId) return;
|
||||||
await aiStore.deleteSuggestion(activeSessionId, sg.id);
|
await aiStore.deleteSuggestion(activeSessionId, sg.id);
|
||||||
@@ -667,7 +923,6 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
|
|||||||
toast.info(`Denied suggestion for Line ${sg.lineNumber ?? "?"}`);
|
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) => {
|
const handleCopySuggestionToNote = async (sg: AISuggestion) => {
|
||||||
if (!aiStore || !activeSessionId) return;
|
if (!aiStore || !activeSessionId) return;
|
||||||
const replacement = (draftEdits[sg.id] ?? sg.newText ?? sg.text).trim();
|
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 renderMessageWithLineLinks = (content: string) => {
|
||||||
const parts = content.split(/(\[Line\s+\d+\])/gi);
|
const parts = content.split(/(\[Line\s+\d+\])/gi);
|
||||||
return parts.map((part, i) => {
|
return parts.map((part, i) => {
|
||||||
@@ -737,10 +991,24 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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 */}
|
{/* Header */}
|
||||||
<div className="ps-ai-sidebar-head">
|
<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>
|
<span style={{ fontSize: 16 }}>✨</span>
|
||||||
<strong style={{ fontSize: 14 }}>AI Studio</strong>
|
<strong style={{ fontSize: 14 }}>AI Studio</strong>
|
||||||
<Badge variant="accent">{activeAgent.name}</Badge>
|
<Badge variant="accent">{activeAgent.name}</Badge>
|
||||||
@@ -980,7 +1248,7 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
|
|||||||
{activeTab === "chat" && (
|
{activeTab === "chat" && (
|
||||||
<div className="ps-ai-chat-body">
|
<div className="ps-ai-chat-body">
|
||||||
<div ref={scrollRef} className="ps-ai-messages">
|
<div ref={scrollRef} className="ps-ai-messages">
|
||||||
{messages.length === 0 ? (
|
{messages.length === 0 && !sending ? (
|
||||||
<div className="ps-ai-empty">
|
<div className="ps-ai-empty">
|
||||||
<div style={{ fontSize: 24, marginBottom: 6 }}>✨</div>
|
<div style={{ fontSize: 24, marginBottom: 6 }}>✨</div>
|
||||||
<div style={{ fontWeight: 600, color: "var(--ps-text)" }}>
|
<div style={{ fontWeight: 600, color: "var(--ps-text)" }}>
|
||||||
@@ -1013,6 +1281,29 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
|
|||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={(e) => void handleSendMessage(e)} className="ps-ai-composer">
|
<form onSubmit={(e) => void handleSendMessage(e)} className="ps-ai-composer">
|
||||||
@@ -1064,7 +1355,7 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* SUGGESTIONS TAB: Step through each line suggestion with Accept / Deny / Copy to Note */}
|
{/* SUGGESTIONS TAB */}
|
||||||
{activeTab === "suggestions" && (
|
{activeTab === "suggestions" && (
|
||||||
<div className="ps-ai-chat-body">
|
<div className="ps-ai-chat-body">
|
||||||
{pendingSuggestions.length > 0 && (
|
{pendingSuggestions.length > 0 && (
|
||||||
@@ -1103,7 +1394,26 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="ps-ai-messages">
|
<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 className="ps-ai-empty">
|
||||||
<div style={{ marginBottom: 8 }}>No pending line suggestions yet.</div>
|
<div style={{ marginBottom: 8 }}>No pending line suggestions yet.</div>
|
||||||
<Button
|
<Button
|
||||||
@@ -1112,7 +1422,7 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
|
|||||||
onClick={() => void handleGenerateLineSuggestions()}
|
onClick={() => void handleGenerateLineSuggestions()}
|
||||||
disabled={sending}
|
disabled={sending}
|
||||||
>
|
>
|
||||||
{sending ? "Analyzing Lines…" : "✨ Generate Line Suggestions"}
|
✨ Generate Line Suggestions
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -1270,6 +1580,38 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
</aside>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -250,6 +250,7 @@ export interface AppSettings {
|
|||||||
// Navigation & AI Sidebars
|
// Navigation & AI Sidebars
|
||||||
navCollapsed: boolean;
|
navCollapsed: boolean;
|
||||||
aiSidebarOpen: boolean;
|
aiSidebarOpen: boolean;
|
||||||
|
aiSidebarWidth: number;
|
||||||
// Spell & grammar (Chunk #7)
|
// Spell & grammar (Chunk #7)
|
||||||
enableSpellcheck: boolean;
|
enableSpellcheck: boolean;
|
||||||
enableGrammarCheck: boolean;
|
enableGrammarCheck: boolean;
|
||||||
@@ -278,6 +279,7 @@ export const DEFAULT_SETTINGS: AppSettings = {
|
|||||||
focusMode: false,
|
focusMode: false,
|
||||||
navCollapsed: false,
|
navCollapsed: false,
|
||||||
aiSidebarOpen: false,
|
aiSidebarOpen: false,
|
||||||
|
aiSidebarWidth: 370,
|
||||||
// Spell & grammar (Chunk #7)
|
// Spell & grammar (Chunk #7)
|
||||||
enableSpellcheck: true,
|
enableSpellcheck: true,
|
||||||
enableGrammarCheck: false,
|
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. */
|
/** Add a note to a session and persist it. */
|
||||||
async addNote(sessionId: string, note: Omit<AINote, "id" | "createdAt" | "updatedAt">): Promise<AISession> {
|
async addNote(sessionId: string, note: Omit<AINote, "id" | "createdAt" | "updatedAt">): Promise<AISession> {
|
||||||
return this.withTransaction(async () => {
|
return this.withTransaction(async () => {
|
||||||
|
|||||||
@@ -184,6 +184,47 @@ body {
|
|||||||
width: 100%;
|
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 {
|
.ps-topbar {
|
||||||
grid-area: topbar;
|
grid-area: topbar;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -648,8 +689,9 @@ body {
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- AI Sidebar (Toggleable Right Rail) ---- */
|
/* ---- AI Sidebar (Toggleable & Resizable Right Rail) ---- */
|
||||||
.ps-ai-sidebar {
|
.ps-ai-sidebar {
|
||||||
|
position: relative;
|
||||||
width: 370px;
|
width: 370px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@@ -660,6 +702,28 @@ body {
|
|||||||
font-family: var(--ps-font-ui);
|
font-family: var(--ps-font-ui);
|
||||||
overflow: hidden;
|
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 {
|
.ps-ai-fullpage {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 900px;
|
max-width: 900px;
|
||||||
@@ -909,6 +973,99 @@ body {
|
|||||||
flex-wrap: wrap;
|
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 ---- */
|
/* ---- Projects Page & Template Wizard ---- */
|
||||||
.ps-projects-grid {
|
.ps-projects-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|||||||
Reference in New Issue
Block a user