Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/skills/coc-knowledge/references/dashboard-spa.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ spa/client/react/
├── features/
│ ├── chat/ # Chat UI: ChatDetail, ChatListPane, ConversationArea
│ ├── memory/ # Memory V2 route, facts/review/episodes tabs, repo memory settings section
│ ├── notes/ # Notes UI: NoteEditor, sidebar, multi-root dropdown (useNotesRoots)
│ ├── notes/ # Notes UI: NoteEditor, Mermaid zoom/pan, sidebar, multi-root dropdown (useNotesRoots)
│ ├── pull-requests/ # PR dashboard: attention groups, BatchCommandPanel
│ └── terminal/ # Terminal UI: TerminalView, pin/unpin
├── processes/ # Process detail, DAG visualization
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
* into an atom block node with a React NodeView that supports preview/source toggle.
*/

import { useRef, useState, useEffect } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import type { MouseEvent as ReactMouseEvent, WheelEvent as ReactWheelEvent } from 'react';
import { Node } from '@tiptap/core';
import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react';
import type { NodeViewProps } from '@tiptap/react';
Expand All @@ -15,16 +16,38 @@ declare const mermaid: {
run(opts: { nodes: NodeListOf<Element> | Element[] }): Promise<void>;
};

const MIN_ZOOM = 0.25;
const MAX_ZOOM = 4;
const ZOOM_STEP = 0.25;

function escapeHtmlForMermaid(str: string): string {
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}

function clampZoom(value: number): number {
return Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, value));
}

function formatZoomLabel(value: number): string {
return `${Math.round(value * 100)}%`;
}

// ── React NodeView Component ────────────────────────────────────────────────

