@joewinke/jatui 0.24.0 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -34,6 +34,8 @@ export interface TranscriptItem {
34
34
  srcLines: number;
35
35
  /** id of the signal node that owns this item (assigned after parse) */
36
36
  signalId: string | null;
37
+ /** ISO write time of the first source message (null on line-parsed/live items) */
38
+ timestamp?: string | null;
37
39
  }
38
40
  export interface FileTouchedEntry {
39
41
  path: string;
@@ -65,11 +67,46 @@ export interface SignalNode {
65
67
  /** Net lines changed across all diffs for this block */
66
68
  linesAdded: number;
67
69
  linesRemoved: number;
70
+ /**
71
+ * Original signal JSON payload (full data for rich card rendering). Always
72
+ * set by the builders below; optional so SignalNodes constructed by hand
73
+ * (pre-0.25 callers) still type-check.
74
+ */
75
+ payload?: Record<string, unknown> | null;
76
+ /**
77
+ * True when this node represents the OPEN, in-progress turn — either a
78
+ * server-marked open block (`WorkBlockClient.open === true`) or the
79
+ * synthesized live node owning the trailing items past the last block.
80
+ * SessionNavigator uses this to render a ● live affordance. Optional so
81
+ * existing SignalNode consumers are unaffected.
82
+ */
83
+ isLive?: boolean;
68
84
  }
69
85
  export interface LinkedSession {
70
86
  items: TranscriptItem[];
71
87
  signals: SignalNode[];
72
88
  }
89
+ /** Tone drives the marker's hue; deliberately NOT a session-state key. */
90
+ export type MarkerTone = 'success' | 'error' | 'warn' | 'neutral' | 'info';
91
+ /** What a card detector declares when its event deserves a rail marker. */
92
+ export interface TimelineMarkerSpec {
93
+ /** glyph shown on the rail — keep it non-emoji so state glyphs stay distinct */
94
+ glyph: string;
95
+ /** short rail label, e.g. "subtask completed" */
96
+ label: string;
97
+ /** longer text for the title/tooltip */
98
+ detail?: string;
99
+ tone: MarkerTone;
100
+ }
101
+ /** A content event promoted onto the signal timeline, anchored to its row. */
102
+ export interface TimelineMarker extends TimelineMarkerSpec {
103
+ /** rail id — "mrk-<itemId>" */
104
+ id: string;
105
+ /** transcript item this marker anchors to (its `[data-row]`) */
106
+ itemId: number;
107
+ /** signal owning that item — clicking a marker inks this signal's ribbon */
108
+ signalId: string | null;
109
+ }
73
110
  export declare function rawTypeToState(raw: string): string;
74
111
  /** Build the linked model from rendered transcript lines. */
75
112
  export declare function buildLinkedSession(lines: string[]): LinkedSession;
@@ -99,6 +136,14 @@ export interface WorkBlockClient {
99
136
  }>;
100
137
  linesAdded?: number;
101
138
  linesRemoved?: number;
139
+ /**
140
+ * True when the server marks this block as the OPEN in-progress turn
141
+ * (populated by the navigator/sessionWork layer — sibling task jat-difet).
142
+ * Read defensively: may be undefined on blocks from older payloads. When
143
+ * true on the last block, that block's node becomes the live node (it
144
+ * absorbs any trailing items instead of synthesizing a separate one).
145
+ */
146
+ open?: boolean;
102
147
  }
