feat: collapsible long code blocks with expand/collapse toggle

Code blocks longer than 25 lines are now automatically collapsed,
showing only the first 10 lines with a gradient fade and a
'Show all N lines' button. Click to expand/collapse.

Also fixes version sync (package.json now matches v1.63.4)
and shows line count in the language header for long blocks.
This commit is contained in:
Nicolas Varrot
2026-02-15 18:04:25 +00:00
parent eb63675761
commit 15ace2a9ba
2 changed files with 44 additions and 17 deletions

View File

@@ -1,6 +1,6 @@
{ {
"name": "pinchchat", "name": "pinchchat",
"version": "1.63.2", "version": "1.63.4",
"description": "A sleek, dark-themed webchat UI for OpenClaw — monitor sessions, stream responses, and inspect tool calls in real-time.", "description": "A sleek, dark-themed webchat UI for OpenClaw — monitor sessions, stream responses, and inspect tool calls in real-time.",
"type": "module", "type": "module",
"repository": { "repository": {

View File

@@ -1,5 +1,5 @@
import { useState, useCallback, type HTMLAttributes, type ReactElement } from 'react'; import { useState, useCallback, type HTMLAttributes, type ReactElement } from 'react';
import { Check, Copy, Hash, WrapText, AlignLeft } from 'lucide-react'; import { Check, Copy, Hash, WrapText, AlignLeft, ChevronDown, ChevronUp } from 'lucide-react';
/** Extract the language from the nested <code> element's className (e.g. "language-ts"). */ /** Extract the language from the nested <code> element's className (e.g. "language-ts"). */
function extractLanguage(children: React.ReactNode): string | null { function extractLanguage(children: React.ReactNode): string | null {
@@ -45,6 +45,8 @@ function formatLanguage(lang: string): string {
const LINE_NUMBER_KEY = 'pinchchat-line-numbers'; const LINE_NUMBER_KEY = 'pinchchat-line-numbers';
const WRAP_KEY = 'pinchchat-code-wrap'; const WRAP_KEY = 'pinchchat-code-wrap';
const LINE_THRESHOLD = 3; // Only show line numbers for blocks with more than this many lines const LINE_THRESHOLD = 3; // Only show line numbers for blocks with more than this many lines
const COLLAPSE_THRESHOLD = 25; // Collapse code blocks longer than this
const COLLAPSE_PREVIEW_LINES = 10; // Lines to show when collapsed
export function CodeBlock(props: HTMLAttributes<HTMLPreElement>) { export function CodeBlock(props: HTMLAttributes<HTMLPreElement>) {
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
@@ -56,11 +58,13 @@ export function CodeBlock(props: HTMLAttributes<HTMLPreElement>) {
const stored = localStorage.getItem(WRAP_KEY); const stored = localStorage.getItem(WRAP_KEY);
return stored === 'true'; return stored === 'true';
}); });
const [isCollapsed, setIsCollapsed] = useState(true);
const language = extractLanguage(props.children); const language = extractLanguage(props.children);
const code = (props.children as ReactElement<{ children?: string }> | undefined)?.props?.children; const code = (props.children as ReactElement<{ children?: string }> | undefined)?.props?.children;
const lines = typeof code === 'string' ? code.replace(/\n$/, '').split('\n') : []; const lines = typeof code === 'string' ? code.replace(/\n$/, '').split('\n') : [];
const hasEnoughLines = lines.length > LINE_THRESHOLD; const hasEnoughLines = lines.length > LINE_THRESHOLD;
const isCollapsible = lines.length > COLLAPSE_THRESHOLD;
const handleCopy = useCallback(() => { const handleCopy = useCallback(() => {
if (typeof code === 'string') { if (typeof code === 'string') {
@@ -88,12 +92,15 @@ export function CodeBlock(props: HTMLAttributes<HTMLPreElement>) {
}, []); }, []);
const shouldShowNumbers = showLineNumbers && hasEnoughLines; const shouldShowNumbers = showLineNumbers && hasEnoughLines;
const collapsed = isCollapsible && isCollapsed;
const wrapStyle = wordWrap ? { whiteSpace: 'pre-wrap' as const, overflowWrap: 'break-word' as const, wordBreak: 'break-word' as const } : undefined;
const collapseStyle = collapsed ? { maxHeight: `${COLLAPSE_PREVIEW_LINES * 1.7142857}em`, overflow: 'hidden' as const } : undefined;
return ( return (
<div className="group/code relative"> <div className="group/code relative">
{language && ( {language && (
<div className="flex items-center justify-between px-4 py-1.5 bg-pc-elevated/80 border-b border-pc-border rounded-t-lg text-[11px] text-pc-text-muted font-mono select-none"> <div className="flex items-center justify-between px-4 py-1.5 bg-pc-elevated/80 border-b border-pc-border rounded-t-lg text-[11px] text-pc-text-muted font-mono select-none">
<span>{formatLanguage(language)}</span> <span>{formatLanguage(language)}{isCollapsible && <span className="ml-1.5 text-pc-text-faint">({lines.length} lines)</span>}</span>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<button <button
onClick={toggleWrap} onClick={toggleWrap}
@@ -117,24 +124,44 @@ export function CodeBlock(props: HTMLAttributes<HTMLPreElement>) {
</div> </div>
</div> </div>
)} )}
{shouldShowNumbers ? ( <div className="relative">
<div className={`flex ${language ? 'rounded-t-none' : 'rounded-lg'} overflow-hidden`}> {shouldShowNumbers ? (
<div <div className={`flex ${language ? 'rounded-t-none' : 'rounded-lg'} overflow-hidden`} style={collapseStyle}>
className="flex-shrink-0 select-none text-right pr-3 pl-3 py-4 text-[11px] leading-[1.7142857] font-mono text-pc-text-muted/40 bg-pc-elevated/40 border-r border-pc-border/50" <div
aria-hidden="true" className="flex-shrink-0 select-none text-right pr-3 pl-3 py-4 text-[11px] leading-[1.7142857] font-mono text-pc-text-muted/40 bg-pc-elevated/40 border-r border-pc-border/50"
> aria-hidden="true"
{lines.map((_, i) => ( >
<div key={i}>{i + 1}</div> {(collapsed ? lines.slice(0, COLLAPSE_PREVIEW_LINES) : lines).map((_, i) => (
))} <div key={i}>{i + 1}</div>
))}
</div>
<pre {...props} className={`${props.className || ''} flex-1 !rounded-none !mt-0 min-w-0`} style={wrapStyle} />
</div> </div>
<pre {...props} className={`${props.className || ''} flex-1 !rounded-none !mt-0 min-w-0`} style={wordWrap ? { whiteSpace: 'pre-wrap', overflowWrap: 'break-word', wordBreak: 'break-word' } : undefined} /> ) : (
</div> <div style={collapseStyle}>
) : ( <pre {...props} className={`${props.className || ''} ${language ? '!rounded-t-none !mt-0' : ''}`} style={wrapStyle} />
<pre {...props} className={`${props.className || ''} ${language ? '!rounded-t-none !mt-0' : ''}`} style={wordWrap ? { whiteSpace: 'pre-wrap', overflowWrap: 'break-word', wordBreak: 'break-word' } : undefined} /> </div>
)}
{collapsed && (
<div className="absolute bottom-0 left-0 right-0 h-16 bg-gradient-to-t from-[var(--pc-bg-surface)] to-transparent pointer-events-none rounded-b-lg" />
)}
</div>
{isCollapsible && (
<button
onClick={() => setIsCollapsed(prev => !prev)}
className="flex items-center justify-center gap-1.5 w-full py-1.5 text-[11px] text-pc-text-muted hover:text-pc-text-secondary bg-pc-elevated/40 hover:bg-pc-elevated/60 border-t border-pc-border transition-colors rounded-b-lg"
type="button"
>
{isCollapsed ? (
<><ChevronDown className="h-3 w-3" /><span>Show all {lines.length} lines</span></>
) : (
<><ChevronUp className="h-3 w-3" /><span>Collapse</span></>
)}
</button>
)} )}
<button <button
onClick={handleCopy} onClick={handleCopy}
className="absolute top-2 right-2 p-1.5 rounded-lg bg-pc-elevated/60 hover:bg-pc-elevated/80 border border-pc-border-strong text-pc-text-secondary hover:text-pc-text opacity-0 group-hover/code:opacity-100 transition-opacity duration-150" className={`absolute ${language ? 'top-10' : 'top-2'} right-2 p-1.5 rounded-lg bg-pc-elevated/60 hover:bg-pc-elevated/80 border border-pc-border-strong text-pc-text-secondary hover:text-pc-text opacity-0 group-hover/code:opacity-100 transition-opacity duration-150`}
title="Copy code" title="Copy code"
aria-label="Copy code to clipboard" aria-label="Copy code to clipboard"
type="button" type="button"