function MermaidBlockView({ node }: NodeViewProps) {
export function MermaidBlockView({ node }: NodeViewProps) {
const [mode, setMode] = useState<'preview' | 'source'>('preview');
const [error, setError] = useState<string | null>(null);
const [zoom, setZoom] = useState(1);
const [pan, setPan] = useState({ x: 0, y: 0 });
const preRef = useRef<HTMLPreElement>(null);
const previewRef = useRef<HTMLDivElement>(null);
const dragRef = useRef<{
active: boolean;
startX: number;
startY: number;
originX: number;
originY: number;
}>({ active: false, startX: 0, startY: 0, originX: 0, originY: 0 });

useEffect(() => {
if (mode !== 'preview') return;
Expand All @@ -40,22 +63,150 @@ function MermaidBlockView({ node }: NodeViewProps) {
.catch((err) => setError(err instanceof Error ? err.message : 'Render error'));
}, [node.attrs.code, mode]);

const setZoomAtPoint = useCallback((nextZoom: number, anchorX: number, anchorY: number) => {
const clamped = clampZoom(nextZoom);
if (clamped === zoom) return;

setPan((currentPan) => ({
x: anchorX - ((anchorX - currentPan.x) / zoom) * clamped,
y: anchorY - ((anchorY - currentPan.y) / zoom) * clamped,
}));
setZoom(clamped);
}, [zoom]);

const zoomBy = useCallback((delta: number) => {
const preview = previewRef.current;
if (!preview) {
setZoom(clampZoom(zoom + delta));
return;
}

const rect = preview.getBoundingClientRect();
setZoomAtPoint(zoom + delta, rect.width / 2, rect.height / 2);
}, [setZoomAtPoint, zoom]);

const resetZoom = useCallback(() => {
setZoom(1);
setPan({ x: 0, y: 0 });
}, []);

const handleWheel = useCallback((event: ReactWheelEvent<HTMLDivElement>) => {
if (!event.ctrlKey && !event.metaKey) return;
event.preventDefault();
event.stopPropagation();

const rect = event.currentTarget.getBoundingClientRect();
setZoomAtPoint(
zoom + (event.deltaY > 0 ? -ZOOM_STEP : ZOOM_STEP),
event.clientX - rect.left,
event.clientY - rect.top,
);
}, [setZoomAtPoint, zoom]);

const handleMouseDown = useCallback((event: ReactMouseEvent<HTMLDivElement>) => {
if (event.button !== 0) return;
event.preventDefault();
event.stopPropagation();
dragRef.current = {
active: true,
startX: event.clientX,
startY: event.clientY,
originX: pan.x,
originY: pan.y,
};
previewRef.current?.classList.add('mermaid-node-view-dragging');
}, [pan]);

useEffect(() => {
const handleMouseMove = (event: MouseEvent) => {
const drag = dragRef.current;
if (!drag.active) return;
setPan({
x: drag.originX + (event.clientX - drag.startX),
y: drag.originY + (event.clientY - drag.startY),
});
};

const handleMouseUp = () => {
if (!dragRef.current.active) return;
dragRef.current.active = false;
previewRef.current?.classList.remove('mermaid-node-view-dragging');
};

document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
}, []);

const zoomLabel = formatZoomLabel(zoom);

return (
<NodeViewWrapper
className="mermaid-node-view"
data-drag-handle=""
>
<div className="mermaid-node-view-toolbar">
<button onClick={() => setMode((m) => (m === 'preview' ? 'source' : 'preview'))}>
<button
type="button"
onClick={() => setMode((m) => (m === 'preview' ? 'source' : 'preview'))}
>
{mode === 'preview' ? '</> Source' : '▶ Preview'}
</button>
{mode === 'preview' && (
<div className="mermaid-node-view-zoom-controls" aria-label="Mermaid zoom controls">
<button
type="button"
aria-label="Zoom out"
title="Zoom out"
disabled={zoom <= MIN_ZOOM}
onClick={() => zoomBy(-ZOOM_STEP)}
>
-
</button>
<span className="mermaid-node-view-zoom-level" aria-live="polite">
{zoomLabel}
</span>
<button
type="button"
aria-label="Zoom in"
title="Zoom in"
disabled={zoom >= MAX_ZOOM}
onClick={() => zoomBy(ZOOM_STEP)}
>
+
</button>
<button
type="button"
aria-label="Reset zoom"
title="Reset zoom"
onClick={resetZoom}
>
Reset
</button>
</div>
)}
</div>

{error && <div className="mermaid-node-view-error">{error}</div>}

{mode === 'preview' ? (
<div className="mermaid-node-view-preview">
<pre ref={preRef} className="mermaid" />
<div
ref={previewRef}
className="mermaid-node-view-preview"
onWheel={handleWheel}
onMouseDown={handleMouseDown}
>
<div
className="mermaid-node-view-canvas"
style={{
transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`,
}}
>
<pre ref={preRef} className="mermaid" />
</div>
</div>
) : (
<pre className="mermaid-node-view-source">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -645,6 +645,7 @@
display: flex;
flex-direction: row;
align-items: center;
flex-wrap: wrap;
gap: 6px;
padding: 4px 8px;
font-size: 12px;
Expand All @@ -668,12 +669,47 @@
border-color: #aaa;
}

.mermaid-node-view-zoom-controls {
display: inline-flex;
align-items: center;
gap: 4px;
}

.mermaid-node-view-toolbar button:disabled {
opacity: 0.45;
cursor: not-allowed;
}

.mermaid-node-view-zoom-level {
min-width: 42px;
text-align: center;
font-variant-numeric: tabular-nums;
color: #555;
}

.mermaid-node-view-preview {
min-height: 60px;
padding: 12px;
overflow: auto;
display: flex;
justify-content: center;
cursor: grab;
touch-action: none;
}

.mermaid-node-view-preview.mermaid-node-view-dragging {
cursor: grabbing;
}

.mermaid-node-view-canvas {
display: inline-block;
min-width: 100%;
transform-origin: 0 0;
transition: transform 120ms ease-out;
will-change: transform;
}

.mermaid-node-view-canvas .mermaid {
display: inline-block;
margin: 0;
}

/* SVG rendered by mermaid is display:block by default; center it */
Expand Down Expand Up @@ -730,6 +766,10 @@
border-color: #666;
}

.dark .mermaid-node-view-zoom-level {
color: #bdbdbd;
}

.dark .mermaid-node-view-source {
background: rgba(255, 255, 255, 0.04);
color: #d4d4d4;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -149,10 +149,11 @@ async function assertDeepLinkRendersDetail(

// The conversation pane must have a real width — the regression collapsed
// it to 0px on the first paint even though the element existed.
const box = await detail.boundingBox();
expect(box, `activity-chat-detail (${urlSegment}) should have a bounding box`).not.toBeNull();
expect(
box!.width,
await expect.poll(
async () => {
const box = await detail.boundingBox();
return box?.width ?? 0;
},
`activity-chat-detail (${urlSegment}) should have non-zero width on mobile`,
).toBeGreaterThan(200);

Expand Down Expand Up @@ -223,9 +224,10 @@ test.describe('Mobile Activity Deep Link', () => {
const detail = page.locator('[data-testid="activity-chat-detail"]');
await expect(detail).toBeVisible({ timeout: 10000 });

const box = await detail.boundingBox();
expect(box, 'activity-chat-detail should have a bounding box after tap').not.toBeNull();
expect(box!.width).toBeGreaterThan(200);
await expect.poll(async () => {
const box = await detail.boundingBox();
return box?.width ?? 0;
}, 'activity-chat-detail should have non-zero width after tap').toBeGreaterThan(200);

// After tapping a completed chat, the list pane is replaced by the detail.
await expect(page.locator('[data-testid="activity-mobile-list"]')).toHaveCount(0);
Expand All @@ -249,9 +251,10 @@ test.describe('Mobile Activity Deep Link', () => {
const detail = page.locator('[data-testid="activity-chat-detail"]');
await expect(detail).toBeVisible({ timeout: 10000 });

const box = await detail.boundingBox();
expect(box, 'activity-chat-detail should have a bounding box after tap').not.toBeNull();
expect(box!.width).toBeGreaterThan(200);
await expect.poll(async () => {
const box = await detail.boundingBox();
return box?.width ?? 0;
}, 'activity-chat-detail should have non-zero width after tap').toBeGreaterThan(200);

await expect(page.locator('[data-testid="activity-mobile-list"]')).toHaveCount(0);
});
Expand Down
Loading
Loading