diff --git a/apps/desktop/src/renderer/src/components/context-room/ported/components/HomeView.tsx b/apps/desktop/src/renderer/src/components/context-room/ported/components/HomeView.tsx index f92df9d9..cd90a53c 100644 --- a/apps/desktop/src/renderer/src/components/context-room/ported/components/HomeView.tsx +++ b/apps/desktop/src/renderer/src/components/context-room/ported/components/HomeView.tsx @@ -7,7 +7,7 @@ import { Search, Trash2, } from 'lucide-react'; -import { useEffect, useMemo, useRef, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { useLocale } from '../../../../i18n/LocaleContext'; import type { ContextRoomRecord } from '../types'; @@ -18,13 +18,15 @@ import { RoomCard } from './RoomCard'; import { RoomCreationStudio } from './RoomCreationStudio'; import { RoomLifecycleDialogs } from './RoomDialogs'; import { isMergeRecommendationCandidate, RoomDuplicateCenter } from './RoomDuplicateCenter'; -import { GraphFitButton } from './GraphFitButton'; -import { RoomGraphCanvas, type RoomGraphCanvasHandle } from './RoomGraphCanvas'; +import { RoomGraphCanvas } from './RoomGraphCanvas'; import { RoomNodeInspector } from './RoomGraphInspector'; import { RoomRelationInspector } from './RoomRelationControls'; import { useRoomRelationGraph } from '../hooks/useRoomRelationGraph'; import { roomKindIcon, roomKindTone } from './utils'; +/** 首页 Room 关系图谱:缩放/拖拽后鼠标静止多久自动复位到自适应视野。 */ +const HOME_GRAPH_AUTO_FIT_IDLE_MS = 5_000; + function RoomGraph({ rooms, onOpen, @@ -38,11 +40,10 @@ function RoomGraph({ const { error, graph, loading, reload } = useRoomRelationGraph(null); const selected = rooms.find((room) => room.id === selectedId); const selectedRelation = (graph?.edges ?? []).find((edge) => edge.id === selectedRelationId) ?? null; - const graphRef = useRef(null); // 首页图谱对齐原型:无工具栏(搜索/筛选/关系管理收进 Room 详情的关系面板), - // 只保留标题 + 画布 + 点选节点/关系出检查器;另加一颗常驻画布的自适应按钮, - // 因为视口能自由缩放平移,缩到最小或拖走后整张图会看不见。 + // 只保留标题 + 画布 + 点选节点/关系出检查器。视口可自由缩放平移, + // 不设「适应画布」按钮:缩放/拖拽后鼠标静止满 5 秒自动弹性复位。 return (
@@ -52,7 +53,7 @@ function RoomGraph({
{loading && !graph ?
{t('contextRoom:relations.loadingGraph')}
: ( )} - {graph ? void graphRef.current?.fitView()} /> : null} {error ?
{t('contextRoom:relations.degradedNoSyntheticEdges')}
: null}
{selectedRelation ? ( diff --git a/apps/desktop/src/renderer/src/components/context-room/ported/components/RoomGraphCanvas.tsx b/apps/desktop/src/renderer/src/components/context-room/ported/components/RoomGraphCanvas.tsx index 9e767a36..d5e95476 100644 --- a/apps/desktop/src/renderer/src/components/context-room/ported/components/RoomGraphCanvas.tsx +++ b/apps/desktop/src/renderer/src/components/context-room/ported/components/RoomGraphCanvas.tsx @@ -1,6 +1,7 @@ import { forwardRef, useCallback, + useEffect, useImperativeHandle, useMemo, useRef, @@ -37,6 +38,14 @@ interface RoomGraphCanvasProps { onOpenRoom: (roomId: string) => void onSelectRelation?: (relationId: string) => void onSelectRoom: (roomId: string | null) => void + /** + * 「无操作自动复位」的空闲阈值(毫秒);不传 = 关闭。 + * + * 语义:用户改过视口(缩放 / 平移拖拽)之后,鼠标**离开画布**满 N 毫秒 → + * 弹性补间回到自适应视野。画布内图谱保持不动;只点击节点(没移动、视口没变) + * 不会武装;移回画布会取消待触发的复位并停掉在播补间。 + */ + autoFitOnIdleMs?: number } const ROOM_KIND_COLORS: Record = { @@ -67,6 +76,7 @@ function RoomGraphCanvasComponent( onOpenRoom, onSelectRelation, onSelectRoom, + autoFitOnIdleMs = 0, }: RoomGraphCanvasProps, ref: React.ForwardedRef, ) { @@ -151,6 +161,78 @@ function RoomGraphCanvasComponent( canvasRef, settleFit, }) + // 「无操作自动复位」:只有视口真被用户改动过(缩放 / 平移拖拽)才武装; + // 复位时机:鼠标**离开**关系图谱画布满 autoFitOnIdleMs → 弹性补间回自适应视野。 + // 鼠标在画布内时图谱必须保持不动(不排倒计时);回进画布会取消待触发的复位和正在播放的补间。 + const autoFitTimer = useRef(null) + const autoFitArmed = useRef(false) + const pointerInside = useRef(false) + const pointerStart = useRef<{ x: number; y: number } | null>(null) + const pointerMoved = useRef(false) + const draggingNode = useRef(false) + const autoFitFire = useCallback(() => { + autoFitTimer.current = null + autoFitArmed.current = false + const canvas = canvasRef.current + // 1s 慢弹簧(一大一小交替)补间归位;不支持补间的画布退回瞬时对准。 + if (canvas?.animateFitView) canvas.animateFitView({ durationMs: 1000 }) + else canvas?.fitView() + }, []) + const clearAutoFitTimer = useCallback(() => { + if (autoFitTimer.current === null) return + window.clearTimeout(autoFitTimer.current) + autoFitTimer.current = null + }, []) + const armAutoFit = useCallback(() => { + // 只武装,不排倒计时:倒计时一律等鼠标离开画布才开始 + autoFitArmed.current = true + }, []) + useEffect(() => clearAutoFitTimer, [clearAutoFitTimer]) + + const handlePointerEnter = useCallback(() => { + pointerInside.current = true + // 鼠标回到画布内:取消待触发的复位;正在播放的补间也立刻停(画布内图谱必须静止)。 + // 若确实停掉了一段在播的补间,重新武装——等下次离开时把它补完。 + clearAutoFitTimer() + if (canvasRef.current?.cancelFitAnimation?.()) autoFitArmed.current = true + }, [clearAutoFitTimer]) + const handlePointerLeave = useCallback(() => { + pointerInside.current = false + pointerStart.current = null + // 离开画布才开始倒计时(视口被改过才需要复位) + clearAutoFitTimer() + if (autoFitOnIdleMs <= 0 || !autoFitArmed.current) return + autoFitTimer.current = window.setTimeout(autoFitFire, autoFitOnIdleMs) + }, [autoFitFire, autoFitOnIdleMs, clearAutoFitTimer]) + const handlePointerDown = useCallback((event: React.PointerEvent) => { + pointerInside.current = true + pointerStart.current = { x: event.clientX, y: event.clientY } + pointerMoved.current = false + draggingNode.current = false + // 用户伸手了:正在播放的复位补间立刻停(否则会跟拖拽抢镜头) + canvasRef.current?.cancelFitAnimation?.() + }, []) + const handlePointerMove = useCallback((event: React.PointerEvent) => { + pointerInside.current = true + const start = pointerStart.current + if (start && !pointerMoved.current) { + // 3px 死区:点击节点时的轻微抖动不算「改动视口」 + if (Math.abs(event.clientX - start.x) > 3 || Math.abs(event.clientY - start.y) > 3) { + pointerMoved.current = true + // 拖的是节点(不是平移画布)→ 视口没变,不武装 + if (!draggingNode.current) armAutoFit() + } + } + }, [armAutoFit]) + const handlePointerEnd = useCallback(() => { + pointerStart.current = null + }, []) + const handleWheel = useCallback(() => { + // 滚轮 = 缩放,视口一定变了;正在播放的补间同样让位 + canvasRef.current?.cancelFitAnimation?.() + armAutoFit() + }, [armAutoFit]) + const resizeLayout = useCallback((width: number, height: number) => { const dimensions = roomGraphLayoutDimensions({ compact, @@ -171,7 +253,16 @@ function RoomGraphCanvasComponent( }), []) return ( -
+
{ + draggingNode.current = true + layout.drag(nodeId, x, y) + }} onReleaseNode={layout.release} onOpenNode={onOpenRoom} onSelectEdge={onSelectRelation} diff --git a/apps/desktop/src/renderer/src/components/context-room/ported/components/detail-panels/RelationsPane.tsx b/apps/desktop/src/renderer/src/components/context-room/ported/components/detail-panels/RelationsPane.tsx index ce9e66ff..8f0ed9b9 100644 --- a/apps/desktop/src/renderer/src/components/context-room/ported/components/detail-panels/RelationsPane.tsx +++ b/apps/desktop/src/renderer/src/components/context-room/ported/components/detail-panels/RelationsPane.tsx @@ -1,5 +1,5 @@ import { Link2, Network } from 'lucide-react' -import { useEffect, useMemo, useRef, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { useLocale } from '../../../../../i18n/LocaleContext' import type { RoomDocument } from '@nxcore/agent-contract' @@ -7,12 +7,14 @@ import type { KnowledgeFileDto } from '../../../../../../../shared/knowledge' import type { ContextRoomRecord, ContextRoomResource } from '../../types' import { createContextRoomResourceLibrary } from '../../resources' import { useRoomRelationGraph } from '../../hooks/useRoomRelationGraph' -import { RoomGraphCanvas, type RoomGraphCanvasHandle } from '../RoomGraphCanvas' +import { RoomGraphCanvas } from '../RoomGraphCanvas' import { RoomNodeInspector } from '../RoomGraphInspector' import { CreateRoomRelationDialog, relationTypeLabel, RoomRelationInspector } from '../RoomRelationControls' -import { GraphFitButton } from '../GraphFitButton' import { PanelEmptyState } from './PanelEmptyState' +/** 用户缩放/拖拽后,鼠标在关系图谱上静止多久自动弹性复位到自适应视野。 */ +const RELATIONS_AUTO_FIT_IDLE_MS = 5_000 + export function RelationsPane({ room, rooms, @@ -31,7 +33,6 @@ export function RelationsPane({ const { locale, t } = useLocale() const [visibility, setVisibility] = useState<'active' | 'hidden'>('active') const { error, graph, loading, reload } = useRoomRelationGraph(room.id, visibility) - const graphRef = useRef(null) const [selectedGraphRoomId, setSelectedGraphRoomId] = useState(room.id) const [selectedRelationId, setSelectedRelationId] = useState(null) const [inspectorOpen, setInspectorOpen] = useState(true) @@ -77,22 +78,18 @@ export function RelationsPane({
{loading && !graph ?
{t('contextRoom:relations.loadingGraph')}
: hasRelations ? ( - <> - { setSelectedRelationId(null); setSelectedGraphRoomId(roomId ?? room.id); setInspectorOpen(Boolean(roomId)) }} - onSelectRelation={(relationId) => { setSelectedRelationId(relationId); setSelectedGraphRoomId(room.id); setInspectorOpen(Boolean(relationId)) }} - onOpenRoom={onOpenRoom} - /> - {/* 视口可自由缩放平移,缩到最小或拖出视野后图会看不见; - 浮层按钮常驻画布右上角,一键把图谱自适应缩放并居中。 */} - void graphRef.current?.fitView()} /> - + { setSelectedRelationId(null); setSelectedGraphRoomId(roomId ?? room.id); setInspectorOpen(Boolean(roomId)) }} + onSelectRelation={(relationId) => { setSelectedRelationId(relationId); setSelectedGraphRoomId(room.id); setInspectorOpen(Boolean(relationId)) }} + onOpenRoom={onOpenRoom} + /> ) : ( 0)前盖住画布,避免 diff --git a/apps/desktop/src/renderer/src/components/graph/pixi/PixiForceGraphRenderer.ts b/apps/desktop/src/renderer/src/components/graph/pixi/PixiForceGraphRenderer.ts index ce7bc086..4a828db2 100644 --- a/apps/desktop/src/renderer/src/components/graph/pixi/PixiForceGraphRenderer.ts +++ b/apps/desktop/src/renderer/src/components/graph/pixi/PixiForceGraphRenderer.ts @@ -10,6 +10,19 @@ import type { PixiSprite, PixiTexture, } from './pixiForceGraphTypes' +/** 复位补间的运行态(scale 与中心按弹簧曲线线性插值)。 */ +interface FitTween { + startedAt: number + durationMs: number + ease: (progress: number) => number + fromScale: number + fromCenterX: number + fromCenterY: number + toScale: number + toCenterX: number + toCenterY: number +} + export type * from './pixiForceGraphTypes' @@ -483,8 +496,66 @@ export function createPixiForceGraphRenderer( // Claim node presses before the drag plugin handles subsequent movement. viewport.drag().wheel().pinch() + /** + * 复位补间:由共享 ticker 逐帧驱动,按「scale 与中心线性插值 × 弹簧曲线」推进。 + * + * 不用 pixi-viewport 的 Animate 插件是有原因的:它按 `ease(elapsed, 0, 1, duration)` + * (Penner 签名)调用、并且以「世界宽度」为插值量,曲线和"一大一小交替"的手感都会被改写。 + * 这里自己插值,与预览页(scratch/spring-fit-demo.html)的数学完全一致。 + */ + const FIT_SPRING_DAMPING = 0.38 + const FIT_SPRING_OMEGA = 10 + /** 欠阻尼二阶阶跃响应(初速度 0):1 − e^(−ζωt)(cos(ω_d t) + ζω/ω_d·sin(ω_d t)) */ + function createFitEase(durationMs: number) { + const dampingRatio = FIT_SPRING_DAMPING + const omega = FIT_SPRING_OMEGA + const dampedOmega = omega * Math.sqrt(1 - dampingRatio ** 2) + const shape = (time: number) => { + const decay = Math.exp(-dampingRatio * omega * time) + return 1 - decay * ( + Math.cos(dampedOmega * time) + + ((dampingRatio * omega) / dampedOmega) * Math.sin(dampedOmega * time) + ) + } + const seconds = Math.max(0.001, durationMs / 1000) + const settled = shape(seconds) || 1 + return (progress: number) => { + if (progress <= 0) return 0 + if (progress >= 1) return 1 + return shape(progress * seconds) / settled + } + } + + let fitTween: FitTween | null = null + const nowMs = () => dependencies.now?.() ?? ( + typeof performance !== 'undefined' && typeof performance.now === 'function' + ? performance.now() + : Date.now() + ) + + function stepFitTween() { + if (!fitTween) return + const elapsed = nowMs() - fitTween.startedAt + const progress = fitTween.durationMs <= 0 ? 1 : Math.min(1, elapsed / fitTween.durationMs) + const value = fitTween.ease(progress) + const scale = fitTween.fromScale + (fitTween.toScale - fitTween.fromScale) * value + const centerX = fitTween.fromCenterX + (fitTween.toCenterX - fitTween.fromCenterX) * value + const centerY = fitTween.fromCenterY + (fitTween.toCenterY - fitTween.fromCenterY) * value + viewport.setZoom?.(Math.max(0.02, scale), false) + viewport.moveCenter?.(centerX, centerY) + if (progress >= 1) fitTween = null + } + + function cancelFitAnimation() { + const wasPlaying = fitTween !== null + fitTween = null + return wasPlaying + } + const drawFrame = () => { if (destroyed) return + // 补间推进放在最前面:它改了视口 x/y/scale,下面的脏检查就会重绘这一帧。 + stepFitTween() if (anchors.length > 0) syncAnchorPositions() const startRevision = effectiveRevision() if ((startRevision & 1) === 1) return @@ -586,11 +657,21 @@ export function createPixiForceGraphRenderer( dependencies.Ticker.shared.add(drawFrame) drawFrame() - const contentBounds = () => { + /** + * 两套包围盒,各司其职: + * - `bounds`:节点半径 + 底边 24px 标签预留 —— 只用于算缩放(标签不能被裁); + * - `center`:只用节点半径的几何中心 —— 用于居中。 + * 只居中的话必须用后者:标签预留只加在底边,用它求中心会让整块内容固定上偏 12px。 + */ + const contentMetrics = () => { let minX = Number.POSITIVE_INFINITY let minY = Number.POSITIVE_INFINITY let maxX = Number.NEGATIVE_INFINITY let maxY = Number.NEGATIVE_INFINITY + let coreMinX = Number.POSITIVE_INFINITY + let coreMinY = Number.POSITIVE_INFINITY + let coreMaxX = Number.NEGATIVE_INFINITY + let coreMaxY = Number.NEGATIVE_INFINITY nodes.forEach((node, index) => { const x = positions[index * 2] const y = positions[index * 2 + 1] @@ -603,15 +684,24 @@ export function createPixiForceGraphRenderer( minY = Math.min(minY, y! - radius) maxX = Math.max(maxX, x! + radius) maxY = Math.max(maxY, y! + radius + 24) + coreMinX = Math.min(coreMinX, x! - radius) + coreMinY = Math.min(coreMinY, y! - radius) + coreMaxX = Math.max(coreMaxX, x! + radius) + coreMaxY = Math.max(coreMaxY, y! + radius) }) if (!Number.isFinite(minX)) return null - return { minX, minY, maxX, maxY } + return { + bounds: { minX, minY, maxX, maxY }, + center: { x: (coreMinX + coreMaxX) / 2, y: (coreMinY + coreMaxY) / 2 }, + } } + const contentCenter = () => contentMetrics()?.center ?? null + const centerOnContent = () => { - const bounds = contentBounds() - if (!bounds) return - viewport.moveCenter?.((bounds.minX + bounds.maxX) / 2, (bounds.minY + bounds.maxY) / 2) + const center = contentCenter() + if (!center) return + viewport.moveCenter?.(center.x, center.y) } // 面板只决定视口:世界大于面板时初始视野对准内容中心、保持原始缩放, @@ -634,10 +724,12 @@ export function createPixiForceGraphRenderer( centerOnLayoutPending = !(hostWidth > 0 && hostHeight > 0) } - const fitView = (minScale = 0.2) => { - if (destroyed || nodes.length === 0) return - const bounds = contentBounds() - if (!bounds) return + /** 自适应目标:缩放取带标签预留的包围盒,居中取节点几何中心;与 fitView 共用。 */ + const fitTarget = (minScale = 0.2) => { + if (destroyed || nodes.length === 0) return null + const metrics = contentMetrics() + if (!metrics) return null + const { bounds, center } = metrics const boundsWidth = Math.max(1, bounds.maxX - bounds.minX) const boundsHeight = Math.max(1, bounds.maxY - bounds.minY) const scale = Math.min(4, Math.max(minScale, Math.min( @@ -650,8 +742,49 @@ export function createPixiForceGraphRenderer( const degenerate = nodes.length > 1 && boundsWidth < textureRadius * 4 && boundsHeight < textureRadius * 4 + 24 - viewport.setZoom?.(degenerate ? Math.min(scale, 1) : scale, false) - viewport.moveCenter?.((bounds.minX + bounds.maxX) / 2, (bounds.minY + bounds.maxY) / 2) + return { + scale: degenerate ? Math.min(scale, 1) : scale, + centerX: center.x, + centerY: center.y, + } + } + + const fitView = (minScale = 0.2) => { + const target = fitTarget(minScale) + if (!target) return + viewport.setZoom?.(target.scale, false) + viewport.moveCenter?.(target.centerX, target.centerY) + } + + /** + * 带补间的自适应:从当前视野弹回内容(默认 1s,慢弹簧:放大→缩小交替衰减后落定)。 + * 用户中途拖拽/缩放时调用 cancelFitAnimation() 立刻交还控制权。 + */ + const animateFitView = ({ minScale = 0.2, durationMs = 1000 }: { + minScale?: number + durationMs?: number + } = {}) => { + const target = fitTarget(minScale) + if (!target) return + if (durationMs <= 0 || destroyed) { + cancelFitAnimation() + viewport.setZoom?.(target.scale, false) + viewport.moveCenter?.(target.centerX, target.centerY) + return + } + fitTween = { + startedAt: nowMs(), + durationMs, + ease: createFitEase(durationMs), + fromScale: viewport.scale?.x ?? target.scale, + fromCenterX: viewport.center?.x ?? target.centerX, + fromCenterY: viewport.center?.y ?? target.centerY, + toScale: target.scale, + toCenterX: target.centerX, + toCenterY: target.centerY, + } + // 立刻推进一帧,避免"点了没反应"的第一帧空档 + stepFitTween() } return { @@ -665,6 +798,8 @@ export function createPixiForceGraphRenderer( activeLabelCount: () => labelManager.activeCount(), createdLabelCount: () => labelManager.createdCount(), fitView, + animateFitView, + cancelFitAnimation, hitTest, resize(nextWidth, nextHeight) { if (destroyed) return diff --git a/apps/desktop/src/renderer/src/components/graph/pixi/pixiForceGraphTypes.ts b/apps/desktop/src/renderer/src/components/graph/pixi/pixiForceGraphTypes.ts index 36452c39..06c10852 100644 --- a/apps/desktop/src/renderer/src/components/graph/pixi/pixiForceGraphTypes.ts +++ b/apps/desktop/src/renderer/src/components/graph/pixi/pixiForceGraphTypes.ts @@ -99,6 +99,11 @@ export interface PixiViewport extends PixiContainer { getVisibleBounds?(): { x: number; y: number; width: number; height: number } moveCenter?(x: number, y: number): PixiViewport setZoom?(scale: number, center?: boolean): PixiViewport + /** 画面尺寸(世界坐标插值要用:中心 = toWorld(screen/2))。 */ + readonly screenWidth?: number + readonly screenHeight?: number + /** 当前画面中心(世界坐标):pixi-viewport 的 getter,复位补间的插值起点。 */ + readonly center?: { x: number; y: number } toWorld?(x: number, y: number): { x: number; y: number } on?(event: string, callback: (event: PixiPointerEvent) => void): void off?(event: string, callback: (event: PixiPointerEvent) => void): void @@ -138,6 +143,8 @@ export interface PixiForceGraphDependencies { autoResize?: boolean, ) => PixiParticleContainer Ticker: { shared: PixiTicker } + /** 时钟(测试注入用);缺省 performance.now()。复位补间用它算进度。 */ + now?: () => number Viewport: PixiViewportConstructor } @@ -249,6 +256,13 @@ export interface PixiForceGraphRenderer { createdLabelCount(): number /** 缩放至内容恰好入屏;minScale 为缩放下限(如 1 = 只居中不缩小)。 */ fitView(minScale?: number): void + /** + * 带补间(默认 1s,慢弹簧:放大→缩小交替衰减)回到自适应视野;用户手势可中途打断。 + * 视口不支持 animate 时内部退化为瞬时对准。 + */ + animateFitView(options?: { minScale?: number; durationMs?: number }): void + /** 立刻取消复位补间,停在当前视野;返回是否真的取消了一段在播的补间。 */ + cancelFitAnimation(): boolean hitTest(x: number, y: number): number | null | undefined resize(width: number, height: number): void setHoveredIndex(index: number | null): void diff --git a/apps/desktop/src/renderer/src/components/pages/memory/AtomicMemoryPane.tsx b/apps/desktop/src/renderer/src/components/pages/memory/AtomicMemoryPane.tsx index 71aefcc6..ee3d04c6 100644 --- a/apps/desktop/src/renderer/src/components/pages/memory/AtomicMemoryPane.tsx +++ b/apps/desktop/src/renderer/src/components/pages/memory/AtomicMemoryPane.tsx @@ -6,7 +6,7 @@ import type { MemoryAtomicItemDto, MemoryAtomicProvenanceDto, MemoryAtomicType } import { dispatchRoomMemoryChanged } from '@/components/context-room/roomMemoryChange' import { RoomAssignControl } from './RoomAssignControl' import { MemoryEmptyView } from './MemoryStatusViews' -import { formatDate, memoryFailureText, toMemoryFailure, type MemoryFailure } from './useMemoryData' +import { formatDate, formatTimeShort, memoryFailureText, toMemoryFailure, type MemoryFailure } from './useMemoryData' const PAGE_SIZE = 50 @@ -312,6 +312,10 @@ export function AtomicMemoryPane({ focusItemId, onOpenDocument, onOpenConversati const [expandedId, setExpandedId] = useState(null) const [reloadTick, setReloadTick] = useState(0) const [featuredDismissed, setFeaturedDismissed] = useState(false) + // 精选正文固定占三行:超出三行折叠,点「展开」看全文(收起后回到三行状态)。 + const [featuredExpanded, setFeaturedExpanded] = useState(false) + const [featuredClamped, setFeaturedClamped] = useState(false) + const featuredContentRef = useRef(null) // 动态类型 chips 从「累计见过的类型」推导而非当前 items:items 会被类型过滤收窄, // 否则一切换筛选,其余动态类型标签会跟着消失,看起来像标签栏自动收起。 const [knownTypes, setKnownTypes] = useState([]) @@ -388,6 +392,25 @@ export function AtomicMemoryPane({ focusItemId, onOpenDocument, onOpenConversati })[0] }, [filtered, items]) + // 换一条精选(或收起)时回到三行态。 + useEffect(() => { + setFeaturedExpanded(false) + }, [featured?.id]) + + // 只有真被截断时才给「展开」:量一次 scrollHeight vs clientHeight, + // 窗口宽度变化会改变折行,所以跟随 resize 复测。 + useEffect(() => { + if (!featured || featuredExpanded) return + const measure = () => { + const element = featuredContentRef.current + if (!element || typeof element.scrollHeight !== 'number') return + setFeaturedClamped(element.scrollHeight > element.clientHeight + 1) + } + measure() + window.addEventListener?.('resize', measure) + return () => window.removeEventListener?.('resize', measure) + }, [featured?.id, featured?.content, featuredExpanded]) + const grouped = useMemo(() => { const groups: Array<{ id: TimelineBucketId; label: string; items: MemoryAtomicItemDto[] }> = [] for (const bucket of TIMELINE_BUCKETS) { @@ -423,9 +446,25 @@ export function AtomicMemoryPane({ focusItemId, onOpenDocument, onOpenConversati {featured.roomTitle ? 仅 Room: {featured.roomTitle} : 全局}
-

{featured.content}

+

+ {featured.content} +

{formatDate(featured.updatedAt, locale)} + {featuredClamped || featuredExpanded ? ( + + ) : null}