103
148
  export interface WorkTranscriptItemClient {
104
149
  id: number;
@@ -107,6 +152,7 @@ export interface WorkTranscriptItemClient {
107
152
  detail: string[];
108
153
  srcLines: number;
109
154
  blockId: string | null;
155
+ timestamp?: string | null;
110
156
  }
111
157
  /**
112
158
  * Build a LinkedSession from the WorkBlock substrate (B1).
@@ -41,10 +41,36 @@ function parseSignalBody(body) {
41
41
  if (!m)
42
42
  return null;
43
43
  let label = '';
44
- const pick = body.match(/"(?:approach|question|reviewFocus|currentStep|summary|reason|taskTitle)"\s*:\s*"([^"]{3,90})/);
45
- if (pick)
46
- label = pick[1];
47
- return { rawType: m[1], label };
44
+ let payload = null;
45
+ // Try to extract and parse the JSON payload from the signal call
46
+ const jsonMatch = body.match(/jat-signal\s+\w+\s+'(\{[\s\S]*?\})'\s*$/) ||
47
+ body.match(/jat-signal\s+\w+\s+"(\{[\s\S]*?\})"\s*$/) ||
48
+ body.match(/jat-signal\s+\w+\s+(\{[\s\S]*?\})\s*$/);
49
+ if (jsonMatch) {
50
+ try {
51
+ payload = JSON.parse(jsonMatch[1]);
52
+ }
53
+ catch {
54
+ // ignore parse errors — payload stays null
55
+ }
56
+ }
57
+ if (payload) {
58
+ // Extract label from full payload
59
+ const p = payload;
60
+ const candidate = (Array.isArray(p.summary) ? p.summary[0] : undefined) ??
61
+ (typeof p.approach === 'string' ? p.approach : undefined) ??
62
+ (typeof p.taskTitle === 'string' ? p.taskTitle : undefined) ??
63
+ (typeof p.question === 'string' ? p.question : undefined) ??
64
+ (typeof p.currentStep === 'string' ? p.currentStep : undefined);
65
+ if (candidate)
66
+ label = candidate.slice(0, 90);
67
+ }
68
+ else {
69
+ const pick = body.match(/"(?:approach|question|reviewFocus|currentStep|summary|reason|taskTitle)"\s*:\s*"([^"]{3,90})/);
70
+ if (pick)
71
+ label = pick[1];
72
+ }
73
+ return { rawType: m[1], label, payload };
48
74
  }
49
75
  /** Build the linked model from rendered transcript lines. */
50
76
  export function buildLinkedSession(lines) {
@@ -64,7 +90,14 @@ export function buildLinkedSession(lines) {
64
90
  } // blank ends a block
65
91
  if (line.startsWith('› ')) {
66
92
  flush();
67
- cur = { kind: 'prompt', headline: line.slice(2), detail: [], srcLines: 1 };
93
+ const headline = line.slice(2);
94
+ // Claude Code's TUI draws a blank "› " input box while idle between
95
+ // turns; a capture that lands mid-frame leaks that empty prompt line
96
+ // into the transcript. Treat it like a blank line (below) rather than
97
+ // a zero-content prompt item — it never carries real content.
98
+ if (!headline.trim())
99
+ continue;
100
+ cur = { kind: 'prompt', headline, detail: [], srcLines: 1 };
68
101
  continue;
69
102
  }
70
103
  if (line.startsWith('⏺ ')) {
@@ -73,7 +106,7 @@ export function buildLinkedSession(lines) {
73
106
  if (sig) {
74
107
  // signal emission = boundary; consume it, don't render as an item
75
108
  flush();
76
- emits.push({ afterIdx: items.length - 1, rawType: sig.rawType, label: sig.label });
109
+ emits.push({ afterIdx: items.length - 1, rawType: sig.rawType, label: sig.label, payload: sig.payload });
77
110
  continue;
78
111
  }
79
112
  flush();
@@ -113,13 +146,33 @@ export function buildLinkedSession(lines) {
113
146
  }
114
147
  flush();
115
148
  // Build signal nodes from emission boundaries.
149
+ // Ownership is FORWARD: each signal owns the items that come AFTER it, up to
150
+ // (but not including) the next signal's emission point. The first signal also
151
+ // absorbs any preamble items that appear before it.
152
+ //
153
+ // Let B[i] = emits[i].afterIdx = last item index before signal[i].
154
+ //
155
+ // Partition (non-overlapping):
156
+ // sig[0] → [0 .. B[1]] preamble + first forward region
157
+ // sig[i>0] → [B[i]+1 .. B[i+1]] region after sig[i], before sig[i+1]
158
+ // sig[last] → [B[last]+1 .. N-1] region after last signal
159
+ //
160
+ // This ensures output printed AFTER a signal emission (e.g. the READY FOR
161
+ // REVIEW banner after `jat-signal review`) is attributed to that signal, not
162
+ // the subsequent one.
116
163
  const signals = [];
117
- let prevBoundary = -1;
164
+ const N = items.length;
118
165
  emits.forEach((e, i) => {
119
- const rowStart = Math.max(0, prevBoundary + 1);
120
- const rowEnd = Math.max(rowStart, e.afterIdx);
166
+ // rowStart: first signal starts at 0 (absorbs preamble items before it);
167
+ // later signals start at the item immediately after this signal's emission.
168
+ const rowStart = i === 0 ? 0 : e.afterIdx + 1;
169
+ // naturalEnd: extends to the next signal's pre-boundary, or to the last item.
170
+ const naturalEnd = i + 1 < emits.length ? emits[i + 1].afterIdx : N - 1;
171
+ // rowEnd: capped at N-1 (never out of bounds); may be < rowStart when this
172
+ // signal has no items after it (e.g. last line is the signal itself).
173
+ const rowEnd = Math.min(naturalEnd, N - 1);
121
174
  let lineCount = 0;
122
- for (let k = rowStart; k <= rowEnd && k < items.length; k++)
175
+ for (let k = rowStart; k <= rowEnd && k < N; k++)
123
176
  lineCount += items[k].srcLines;
124
177
  signals.push({
125
178
  id: `sig-${i}`,
@@ -135,9 +188,9 @@ export function buildLinkedSession(lines) {
135
188
  filesTouched: [],
136
189
  commits: [],
137
190
  linesAdded: 0,
138
- linesRemoved: 0
191
+ linesRemoved: 0,
192
+ payload: e.payload
139
193
  });
140
- prevBoundary = e.afterIdx;
141
194
  });
142
195
  for (const sig of signals) {
143
196
  for (let k = sig.rowStart; k <= sig.rowEnd && k < items.length; k++) {
@@ -168,9 +221,11 @@ export function buildLinkedSessionFromWork(blocks, items) {
168
221
  headline: it.headline,
169
222
  detail: it.detail,
170
223
  srcLines: it.srcLines,
171
- signalId: null // filled in below
224
+ signalId: null, // filled in below
225
+ timestamp: it.timestamp ?? null
172
226
  }));
173
- // Map WorkBlock → SignalNode
227
+ // Map WorkBlock → SignalNode. `isLive` is seeded from the server's open flag
228
+ // (read defensively — jat-difet adds it; may be undefined on older payloads).
174
229
  const signals = blocks.map((blk) => ({
175
230
  id: blk.id,
176
231
  state: rawTypeToState(blk.signal.type),
@@ -185,11 +240,80 @@ export function buildLinkedSessionFromWork(blocks, items) {
185
240
  filesTouched: blk.filesTouched,
186
241
  commits: blk.commits ?? [],
187
242
  linesAdded: blk.linesAdded ?? 0,
188
- linesRemoved: blk.linesRemoved ?? 0
243
+ linesRemoved: blk.linesRemoved ?? 0,
244
+ payload: blk.signal.payload,
245
+ isLive: blk.open === true
189
246
  }));
190
- // Assign signalId to transcript items via block ownership
247
+ const N = transcriptItems.length;
248
+ // Helper: synthesize the live node owning the open in-progress turn.
249
+ const makeLiveNode = (start, end, label) => {
250
+ let lineCount = 0;
251
+ for (let k = start; k <= end && k < N; k++)
252
+ lineCount += transcriptItems[k].srcLines;
253
+ return {
254
+ id: 'sig-live',
255
+ state: rawTypeToState('working'),
256
+ rawType: 'working',
257
+ label,
258
+ rowStart: start,
259
+ rowEnd: end,
260
+ lineCount: Math.max(1, lineCount),
261
+ blockId: null,
262
+ summary: null,
263
+ filesTouched: [],
264
+ commits: [],
265
+ linesAdded: 0,
266
+ linesRemoved: 0,
267
+ payload: null,
268
+ isLive: true
269
+ };
270
+ };
271
+ if (N > 0) {
272
+ if (signals.length === 0) {
273
+ // No blocks but live items exist → the whole transcript is the open
274
+ // turn. Give it a single live node so nothing is left unsignaled.
275
+ signals.push(makeLiveNode(0, N - 1, 'current'));
276
+ }
277
+ else {
278
+ // (#1 gap-fill) Guarantee a contiguous [0..N-1] partition so EVERY
279
+ // item ends up owning a signal:
280
+ // (a) first signal absorbs any preamble before its range
281
+ signals[0].rowStart = 0;
282
+ // (b) extend each signal's rowEnd to close the gap before the next
283
+ // signal's range (no-op when ranges already touch/overlap)
284
+ for (let i = 0; i < signals.length - 1; i++) {
285
+ const nextStart = signals[i + 1].rowStart;
286
+ if (signals[i].rowEnd < nextStart - 1) {
287
+ signals[i].rowEnd = nextStart - 1;
288
+ }
289
+ }
290
+ // (#2 live node) Trailing items past the last block's range are the
291
+ // OPEN in-progress turn — they must NOT be folded into the previous
292
+ // signal (e.g. a 'review'). Honor a server-marked open block first;
293
+ // otherwise synthesize a dedicated live node.
294
+ const lastSig = signals[signals.length - 1];
295
+ const lastBlockEnd = lastSig.rowEnd;
296
+ if (lastSig.isLive) {
297
+ // Server already marked the last block open → IT is the live node;
298
+ // extend it to absorb any trailing items.
299
+ lastSig.rowEnd = N - 1;
300
+ }
301
+ else if (lastBlockEnd < N - 1) {
302
+ // Synthesize a live node owning [lastBlockEnd+1 .. N-1]. Label from
303
+ // the live task title when available, else a neutral 'current'.
304
+ const p = lastSig.payload;
305
+ const title = p && typeof p.taskTitle === 'string' ? p.taskTitle.slice(0, 90) : null;
306
+ signals.push(makeLiveNode(lastBlockEnd + 1, N - 1, title ?? 'current'));
307
+ }
308
+ // else: the last block already reaches N-1 — nothing trailing.
309
+ }
310
+ }
311
+ // Assign signalId to transcript items via (gap-filled) ownership ranges.
312
+ // Clamp to bounds; later signals (incl. the live node) win on any overlap.
191
313
  for (const sig of signals) {
192
- for (let k = sig.rowStart; k <= sig.rowEnd && k < transcriptItems.length; k++) {
314
+ const start = Math.max(0, sig.rowStart);
315
+ const end = Math.min(sig.rowEnd, N - 1);
316
+ for (let k = start; k <= end; k++) {
193
317
  transcriptItems[k].signalId = sig.id;
194
318
  }
195
319
  }
@@ -202,7 +326,7 @@ export function buildLinkedSessionFromWork(blocks, items) {
202
326
  */
203
327
  export const SAMPLE_TRANSCRIPT_LINES = [
204
328
  '',
205
- '› /jat:start GentleCoast jat-7h2k',
329
+ '› /jat-start GentleCoast jat-7h2k',
206
330
  '⏺ Bash(get-current-session-id)',
207
331
  ' ⎿ 4f9c1a2e-7b3d-4c8a-9e1f-2a6b8c0d4e5f',
208
332
  '⏺ Bash(jat-signal starting \'{"agentName":"GentleCoast","taskTitle":"Add password reset flow"}\')',
@@ -256,7 +380,7 @@ export const SAMPLE_TRANSCRIPT_LINES = [
256
380
  '',
257
381
  '🔍 READY FOR REVIEW: jat-7h2k — password reset flow implemented, tested, rate-limited.',
258
382
  '',
259
- '› /jat:complete',
383
+ '› /jat-complete',
260
384
  '⏺ Bash(jat-step committing --task jat-7h2k)',
261
385
  ' ⎿ Committed: feat(jat-7h2k): add password reset flow',
262
386
  '⏺ Bash(jat-signal complete \'{"taskId":"jat-7h2k","summary":"Shipped password reset flow"}\')',
package/dist/index.d.ts CHANGED
@@ -25,7 +25,7 @@ export { default as Flythrough } from './components/Flythrough.svelte';
25
25
  export { default as LessonEmbed } from './components/LessonEmbed.svelte';
26
26
  export type { Collection, CollectionCut, CollectionBeat, } from './components/LessonEmbed.svelte';
27
27
  export { buildLinkedSession, buildLinkedSessionFromWork, rawTypeToState, sampleLinkedSession, SAMPLE_TRANSCRIPT_LINES, } from './components/session-nav/transcriptModel';
28
- export type { ItemKind, TranscriptItem, FileTouchedEntry, SignalNode, LinkedSession, WorkBlockClient, WorkTranscriptItemClient, } from './components/session-nav/transcriptModel';
28
+ export type { ItemKind, TranscriptItem, FileTouchedEntry, SignalNode, LinkedSession, WorkBlockClient, WorkTranscriptItemClient, MarkerTone, TimelineMarkerSpec, TimelineMarker, } from './components/session-nav/transcriptModel';
29
29
  export { default as UserAvatar } from './components/UserAvatar.svelte';
30
30
  export { default as SidebarUserFooter } from './components/SidebarUserFooter.svelte';
31
31
  export { default as TaskTypeIcon } from './components/TaskTypeIcon.svelte';
package/dist/index.js CHANGED
@@ -44,7 +44,8 @@ export { qrMatrix, qrCanvas } from './video/advisors/qr';
44
44
  // LessonEmbed renders a demo-assemble collection.json (cut picker + player).
45
45
  export { default as Flythrough } from './components/Flythrough.svelte';
46
46
  export { default as LessonEmbed } from './components/LessonEmbed.svelte';
47
- // Data models — Session transcript (from JAT IDE, jat-jj79f.16 G1)
47
+ // Data models — Session transcript (from JAT IDE, jat-jj79f.16 G1; resynced
48
+ // with the IDE's payload/timestamp/live-node/F13-marker model in jatui-ucmkz)
48
49
  export { buildLinkedSession, buildLinkedSessionFromWork, rawTypeToState, sampleLinkedSession, SAMPLE_TRANSCRIPT_LINES, } from './components/session-nav/transcriptModel';
49
50
  // Components — Universal
50
51
  export { default as UserAvatar } from './components/UserAvatar.svelte';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@joewinke/jatui",
3
- "version": "0.24.0",
3
+ "version": "0.25.0",
4
4
  "private": false,
5
5
  "description": "Shared Svelte 5 component library for JAT projects",
6
6
  "type": "module",