Skip to content

Commit 42a1d47

Browse files
committed
Add DotPlot demos & fix conversationArc listeners
Add DotPlot-based live demos to docs (CategoryColorProvider, DotPlot, timeFormat, chart refs and push/clear usage) and tweak demo UI spacing/sizes. Change prerender.d.mts prerender() return type to Promise<void>. Update annotationProvenance doc string to use stableId. Refactor conversationArc internals: make ConversationArcEventInput distributive to preserve variant payloads, move subscriber Set to module scope so listeners registered before enable remain attached across disable/enable, and adjust subscribe/record/reset/clear behavior to use disableConversationArc() + store.clear() instead of reset(). Add tests ensuring pre-enable subscriptions receive events and that subscribers persist across disable/re-enable. Update variantDiscovery test stubs (family -> "time-series" and add missing profile fields).
1 parent 85675b0 commit 42a1d47

7 files changed

Lines changed: 195 additions & 26 deletions

File tree

docs/src/blog/entries/talk-track-intelligence.js

Lines changed: 51 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/* eslint-disable react/no-unescaped-entities */
2-
import React, { useEffect, useMemo, useState } from "react"
2+
import React, { useEffect, useMemo, useRef, useState } from "react"
33
import { Link } from "react-router-dom"
4-
import { LineChart } from "semiotic"
4+
import { CategoryColorProvider, DotPlot, LineChart } from "semiotic"
55
import {
66
disableConversationArc,
77
enableConversationArc,
@@ -75,13 +75,35 @@ const TYPE_COLOR = {
7575
"chart-abandoned": "#c43d3d",
7676
}
7777

78+
const timeFormat = (ms) => {
79+
const d = new Date(ms)
80+
return d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit", second: "2-digit" })
81+
}
82+
7883
function ConversationArcLiveDemo() {
7984
const store = useMemo(() => getConversationArcStore(), [])
85+
const chartRef = useRef(null)
86+
const dotIdRef = useRef(0)
8087
const [enabled, setEnabled] = useState(store.enabled)
8188
const [events, setEvents] = useState(() => store.getEvents())
8289

83-
useEffect(() => store.subscribe(() => setEvents(store.getEvents())), [store])
84-
useEffect(() => () => store.reset(), [store])
90+
useEffect(() => {
91+
return store.subscribe((event) => {
92+
setEvents(store.getEvents())
93+
chartRef.current?.push({
94+
id: ++dotIdRef.current,
95+
type: event.type,
96+
time: event.timestamp,
97+
})
98+
})
99+
}, [store])
100+
101+
useEffect(() => () => {
102+
// Don't `reset()` — that would wipe listeners other parts of the
103+
// app might have set up. Just stop recording and drop the buffer.
104+
disableConversationArc()
105+
store.clear()
106+
}, [store])
85107

86108
const toggle = () => {
87109
if (enabled) {
@@ -136,14 +158,38 @@ function ConversationArcLiveDemo() {
136158
))}
137159
</div>
138160

161+
<CategoryColorProvider colors={TYPE_COLOR}>
162+
<DotPlot
163+
ref={chartRef}
164+
categoryAccessor="type"
165+
valueAccessor="time"
166+
dataIdAccessor="id"
167+
colorBy="type"
168+
orientation="horizontal"
169+
dotRadius={6}
170+
valueFormat={timeFormat}
171+
height={240}
172+
margin={{ left: 130, right: 24, top: 8, bottom: 32 }}
173+
showLegend={false}
174+
title="Events arriving via the DotPlot push API"
175+
summary="Horizontal dot plot. Each dot is one recorded arc event; x is the timestamp, y is the event type, color matches the button above."
176+
emptyContent={
177+
<div style={{ color: "var(--text-secondary)", fontSize: 13, padding: "30px 0", textAlign: "center" }}>
178+
{enabled ? "Click an event button — dots arrive via push API." : "Enable recording, then click buttons."}
179+
</div>
180+
}
181+
/>
182+
</CategoryColorProvider>
183+
139184
<div style={{
140185
background: "var(--surface-2)",
141186
borderRadius: 6,
142187
padding: 8,
143188
fontFamily: "var(--semiotic-font-family-mono, ui-monospace, monospace)",
144189
fontSize: 12,
145-
maxHeight: 200,
190+
maxHeight: 180,
146191
overflowY: "auto",
192+
marginTop: 12,
147193
}}>
148194
{events.length === 0 ? (
149195
<em style={{ color: "var(--text-secondary)" }}>

docs/src/pages/features/ConversationArcPage.js

Lines changed: 70 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import {
55
getConversationArcStore,
66
withProvenance,
77
} from "semiotic/ai"
8-
import { LineChart } from "semiotic"
8+
import { CategoryColorProvider, DotPlot, LineChart } from "semiotic"
99
import PageLayout from "../../components/PageLayout"
1010
import CodeBlock from "../../components/CodeBlock"
1111

@@ -101,23 +101,60 @@ const TYPE_COLORS = {
101101
"chart-abandoned": "var(--semiotic-danger, #c43d3d)",
102102
}
103103

104+
// Same palette as the button borders, but using the hex fallbacks
105+
// directly so the CategoryColorProvider hands canvas-renderable strings
106+
// to the DotPlot instead of unresolved `var(...)` references.
107+
const TYPE_COLORS_HEX = {
108+
"suggestion-shown": "#3a8eff",
109+
"suggestion-chosen": "#3a8eff",
110+
"audience-set": "#d49a00",
111+
"chart-rendered": "#2d8a4a",
112+
"chart-edited": "#2d8a4a",
113+
"chart-replaced": "#d49a00",
114+
"chart-exported": "#6a52d9",
115+
"chart-abandoned": "#c43d3d",
116+
}
117+
118+
// Categories on the y-axis appear in the order the first event of
119+
// each type arrives. That's `sort: "auto"`'s streaming behavior on
120+
// DotPlot — honest with the "watch the arc unfold" framing.
121+
const timeFormat = (ms) => {
122+
const d = new Date(ms)
123+
return d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit", second: "2-digit" })
124+
}
125+
104126
function ArcDemo() {
105127
const store = useMemo(() => getConversationArcStore(), [])
128+
const chartRef = useRef(null)
129+
const dotIdRef = useRef(0)
106130
const [enabled, setEnabled] = useState(store.enabled)
107131
const [events, setEvents] = useState(() => store.getEvents())
108132
const [sessionId, setSessionId] = useState(store.sessionId)
109133

110134
useEffect(() => {
111-
const unsubscribe = store.subscribe(() => {
135+
const unsubscribe = store.subscribe((event) => {
112136
setEvents(store.getEvents())
113137
setSessionId(store.sessionId)
138+
// Mirror the same event into the DotPlot via its push API.
139+
// Stable ID per dot so the chart can update/remove individuals
140+
// later if we ever want to.
141+
chartRef.current?.push({
142+
id: ++dotIdRef.current,
143+
type: event.type,
144+
time: event.timestamp,
145+
})
114146
})
115147
return unsubscribe
116148
}, [store])
117149

118-
// Clean up on unmount so other docs pages aren't recording into our
119-
// demo session.
120-
useEffect(() => () => store.reset(), [store])
150+
// Clean up on unmount so navigating away doesn't leave recording on
151+
// for other consumers. Intentionally not `reset()` — that would wipe
152+
// listeners other parts of the app sharing the same store may have
153+
// attached.
154+
useEffect(() => () => {
155+
disableConversationArc()
156+
store.clear()
157+
}, [store])
121158

122159
const toggle = () => {
123160
if (enabled) {
@@ -135,6 +172,8 @@ function ArcDemo() {
135172

136173
const clear = () => {
137174
store.clear()
175+
chartRef.current?.clear()
176+
dotIdRef.current = 0
138177
setEvents([])
139178
}
140179

@@ -215,8 +254,33 @@ function ArcDemo() {
215254
))}
216255
</div>
217256

257+
<CategoryColorProvider colors={TYPE_COLORS_HEX}>
258+
<DotPlot
259+
ref={chartRef}
260+
categoryAccessor="type"
261+
valueAccessor="time"
262+
dataIdAccessor="id"
263+
colorBy="type"
264+
orientation="horizontal"
265+
dotRadius={6}
266+
valueFormat={timeFormat}
267+
height={260}
268+
margin={{ left: 130, right: 24, top: 12, bottom: 36 }}
269+
showLegend={false}
270+
title="Events over time"
271+
summary="Horizontal dot plot. Each dot is a recorded conversation-arc event placed at its timestamp on the x-axis and its event type on the y-axis. Colors match the event-type buttons above."
272+
emptyContent={
273+
<div style={{ color: "var(--text-secondary)", fontSize: 13, padding: "40px 0", textAlign: "center" }}>
274+
{enabled
275+
? "Click an event button above — dots will arrive via the DotPlot push API."
276+
: "Enable recording, then click an event button to drop dots onto the chart."}
277+
</div>
278+
}
279+
/>
280+
</CategoryColorProvider>
281+
218282
<div style={{
219-
maxHeight: 280,
283+
maxHeight: 220,
220284
overflowY: "auto",
221285
background: "var(--surface-2)",
222286
padding: 10,

scripts/prerender.d.mts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,4 +20,4 @@ export function generatePage(
2020
blogMeta?: BlogEntryMeta | null
2121
): string
2222

23-
export function prerender(): void
23+
export function prerender(): Promise<void>

src/components/ai/annotationProvenance.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ export type AnnotationFreshness = "fresh" | "aging" | "stale" | "expired"
7070
* - `latest` — re-pins to the most recent data point on each refresh.
7171
* - `sticky` — keeps its position until explicitly removed (same
7272
* semantics as `RealtimeLineChart`'s `sticky` annotation anchor).
73-
* - `semantic` — re-resolves via `provenance.stable_id`, falling
73+
* - `semantic` — re-resolves via `provenance.stableId`, falling
7474
* back to the recorded coordinate when the anchor can no longer
7575
* be located. Implementation lands in M3.
7676
*/

src/components/ai/conversationArc.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,37 @@ describe("conversationArc — default (disabled) surface", () => {
3232
expect(typeof unsub).toBe("function")
3333
expect(() => unsub()).not.toThrow()
3434
})
35+
36+
// Regression: a listener registered before enable used to silently
37+
// drop because the per-session listener Set didn't exist yet. The
38+
// docs demo subscribed at mount and saw no events until the user
39+
// toggled enable, but no events arrived after that either.
40+
it("delivers events to listeners that subscribed before enable", () => {
41+
const seen: string[] = []
42+
const unsub = getConversationArcStore().subscribe((e) => seen.push(e.type))
43+
44+
enableConversationArc()
45+
getConversationArcStore().record({ type: "chart-rendered", component: "LineChart" })
46+
47+
expect(seen).toEqual(["chart-rendered"])
48+
unsub()
49+
})
50+
51+
it("keeps subscribers attached across disable / re-enable transitions", () => {
52+
enableConversationArc()
53+
const seen: string[] = []
54+
const unsub = getConversationArcStore().subscribe((e) => seen.push(e.type))
55+
56+
getConversationArcStore().record({ type: "chart-rendered", component: "A" })
57+
disableConversationArc()
58+
// While disabled, record() returns null and listeners aren't notified.
59+
getConversationArcStore().record({ type: "chart-rendered", component: "B" })
60+
enableConversationArc()
61+
getConversationArcStore().record({ type: "chart-rendered", component: "C" })
62+
63+
expect(seen).toEqual(["chart-rendered", "chart-rendered"])
64+
unsub()
65+
})
3566
})
3667

3768
describe("conversationArc — enable / record / subscribe", () => {

src/components/ai/conversationArc.ts

Lines changed: 32 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -114,10 +114,18 @@ export type ConversationArcEvent =
114114
* Input shape accepted by `record()`: the event variant without the
115115
* stamped fields (`timestamp` and `sessionId`). Callers may still
116116
* provide them to backfill historical events.
117+
*
118+
* Implemented as a distributive conditional so each member of the
119+
* discriminated union keeps its variant-specific payload (e.g.
120+
* `SuggestionShownEvent.components`). A non-distributive
121+
* `Omit<ConversationArcEvent, ...>` collapses to the union's common
122+
* fields and rejects every variant-specific key.
117123
*/
118-
export type ConversationArcEventInput =
119-
& Omit<ConversationArcEvent, "timestamp" | "sessionId">
120-
& Partial<Pick<ConversationArcEvent, "timestamp" | "sessionId">>
124+
export type ConversationArcEventInput = ConversationArcEvent extends infer E
125+
? E extends ConversationArcEvent
126+
? Omit<E, "timestamp" | "sessionId"> & Partial<Pick<E, "timestamp" | "sessionId">>
127+
: never
128+
: never
121129

122130
export type ConversationArcListener = (event: ConversationArcEvent) => void
123131

@@ -137,7 +145,13 @@ export interface ConversationArcStore {
137145
flush(): ConversationArcEvent[]
138146
/** Returns a snapshot of the current buffer without clearing. */
139147
getEvents(): ConversationArcEvent[]
140-
/** Subscribe to new events. Returns an unsubscribe function. */
148+
/**
149+
* Subscribe to new events. Returns an unsubscribe function.
150+
*
151+
* Subscriptions persist across enable/disable transitions — a
152+
* subscriber registered before `enableConversationArc()` still
153+
* receives events once recording starts. Cleared by `reset()`.
154+
*/
141155
subscribe(listener: ConversationArcListener): () => void
142156
/** Empties the buffer without disabling the store. */
143157
clear(): void
@@ -154,12 +168,19 @@ export interface EnableConversationArcOptions {
154168

155169
let store: ConversationArcStoreInternal | null = null
156170

171+
// Subscriptions live at module scope, outside the per-session store,
172+
// so a subscriber registered before `enableConversationArc()` (e.g. a
173+
// React effect that runs at mount before the user clicks "enable") is
174+
// still attached once recording starts. Cleared by `reset()`; never
175+
// touched by `disable()` since buffered events stay correlatable and
176+
// re-enable should resume notifying.
177+
const listeners = new Set<ConversationArcListener>()
178+
157179
interface ConversationArcStoreInternal {
158180
enabled: boolean
159181
sessionId: string
160182
capacity: number
161183
buffer: ConversationArcEvent[]
162-
listeners: Set<ConversationArcListener>
163184
}
164185

165186
function newSessionId(): string {
@@ -195,7 +216,6 @@ export function enableConversationArc(
195216
sessionId: options.sessionId ?? newSessionId(),
196217
capacity,
197218
buffer: [],
198-
listeners: new Set(),
199219
}
200220
} else {
201221
store.enabled = true
@@ -247,7 +267,7 @@ const facade: ConversationArcStore = {
247267
} as ConversationArcEvent
248268
s.buffer.push(event)
249269
while (s.buffer.length > s.capacity) s.buffer.shift()
250-
for (const listener of s.listeners) {
270+
for (const listener of listeners) {
251271
try {
252272
listener(event)
253273
} catch (err) {
@@ -272,20 +292,20 @@ const facade: ConversationArcStore = {
272292
return s ? s.buffer.slice() : []
273293
},
274294
subscribe(listener) {
275-
const s = ensureStore()
276-
if (!s) return () => {}
277-
s.listeners.add(listener)
295+
// Subscriptions persist across enable/disable transitions — see
296+
// the module-scope `listeners` Set above for the rationale.
297+
listeners.add(listener)
278298
return () => {
279-
s.listeners.delete(listener)
299+
listeners.delete(listener)
280300
}
281301
},
282302
clear() {
283303
const s = ensureStore()
284304
if (s) s.buffer = []
285305
},
286306
reset() {
307+
listeners.clear()
287308
if (!store) return
288-
store.listeners.clear()
289309
store.buffer = []
290310
store.enabled = false
291311
store = null

src/components/ai/variantDiscovery.test.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ afterEach(() => {
1919
// implementations.
2020
const stubCapability: ChartCapability = {
2121
component: "LineChart",
22-
family: "xy",
22+
family: "time-series",
2323
importPath: "semiotic/xy",
2424
rubric: { familiarity: 5, accuracy: 4, precision: 4 },
2525
fits: () => null,
@@ -30,6 +30,7 @@ const stubCapability: ChartCapability = {
3030
const stubProfile: ChartDataProfile = {
3131
rowCount: 0,
3232
fields: {},
33+
sample: [],
3334
data: [],
3435
candidates: {
3536
x: [],
@@ -40,6 +41,13 @@ const stubProfile: ChartDataProfile = {
4041
time: [],
4142
},
4243
primary: {},
44+
hasRepeatedX: false,
45+
monotonicX: false,
46+
hasTimeAxis: false,
47+
xProvenance: "none",
48+
hasHierarchy: false,
49+
hasNetwork: false,
50+
hasGeo: false,
4351
}
4452

4553
describe("variantDiscovery — M1 stubs", () => {

0 commit comments

Comments
 (0)