feat: show experiences outside the time spiral

This commit is contained in:
mesalogo
2026-10-02 18:49:24 +08:00
parent 1f420cd042
commit ee4eb737e0
12 changed files with 417 additions and 30 deletions
@@ -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 步:经验抽取
+13 -6
View File
@@ -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 {
</nav>
<section className="sidebar-conversations" aria-label={t("sidebar.recent")}>
<div className="conversation-list">
<div className="conversation-list" {...conversationListProps}>
{!conversationLoadError &&
filteredConversations.map((conversation) => {
const conversationTasks =
+52 -9
View File
@@ -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<HTMLCanvasElement>(null)
const overlayRef = useRef<HTMLCanvasElement>(null)
@@ -42,12 +48,13 @@ export default function StoryGraph3D({ stories, attention, selectedEventId, onSe
const [view, setView] = useState<ViewName>('oblique')
const [hovered, setHovered] = useState<string>()
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<string, StoryCluster[]>()
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<typeof camera.current>) => {
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
</div>
<p className="supervisor-workspace__muted">{t('supervisor.graph3d.legend', { turn: t(`supervisor.graph3d.turns.${range.turnHours}`), count: Math.round(range.turns * 10) / 10 })}</p>
{/* The same staves as a list, for keyboard and screen readers. */}
{shownLinks.length > 0 && <ul className="story-graph-3d__list" aria-label={t('supervisor.graph3d.experiences')}>
{shownLinks.map(link => <li key={link.id}><button type="button" className="link-button" aria-pressed={link.id === selectedExperienceId}
onClick={() => onSelectExperience?.(link.id)}>
{link.statement} <small>{link.from.stave.name} → {link.to.stave.name}</small>
</button></li>)}
</ul>}
<ul className="story-graph-3d__list" aria-label={t('supervisor.graph3d.staves')}>
{level.children.map(node => <li key={node.id}><button type="button" className="link-button" aria-pressed={node.id === focus.id} onClick={() => open(node)}>
{node.name} <small>{t('supervisor.stories.count', { count: node.events.length })} · {dateText(node.start)} – {dateText(node.end)}</small>
+4 -1
View File
@@ -849,7 +849,10 @@ export function SupervisorWorkspace({
<StoryGraph3D stories={storyState.view.stories} attention={graph.attention ?? []}
selectedEventId={selection?.kind === 'event' ? selection.id : undefined}
onSelectEvent={(id) => { if (layout.eventMap.has(id)) select({ kind: 'event', id }) }}
onSelectStory={(id) => select({ kind: 'story', id })} />
onSelectStory={(id) => select({ kind: 'story', id })}
experiences={storyState.view.experiences}
selectedExperienceId={selection?.kind === 'experience' ? selection.id : undefined}
onSelectExperience={(id) => select({ kind: 'experience', id })} />
</Suspense>
) : <>
<figure className="supervisor-workspace__figure">
@@ -119,7 +119,8 @@ export const heartbeat = {
levels: 'Stave levels', all: 'All', views: 'Viewpoint', canvas: 'Time spiral. Keys 1, 2 and 3 switch the viewpoint, arrow keys rotate, Esc goes up a level; the list below offers the same staves.',
view: { oblique: 'Oblique', side: 'Side', top: 'Top', free: 'Free' },
staves: 'Staves at this level',
legend: 'Height is time, one turn is {{turn}} ({{count}} turns); radius is conversation density at the time and only shows how concentrated the work was. A stave spans its story from first to last event.',
experiences: 'Experiences across staves',
legend: 'Height is time, one turn is {{turn}} ({{count}} turns); radius is conversation density at the time and only shows how concentrated the work was. A stave spans its story from first to last event. Diamonds outside the barrel are experiences; arcs run from the stave they formed in to the stave that later used them.',
turns: { 3: '3 hours', 6: '6 hours', 12: '12 hours', 24: '1 day', 168: '1 week', 720: '1 month', 2160: '1 quarter', 8760: '1 year' },
unsupported: 'This device cannot show 3D. Use the flat view and the story list.'
},
@@ -116,7 +116,8 @@ export const heartbeat = {
levels: '木片层级', all: '全部', views: '视角', canvas: '时间螺旋。数字键 1、2、3 切换视角,方向键旋转,Esc 返回上一层;下方列表提供同样的木片。',
view: { oblique: '斜俯视', side: '侧视', top: '俯视', free: '自由' },
staves: '当前层的木片',
legend: '高度为时间,一圈 {{turn}}(共 {{count}} 圈);半径为当时的对话密度,只表示投入集中程度。木片上下跨度为故事的起止时间。',
experiences: '跨木片的经验',
legend: '高度为时间,一圈 {{turn}}(共 {{count}} 圈);半径为当时的对话密度,只表示投入集中程度。木片上下跨度为故事的起止时间。桶外的菱形是经验,弧线从形成它的木片连到后来用上它的木片。',
turns: { 3: '3 小时', 6: '6 小时', 12: '12 小时', 24: '1 天', 168: '1 周', 720: '1 个月', 2160: '1 个季度', 8760: '1 年' },
unsupported: '当前设备不支持三维显示,请使用平铺视图和左侧故事列表。'
},
@@ -145,6 +145,24 @@ describe("live message store", () => {
expect(store.resolve(committed).content).toBe("prefix that is longhello");
});
it("trims absorbed deltas while a reply keeps streaming", () => {
const h = harness();
for (let index = 0; index < 50; index += 1) {
h.append("text", `${index},`);
const captured = h.store.capture();
h.append("text", "x");
// An update dispatched before the newest delta absorbs only older ones.
const next = h.store.applyUpdate(h.state, (current) => current, captured);
h.setConversations(next);
expect(h.store.getSnapshot("m1")?.deltas.length ?? 0).toBeLessThanOrEqual(1);
}
const expected = Array.from({ length: 50 }, (_, index) => `${index},x`).join("");
expect(h.displayed().content).toBe(expected);
h.setConversations((current) => current);
expect(h.committed().content).toBe(expected);
expect(h.store.hasEntries()).toBe(false);
});
it("drops entries for messages that no longer exist", () => {
const h = harness();
h.append("text", "gone");
+36 -9
View File
@@ -107,7 +107,11 @@ export function createLiveMessageStore(options: {
const listeners = new Map<string, Set<Listener>>();
/** Last delta sequence contained by message objects this store produced. */
const absorbed = new WeakMap<Message, number>();
const resolved = new WeakMap<Message, { snapshot: LiveMessageSnapshot; message: Message }>();
/** Last display per committed message, extended incrementally per delta. */
const resolved = new WeakMap<
Message,
{ snapshot: LiveMessageSnapshot; message: Message; count: number }
>();
let seq = 0;
let version = 0;
let resolvedConversations:
@@ -244,11 +248,27 @@ export function createLiveMessageStore(options: {
if (!snapshot) return message;
const cached = resolved.get(message);
if (cached?.snapshot === snapshot) return cached.message;
const count = containedCount(message, snapshot);
const display = count < snapshot.deltas.length
? foldLiveDeltas(message, snapshot.deltas.slice(count))
: message;
resolved.set(message, { snapshot, message: display });
const total = snapshot.deltas.length;
let display: Message;
let count: number;
if (
cached &&
cached.snapshot.messageId === snapshot.messageId &&
cached.snapshot.deltas.length <= total &&
cached.snapshot.deltas[cached.snapshot.deltas.length - 1] ===
snapshot.deltas[cached.snapshot.deltas.length - 1]
) {
// Same buffer, more deltas: extend the previous display.
count = total;
display = foldLiveDeltas(cached.message, snapshot.deltas.slice(cached.count));
} else {
const contained = containedCount(message, snapshot);
count = total;
display = contained < total
? foldLiveDeltas(message, snapshot.deltas.slice(contained))
: message;
}
resolved.set(message, { snapshot, message: display, count });
return display;
},
/** Conversations with every pending delta applied, without changing state. */
@@ -285,15 +305,22 @@ export function createLiveMessageStore(options: {
}
return next;
},
/** Drops deltas fully contained in committed state, and orphaned entries. */
/** Drops deltas contained in committed state, and orphaned entries. */
prune(conversations: readonly Conversation[]): void {
for (const snapshot of [...entries.values()]) {
const message = findMessage(conversations, snapshot);
if (message && containedCount(message, snapshot) < snapshot.deltas.length) continue;
entries.delete(snapshot.messageId);
const contained = message ? containedCount(message, snapshot) : snapshot.deltas.length;
if (contained === 0) continue;
version += 1;
if (contained >= snapshot.deltas.length) {
entries.delete(snapshot.messageId);
} else {
// Keep the buffer short while a long reply keeps streaming.
entries.set(snapshot.messageId, { ...snapshot, deltas: snapshot.deltas.slice(contained) });
}
notify(snapshot.messageId);
}
if (entries.size) options.onPending?.();
},
};
}
+17 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import type { SupervisionStory } from '../../shared/supervision-story-contracts'
import { buildStoryTree, clusterEvents, radiusLevels, storyWindow, visibleLevel } from './story-graph-3d-model'
import { buildStoryTree, clusterEvents, experienceLinks, radiusLevels, storyWindow, visibleLevel } from './story-graph-3d-model'
const day = 86_400_000
const at = (d: number) => new Date(Date.parse('2026-09-01T00:00:00Z') + d * day).toISOString()
@@ -55,4 +55,20 @@ describe('story graph 3d model', () => {
expect(clusterEvents(events, 5).map(cluster => cluster.events.length)).toEqual([3, 1])
expect(clusterEvents(events, 0).map(cluster => cluster.events.length)).toEqual([1, 1, 1, 1])
})
it('links an experience from the stave it formed in to the first later stave that applied it, once', () => {
const tree = buildStoryTree([story('a', 'feature', [1, 2]), story('b', 'feature', [3, 6]), story('c', 'feature', [4])], new Map())
const staves = tree.children[0]!.children
const event = (id: string, role: 'formed' | 'applied', d: number) => ({ id, role, note: '', title: id, projectId: 'p1', at: at(d), storyId: null, storyName: null })
const experience = (id: string, events: ReturnType<typeof event>[]) => ({ id, statement: id, conditions: '', boundaries: '', userEdited: false, events })
const links = experienceLinks([
experience('reused', [event('a-0', 'formed', 1), event('a-1', 'applied', 2), event('c-0', 'applied', 4), event('b-1', 'applied', 6)]),
experience('same story only', [event('a-0', 'formed', 1), event('a-1', 'applied', 2)]),
experience('applied before formed', [event('b-1', 'formed', 6), event('a-0', 'applied', 1)]),
experience('outside the level', [event('zzz', 'formed', 1), event('b-0', 'applied', 3)])
], staves)
expect(links.map(link => [link.id, link.from.stave.name, link.to.stave.name])).toEqual([['reused', 'a', 'c']])
const [link] = links
expect(link!.t).toBe((Date.parse(at(1)) + Date.parse(at(4))) / 2)
expect(experienceLinks([experience('reused', [event('a-0', 'formed', 1), event('c-0', 'applied', 4)])], staves, 0)).toEqual([])
})
})
+40 -1
View File
@@ -1,5 +1,5 @@
import type { SupervisionAttentionSlot } from '../../shared/supervision-contracts'
import type { SupervisionStory } from '../../shared/supervision-story-contracts'
import type { SupervisionExperience, SupervisionStory } from '../../shared/supervision-story-contracts'
/*
* Pure model for the 3D story view (storyline model step 3), shared by the renderer and tests.
@@ -137,3 +137,42 @@ export function findNode(root: StoryNode, id: string): StoryNode | undefined {
for (const child of root.children) { const hit = findNode(child, id); if (hit) return hit }
return undefined
}
export type ExperienceLink = {
id: string
statement: string
/** Visible stave and event time where the experience formed (earliest) and where it was applied. */
from: { stave: StoryNode; t: number }
to: { stave: StoryNode; t: number }
/** Node position outside the barrel: between the two staves in angle, between the two times in height. */
angle: number
t: number
}
/**
* Experiences that link two different staves of the shown level: formed in one, applied later in another.
* Each experience appears once, from its earliest formation to its earliest later application elsewhere.
* At most `limit`, most-applied first, so the barrel never fills with arcs.
*/
export function experienceLinks(experiences: SupervisionExperience[], staves: StoryNode[], limit = 6): ExperienceLink[] {
const owner = new Map<string, StoryNode>()
for (const stave of staves) for (const event of stave.events) owner.set(event.id, stave)
const links: Array<{ link: ExperienceLink; weight: number }> = []
for (const experience of experiences) {
const at = (event: SupervisionExperience['events'][number]) => ({ stave: owner.get(event.id), t: Date.parse(event.at) })
const formed = experience.events.filter(event => event.role === 'formed').map(at).filter(item => item.stave).sort((a, b) => a.t - b.t)[0]
if (!formed) continue
const applied = experience.events.filter(event => event.role === 'applied').map(at)
.filter(item => item.stave && item.stave !== formed.stave && item.t > formed.t).sort((a, b) => a.t - b.t)
const to = applied[0]
if (!to) continue
const a = (formed.stave!.a0 + formed.stave!.a1) / 2, b = (to.stave!.a0 + to.stave!.a1) / 2
// Midpoint along the shorter way round the barrel.
let delta = b - a
if (delta > Math.PI) delta -= TAU
if (delta < -Math.PI) delta += TAU
links.push({ link: { id: experience.id, statement: experience.statement, from: { stave: formed.stave!, t: formed.t }, to: { stave: to.stave!, t: to.t },
angle: a + delta / 2, t: (formed.t + to.t) / 2 }, weight: new Set(applied.map(item => item.stave)).size })
}
return links.sort((x, y) => y.weight - x.weight || x.link.from.t - y.link.from.t).slice(0, limit).map(item => item.link)
}
@@ -0,0 +1,155 @@
import { StrictMode } from 'react'
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { Conversation } from './chat-conversation'
import { useConversationListOrder } from './use-conversation-list-order'
function conversation(id: string, time: number, pinned = false): Conversation {
return { id, title: id, updatedAt: time, pinned, messages: [], messageSummary: { count: 1, latestMessageAt: time } }
}
function List({ items, scope = 'all', menuOpen = false }: {
items: Conversation[]; scope?: string; menuOpen?: boolean
}) {
const { conversations, listProps } = useConversationListOrder(items, scope, menuOpen)
return <>
<div data-testid="list" {...listProps}>
{conversations.map(item => <button key={item.id} data-id={item.id}>{item.title}</button>)}
</div>
<button>Outside</button>
</>
}
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(<List items={[b, a]} />)
expect(ids()).toEqual(['a', 'b'])
for (let delta = 1; delta <= 4; delta++) {
advance(1000)
rerender(<List items={[a, { ...b, title: `Live ${delta}`, messageSummary: { count: delta, latestMessageAt: 30 + delta } }]} />)
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(<List items={[{ ...a, messageSummary: { count: 2, latestMessageAt: 100 } }, b]} />)
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(<List items={[conversation('a', 20), conversation('b', 10)]} />)
fireEvent.mouseEnter(screen.getByTestId('list'))
rerender(<List items={[conversation('a', 20), conversation('b', 30)]} />)
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(<List items={[conversation('a', 20), conversation('b', 10)]} />)
act(() => screen.getByText('a').focus())
rerender(<List items={[conversation('a', 20), conversation('b', 30)]} />)
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(<List items={[conversation('a', 20), conversation('b', 10)]} />)
rerender(<List items={[conversation('a', 20), conversation('b', 30)]} />)
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(<List items={initial} />)
advance(4000)
rerender(<List items={updated} menuOpen />)
advance(1000)
expect(ids()).toEqual(['a', 'b'])
advance(4000)
rerender(<List items={updated} />)
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(<List items={[a, b, c]} menuOpen />)
const newerB = conversation('b', 40)
rerender(<List items={[a, newerB, c]} menuOpen />)
const d = conversation('d', 50)
rerender(<List items={[a, newerB, c, d]} menuOpen />)
expect(ids()).toEqual(['d', 'a', 'b', 'c'])
rerender(<List items={[a, newerB, c]} menuOpen />)
expect(ids()).toEqual(['a', 'b', 'c'])
rerender(<List items={[a, newerB, { ...c, pinned: true }]} menuOpen />)
expect(ids()).toEqual(['c', 'a', 'b'])
rerender(<List items={[a, newerB, c]} menuOpen />)
expect(ids()).toEqual(['a', 'b', 'c'])
advance(5000)
expect(ids()).toEqual(['a', 'b', 'c'])
rerender(<List items={[a, newerB, c]} />)
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(<List items={[a, b]} menuOpen />)
const newerB = conversation('b', 30)
rerender(<List items={[a, newerB]} menuOpen />)
expect(ids()).toEqual(['a', 'b'])
rerender(<List items={[a, newerB]} scope="search" menuOpen />)
expect(ids()).toEqual(['b', 'a'])
rerender(<List items={[a]} scope="search" menuOpen />)
expect(ids()).toEqual(['a'])
rerender(<List items={[a, newerB]} scope="search" menuOpen />)
expect(ids()).toEqual(['b', 'a'])
rerender(<List items={[]} scope="project" menuOpen />)
expect(ids()).toEqual([])
})
it('cleans up the interval on unmount and does not duplicate it in Strict Mode', () => {
const { unmount } = render(<StrictMode><List items={[]} /></StrictMode>)
expect(vi.getTimerCount()).toBe(1)
unmount()
expect(vi.getTimerCount()).toBe(0)
})
})
@@ -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<HTMLDivElement>(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 },
},
}
}