@joewinke/jatui 0.1.11 → 0.1.20

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.
Files changed (100) hide show
  1. package/README.md +123 -0
  2. package/package.json +2 -1
  3. package/src/lib/actions/railNav.ts +473 -0
  4. package/src/lib/components/AnnotationLayer.svelte +108 -0
  5. package/src/lib/components/AnnotationPanel.svelte +319 -0
  6. package/src/lib/components/AudioWaveform.svelte +9 -5
  7. package/src/lib/components/AvailabilityModal.svelte +7 -3
  8. package/src/lib/components/AvatarUpload.svelte +27 -4
  9. package/src/lib/components/BookingForm.svelte +11 -9
  10. package/src/lib/components/BurndownChart.svelte +778 -0
  11. package/src/lib/components/Button.svelte +10 -1
  12. package/src/lib/components/CalendarPicker.svelte +3 -3
  13. package/src/lib/components/Card.svelte +2 -2
  14. package/src/lib/components/ChipInput.svelte +8 -3
  15. package/src/lib/components/ColorSelector.svelte +17 -13
  16. package/src/lib/components/CommentThread.svelte +773 -0
  17. package/src/lib/components/ConfirmDialog.svelte +348 -0
  18. package/src/lib/components/ConfirmModal.svelte +78 -11
  19. package/src/lib/components/ContextMenu.svelte +59 -19
  20. package/src/lib/components/CountdownTimer.svelte +1 -1
  21. package/src/lib/components/DateRangePicker.svelte +6 -4
  22. package/src/lib/components/Drawer.svelte +36 -3
  23. package/src/lib/components/EntityPreviewCard.svelte +104 -0
  24. package/src/lib/components/FileDropzone.svelte +493 -0
  25. package/src/lib/components/FilePicker.svelte +83 -14
  26. package/src/lib/components/FileThumbnail.svelte +80 -0
  27. package/src/lib/components/FilterDropdown.svelte +11 -11
  28. package/src/lib/components/GPSTracker.svelte +202 -0
  29. package/src/lib/components/HunkDiffView.svelte +348 -0
  30. package/src/lib/components/ImageLightbox.svelte +274 -0
  31. package/src/lib/components/ImageUpload.svelte +58 -9
  32. package/src/lib/components/InlineEdit.svelte +6 -2
  33. package/src/lib/components/InputDialog.svelte +327 -0
  34. package/src/lib/components/KeyboardShortcutsOverlay.svelte +296 -0
  35. package/src/lib/components/LazyImage.svelte +1 -0
  36. package/src/lib/components/LinkShortener.svelte +1 -1
  37. package/src/lib/components/LoadingSpinner.svelte +6 -2
  38. package/src/lib/components/LocationMap.svelte +186 -0
  39. package/src/lib/components/MapView.svelte +341 -0
  40. package/src/lib/components/MarkupEditor.svelte +485 -0
  41. package/src/lib/components/MarkupOverlay.svelte +55 -0
  42. package/src/lib/components/MediaWorkbench.svelte +871 -0
  43. package/src/lib/components/MilestoneCard.svelte +1 -1
  44. package/src/lib/components/MilestoneTimeline.svelte +1 -1
  45. package/src/lib/components/Modal.svelte +39 -4
  46. package/src/lib/components/PDFViewer.svelte +105 -0
  47. package/src/lib/components/PdfThumbnail.svelte +3 -1
  48. package/src/lib/components/PhoneInput.svelte +1 -1
  49. package/src/lib/components/ResizablePanel.svelte +4 -4
  50. package/src/lib/components/SearchDropdown.svelte +26 -13
  51. package/src/lib/components/SelectInput.svelte +26 -4
  52. package/src/lib/components/SidebarUserFooter.svelte +1 -1
  53. package/src/lib/components/SignaturePad.svelte +8 -4
  54. package/src/lib/components/SmartImageEditor.svelte +720 -0
  55. package/src/lib/components/SortDropdown.svelte +9 -3
  56. package/src/lib/components/Sparkline.svelte +9 -0
  57. package/src/lib/components/StatusBadge.svelte +20 -18
  58. package/src/lib/components/TextArea.svelte +24 -5
  59. package/src/lib/components/TextInput.svelte +29 -6
  60. package/src/lib/components/ThemeSelector.svelte +15 -4
  61. package/src/lib/components/TimeSlotPicker.svelte +7 -7
  62. package/src/lib/components/UserAvatar.svelte +14 -1
  63. package/src/lib/components/VariablePicker.svelte +170 -0
  64. package/src/lib/components/VoicePlayer.svelte +4 -3
  65. package/src/lib/components/linked-columns/LinkedColumns.svelte +520 -0
  66. package/src/lib/components/markup.ts +287 -0
  67. package/src/lib/components/messaging/ChannelInfoModal.svelte +9 -9
  68. package/src/lib/components/messaging/ChannelList.svelte +1 -1
  69. package/src/lib/components/messaging/ChannelMembersModal.svelte +1 -1
  70. package/src/lib/components/messaging/CreateChannelModal.svelte +1 -1
  71. package/src/lib/components/messaging/DirectMessageList.svelte +1 -1
  72. package/src/lib/components/messaging/EmojiSelector.svelte +2 -1
  73. package/src/lib/components/messaging/MentionAutocomplete.svelte +1 -1
  74. package/src/lib/components/messaging/MessageAttachment.svelte +3 -3
  75. package/src/lib/components/messaging/MessageAttachmentUpload.svelte +3 -3
  76. package/src/lib/components/messaging/MessageInput.svelte +1 -1
  77. package/src/lib/components/messaging/MessageItem.svelte +6 -3
  78. package/src/lib/components/messaging/NotificationSettingsModal.svelte +1 -1
  79. package/src/lib/components/messaging/QuotedMessageDisplay.svelte +6 -1
  80. package/src/lib/components/messaging/StartDMModal.svelte +1 -1
  81. package/src/lib/components/pipeline/Pipeline.svelte +4 -4
  82. package/src/lib/components/pipeline/PipelineCard.svelte +1 -1
  83. package/src/lib/components/pipeline/PipelineColumn.svelte +8 -3
  84. package/src/lib/components/replay/ChapterTimeline.svelte +326 -0
  85. package/src/lib/components/session-nav/transcriptModel.ts +352 -0
  86. package/src/lib/index.ts +138 -0
  87. package/src/lib/stores/confirmDialog.svelte.ts +48 -0
  88. package/src/lib/stores/inputDialog.svelte.ts +51 -0
  89. package/src/lib/styles/rail.css +63 -0
  90. package/src/lib/types/annotation.ts +38 -0
  91. package/src/lib/types/comments.ts +97 -0
  92. package/src/lib/types/entityPreview.ts +45 -0
  93. package/src/lib/types/filePicker.ts +2 -0
  94. package/src/lib/types/googleMaps.d.ts +51 -0
  95. package/src/lib/types/maps.ts +43 -0
  96. package/src/lib/types/smartImageEditor.ts +39 -0
  97. package/src/lib/types/templateVars.ts +36 -0
  98. package/src/lib/utils/dateFormatters.ts +12 -10
  99. package/src/lib/utils/googleMapsLoader.ts +84 -0
  100. package/src/lib/utils/taskUtils.ts +21 -7
