feat(web): workspace components — ComparePane, Memory page, McpDialog, error boundaries, message-parts
- Add ComparePane.tsx: side-by-side AI response comparison - Add Memory.tsx: memory management page with CRUD UI - Add McpPermissionDialog.tsx: MCP tool permission approval dialog - Add McpResponseDisplay.tsx: MCP response visualization - Add MessageBoundary.tsx + MessageListErrorBoundary.tsx: error resilience - Add EmptyState.tsx: contextual empty state component - Add KeyboardShortcutsDialog.tsx: keyboard shortcut reference - Add message-parts/: ActionRow, CompactCard, MistakeRecoverySentinel, ReasoningBlock, SendToTerminalMenu, StatsLine, SummaryCard - Add useDraftPersistence.ts: draft message persistence hook - Add useTerminals.ts: terminal session management hook - Add keyboard-shortcuts.ts + tool-utils.ts: shared utilities - Extend components: ChatInput, MessageBubble, MessageList, Workspace, panes - Extend hooks: useTerminalSocket, useSessionStream test suite - Update pages: Home, Project — workspace layout and session flow
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Check, Copy } from 'lucide-react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Check, Copy, Moon, Sun, WrapText } from 'lucide-react';
|
||||
import { codeToHtml } from 'shiki';
|
||||
|
||||
// NOTE: spec calls for syntax-highlighted code blocks. Added Shiki in v1.1.
|
||||
@@ -45,35 +45,111 @@ const LANG_MAP: Record<string, string> = {
|
||||
css: 'css',
|
||||
};
|
||||
|
||||
const SHIKI_THEME = 'github-dark';
|
||||
// ── LRU highlight cache (module-scoped) ──────────────────────────
|
||||
// Key = `${code}|${theme}|${mappedLang}`, max 50 entries.
|
||||
// Avoids redundant codeToHtml calls when the same code/theme/lang
|
||||
// combination is rendered multiple times (e.g. across messages).
|
||||
const HIGHLIGHT_CACHE = new Map<string, string>();
|
||||
const MAX_CACHE_ENTRIES = 50;
|
||||
|
||||
function cacheGet(key: string): string | undefined {
|
||||
if (!HIGHLIGHT_CACHE.has(key)) return undefined;
|
||||
const val = HIGHLIGHT_CACHE.get(key)!;
|
||||
// LRU touch — delete & re-set to move to end (most recently used)
|
||||
HIGHLIGHT_CACHE.delete(key);
|
||||
HIGHLIGHT_CACHE.set(key, val);
|
||||
return val;
|
||||
}
|
||||
|
||||
function cacheSet(key: string, html: string): void {
|
||||
if (HIGHLIGHT_CACHE.size >= MAX_CACHE_ENTRIES) {
|
||||
const oldest = HIGHLIGHT_CACHE.keys().next().value;
|
||||
if (oldest !== undefined) HIGHLIGHT_CACHE.delete(oldest);
|
||||
}
|
||||
HIGHLIGHT_CACHE.set(key, html);
|
||||
}
|
||||
|
||||
export function CodeBlock({ code, lang }: Props) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [html, setHtml] = useState<string | null>(null);
|
||||
const highlightRef = useRef<HTMLDivElement | null>(null);
|
||||
const [theme, setTheme] = useState<'github-dark' | 'github-light'>(() => {
|
||||
try {
|
||||
if (localStorage.getItem('codeblock-theme') === 'github-light') return 'github-light';
|
||||
} catch {
|
||||
/* localStorage unavailable */
|
||||
}
|
||||
return 'github-dark';
|
||||
});
|
||||
const [wordWrap, setWordWrap] = useState(false);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const highlightRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// ── Derived state ──────────────────────────────────────────────
|
||||
|
||||
// Diff mode: detect `diff-` prefix (e.g. diff-ts, diff-py).
|
||||
// The actual lang for highlighting is the part after `diff-`.
|
||||
const isDiff = !!lang && lang.startsWith('diff-');
|
||||
const actualLang = isDiff && lang ? lang.slice('diff-'.length) : lang ?? '';
|
||||
const mappedLang = actualLang ? (LANG_MAP[actualLang.toLowerCase()] ?? null) : null;
|
||||
|
||||
// Strip leading `+`/`-` from code lines when in diff mode.
|
||||
// The markers are rendered in the gutter instead.
|
||||
const cleanCode = useMemo(
|
||||
() => (isDiff ? code.replace(/^[+-]/gm, '') : code),
|
||||
[code, isDiff],
|
||||
);
|
||||
|
||||
const codeLines = useMemo(() => code.split('\n'), [code]);
|
||||
const totalLines = codeLines.length;
|
||||
|
||||
// Gutter is hidden entirely when code has >= 1000 lines.
|
||||
const showGutter = totalLines < 1000;
|
||||
|
||||
// Collapsible: auto-collapse to 15 lines when >= 30 lines total.
|
||||
const isLong = totalLines >= 30;
|
||||
const collapsed = isLong && !expanded;
|
||||
const visibleLines = collapsed ? codeLines.slice(0, 15) : codeLines;
|
||||
|
||||
// Diff marker array: '+' / '-' / '' per line for the gutter.
|
||||
const diffMarkers = useMemo(
|
||||
() => (isDiff ? codeLines.map((l) => (l[0] === '+' ? '+' : l[0] === '-' ? '-' : '')) : null),
|
||||
[isDiff, codeLines],
|
||||
);
|
||||
|
||||
// ── Shiki highlighting ─────────────────────────────────────────
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const mappedLang = (lang && LANG_MAP[lang.toLowerCase()]) ?? null;
|
||||
|
||||
if (!mappedLang) {
|
||||
setHtml(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const cacheKey = `${cleanCode}|${theme}|${mappedLang}`;
|
||||
const cached = cacheGet(cacheKey);
|
||||
if (cached !== undefined) {
|
||||
setHtml(cached);
|
||||
return;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const result = await codeToHtml(code, { lang: mappedLang, theme: SHIKI_THEME });
|
||||
const result = await codeToHtml(cleanCode, { lang: mappedLang, theme });
|
||||
cacheSet(cacheKey, result);
|
||||
if (!cancelled) setHtml(result);
|
||||
} catch (err) {
|
||||
console.warn('shiki failed', err);
|
||||
console.warn('shiki highlight failed:', err);
|
||||
if (!cancelled) setHtml(null);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [code, lang]);
|
||||
}, [cleanCode, mappedLang, theme]);
|
||||
|
||||
// Inject Shiki HTML via ref; output is compiler-generated, not user input.
|
||||
// Inject Shiki HTML via ref (output is compiler-generated, not user input)
|
||||
useEffect(() => {
|
||||
if (highlightRef.current) {
|
||||
// Shiki generates sanitized HTML spans — not user-supplied content.
|
||||
@@ -82,39 +158,138 @@ export function CodeBlock({ code, lang }: Props) {
|
||||
}
|
||||
}, [html]);
|
||||
|
||||
async function copy() {
|
||||
// Sync word-wrap state to the injected <pre> element inside shiki's output
|
||||
useEffect(() => {
|
||||
const pre = highlightRef.current?.querySelector('pre');
|
||||
if (pre) {
|
||||
pre.style.whiteSpace = wordWrap ? 'pre-wrap' : 'nowrap';
|
||||
}
|
||||
}, [html, wordWrap]);
|
||||
|
||||
// ── Handlers ───────────────────────────────────────────────────
|
||||
|
||||
const handleToggleTheme = useCallback(() => {
|
||||
setTheme((prev) => {
|
||||
const next = prev === 'github-dark' ? 'github-light' : 'github-dark';
|
||||
try {
|
||||
localStorage.setItem('codeblock-theme', next);
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleCopy = useCallback(async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(code);
|
||||
await navigator.clipboard.writeText(cleanCode);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1200);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}, [cleanCode]);
|
||||
|
||||
// ── Shared class segments ──────────────────────────────────────
|
||||
|
||||
const preBaseClass = 'overflow-x-auto px-3 py-2 font-mono text-xs leading-relaxed';
|
||||
const shikiWrapperClass = `${preBaseClass} [&>pre]:!bg-transparent [&>pre]:!m-0 [&>pre]:!p-0`;
|
||||
const collapsedClass = collapsed ? 'max-h-[calc(15*1.625em)] overflow-hidden' : '';
|
||||
|
||||
// ── Render ─────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<div className="rounded-md border bg-muted/40 overflow-hidden text-sm my-1">
|
||||
{/* ── Toolbar ──────────────────────────────────────────── */}
|
||||
<div className="flex items-center justify-between px-2 py-1 border-b text-xs text-muted-foreground">
|
||||
<span className="font-mono">{lang || 'code'}</span>
|
||||
<span className="font-mono">{actualLang || lang || 'code'}</span>
|
||||
<div className="flex items-center gap-0.5">
|
||||
{/* Theme toggle — persists to localStorage key 'codeblock-theme' */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleToggleTheme}
|
||||
className="flex items-center gap-1 px-1.5 py-0.5 rounded hover:bg-muted text-foreground"
|
||||
aria-label={`Switch to ${theme === 'github-dark' ? 'light' : 'dark'} theme`}
|
||||
>
|
||||
{theme === 'github-dark' ? <Sun className="size-3" /> : <Moon className="size-3" />}
|
||||
</button>
|
||||
|
||||
{/* Word-wrap toggle */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setWordWrap((prev) => !prev)}
|
||||
className={`flex items-center gap-1 px-1.5 py-0.5 rounded hover:bg-muted text-foreground ${wordWrap ? 'bg-muted' : ''}`}
|
||||
aria-label={wordWrap ? 'Disable word wrap' : 'Enable word wrap'}
|
||||
>
|
||||
<WrapText className="size-3" />
|
||||
</button>
|
||||
|
||||
{/* Copy button — existing behavior (Check icon, 1200ms revert) */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleCopy()}
|
||||
className="flex items-center gap-1 px-1.5 py-0.5 rounded hover:bg-muted text-foreground"
|
||||
aria-label="Copy code"
|
||||
>
|
||||
{copied ? <Check className="size-3" /> : <Copy className="size-3" />}
|
||||
<span>{copied ? 'Copied' : 'Copy'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Code body (flex row: gutter + code) ──────────────── */}
|
||||
<div className="flex">
|
||||
{/* Gutter — line numbers or diff markers */}
|
||||
{showGutter && (
|
||||
<div
|
||||
className="flex-none select-none text-right text-muted-foreground/50 font-mono text-xs leading-relaxed py-2 border-r border-border/30"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{visibleLines.map((line, i) => {
|
||||
const isPlus = diffMarkers?.[i] === '+';
|
||||
const isMinus = diffMarkers?.[i] === '-';
|
||||
let gutterCellClass = 'px-2 leading-relaxed';
|
||||
if (isPlus) gutterCellClass += ' bg-green-500/10 border-l-2 border-green-500';
|
||||
if (isMinus) gutterCellClass += ' bg-red-500/10 border-l-2 border-red-500';
|
||||
const content = diffMarkers ? diffMarkers[i] : String(i + 1);
|
||||
return (
|
||||
<div key={i} className={gutterCellClass}>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Code area */}
|
||||
<div className="relative flex-1 min-w-0">
|
||||
{html !== null ? (
|
||||
<div ref={highlightRef} className={`${shikiWrapperClass} ${collapsedClass}`} />
|
||||
) : (
|
||||
<pre
|
||||
className={`${preBaseClass} ${collapsedClass}`}
|
||||
style={{ whiteSpace: wordWrap ? 'pre-wrap' : 'nowrap' }}
|
||||
>
|
||||
{collapsed ? codeLines.slice(0, 15).join('\n') : code}
|
||||
</pre>
|
||||
)}
|
||||
|
||||
{/* Gradient fade overlay for collapsed state */}
|
||||
{collapsed && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-12 bg-gradient-to-b from-transparent to-background pointer-events-none" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* "Show N more" button for collapsed state */}
|
||||
{collapsed && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void copy()}
|
||||
className="flex items-center gap-1 px-1.5 py-0.5 rounded hover:bg-muted text-foreground"
|
||||
aria-label="Copy code"
|
||||
onClick={() => setExpanded(true)}
|
||||
className="w-full text-xs text-muted-foreground hover:text-foreground py-1 border-t border-border/30 bg-muted/20"
|
||||
>
|
||||
{copied ? <Check className="size-3" /> : <Copy className="size-3" />}
|
||||
<span>{copied ? 'Copied' : 'Copy'}</span>
|
||||
Show {totalLines - 15} more {totalLines - 15 === 1 ? 'line' : 'lines'}
|
||||
</button>
|
||||
</div>
|
||||
{html !== null ? (
|
||||
<div
|
||||
ref={highlightRef}
|
||||
className="overflow-x-auto px-3 py-2 font-mono text-xs leading-relaxed [&>pre]:!bg-transparent [&>pre]:!m-0 [&>pre]:!p-0"
|
||||
/>
|
||||
) : (
|
||||
<pre className="overflow-x-auto px-3 py-2 font-mono text-xs leading-relaxed">
|
||||
{code}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user