diff --git a/docs/features/conversation-supervision/progress.md b/docs/features/conversation-supervision/progress.md index 6fdc1d2b..2289939f 100644 --- a/docs/features/conversation-supervision/progress.md +++ b/docs/features/conversation-supervision/progress.md @@ -4,6 +4,21 @@ 当前记录以已验证生产行为为准。监督者尚未覆盖全部 user stories。 +## 2026-10-02 时间螺旋显示经验节点 + +对应 [3D 概念](../story-graph/3d-concept.md) 中的桶外经验节点。 + +实现: + +- `experienceLinks()`(`story-graph-3d-model.ts`):经验在当前层中,从最早形成依据所在的木片,连到之后第一个在另一片木片上的应用。只在同一片木片里用过的、应用不晚于形成的、依据不在当前层的经验不画。每层最多 6 条,按应用到的不同木片数排序。 +- 时间螺旋:经验画成桶外的菱形,高度在形成与应用两个时间之间,角度在两片木片之间;两段向外弯的弧线分别连到形成木片和应用木片的对应高度。只有选中的经验在画布上显示名称。画布下方新增“跨木片的经验”列表,可用键盘选择;点击菱形或列表项打开经验详情。 + +验证: + +- `story-graph-3d-model.test.ts` 新增 1 项(只连到跨木片的后续应用、只出现一次、层外依据与倒序应用排除、上限)。相关组件测试 3 个文件 32 项通过,Web 类型检查、相关 ESLint 通过。 +- Electron 实测(生产组件,第 5 步数据副本的 46 个故事、38 条经验;1440 浅色、390 深色):无报错、无横向溢出;根层 3 条、goodbuddy 层 6 条跨木片经验;从列表选中和在画布上点中菱形都能打开经验。 +- 38 条经验中,在当前数据里能形成跨木片连线的只是少数,其余只在同一故事中形成或应用,不在螺旋上显示,仍可在“经验”页签中查看。 + ## 2026-10-02 故事线模型第 5 步:工作回顾按故事组织 对应[故事线模型改造设计](./storyline-model-design.md)第 5 步“工作回顾页”。 @@ -48,7 +63,7 @@ - 再次生成时没有重复任何待处理建议;只补上了上一次被 20 条上限挤掉的未决事项(1 次调用)。 - MCP:全局范围搜到 6 个相关故事、9 条相关经验;一个功能的上下文返回 96 个事件(含子线索),一条经验返回 5 个依据事件。 -边界:时间螺旋视图尚未显示经验节点;是否在普通对话中主动查询故事与经验,取决于 Agent 是否调用工具,未做额外注入。 +边界:是否在普通对话中主动查询故事与经验,取决于 Agent 是否调用工具,未做额外注入。 ## 2026-10-02 故事线模型第 4 步:经验抽取 diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 84e5261e..a02a4283 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -76,7 +76,6 @@ import { import { Component, memo, - startTransition, Suspense, useCallback, useDeferredValue, @@ -227,6 +226,7 @@ import { } from "./conversation-activity"; import { sameArrayItems, sameMapEntries, useStableDerivedValue, useStableHandlers } from "./stable-derived-value"; import { useUnviewedCompletions } from "./use-unviewed-completions"; +import { useConversationListOrder } from "./use-conversation-list-order"; import { useExecutionStats } from "./use-execution-stats"; import { RightAssistantSidebar, @@ -2207,10 +2207,12 @@ function App(): React.JSX.Element { const store = createLiveMessageStore({ onPending: () => { if (flushTimer !== undefined) return; + // A plain update: a transition would keep being interrupted by the + // row's synchronous store updates and might never absorb anything. flushTimer = setTimeout(() => { flushTimer = undefined; if (!store.hasEntries()) return; - startTransition(() => update((current) => current)); + update((current) => current); }, liveMessageFlushIntervalMs); }, }); @@ -4180,9 +4182,9 @@ function App(): React.JSX.Element { () => new Set(conversationQueueItems.map((item) => item.conversationId)), [conversationQueueItems], ); - const filteredConversations = useMemo(() => { + const matchingConversations = useMemo(() => { const query = deferredSearchQuery.trim().toLocaleLowerCase(); - return sortConversationsForDisplay(conversations.filter( + return conversations.filter( (conversation) => (!activeProjectId || conversation.projectId === activeProjectId) && (activeProject?.kind !== "channel" || @@ -4191,7 +4193,7 @@ function App(): React.JSX.Element { (persistedSearchMatches.query === query && persistedSearchMatches.ids.has(conversation.id)) || conversation.title.toLocaleLowerCase().includes(query) || (localSearchMatches.query === query && localSearchMatches.ids.has(conversation.id))), - )); + ); }, [ activeProject, activeProjectId, @@ -4200,6 +4202,11 @@ function App(): React.JSX.Element { localSearchMatches, persistedSearchMatches, ]); + const { conversations: filteredConversations, listProps: conversationListProps } = useConversationListOrder( + matchingConversations, + JSON.stringify([activeProjectId, activeProject?.kind, deferredSearchQuery.trim().toLocaleLowerCase()]), + Boolean(conversationActionsId), + ); const productAssistantTasks = useMemo( () => assistantTasks.filter( @@ -9464,7 +9471,7 @@ function App(): React.JSX.Element {
-
+
{!conversationLoadError && filteredConversations.map((conversation) => { const conversationTasks = diff --git a/src/renderer/src/StoryGraph3D.tsx b/src/renderer/src/StoryGraph3D.tsx index cd4561f6..f0199722 100644 --- a/src/renderer/src/StoryGraph3D.tsx +++ b/src/renderer/src/StoryGraph3D.tsx @@ -2,8 +2,8 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import * as THREE from 'three' import type { SupervisionAttentionSlot } from '../../shared/supervision-contracts' -import type { SupervisionStory } from '../../shared/supervision-story-contracts' -import { buildStoryTree, clusterEvents, findNode, radiusLevels, storyWindow, visibleLevel, type StoryCluster, type StoryNode } from './story-graph-3d-model' +import type { SupervisionExperience, SupervisionStory } from '../../shared/supervision-story-contracts' +import { buildStoryTree, clusterEvents, experienceLinks, findNode, radiusLevels, storyWindow, visibleLevel, type ExperienceLink, type StoryCluster, type StoryNode } from './story-graph-3d-model' /* * 3D story view: one helix of time inside, story staves on the outer cylinder. @@ -16,8 +16,14 @@ type Props = { selectedEventId?: string onSelectEvent: (id: string) => void onSelectStory: (id: string) => void + /** Experiences (W) drawn outside the barrel, linking the staves they formed in and were applied to. */ + experiences?: SupervisionExperience[] + selectedExperienceId?: string + onSelectExperience?: (id: string) => void } -const TAU = Math.PI * 2, HEIGHT = 430, STAVE_R = 188, DIST = 900, CLUSTER_PX = 9 +const TAU = Math.PI * 2, HEIGHT = 430, STAVE_R = 188, DIST = 900, CLUSTER_PX = 9, W_R = 232 +const noExperiences: SupervisionExperience[] = [] +type EngineState = { focus: StoryNode; hovered?: string; selected?: string; experiences: SupervisionExperience[]; experience?: string } const VIEWS = { oblique: { yaw: -0.4, tilt: 0.38 }, side: { yaw: -0.4, tilt: 0 }, top: { yaw: -0.4, tilt: Math.PI / 2 - 1e-3 } } as const type ViewName = keyof typeof VIEWS | 'free' // Existing graph tokens, so light and dark themes keep the same story colours as the flat view. @@ -31,7 +37,7 @@ function webglAvailable(): boolean { catch { return false } } -export default function StoryGraph3D({ stories, attention, selectedEventId, onSelectEvent, onSelectStory }: Props) { +export default function StoryGraph3D({ stories, attention, selectedEventId, onSelectEvent, onSelectStory, experiences = noExperiences, selectedExperienceId, onSelectExperience }: Props) { const { t, i18n } = useTranslation('heartbeat') const canvasRef = useRef(null) const overlayRef = useRef(null) @@ -42,12 +48,13 @@ export default function StoryGraph3D({ stories, attention, selectedEventId, onSe const [view, setView] = useState('oblique') const [hovered, setHovered] = useState() const camera = useRef<{ yaw: number; tilt: number; zoom: number; pan: number }>({ yaw: VIEWS.oblique.yaw, tilt: VIEWS.oblique.tilt, zoom: 1, pan: 0 }) - const engine = useRef<{ draw: () => void; update: (next: { focus: StoryNode; hovered?: string; selected?: string }) => void; dispose: () => void; hit: (x: number, y: number) => { node?: StoryNode; cluster?: StoryCluster } | undefined }>(undefined) + const engine = useRef<{ draw: () => void; update: (next: EngineState) => void; dispose: () => void; hit: (x: number, y: number) => { node?: StoryNode; cluster?: StoryCluster; link?: ExperienceLink } | undefined }>(undefined) const range = useMemo(() => { const times = tree.events.map(event => event.t) return times.length ? storyWindow(Math.min(...times) - 1_800_000, Math.max(...times) + 1_800_000) : undefined }, [tree]) const level = visibleLevel(focus) + const shownLinks = useMemo(() => experienceLinks(experiences, level.children), [experiences, level]) const trail: StoryNode[] = [] for (let node: StoryNode | undefined = focus; node; node = node.parent) trail.unshift(node) const dateText = (value: number) => new Date(value).toLocaleString(i18n.resolvedLanguage, range && range.turnHours >= 24 @@ -89,10 +96,11 @@ export default function StoryGraph3D({ stories, attention, selectedEventId, onSe const ticks = Array.from({ length: Math.floor(range.turns + 1e-6) + 1 }, (_, d) => range.from + d * range.turnHours * 3_600_000).filter(time => time < range.to) scene.add(base) // What is shown; the renderer and scene persist while focus, hover and selection change. - const state: { focus: StoryNode; hovered?: string; selected?: string } = { focus } + const state: EngineState = { focus, experiences } let staves = visibleLevel(focus).children const meshes: THREE.Mesh[] = [] const groups = new Map() + const wisdom = cssColor(host, '--graph-node-4-border', '#b45309') const world = new THREE.Group(); scene.add(world) const clusterSpan = () => CLUSTER_PX / (HEIGHT * Math.min(width / 500, height / 560) * camera.current.zoom) * (range.to - range.from) let builtSpan = -1 @@ -132,6 +140,23 @@ export default function StoryGraph3D({ stories, attention, selectedEventId, onSe world.add(dot); meshes.push(dot) } } + // Experiences: a diamond outside the barrel, arcs from the forming stave to it and on to the applying stave. + for (const link of experienceLinks(state.experiences, staves)) { + const active = link.id === state.experience + const node = on(link.t, link.angle, W_R) + const arc = (end: { stave: StoryNode; t: number }) => { + const start = on(end.t, center(end.stave), STAVE_R + 1) + const mid = start.clone().add(node).multiplyScalar(0.5) + mid.setLength(mid.length() + 26) + return new THREE.QuadraticBezierCurve3(start, mid, node).getPoints(24) + } + const material = solid(wisdom, active ? 1 : 0.55) + world.add(tube(arc(link.from), active ? 0.9 : 0.55, material, 24)) + world.add(tube(arc(link.to).reverse(), active ? 0.9 : 0.55, material, 24)) + const diamond = new THREE.Mesh(own(new THREE.OctahedronGeometry(active ? 7 : 5.5)), solid(wisdom)) + diamond.position.copy(node); diamond.userData.link = link + world.add(diamond); meshes.push(diamond) + } } const project = (point: THREE.Vector3) => { const v = point.clone().project(view3d); return { x: (v.x + 1) / 2 * width, y: (1 - v.y) / 2 * height, behind: v.z > 1 } } const place = () => { @@ -172,6 +197,15 @@ export default function StoryGraph3D({ stories, attention, selectedEventId, onSe const p = project(mesh.position); if (p.behind) continue const label = String(cluster.events.length); ctx.fillText(label, p.x - ctx.measureText(label).width / 2, p.y + 3) } + // Only the selected experience is named on the canvas; the list below names all of them. + ctx.font = '600 11px system-ui'; ctx.fillStyle = wisdom + for (const mesh of meshes) { + const link = mesh.userData.link as ExperienceLink | undefined + if (!link || link.id !== state.experience) continue + const p = project(mesh.position); if (p.behind) continue + const text = link.statement.length > 28 ? `${link.statement.slice(0, 27)}…` : link.statement + ctx.fillText(text, Math.max(4, Math.min(width - ctx.measureText(text).width - 4, p.x + 10)), p.y + 4) + } } const resize = new ResizeObserver(() => { const rect = canvas.getBoundingClientRect(); width = rect.width; height = rect.height @@ -195,14 +229,16 @@ export default function StoryGraph3D({ stories, attention, selectedEventId, onSe hit: (x, yPos) => { raycaster.setFromCamera(new THREE.Vector2(x / width * 2 - 1, 1 - yPos / height * 2), view3d) const hit = raycaster.intersectObjects(meshes, false)[0] - return hit && { node: hit.object.userData.node as StoryNode, cluster: hit.object.userData.cluster as StoryCluster | undefined } + return hit && { node: hit.object.userData.node as StoryNode | undefined, cluster: hit.object.userData.cluster as StoryCluster | undefined, + link: hit.object.userData.link as ExperienceLink | undefined } }, dispose: () => { canvas.removeEventListener('wheel', zoom); resize.disconnect(); disposables.forEach(item => item.dispose()); renderer.dispose() } } return () => { engine.current?.dispose(); engine.current = undefined } // Focus, hover and selection are pushed through update() below; only data and locale recreate the scene. }, [supported, range, attention, i18n.resolvedLanguage]) // eslint-disable-line react-hooks/exhaustive-deps - useEffect(() => { engine.current?.update({ focus, hovered, selected: selectedEventId }) }, [focus, hovered, selectedEventId, range, attention]) + useEffect(() => { engine.current?.update({ focus, hovered, selected: selectedEventId, experiences, experience: selectedExperienceId }) }, + [focus, hovered, selectedEventId, range, attention, experiences, selectedExperienceId]) const animate = (goal: Partial) => { const from = { ...camera.current } @@ -268,7 +304,8 @@ export default function StoryGraph3D({ stories, attention, selectedEventId, onSe drag.current = undefined if (!start || Math.hypot(x - start.sx, y - start.sy) >= 5) return const hit = engine.current?.hit(x, y) - if (hit?.cluster) { if (hit.cluster.events.length === 1) onSelectEvent(hit.cluster.events[0]!.id); else open(hit.node!) } + if (hit?.link) onSelectExperience?.(hit.link.id) + else if (hit?.cluster) { if (hit.cluster.events.length === 1) onSelectEvent(hit.cluster.events[0]!.id); else open(hit.node!) } else if (hit?.node) open(hit.node) }} onPointerLeave={() => { if (!drag.current) setHovered(undefined) }} @@ -286,6 +323,12 @@ export default function StoryGraph3D({ stories, attention, selectedEventId, onSe

{t('supervisor.graph3d.legend', { turn: t(`supervisor.graph3d.turns.${range.turnHours}`), count: Math.round(range.turns * 10) / 10 })}

{/* The same staves as a list, for keyboard and screen readers. */} + {shownLinks.length > 0 && }
+ + +} + +const ids = () => Array.from(screen.getByTestId('list').querySelectorAll('button'), item => item.dataset.id) +const advance = (ms: number) => act(() => { vi.advanceTimersByTime(ms) }) + +beforeEach(() => { vi.useFakeTimers() }) +afterEach(() => { cleanup(); vi.useRealTimers(); vi.restoreAllMocks() }) + +describe('useConversationListOrder', () => { + it('keeps content live and applies the latest order on a fixed cadence despite continuous deltas', () => { + const a = conversation('a', 20) + const b = conversation('b', 10) + const { rerender } = render() + expect(ids()).toEqual(['a', 'b']) + for (let delta = 1; delta <= 4; delta++) { + advance(1000) + rerender() + expect(screen.getByText(`Live ${delta}`)).toBeTruthy() + expect(ids()).toEqual(['a', 'b']) + } + advance(999) + expect(ids()).toEqual(['a', 'b']) + advance(1) + expect(ids()).toEqual(['b', 'a']) + rerender() + advance(4999) + expect(ids()).toEqual(['b', 'a']) + advance(1) + expect(ids()).toEqual(['a', 'b']) + }) + + it('skips hovered ticks and waits for the next tick after the mouse leaves', () => { + const { rerender } = render() + fireEvent.mouseEnter(screen.getByTestId('list')) + rerender() + advance(10000) + expect(ids()).toEqual(['a', 'b']) + fireEvent.mouseLeave(screen.getByTestId('list')) + advance(4999) + expect(ids()).toEqual(['a', 'b']) + advance(1) + expect(ids()).toEqual(['b', 'a']) + }) + + it('keeps order while focus moves between rows and resumes only after focus leaves', () => { + const { rerender } = render() + act(() => screen.getByText('a').focus()) + rerender() + advance(5000) + act(() => screen.getByText('b').focus()) + advance(5000) + expect(ids()).toEqual(['a', 'b']) + act(() => screen.getByText('Outside').focus()) + expect(ids()).toEqual(['a', 'b']) + advance(5000) + expect(ids()).toEqual(['b', 'a']) + }) + + it('extends scroll activity on each event and resumes on a tick after 200ms idle', () => { + const { rerender } = render() + rerender() + advance(4750) + fireEvent.scroll(screen.getByTestId('list')) + advance(150) + fireEvent.scroll(screen.getByTestId('list')) + advance(100) + expect(ids()).toEqual(['a', 'b']) + advance(4999) + expect(ids()).toEqual(['a', 'b']) + advance(1) + expect(ids()).toEqual(['b', 'a']) + }) + + it('reads the latest menu state without restarting the interval', () => { + const initial = [conversation('a', 20), conversation('b', 10)] + const updated = [conversation('a', 20), conversation('b', 30)] + const { rerender } = render() + advance(4000) + rerender() + advance(1000) + expect(ids()).toEqual(['a', 'b']) + advance(4000) + rerender() + expect(ids()).toEqual(['a', 'b']) + advance(1000) + expect(ids()).toEqual(['b', 'a']) + }) + + it('creates, deletes, pins and unpins immediately without flushing unrelated pending moves', () => { + const a = conversation('a', 30) + const b = conversation('b', 20) + const c = conversation('c', 10) + const { rerender } = render() + const newerB = conversation('b', 40) + rerender() + const d = conversation('d', 50) + rerender() + expect(ids()).toEqual(['d', 'a', 'b', 'c']) + rerender() + expect(ids()).toEqual(['a', 'b', 'c']) + rerender() + expect(ids()).toEqual(['c', 'a', 'b']) + rerender() + expect(ids()).toEqual(['a', 'b', 'c']) + advance(5000) + expect(ids()).toEqual(['a', 'b', 'c']) + rerender() + advance(5000) + expect(ids()).toEqual(['b', 'a', 'c']) + }) + + it('applies scope changes and asynchronous filter membership immediately while paused', () => { + const a = conversation('a', 20) + const b = conversation('b', 10) + const { rerender } = render() + const newerB = conversation('b', 30) + rerender() + expect(ids()).toEqual(['a', 'b']) + rerender() + expect(ids()).toEqual(['b', 'a']) + rerender() + expect(ids()).toEqual(['a']) + rerender() + expect(ids()).toEqual(['b', 'a']) + rerender() + expect(ids()).toEqual([]) + }) + + it('cleans up the interval on unmount and does not duplicate it in Strict Mode', () => { + const { unmount } = render() + expect(vi.getTimerCount()).toBe(1) + unmount() + expect(vi.getTimerCount()).toBe(0) + }) +}) diff --git a/src/renderer/src/use-conversation-list-order.ts b/src/renderer/src/use-conversation-list-order.ts new file mode 100644 index 00000000..04a997b5 --- /dev/null +++ b/src/renderer/src/use-conversation-list-order.ts @@ -0,0 +1,62 @@ +import { useEffect, useEffectEvent, useMemo, useRef, useState } from 'react' +import { sortConversationsForDisplay, type Conversation } from './chat-conversation' + +export function useConversationListOrder( + conversations: readonly Conversation[], + scope: string, + menuOpen: boolean, +) { + const listRef = useRef(null) + const mouseWithin = useRef(false) + const scrollIdleAt = useRef(0) + const [order, setOrder] = useState(() => ({ + scope, + ids: sortConversationsForDisplay(conversations).map(item => item.id), + pins: new Map(conversations.map(item => [item.id, Boolean(item.pinned)])), + })) + const byId = useMemo(() => new Map(conversations.map(item => [item.id, item])), [conversations]) + const reconciled = useMemo(() => { + const changed = new Set(conversations.filter(item => + !order.pins.has(item.id) || order.pins.get(item.id) !== Boolean(item.pinned), + ).map(item => item.id)) + const scopeChanged = order.scope !== scope + if (!scopeChanged && !changed.size && order.ids.length === byId.size) return order + + const sorted = sortConversationsForDisplay(conversations).map(item => item.id) + const ids = scopeChanged ? sorted : order.ids.filter(id => byId.has(id) && !changed.has(id)) + if (!scopeChanged) { + const ranks = new Map(sorted.map((id, index) => [id, index])) + // Insert only new or repinned rows; leave unrelated pending moves for a tick. + for (const id of sorted) { + if (!changed.has(id)) continue + const index = ids.findIndex(existing => ranks.get(existing)! > ranks.get(id)!) + ids.splice(index < 0 ? ids.length : index, 0, id) + } + } + return { scope, ids, pins: new Map(conversations.map(item => [item.id, Boolean(item.pinned)])) } + }, [byId, conversations, order, scope]) + if (reconciled !== order) setOrder(reconciled) + + const reorder = useEffectEvent(() => { + if (menuOpen || mouseWithin.current || Date.now() < scrollIdleAt.current || + listRef.current?.contains(document.activeElement)) return + const ids = sortConversationsForDisplay(conversations).map(item => item.id) + setOrder(current => ids.every((id, index) => current.ids[index] === id) && ids.length === current.ids.length + ? current : { ...current, ids }) + }) + useEffect(() => { + const timer = window.setInterval(() => reorder(), 5000) + return () => window.clearInterval(timer) + }, []) + + const orderedConversations = useMemo(() => reconciled.ids.map(id => byId.get(id)!), [byId, reconciled]) + return { + conversations: orderedConversations, + listProps: { + ref: listRef, + onMouseEnter: () => { mouseWithin.current = true }, + onMouseLeave: () => { mouseWithin.current = false }, + onScroll: () => { scrollIdleAt.current = Date.now() + 200 }, + }, + } +}