/** * The free-viewport canvas: a dot-grid surface with wheel-anchored zoom, * background-drag panning, the zoom controls (− / level / + / fit / relayout * / reset / locate), a title filter, and the minimap, rendering the laid-out * graph inside one transformed content layer. Hovering emphasizes one * branch lineage (or one edge's endpoints) while the rest dims; a dwell * opens the node detail card; drags snap to sibling edges behind alignment * guides; programmatic jumps glide while gestures stay immediate. Every * gesture resolves through the pure viewport math module. */ import clsx from 'clsx' import { useCallback, useEffect, useId, useMemo, useRef, useState, type ReactElement, } from 'react' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { SessionDigest } from '../session-digest.ts' import { SessionHistory } from './SessionHistory.tsx' import { containsSessionReferenceUri } from '../session-merge.ts' import { CLUSTER_COLORS } from './clusters.ts' import type { ClusterInfo, DisplayStatus, GraphNode, SessionGraphNode } from './graph-model.ts' import { branchLineage, matchFilter } from './graph-model.ts' import { CARD_H, NODE_W, type ContentBounds, type LaidOutGraph, type LaidOutNode, } from './layout.ts' import { type ClusterOffset, loadArrangement, saveLayout, type NodePosition, type SessionArrangementIdentity, type LayoutState, } from './layout-store.ts' import type { SessionGraphKey } from './locales.ts' import { placePreview } from './preview-placement.ts' import { snapPosition } from './snap.ts' import type { GraphViewInjected } from './GraphView.tsx' import type { LaidOutFrame } from './clusters.ts' import { deriveCanvasPresentation } from './canvas-presentation.ts' import { fitViewport, initialViewport, minimapProjection, panBy, resizeViewport, zoomAt, } from './viewport.ts' import styles from './GraphView.module.css' import { loadWorkingPosition, saveWorkingPosition } from './working-position.ts' /** Screen movement below this many px stays a click, not a drag. */ const DRAG_THRESHOLD = 3 /** Translation seat over the sessionGraph namespace. */ type Translate = (key: SessionGraphKey, params?: Record) => string interface MergeFailure { readonly code: string | undefined readonly stage: string | undefined readonly message: string readonly targetSessionId: SessionId | undefined } type MergeRunState = | { readonly phase: 'idle' } | { readonly phase: 'submitting' } | { readonly phase: 'error'; readonly failure: MergeFailure } /** Preserve only the stable, user-actionable fields exposed by SessionMergeError. */ function mergeFailureOf(error: unknown): MergeFailure { if (!(error instanceof Error)) { return { code: undefined, stage: undefined, message: String(error), targetSessionId: undefined } } const details = error as Error & { readonly code?: unknown readonly stage?: unknown readonly targetSessionId?: unknown } return { code: typeof details.code === 'string' ? details.code : undefined, stage: typeof details.stage === 'string' ? details.stage : undefined, message: error.message, targetSessionId: typeof details.targetSessionId === 'string' ? details.targetSessionId as SessionId : undefined, } } function mergeFailureKey(failure: MergeFailure): SessionGraphKey { if (failure.stage === 'validating') return 'merge.errorValidation' if (failure.stage === 'creating') return 'merge.errorCreating' if (failure.stage === 'naming') return 'merge.errorNaming' if (failure.stage === 'submitting') return 'merge.errorSubmitting' if (failure.stage === 'opening') return 'merge.errorOpening' return 'merge.errorUnknown' } /** Grid dot spacing in content px. */ const GRID = 24 /** Fit-view inset in screen px. */ const FIT_PADDING = 48 /** One control-button zoom step as a multiplicative factor. */ const CONTROL_STEP = 1.2 /** Below this scale the card text is unreadable; the LOD pass fades it. */ const LOD_SCALE = 0.45 /** Drag-alignment snap distance in screen px (scaled into content px). */ const SNAP_PX = 6 /** Hover dwell in ms before the node detail card opens. */ const PREVIEW_DELAY = 400 /** Node detail card width in screen px. */ const PREVIEW_W = 240 /** Conservative detail-card height used for collision-free placement. */ const PREVIEW_H = 112 /** Screen inset occupied by the filter and canvas controls. */ const PREVIEW_TOP_INSET = 56 /** Right-side canvas inset occupied by the Selected Session inspector. */ const INSPECTOR_RIGHT_INSET = 444 /** Restore one record key to its pre-gesture value, removing a previously absent key. */ function restoreEntry( record: Readonly>, key: string, previous: T | undefined, ): Record { if (previous !== undefined) return { ...record, [key]: previous } return Object.fromEntries(Object.entries(record).filter(([entryKey]) => entryKey !== key)) } /** Localized compact relative time, bucketed exactly like the sidebar rows. */ function timeLabel(updatedAt: number, now: number, t: Translate): string { const MIN = 60_000 const HOUR = 3_600_000 const DAY = 86_400_000 const diff = Math.max(0, now - updatedAt) if (diff < MIN) return t('time.now') if (diff < HOUR) return t('time.minutes', { n: Math.floor(diff / MIN) }) if (diff < DAY) return t('time.hours', { n: Math.floor(diff / HOUR) }) if (diff < 30 * DAY) return t('time.days', { n: Math.floor(diff / DAY) }) if (diff < 365 * DAY) return t('time.months', { n: Math.floor(diff / (30 * DAY)) }) return t('time.years', { n: Math.floor(diff / (365 * DAY)) }) } /** Translate the single Display Status derived from overlapping activity facts. */ function displayStatusLabel(status: DisplayStatus | undefined, t: Translate): string { if (status === 'running') return t('preview.status.running') if (status === 'waiting-input') return t('preview.status.pending') if (status === 'completed') return t('preview.status.completed') return '' } /** Node pointer-gesture callbacks owned by the canvas (drag + click routing). */ interface NodeGestureHandlers { onPointerDown: (event: React.PointerEvent) => void onPointerMove: (event: React.PointerEvent) => void onPointerUp: (event: React.PointerEvent) => void onPointerCancel: (event: React.PointerEvent) => void onClick: () => void onDoubleClick: () => void } /** One Canvas Session card: title-first hierarchy, state, metadata, and terminals. */ function NodeCard({ laid, now, t, gestures, clusterColor, selected, onHoverBadge, badgeHovered, dimClass, onHoverNode, mergeOrder, }: { laid: LaidOutNode now: number t: Translate gestures: NodeGestureHandlers clusterColor: string selected: boolean mergeOrder: number | undefined onHoverBadge: (key: string | null) => void badgeHovered: boolean dimClass: string | null | undefined onHoverNode: (key: string | null) => void }) { const { node, key, x, y } = laid const session = node.kind === 'knowledge' ? undefined : node const source = session?.topicSource const badge = session !== undefined && session.subagentCount > 0 ? `${t('node.subagents', { count: session.subagentCount })}${session.runningSubagents > 0 ? ` (${t('node.running', { count: session.runningSubagents })})` : ''}` : '' return ( <>