feat: show experiences outside the time spiral
This commit is contained in:
@@ -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 步:经验抽取
|
||||
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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?.();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user