@@ -0,0 +1,326 @@
1
+ <script lang="ts">
2
+ /**
3
+ * ChapterTimeline — horizontal chapter-marker scrubber for WorkBlocks.
4
+ *
5
+ * Renders one chapter marker per WorkBlock, colored by the block's signal
6
+ * type. Clicking a marker seeks to that block. The active block is
7
+ * highlighted. Keyboard left/right arrows step through chapters.
8
+ *
9
+ * Extracted from the JAT IDE (jat-jj79f.5 C1) and promoted into jatui
10
+ * so JST projects can embed session replay without taking a dependency
11
+ * on the full IDE codebase (jat-jj79f.16 G1).
12
+ *
13
+ * The IDE-specific `SESSION_STATE_VISUALS` import has been replaced with
14
+ * an inline `SIGNAL_COLORS` map that covers the same canonical signal types.
15
+ */
16
+
17
+ export interface ChapterBlock {
18
+ id: string;
19
+ signal: { type: string; label: string };
20
+ itemCount: number;
21
+ lineCount: number;
22
+ filesTouched: { path: string; how: string }[];
23
+ checkpoint: { timestamp: string | null };
24
+ summary: string | null;
25
+ }
26
+
27
+ /** Minimal color descriptors for each canonical signal state. */
28
+ interface SignalColor {
29
+ accent: string;
30
+ bgTint: string;
31
+ glow: string;
32
+ shortLabel: string;
33
+ }
34
+
35
+ const SIGNAL_COLORS: Record<string, SignalColor> = {
36
+ starting: { accent: 'oklch(0.70 0.16 250)', bgTint: 'oklch(0.70 0.16 250 / 0.10)', glow: 'oklch(0.70 0.16 250 / 0.45)', shortLabel: '🚀 Starting' },
37
+ working: { accent: 'oklch(0.75 0.16 85)', bgTint: 'oklch(0.75 0.16 85 / 0.08)', glow: 'oklch(0.75 0.16 85 / 0.45)', shortLabel: '🔧 Working' },
38
+ 'needs-input': { accent: 'oklch(0.68 0.19 25)', bgTint: 'oklch(0.68 0.19 25 / 0.08)', glow: 'oklch(0.68 0.19 25 / 0.4)', shortLabel: '❓ Needs Input' },
39
+ 'ready-for-review': { accent: 'oklch(0.72 0.16 200)', bgTint: 'oklch(0.72 0.16 200 / 0.08)', glow: 'oklch(0.72 0.16 200 / 0.4)', shortLabel: '🔍 Review' },
40
+ completing: { accent: 'oklch(0.72 0.16 170)', bgTint: 'oklch(0.72 0.16 170 / 0.08)', glow: 'oklch(0.72 0.16 170 / 0.4)', shortLabel: '⚙️ Completing' },
41
+ completed: { accent: 'oklch(0.66 0.18 145)', bgTint: 'oklch(0.66 0.18 145 / 0.08)', glow: 'oklch(0.66 0.18 145 / 0.4)', shortLabel: '✅ Done' },
42
+ paused: { accent: 'oklch(0.65 0.08 250)', bgTint: 'oklch(0.65 0.08 250 / 0.06)', glow: 'oklch(0.65 0.08 250 / 0.25)', shortLabel: '⏸ Paused' },
43
+ idle: { accent: 'oklch(0.55 0.03 250)', bgTint: 'oklch(0.55 0.03 250 / 0.04)', glow: 'oklch(0.55 0.03 250 / 0.2)', shortLabel: 'Idle' },
44
+ };
45
+
46
+ const DEFAULT_COLOR: SignalColor = SIGNAL_COLORS.idle;
47
+
48
+ let {
49
+ blocks = [],
50
+ activeBlockId = null,
51
+ onSeek = (_blockId: string) => {},
52
+ }: {
53
+ blocks: ChapterBlock[];
54
+ activeBlockId: string | null;
55
+ onSeek?: (blockId: string) => void;
56
+ } = $props();
57
+
58
+ function rawTypeToState(raw: string): string {
59
+ switch (raw) {
60
+ case 'needs_input': return 'needs-input';
61
+ case 'review': return 'ready-for-review';
62
+ case 'complete':
63
+ case 'completed': return 'completed';
64
+ default: return raw;
65
+ }
66
+ }
67
+
68
+ function visual(signalType: string): SignalColor {
69
+ return SIGNAL_COLORS[rawTypeToState(signalType)] ?? DEFAULT_COLOR;
70
+ }
71
+
72
+ function glyph(signalType: string): string {
73
+ const v = visual(signalType);
74
+ const first = v.shortLabel.trim().split(/\s+/)[0];
75
+ return /\p{Emoji}/u.test(first) ? first : '●';
76
+ }
77
+
78
+ function formatTimestamp(ts: string | null): string {
79
+ if (!ts) return '';
80
+ try {
81
+ return new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
82
+ } catch {
83
+ return ts;
84
+ }
85
+ }
86
+
87
+ function handleKey(e: KeyboardEvent) {
88
+ if (!blocks.length) return;
89
+ const cur = blocks.findIndex((b) => b.id === activeBlockId);
90
+ if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
91
+ e.preventDefault();
92
+ const prev = Math.max(0, cur - 1);
93
+ onSeek(blocks[prev].id);
94
+ } else if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
95
+ e.preventDefault();
96
+ const next = Math.min(blocks.length - 1, cur + 1);
97
+ onSeek(blocks[next].id);
98
+ }
99
+ }
100
+
101
+ // Proportional widths: each chapter segment is proportional to its lineCount.
102
+ const totalLines = $derived(blocks.reduce((s, b) => s + Math.max(1, b.lineCount), 0));
103
+ function widthPct(b: ChapterBlock): number {
104
+ return (Math.max(1, b.lineCount) / (totalLines || 1)) * 100;
105
+ }
106
+ </script>
107
+
108
+ <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
109
+ <div
110
+ class="chapter-timeline"
111
+ role="slider"
112
+ tabindex="0"
113
+ aria-label="Session chapter timeline"
114
+ aria-valuenow={blocks.findIndex((b) => b.id === activeBlockId) + 1}
115
+ aria-valuemin={1}
116
+ aria-valuemax={blocks.length}
117
+ onkeydown={handleKey}
118
+ >
119
+ {#if blocks.length === 0}
120
+ <div class="empty">No chapters — session has no jat-signal boundaries yet.</div>
121
+ {:else}
122
+ <div class="track">
123
+ {#each blocks as block, i (block.id)}
124
+ {@const v = visual(block.signal.type)}
125
+ {@const isActive = block.id === activeBlockId}
126
+ {@const w = widthPct(block)}
127
+ <button
128
+ class="chapter"
129
+ class:active={isActive}
130
+ style="
131
+ width: {w}%;
132
+ min-width: 2rem;
133
+ --chapter-accent: {v.accent};
134
+ --chapter-tint: {v.bgTint};
135
+ --chapter-glow: {v.glow};
136
+ "
137
+ title="Block {i + 1}: {block.signal.label || block.signal.type}{block.summary ? '\n' + block.summary : ''}{block.checkpoint.timestamp ? '\n' + formatTimestamp(block.checkpoint.timestamp) : ''}"
138
+ onclick={() => onSeek(block.id)}
139
+ aria-pressed={isActive}
140
+ >
141
+ <span class="ch-glyph">{glyph(block.signal.type)}</span>
142
+ <span class="ch-label">{block.signal.label || block.signal.type}</span>
143
+ <span class="ch-meta">{block.itemCount}i</span>
144
+ </button>
145
+ {/each}
146
+ </div>
147
+
148
+ <!-- Chapter detail strip: show summary / files / timestamp for active block -->
149
+ {#each blocks as block (block.id)}
150
+ {#if block.id === activeBlockId}
151
+ <div class="ch-detail">
152
+ <span class="ch-detail-idx">
153
+ Block {blocks.indexOf(block) + 1}/{blocks.length}
154
+ </span>
155
+ {#if block.summary}
156
+ <span class="ch-detail-summary">{block.summary}</span>
157
+ {:else}
158
+ <span class="ch-detail-summary muted">{block.signal.label || block.signal.type}</span>
159
+ {/if}
160
+ {#if block.filesTouched.length > 0}
161
+ <span class="ch-detail-files">
162
+ {#each block.filesTouched as f (f.path)}
163
+ <span class="ch-file">{f.path.split('/').pop()}</span>
164
+ {/each}
165
+ </span>
166
+ {/if}
167
+ {#if block.checkpoint.timestamp}
168
+ <span class="ch-detail-ts">{formatTimestamp(block.checkpoint.timestamp)}</span>
169
+ {/if}
170
+ </div>
171
+ {/if}
172
+ {/each}
173
+ {/if}
174
+ </div>
175
+
176
+ <style>
177
+ .chapter-timeline {
178
+ display: flex;
179
+ flex-direction: column;
180
+ gap: 0.4rem;
181
+ outline: none;
182
+ }
183
+
184
+ .empty {
185
+ font-size: 0.75rem;
186
+ color: oklch(0.55 0.02 250);
187
+ padding: 0.5rem;
188
+ text-align: center;
189
+ }
190
+
191
+ .track {
192
+ display: flex;
193
+ height: 2.25rem;
194
+ border-radius: 0.5rem;
195
+ overflow: hidden;
196
+ background: oklch(0.14 0.01 250);
197
+ border: 1px solid oklch(0.22 0.02 250);
198
+ gap: 1px;
199
+ }
200
+
201
+ .chapter {
202
+ display: flex;
203
+ align-items: center;
204
+ gap: 0.3rem;
205
+ padding: 0 0.5rem;
206
+ background: var(--chapter-tint, oklch(0.18 0.01 250));
207
+ border: none;
208
+ border-right: 1px solid oklch(0.20 0.02 250);
209
+ cursor: pointer;
210
+ transition: background 0.12s ease, box-shadow 0.12s ease;
211
+ overflow: hidden;
212
+ white-space: nowrap;
213
+ position: relative;
214
+ }
215
+
216
+ .chapter:last-child {
217
+ border-right: none;
218
+ }
219
+
220
+ .chapter:hover {
221
+ background: color-mix(in oklch, var(--chapter-accent) 18%, oklch(0.16 0.01 250));
222
+ box-shadow: inset 0 0 0 1px color-mix(in oklch, var(--chapter-accent) 40%, transparent);
223
+ }
224
+
225
+ .chapter.active {
226
+ background: color-mix(in oklch, var(--chapter-accent) 22%, oklch(0.16 0.01 250));
227
+ box-shadow:
228
+ inset 0 0 0 2px color-mix(in oklch, var(--chapter-accent) 70%, transparent),
229
+ 0 0 12px color-mix(in oklch, var(--chapter-glow) 30%, transparent);
230
+ }
231
+
232
+ .chapter.active::after {
233
+ content: '';
234
+ position: absolute;
235
+ bottom: 0;
236
+ left: 0;
237
+ right: 0;
238
+ height: 2px;
239
+ background: var(--chapter-accent);
240
+ border-radius: 0 0 0.25rem 0.25rem;
241
+ }
242
+
243
+ .ch-glyph {
244
+ font-size: 0.95rem;
245
+ flex-shrink: 0;
246
+ }
247
+
248
+ .ch-label {
249
+ font-size: 0.7rem;
250
+ color: oklch(0.78 0.04 250);
251
+ overflow: hidden;
252
+ text-overflow: ellipsis;
253
+ min-width: 0;
254
+ }
255
+
256
+ .chapter.active .ch-label {
257
+ color: oklch(0.92 0.06 250);
258
+ font-weight: 600;
259
+ }
260
+
261
+ .ch-meta {
262
+ font-size: 0.6rem;
263
+ color: oklch(0.50 0.02 250);
264
+ margin-left: auto;
265
+ flex-shrink: 0;
266
+ font-family: monospace;
267
+ }
268
+
269
+ /* Detail strip under the track */
270
+ .ch-detail {
271
+ display: flex;
272
+ align-items: center;
273
+ gap: 0.6rem;
274
+ padding: 0.25rem 0.5rem;
275
+ font-size: 0.72rem;
276
+ color: oklch(0.70 0.04 250);
277
+ background: oklch(0.15 0.01 250);
278
+ border-radius: 0.375rem;
279
+ border: 1px solid oklch(0.22 0.02 250);
280
+ min-height: 1.75rem;
281
+ flex-wrap: wrap;
282
+ gap-y: 0.15rem;
283
+ }
284
+
285
+ .ch-detail-idx {
286
+ font-size: 0.65rem;
287
+ font-family: monospace;
288
+ color: oklch(0.50 0.02 250);
289
+ flex-shrink: 0;
290
+ }
291
+
292
+ .ch-detail-summary {
293
+ flex: 1;
294
+ overflow: hidden;
295
+ text-overflow: ellipsis;
296
+ white-space: nowrap;
297
+ }
298
+
299
+ .ch-detail-summary.muted {
300
+ color: oklch(0.55 0.02 250);
301
+ font-style: italic;
302
+ }
303
+
304
+ .ch-detail-files {
305
+ display: flex;
306
+ gap: 0.3rem;
307
+ flex-wrap: wrap;
308
+ }
309
+
310
+ .ch-file {
311
+ font-size: 0.64rem;
312
+ font-family: monospace;
313
+ background: oklch(0.19 0.02 250);
314
+ border: 1px solid oklch(0.25 0.02 250);
315
+ border-radius: 0.2rem;
316
+ padding: 0.1rem 0.35rem;
317
+ color: oklch(0.70 0.08 200);
318
+ }
319
+
320
+ .ch-detail-ts {
321
+ font-size: 0.62rem;
322
+ font-family: monospace;
323
+ color: oklch(0.48 0.02 250);
324
+ flex-shrink: 0;
325
+ }
326
+ </style>
@@ -0,0 +1,352 @@
1
+ /**
2
+ * transcriptModel.ts — transcript → linked (transcript items ↔ signal nodes) model
3
+ * for the session navigator. Extracted from sankey/sankeyData.ts (jat-w8tgz.1).
4
+ *
5
+ * THE CONCEPT (from ~/Documents/many_to_one.mp4): a two-column linked view like
6
+ * an academic article + its citations. LEFT = the session transcript (prose),
7
+ * RIGHT = the signal events (the "references"). Curved ribbons connect spans of
8
+ * the transcript to the signal they produced, and ink up as you scroll/hover.
9
+ *
10
+ * THE ANCHORING TRICK: a JAT agent emits state by running `jat-signal <type> …`,
11
+ * which appears in Claude's transcript as a Bash tool call. So we scan the
12
+ * transcript for those calls and treat each as a signal-emission boundary. Each
13
+ * signal then "owns" the run of transcript that led up to it — the genuine
14
+ * many-to-one mapping. The jat-signal lines themselves are plumbing, so we
15
+ * consume them as boundaries and do NOT render them.
16
+ *
17
+ * COMPACTION: raw transcript lines are noisy (every tool result, every "file
18
+ * updated successfully" notice). We group lines into ITEMS:
19
+ * - prompt — a user message (shown in full; it's the human's intent)
20
+ * - assistant — the agent's prose (shown in full; it's the narrative)
21
+ * - action — a tool call, with its result body tucked into `detail` so the
22
+ * transcript shows just the headline by default and the body is
23
+ * a drill-down. This is what keeps the left column readable.
24
+ */
25
+
26
+ export type ItemKind = 'prompt' | 'assistant' | 'action';
27
+
28
+ export interface TranscriptItem {
29
+ id: number;
30
+ kind: ItemKind;
31
+ /** the line shown when collapsed (prose, or the tool call) */
32
+ headline: string;
33
+ /** collapsible body — tool result lines; empty for prompt/assistant */
34
+ detail: string[];
35
+ /** how many raw transcript lines this item consumed (feeds the line-count badge) */
36
+ srcLines: number;
37
+ /** id of the signal node that owns this item (assigned after parse) */
38
+ signalId: string | null;
39
+ }
40
+
41
+ export interface FileTouchedEntry {
42
+ path: string;
43
+ how: 'written' | 'edited' | 'notebook';
44
+ }
45
+
46
+ export interface SignalNode {
47
+ id: string;
48
+ /** canonical SESSION_STATE_VISUALS key (e.g. 'working', 'needs-input', 'ready-for-review') */
49
+ state: string;
50
+ rawType: string;
51
+ /** short human label pulled from the signal payload, if any */
52
+ label: string;
53
+ /** inclusive transcript-item id range this signal owns */
54
+ rowStart: number;
55
+ rowEnd: number;
56
+ /** raw transcript lines feeding this signal — the "many" in many-to-one */
57
+ lineCount: number;
58
+ // ── WorkBlock substrate fields (B1: populated when built from getSessionWork) ──
59
+ /** WorkBlock id (e.g. "blk-000"); null when built from raw transcript lines */
60
+ blockId: string | null;
61
+ /** One-line LLM summary of this block (A2); null if not yet populated */
62
+ summary: string | null;
63
+ /** Files touched in this block (A3 file correlation) */
64
+ filesTouched: FileTouchedEntry[];
65
+ /** Commit SHAs + subjects for this block (A3 diff data) */
66
+ commits: Array<{ shaShort: string; subject: string }>;
67
+ /** Net lines changed across all diffs for this block */
68
+ linesAdded: number;
69
+ linesRemoved: number;
70
+ }
71
+
72
+ export interface LinkedSession {
73
+ items: TranscriptItem[];
74
+ signals: SignalNode[];
75
+ }
76
+
77
+ export function rawTypeToState(raw: string): string {
78
+ switch (raw) {
79
+ case 'needs_input':
80
+ return 'needs-input';
81
+ case 'review':
82
+ return 'ready-for-review';
83
+ case 'complete':
84
+ case 'completed':
85
+ return 'completed';
86
+ default:
87
+ return raw; // starting, working, completing, paused, idle pass through
88
+ }
89
+ }
90
+
91
+ /** Detect a `jat-signal <type> '<json>'` invocation in a tool-call body. */
92
+ function parseSignalBody(body: string): { rawType: string; label: string } | null {
93
+ const m = body.match(/jat-signal\s+(starting|working|needs_input|review|completing|complete|completed|paused|idle)\b/);
94
+ if (!m) return null;
95
+ let label = '';
96
+ const pick = body.match(/"(?:approach|question|reviewFocus|currentStep|summary|reason|taskTitle)"\s*:\s*"([^"]{3,90})/);
97
+ if (pick) label = pick[1];
98
+ return { rawType: m[1], label };
99
+ }
100
+
101
+ interface OpenItem { kind: ItemKind; headline: string; detail: string[]; srcLines: number }
102
+
103
+ /** Build the linked model from rendered transcript lines. */
104
+ export function buildLinkedSession(lines: string[]): LinkedSession {
105
+ const items: TranscriptItem[] = [];
106
+ interface Emit { afterIdx: number; rawType: string; label: string }
107
+ const emits: Emit[] = [];
108
+
109
+ let cur: OpenItem | null = null;
110
+ const flush = () => {
111
+ if (cur) {
112
+ items.push({ id: items.length, ...cur, signalId: null });
113
+ cur = null;
114
+ }
115
+ };
116
+
117
+ for (const line of lines) {
118
+ if (!line.trim()) { flush(); continue; } // blank ends a block
119
+
120
+ if (line.startsWith('› ')) {
121
+ flush();
122
+ cur = { kind: 'prompt', headline: line.slice(2), detail: [], srcLines: 1 };
123
+ continue;
124
+ }
125
+
126
+ if (line.startsWith('⏺ ')) {
127
+ const body = line.slice(2);
128
+ const sig = parseSignalBody(body);
129
+ if (sig) {
130
+ // signal emission = boundary; consume it, don't render as an item
131
+ flush();
132
+ emits.push({ afterIdx: items.length - 1, rawType: sig.rawType, label: sig.label });
133
+ continue;
134
+ }
135
+ flush();
136
+ cur = { kind: 'action', headline: body, detail: [], srcLines: 1 };
137
+ continue;
138
+ }
139
+
140
+ if (line.startsWith(' ⎿ ')) {
141
+ const body = line.slice(4);
142
+ if (cur) { cur.detail.push(body); cur.srcLines++; }
143
+ continue;
144
+ }
145
+
146
+ if (line.startsWith(' ')) {
147
+ // continuation: tool detail for actions, prose for prompt/assistant
148
+ const body = line.trimStart();
149
+ if (cur && cur.kind === 'action') { cur.detail.push(body); cur.srcLines++; }
150
+ else if (cur) { cur.headline += '\n' + body; cur.srcLines++; }
151
+ continue;
152
+ }
153
+
154
+ // plain assistant prose
155
+ if (cur && cur.kind === 'assistant') { cur.headline += '\n' + line; cur.srcLines++; }
156
+ else { flush(); cur = { kind: 'assistant', headline: line, detail: [], srcLines: 1 }; }
157
+ }
158
+ flush();
159
+
160
+ // Build signal nodes from emission boundaries.
161
+ const signals: SignalNode[] = [];
162
+ let prevBoundary = -1;
163
+ emits.forEach((e, i) => {
164
+ const rowStart = Math.max(0, prevBoundary + 1);
165
+ const rowEnd = Math.max(rowStart, e.afterIdx);
166
+ let lineCount = 0;
167
+ for (let k = rowStart; k <= rowEnd && k < items.length; k++) lineCount += items[k].srcLines;
168
+ signals.push({
169
+ id: `sig-${i}`,
170
+ state: rawTypeToState(e.rawType),
171
+ rawType: e.rawType,
172
+ label: e.label,
173
+ rowStart,
174
+ rowEnd,
175
+ lineCount: Math.max(1, lineCount),
176
+ // No WorkBlock substrate data in the raw-lines path
177
+ blockId: null,
178
+ summary: null,
179
+ filesTouched: [],
180
+ commits: [],
181
+ linesAdded: 0,
182
+ linesRemoved: 0
183
+ });
184
+ prevBoundary = e.afterIdx;
185
+ });
186
+
187
+ for (const sig of signals) {
188
+ for (let k = sig.rowStart; k <= sig.rowEnd && k < items.length; k++) {
189
+ items[k].signalId = sig.id;
190
+ }
191
+ }
192
+
193
+ return { items, signals };
194
+ }
195
+
196
+ // ── WorkBlock substrate types (B1) ─────────────────────────────────────────
197
+ // These mirror the server-side types from sessionWork.ts / sessionWorkDiffs.ts
198
+ // but are kept here as lightweight client-side shapes to avoid importing server
199
+ // modules into the browser bundle.
200
+
201
+ export interface WorkBlockClient {
202
+ id: string;
203
+ signal: { type: string; label: string; payload: Record<string, unknown> | null };
204
+ itemRange: { start: number; end: number };
205
+ itemCount: number;
206
+ lineCount: number;
207
+ summary: string | null;
208
+ filesTouched: FileTouchedEntry[];
209
+ checkpoint: { promptUuid: string; timestamp: string | null };
210
+ /** Optional diff data (A3) — present when endpoint returns enriched data */
211
+ commits?: Array<{ shaShort: string; subject: string }>;
212
+ linesAdded?: number;
213
+ linesRemoved?: number;
214
+ }
215
+
216
+ export interface WorkTranscriptItemClient {
217
+ id: number;
218
+ kind: 'prompt' | 'assistant' | 'action';
219
+ headline: string;
220
+ detail: string[];
221
+ srcLines: number;
222
+ blockId: string | null;
223
+ }
224
+
225
+ /**
226
+ * Build a LinkedSession from the WorkBlock substrate (B1).
227
+ *
228
+ * This is the preferred path for live sessions — it uses the richer data from
229
+ * `getSessionWork` (A1) including per-block summaries (A2) and file lists (A3)
230
+ * instead of re-parsing transcript lines.
231
+ *
232
+ * The `items` array from the server is used directly as `TranscriptItem[]` since
233
+ * both shapes share `id`, `kind`, `headline`, `detail`, `srcLines` with the
234
+ * `signalId` replaced by `blockId → signalId` mapping done here.
235
+ *
236
+ * @param blocks WorkBlock array from `/api/work/[sessionId]/navigator`
237
+ * @param items WorkTranscriptItem array from the same endpoint
238
+ */
239
+ export function buildLinkedSessionFromWork(
240
+ blocks: WorkBlockClient[],
241
+ items: WorkTranscriptItemClient[]
242
+ ): LinkedSession {
243
+ // Map WorkTranscriptItem → TranscriptItem (blockId → signalId mapping happens after)
244
+ const transcriptItems: TranscriptItem[] = items.map((it) => ({
245
+ id: it.id,
246
+ kind: it.kind,
247
+ headline: it.headline,
248
+ detail: it.detail,
249
+ srcLines: it.srcLines,
250
+ signalId: null // filled in below
251
+ }));
252
+
253
+ // Map WorkBlock → SignalNode
254
+ const signals: SignalNode[] = blocks.map((blk) => ({
255
+ id: blk.id,
256
+ state: rawTypeToState(blk.signal.type),
257
+ rawType: blk.signal.type,
258
+ label: blk.signal.label,
259
+ rowStart: blk.itemRange.start,
260
+ rowEnd: blk.itemRange.end,
261
+ lineCount: blk.lineCount,
262
+ // WorkBlock substrate fields
263
+ blockId: blk.id,
264
+ summary: blk.summary,
265
+ filesTouched: blk.filesTouched,
266
+ commits: blk.commits ?? [],
267
+ linesAdded: blk.linesAdded ?? 0,
268
+ linesRemoved: blk.linesRemoved ?? 0
269
+ }));
270
+
271
+ // Assign signalId to transcript items via block ownership
272
+ for (const sig of signals) {
273
+ for (let k = sig.rowStart; k <= sig.rowEnd && k < transcriptItems.length; k++) {
274
+ transcriptItems[k].signalId = sig.id;
275
+ }
276
+ }
277
+
278
+ return { items: transcriptItems, signals };
279
+ }
280
+
281
+ /**
282
+ * A baked, realistic sample so /sankey always demos beautifully even with no live
283
+ * session. Authored as raw transcript lines with real `jat-signal` calls, so it
284
+ * flows through the EXACT same parser as live data.
285
+ */
286
+ export const SAMPLE_TRANSCRIPT_LINES: string[] = [
287
+ '',
288
+ '› /jat:start GentleCoast jat-7h2k',
289
+ '⏺ Bash(get-current-session-id)',
290
+ ' ⎿ 4f9c1a2e-7b3d-4c8a-9e1f-2a6b8c0d4e5f',
291
+ '⏺ Bash(jat-signal starting \'{"agentName":"GentleCoast","taskTitle":"Add password reset flow"}\')',
292
+ ' ⎿ Signal: starting (agent: GentleCoast, task: jat-7h2k)',
293
+ '',
294
+ 'I\'ll implement the password reset flow. Let me first read the existing auth module',
295
+ 'to understand the current login + token handling before adding the reset endpoints.',
296
+ '⏺ Read(src/lib/auth/login.ts)',
297
+ ' ⎿ export async function login(email, password) {',
298
+ ' const user = await findUser(email);',
299
+ ' … +48 more lines',
300
+ '⏺ Read(src/lib/auth/tokens.ts)',
301
+ ' ⎿ export function signToken(payload) {',
302
+ ' return jwt.sign(payload, SECRET, { expiresIn: \'15m\' });',
303
+ ' … +22 more lines',
304
+ '⏺ Bash(jat-signal working \'{"taskId":"jat-7h2k","approach":"Add reset-request + reset-confirm endpoints, reuse signToken with a short-lived scope"}\')',
305
+ ' ⎿ Signal: working (task: jat-7h2k)',
306
+ '',
307
+ 'The token helper already supports short expiries. I\'ll add a reset-scoped token',
308
+ 'and two endpoints: one to request a reset email, one to confirm with the token.',
309
+ '⏺ Write(src/routes/api/auth/reset-request/+server.ts)',
310
+ ' ⎿ Wrote 41 lines',
311
+ '⏺ Write(src/routes/api/auth/reset-confirm/+server.ts)',
312
+ ' ⎿ Wrote 56 lines',
313
+ '⏺ Edit(src/lib/auth/tokens.ts)',
314
+ ' ⎿ Added signResetToken() with a 30-minute reset scope',
315
+ '⏺ Bash(npm run check)',
316
+ ' ⎿ svelte-check found 0 errors and 0 warnings',
317
+ '',
318
+ 'Both endpoints compile cleanly. One open question before I wire the email send:',
319
+ 'should reset links expire in 30 minutes, or match the 15-minute access-token window?',
320
+ '⏺ Bash(jat-signal needs_input \'{"taskId":"jat-7h2k","question":"Reset link expiry: 30 min or match the 15-min access token?"}\')',
321
+ ' ⎿ Signal: needs_input (task: jat-7h2k)',
322
+ '',
323
+ '› 30 minutes is fine — people read email slowly.',
324
+ '',
325
+ 'Got it — keeping the 30-minute reset scope. Now wiring the email send through',
326
+ 'the existing Resend helper and adding a rate-limit guard so the endpoint can\'t be',
327
+ 'used to spam a victim\'s inbox.',
328
+ '⏺ Bash(jat-signal working \'{"taskId":"jat-7h2k","approach":"Wire Resend email + add per-email rate limit on reset-request"}\')',
329
+ ' ⎿ Signal: working (task: jat-7h2k)',
330
+ '⏺ Edit(src/routes/api/auth/reset-request/+server.ts)',
331
+ ' ⎿ Sends reset email via sendEmail(); added 3-per-hour rate limit',
332
+ '⏺ Bash(npm run test -- auth)',
333
+ ' ⎿ 12 passing (auth reset flow covered)',
334
+ '',
335
+ 'All tests pass. The flow is complete: request → email → confirm → new password.',
336
+ 'Summarizing for review.',
337
+ '⏺ Bash(jat-signal review \'{"taskId":"jat-7h2k","summary":["Added reset-request + reset-confirm endpoints","30-min reset-scoped token","Resend email + 3/hr rate limit","12 tests passing"]}\')',
338
+ ' ⎿ Signal: review (task: jat-7h2k)',
339
+ '',
340
+ '🔍 READY FOR REVIEW: jat-7h2k — password reset flow implemented, tested, rate-limited.',
341
+ '',
342
+ '› /jat:complete',
343
+ '⏺ Bash(jat-step committing --task jat-7h2k)',
344
+ ' ⎿ Committed: feat(jat-7h2k): add password reset flow',
345
+ '⏺ Bash(jat-signal complete \'{"taskId":"jat-7h2k","summary":"Shipped password reset flow"}\')',
346
+ ' ⎿ Signal: complete (task: jat-7h2k)',
347
+ ''
348
+ ];
349
+
350
+ export function sampleLinkedSession(): LinkedSession {
351
+ return buildLinkedSession(SAMPLE_TRANSCRIPT_LINES);
352
+ }