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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user