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:
Schwitzz
2026-10-30 11:53:32 -05:00
parent b1c22229c5
commit 271d1e2c79
6 changed files with 617 additions and 78 deletions
-1
View File
@@ -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 },
+65 -45
View File
@@ -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>
);
+373 -31
View File
@@ -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>
);
}
+2
View File
@@ -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,
+19
View File
@@ -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 () => {
+158 -1
View File
@@ -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;