@flowdrop/flowdrop 1.13.0 → 1.14.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.
Files changed (38) hide show
  1. package/README.md +5 -0
  2. package/dist/components/ConfigPanel.svelte +7 -1
  3. package/dist/components/NodeSwapPicker.svelte +5 -1
  4. package/dist/components/PipelineStatus.svelte +11 -2
  5. package/dist/components/SettingsPanel.svelte +5 -1
  6. package/dist/components/WorkflowEditor.svelte +5 -1
  7. package/dist/components/chat/AIChatPanel.svelte +1 -5
  8. package/dist/components/form/FormAutocomplete.svelte +2 -5
  9. package/dist/components/interrupt/ChoicePrompt.svelte +5 -1
  10. package/dist/components/interrupt/InterruptBubble.svelte +4 -5
  11. package/dist/components/playground/ChatBubble.svelte +6 -8
  12. package/dist/components/playground/ChatInput.svelte +11 -5
  13. package/dist/components/playground/ControlPanel.svelte +42 -29
  14. package/dist/components/playground/ExecutionConsole.svelte +5 -1
  15. package/dist/components/playground/ExecutionConsole.svelte.d.ts +2 -0
  16. package/dist/components/playground/ExecutionList.svelte +7 -2
  17. package/dist/components/playground/LogRow.svelte +2 -1
  18. package/dist/components/playground/MessageBubble.svelte +1 -4
  19. package/dist/components/playground/MessageCard.svelte +2 -1
  20. package/dist/components/playground/MessageMarkdown.svelte +15 -5
  21. package/dist/components/playground/MessageNotice.svelte +2 -1
  22. package/dist/components/playground/MessageStream.svelte +138 -17
  23. package/dist/components/playground/MessageStream.svelte.d.ts +5 -0
  24. package/dist/components/playground/MessageTagChip.svelte +24 -6
  25. package/dist/components/playground/PipelineKanbanView.svelte +40 -11
  26. package/dist/components/playground/PipelinePanel.svelte +5 -1
  27. package/dist/components/playground/PipelineTableView.svelte +20 -6
  28. package/dist/components/playground/Playground.svelte +84 -22
  29. package/dist/components/playground/PlaygroundStudio.svelte +21 -7
  30. package/dist/components/playground/pipelineViewUtils.svelte.js +11 -4
  31. package/dist/openapi/v1/openapi.yaml +6403 -0
  32. package/dist/schemas/v1/workflow.schema.json +36 -0
  33. package/dist/services/playgroundService.d.ts +23 -4
  34. package/dist/services/playgroundService.js +22 -9
  35. package/dist/stores/playgroundStore.svelte.d.ts +22 -1
  36. package/dist/stores/playgroundStore.svelte.js +109 -32
  37. package/dist/types/playground.d.ts +36 -2
  38. package/package.json +7 -1
@@ -24,7 +24,8 @@
24
24
  getChatMessages,
25
25
  getIsExecuting,
26
26
  getCurrentSession,
27
- getShowLogs
27
+ getShowLogs,
28
+ getHasOlder
28
29
  } from '../../stores/playgroundStore.svelte.js';
29
30
  import {
30
31
  getInterruptsMap,
@@ -51,6 +52,11 @@
51
52
  compactSystemMessages?: boolean;
52
53
  /** Called when an interrupt is resolved */
53
54
  onInterruptResolved?: () => void;
55
+ /**
56
+ * Called when the user scrolls near the top, to load older messages.
57
+ * When omitted, scroll-up paging is disabled (e.g. view-only surfaces).
58
+ */
59
+ onLoadOlder?: () => void | Promise<void>;
54
60
  /** Custom render for the no-session welcome state */
55
61
  welcome?: Snippet;
56
62
  /** Custom render for the empty-session state */
@@ -64,6 +70,7 @@
64
70
  allowLogs = false,
65
71
  compactSystemMessages = true,
66
72
  onInterruptResolved,
73
+ onLoadOlder,
67
74
  welcome,
68
75
  emptySession
69
76
  }: Props = $props();
@@ -71,14 +78,14 @@
71
78
  const states = $derived(m().playground.states);
72
79
 
73
80
  /** Reference to the messages container for scrolling */
74
- let messagesContainer: HTMLDivElement | undefined;
81
+ let messagesContainer = $state<HTMLDivElement | undefined>();
75
82
 
76
- const displayMessages = $derived(
77
- allowLogs && getShowLogs() ? getMessages() : getChatMessages()
78
- );
83
+ const displayMessages = $derived(allowLogs && getShowLogs() ? getMessages() : getChatMessages());
79
84
 
80
85
  let previousMessageCount = 0;
81
86
  let userScrolledUp = false;
87
+ let isLoadingOlder = $state(false);
88
+ let topSentinel = $state<HTMLDivElement | undefined>();
82
89
 
83
90
  function handleScroll() {
84
91
  if (!messagesContainer) return;
@@ -86,6 +93,48 @@
86
93
  userScrolledUp = scrollHeight - scrollTop - clientHeight > 50;
87
94
  }
88
95
 
96
+ // Load older messages when the top sentinel scrolls into view. An observer is
97
+ // self-throttling and keeps layout reads off the scroll path; rootMargin
98
+ // pre-fetches the next page slightly before the user reaches the very top.
99
+ $effect(() => {
100
+ if (!topSentinel || !messagesContainer || !onLoadOlder) return;
101
+ const observer = new IntersectionObserver(
102
+ (entries) => {
103
+ if (entries[0]?.isIntersecting) void loadOlder();
104
+ },
105
+ { root: messagesContainer, rootMargin: '300px 0px 0px 0px' }
106
+ );
107
+ observer.observe(topSentinel);
108
+ return () => observer.disconnect();
109
+ });
110
+
111
+ /**
112
+ * Fetch the previous page and keep the viewport pinned to the message the
113
+ * user is reading. We anchor on a real DOM node (the first rendered message)
114
+ * and compensate scrollTop by however far it moved — robust against the
115
+ * loading spinner (which is out of flow) and any late reflow above it.
116
+ * A scroll during the in-flight fetch is intentionally overridden so the
117
+ * prepend doesn't shift the reading position.
118
+ */
119
+ async function loadOlder() {
120
+ if (!onLoadOlder || !messagesContainer || isLoadingOlder || !getHasOlder()) return;
121
+
122
+ const anchor = topSentinel?.nextElementSibling as HTMLElement | null;
123
+ const anchorTopBefore = anchor?.getBoundingClientRect().top ?? 0;
124
+
125
+ isLoadingOlder = true;
126
+ try {
127
+ await onLoadOlder();
128
+ await tick();
129
+ if (messagesContainer && anchor) {
130
+ const shift = anchor.getBoundingClientRect().top - anchorTopBefore;
131
+ messagesContainer.scrollTop += shift;
132
+ }
133
+ } finally {
134
+ isLoadingOlder = false;
135
+ }
136
+ }
137
+
89
138
  function isFormFocused(): boolean {
90
139
  if (!messagesContainer) return false;
91
140
  const activeElement = document.activeElement;
@@ -154,14 +203,20 @@
154
203
  const currentCount = displayMessages.length;
155
204
 
156
205
  if (!autoScroll || !messagesContainer) {
157
- untrack(() => { previousMessageCount = currentCount; });
206
+ untrack(() => {
207
+ previousMessageCount = currentCount;
208
+ });
158
209
  return;
159
210
  }
160
211
 
161
212
  const hasNewMessage = currentCount > previousMessageCount;
162
- untrack(() => { previousMessageCount = currentCount; });
213
+ untrack(() => {
214
+ previousMessageCount = currentCount;
215
+ });
163
216
 
164
- if (!hasNewMessage || userScrolledUp || isFormFocused()) return;
217
+ // Don't chase the bottom while a backward page is landing — loadOlder owns
218
+ // scroll position during a prepend and anchors it to the message in view.
219
+ if (!hasNewMessage || userScrolledUp || isFormFocused() || isLoadingOlder) return;
165
220
 
166
221
  tick().then(() => {
167
222
  if (messagesContainer) {
@@ -171,7 +226,13 @@
171
226
  });
172
227
  </script>
173
228
 
174
- <div class="message-stream" role="log" aria-label={m().playground.controlPanel.messageStreamLabel} bind:this={messagesContainer} onscroll={handleScroll}>
229
+ <div
230
+ class="message-stream"
231
+ role="log"
232
+ aria-label={m().playground.controlPanel.messageStreamLabel}
233
+ bind:this={messagesContainer}
234
+ onscroll={handleScroll}
235
+ >
175
236
  {#if showWelcome}
176
237
  {#if welcome}
177
238
  {@render welcome()}
@@ -181,6 +242,12 @@
181
242
  {@render emptySession()}
182
243
  {/if}
183
244
  {:else}
245
+ <div bind:this={topSentinel} class="message-stream__sentinel" aria-hidden="true"></div>
246
+ {#if isLoadingOlder}
247
+ <div class="message-stream__loading-older" aria-hidden="true">
248
+ <span class="message-stream__loading-older-spinner"></span>
249
+ </div>
250
+ {/if}
184
251
  {#each displayMessages as message, index (message.id)}
185
252
  {#if isInterruptMessage(message)}
186
253
  {@const interrupt = getInterruptForMessage(message)}
@@ -219,6 +286,7 @@
219
286
 
220
287
  <style>
221
288
  .message-stream {
289
+ position: relative;
222
290
  flex: 1;
223
291
  min-height: 0;
224
292
  overflow-y: auto;
@@ -232,6 +300,12 @@
232
300
  container-name: fd-message-stream;
233
301
  }
234
302
 
303
+ /* Zero-height marker the IntersectionObserver watches to trigger
304
+ backward pagination as it nears the top of the scroll area. */
305
+ .message-stream__sentinel {
306
+ height: 0;
307
+ }
308
+
235
309
  /* Shared fade-in for newly-appended message rows. `-global-` so
236
310
  ChatBubble.svelte / MessageCard.svelte can reference it without
237
311
  redeclaring. Honour reduced-motion in the same place. */
@@ -264,24 +338,35 @@
264
338
  display: grid;
265
339
  grid-template-columns: auto 1fr auto;
266
340
  grid-template-areas:
267
- "level body body"
268
- ". tags timestamp";
341
+ 'level body body'
342
+ '. tags timestamp';
269
343
  align-items: baseline;
270
344
  row-gap: var(--fd-space-2xs);
271
345
  column-gap: var(--fd-space-sm);
272
346
  }
273
- :global(.log-row__level) { grid-area: level; }
274
- :global(.log-row__body) { grid-area: body; min-width: 0; }
275
- :global(.log-row__tags) { grid-area: tags; justify-self: start; }
276
- :global(.log-row__timestamp) { grid-area: timestamp; justify-self: end; }
347
+ :global(.log-row__level) {
348
+ grid-area: level;
349
+ }
350
+ :global(.log-row__body) {
351
+ grid-area: body;
352
+ min-width: 0;
353
+ }
354
+ :global(.log-row__tags) {
355
+ grid-area: tags;
356
+ justify-self: start;
357
+ }
358
+ :global(.log-row__timestamp) {
359
+ grid-area: timestamp;
360
+ justify-self: end;
361
+ }
277
362
  }
278
363
 
279
364
  @container fd-message-stream (max-width: 480px) {
280
365
  :global(.log-row) {
281
366
  grid-template-columns: auto 1fr;
282
367
  grid-template-areas:
283
- "level body"
284
- ". tags";
368
+ 'level body'
369
+ '. tags';
285
370
  }
286
371
  :global(.log-row__text) {
287
372
  flex-basis: 100%;
@@ -307,6 +392,42 @@
307
392
  }
308
393
  }
309
394
 
395
+ /* Overlay, out of flow — its presence must not shift message layout, or it
396
+ would corrupt the scroll anchoring in loadOlder(). */
397
+ .message-stream__loading-older {
398
+ position: absolute;
399
+ top: 0;
400
+ left: 0;
401
+ right: 0;
402
+ display: flex;
403
+ align-items: center;
404
+ justify-content: center;
405
+ padding: var(--fd-space-sm) 0;
406
+ pointer-events: none;
407
+ z-index: 1;
408
+ }
409
+
410
+ .message-stream__loading-older-spinner {
411
+ width: var(--fd-space-lg);
412
+ height: var(--fd-space-lg);
413
+ border: 2px solid var(--fd-border-strong);
414
+ border-top-color: transparent;
415
+ border-radius: var(--fd-radius-full);
416
+ animation: message-stream-spin 0.8s linear infinite;
417
+ }
418
+
419
+ @keyframes message-stream-spin {
420
+ to {
421
+ transform: rotate(360deg);
422
+ }
423
+ }
424
+
425
+ @media (prefers-reduced-motion: reduce) {
426
+ .message-stream__loading-older-spinner {
427
+ animation: none;
428
+ }
429
+ }
430
+
310
431
  .message-stream__typing {
311
432
  display: flex;
312
433
  align-items: center;
@@ -17,6 +17,11 @@ interface Props {
17
17
  compactSystemMessages?: boolean;
18
18
  /** Called when an interrupt is resolved */
19
19
  onInterruptResolved?: () => void;
20
+ /**
21
+ * Called when the user scrolls near the top, to load older messages.
22
+ * When omitted, scroll-up paging is disabled (e.g. view-only surfaces).
23
+ */
24
+ onLoadOlder?: () => void | Promise<void>;
20
25
  /** Custom render for the no-session welcome state */
21
26
  welcome?: Snippet;
22
27
  /** Custom render for the empty-session state */
@@ -66,12 +66,30 @@
66
66
  }
67
67
 
68
68
  /* Color hooks — one line per color. To add a color, add a row here. */
69
- .message-tag-chip[data-color='muted'] { --chip-c: var(--fd-muted-foreground); --chip-c-on: var(--fd-background); }
70
- .message-tag-chip[data-color='primary'] { --chip-c: var(--fd-primary); --chip-c-on: var(--fd-primary-foreground); }
71
- .message-tag-chip[data-color='success'] { --chip-c: var(--fd-success, oklch(55% 0.15 145)); --chip-c-on: white; }
72
- .message-tag-chip[data-color='warning'] { --chip-c: var(--fd-warning); --chip-c-on: var(--fd-background); }
73
- .message-tag-chip[data-color='error'] { --chip-c: var(--fd-error); --chip-c-on: white; }
74
- .message-tag-chip[data-color='info'] { --chip-c: var(--fd-info); --chip-c-on: var(--fd-background); }
69
+ .message-tag-chip[data-color='muted'] {
70
+ --chip-c: var(--fd-muted-foreground);
71
+ --chip-c-on: var(--fd-background);
72
+ }
73
+ .message-tag-chip[data-color='primary'] {
74
+ --chip-c: var(--fd-primary);
75
+ --chip-c-on: var(--fd-primary-foreground);
76
+ }
77
+ .message-tag-chip[data-color='success'] {
78
+ --chip-c: var(--fd-success, oklch(55% 0.15 145));
79
+ --chip-c-on: white;
80
+ }
81
+ .message-tag-chip[data-color='warning'] {
82
+ --chip-c: var(--fd-warning);
83
+ --chip-c-on: var(--fd-background);
84
+ }
85
+ .message-tag-chip[data-color='error'] {
86
+ --chip-c: var(--fd-error);
87
+ --chip-c-on: white;
88
+ }
89
+ .message-tag-chip[data-color='info'] {
90
+ --chip-c: var(--fd-info);
91
+ --chip-c-on: var(--fd-background);
92
+ }
75
93
 
76
94
  /* Variants — derive bg/fg/border from --chip-c. */
77
95
  .message-tag-chip[data-variant='subtle'] {
@@ -2,10 +2,34 @@
2
2
  import type { KanbanColumnDef } from '../../types/index.js';
3
3
 
4
4
  const DEFAULT_COLUMNS: KanbanColumnDef[] = [
5
- { key: 'pending', label: 'Pending', statuses: ['idle', 'pending'], icon: 'mdi:clock-outline', color: 'var(--fd-muted-foreground)' },
6
- { key: 'in_progress', label: 'In Progress', statuses: ['running', 'paused', 'interrupted'], icon: 'mdi:play-circle-outline', color: 'var(--fd-warning)' },
7
- { key: 'done', label: 'Done', statuses: ['completed', 'skipped'], icon: 'mdi:check-circle', color: 'var(--fd-success)' },
8
- { key: 'failed', label: 'Failed', statuses: ['failed', 'cancelled'], icon: 'mdi:alert-circle', color: 'var(--fd-error)' },
5
+ {
6
+ key: 'pending',
7
+ label: 'Pending',
8
+ statuses: ['idle', 'pending'],
9
+ icon: 'mdi:clock-outline',
10
+ color: 'var(--fd-muted-foreground)'
11
+ },
12
+ {
13
+ key: 'in_progress',
14
+ label: 'In Progress',
15
+ statuses: ['running', 'paused', 'interrupted'],
16
+ icon: 'mdi:play-circle-outline',
17
+ color: 'var(--fd-warning)'
18
+ },
19
+ {
20
+ key: 'done',
21
+ label: 'Done',
22
+ statuses: ['completed', 'skipped'],
23
+ icon: 'mdi:check-circle',
24
+ color: 'var(--fd-success)'
25
+ },
26
+ {
27
+ key: 'failed',
28
+ label: 'Failed',
29
+ statuses: ['failed', 'cancelled'],
30
+ icon: 'mdi:alert-circle',
31
+ color: 'var(--fd-error)'
32
+ }
9
33
  ];
10
34
  </script>
11
35
 
@@ -13,7 +37,11 @@
13
37
  import { onMount } from 'svelte';
14
38
  import Icon from '@iconify/svelte';
15
39
  import { createPipelineDataFetcher, resolveStatus } from './pipelineViewUtils.svelte.js';
16
- import { getStatusLabel, getStatusTextColor, getStatusBackgroundColor } from '../../utils/nodeStatus.js';
40
+ import {
41
+ getStatusLabel,
42
+ getStatusTextColor,
43
+ getStatusBackgroundColor
44
+ } from '../../utils/nodeStatus.js';
17
45
  import type { NodeStatus } from './pipelineViewUtils.svelte.js';
18
46
  import type { Workflow, WorkflowNode } from '../../types/index.js';
19
47
  import type { EndpointConfig } from '../../config/endpoints.js';
@@ -27,6 +55,7 @@
27
55
 
28
56
  let { pipelineId, workflow, endpointConfig, refreshTrigger = 0 }: Props = $props();
29
57
 
58
+ // svelte-ignore state_referenced_locally — endpointConfig is consumed once to build the API client; it must be stable
30
59
  const fetcher = createPipelineDataFetcher(() => pipelineId, endpointConfig);
31
60
 
32
61
  $effect(() => {
@@ -88,10 +117,7 @@
88
117
  style="--col-color: {col.color ?? 'var(--fd-muted-foreground)'}"
89
118
  >
90
119
  <div class="pipeline-kanban__column-header">
91
- <Icon
92
- icon={col.icon ?? 'mdi:circle-outline'}
93
- class="pipeline-kanban__col-icon"
94
- />
120
+ <Icon icon={col.icon ?? 'mdi:circle-outline'} class="pipeline-kanban__col-icon" />
95
121
  <span class="pipeline-kanban__col-label">{col.label}</span>
96
122
  <span class="pipeline-kanban__col-count">{items.length}</span>
97
123
  </div>
@@ -104,8 +130,11 @@
104
130
  {#if showStatusPill}
105
131
  <span
106
132
  class="pipeline-kanban__card-status"
107
- style="color: {getStatusTextColor(status)}; background-color: {getStatusBackgroundColor(status)}"
108
- >{getStatusLabel(status)}</span>
133
+ style="color: {getStatusTextColor(
134
+ status
135
+ )}; background-color: {getStatusBackgroundColor(status)}"
136
+ >{getStatusLabel(status)}</span
137
+ >
109
138
  {/if}
110
139
  </div>
111
140
  <span class="pipeline-kanban__card-type">{node.data.metadata.id}</span>
@@ -2,7 +2,7 @@
2
2
  const VIEW_MODE_KEY = 'fd-pipeline-view-mode';
3
3
  const BUILTIN_VIEWS = ['graph', 'kanban', 'table'] as const;
4
4
  // `string & {}` preserves autocomplete for built-in values while still accepting arbitrary strings from extraViews.
5
- type ViewMode = typeof BUILTIN_VIEWS[number] | (string & {});
5
+ type ViewMode = (typeof BUILTIN_VIEWS)[number] | (string & {});
6
6
  </script>
7
7
 
8
8
  <script lang="ts">
@@ -177,6 +177,7 @@
177
177
  class="pipeline-panel__run-popover"
178
178
  bind:this={runPopoverEl}
179
179
  role="menu"
180
+ tabindex="-1"
180
181
  onkeydown={(e) => {
181
182
  if (e.key === 'Escape') {
182
183
  runDropdownOpen = false;
@@ -415,6 +416,9 @@
415
416
  right: 0;
416
417
  z-index: 50;
417
418
  min-width: 160px;
419
+ max-width: 320px;
420
+ max-height: min(60vh, 420px);
421
+ overflow-y: auto;
418
422
  padding: var(--fd-space-xs);
419
423
  background-color: var(--fd-background);
420
424
  border: 1px solid var(--fd-border);
@@ -64,6 +64,7 @@
64
64
  statusData: NodeStatusData | undefined;
65
65
  }
66
66
 
67
+ // svelte-ignore state_referenced_locally — endpointConfig is consumed once to build the API client; it must be stable
67
68
  const fetcher = createPipelineDataFetcher(() => pipelineId, endpointConfig);
68
69
 
69
70
  $effect(() => {
@@ -137,14 +138,24 @@
137
138
  {#if expandable}
138
139
  <Icon
139
140
  icon="mdi:chevron-right"
140
- class="pipeline-table__chevron {expanded ? 'pipeline-table__chevron--open' : ''}"
141
+ class="pipeline-table__chevron {expanded
142
+ ? 'pipeline-table__chevron--open'
143
+ : ''}"
141
144
  />
142
145
  {/if}
143
146
  </td>
144
- <td class="pipeline-table__td pipeline-table__td--label" title={row.node.data.label}>{row.node.data.label}</td>
145
- <td class="pipeline-table__td pipeline-table__td--muted" title={row.node.data.metadata.id}>{row.node.data.metadata.id}</td>
147
+ <td class="pipeline-table__td pipeline-table__td--label" title={row.node.data.label}
148
+ >{row.node.data.label}</td
149
+ >
150
+ <td
151
+ class="pipeline-table__td pipeline-table__td--muted"
152
+ title={row.node.data.metadata.id}>{row.node.data.metadata.id}</td
153
+ >
146
154
  <td class="pipeline-table__td">
147
- <span class="pipeline-table__status" style="color: {getStatusTextColor(row.status)}">
155
+ <span
156
+ class="pipeline-table__status"
157
+ style="color: {getStatusTextColor(row.status)}"
158
+ >
148
159
  <Icon
149
160
  icon={STATUS_ICON[row.status] ?? 'mdi:circle-outline'}
150
161
  class="pipeline-table__status-icon"
@@ -152,7 +163,9 @@
152
163
  {row.status}
153
164
  </span>
154
165
  </td>
155
- <td class="pipeline-table__td pipeline-table__td--id" title={row.node.id}>{row.node.id}</td>
166
+ <td class="pipeline-table__td pipeline-table__td--id" title={row.node.id}
167
+ >{row.node.id}</td
168
+ >
156
169
  </tr>
157
170
  {#if expanded && expandable}
158
171
  <tr class="pipeline-table__detail-row">
@@ -320,7 +333,8 @@
320
333
  }
321
334
 
322
335
  .pipeline-table__detail-cell {
323
- padding: var(--fd-space-sm) var(--fd-space-md) var(--fd-space-sm) calc(1.5rem + var(--fd-space-md));
336
+ padding: var(--fd-space-sm) var(--fd-space-md) var(--fd-space-sm)
337
+ calc(1.5rem + var(--fd-space-md));
324
338
  border-bottom: 1px solid var(--fd-border);
325
339
  }
326
340
 
@@ -33,8 +33,11 @@
33
33
  getError,
34
34
  playgroundActions,
35
35
  applyServerResponse,
36
- getLatestSequenceNumber
36
+ getLatestSequenceNumber,
37
+ getOldestSequenceNumber,
38
+ setHasOlder
37
39
  } from '../../stores/playgroundStore.svelte.js';
40
+ import type { PlaygroundMessagesApiResponse } from '../../types/playground.js';
38
41
  import { interruptActions } from '../../stores/interruptStore.svelte.js';
39
42
  import { logger } from '../../utils/logger.js';
40
43
 
@@ -67,6 +70,11 @@
67
70
  let loadedInitialSessionId = $state<string | undefined>(undefined);
68
71
  let autoRunTriggered = $state(false);
69
72
  let isRefreshing = $state(false);
73
+ // Monotonic token so a slow session load can't overwrite a newer one when the
74
+ // user switches sessions faster than the network responds (last-load wins).
75
+ let loadToken = 0;
76
+
77
+ const messagePageSize = $derived(config.messagePageSize ?? 50);
70
78
 
71
79
  // Vertical resizer state for the ExecutionConsole ↔ ControlPanel split.
72
80
  let playgroundContentEl = $state<HTMLElement | null>(null);
@@ -90,16 +98,15 @@
90
98
  }
91
99
  });
92
100
 
93
- const maxControlPanelHeight = $derived(
94
- containerHeight ? Math.round(containerHeight * 0.6) : 600
95
- );
101
+ const maxControlPanelHeight = $derived(containerHeight ? Math.round(containerHeight * 0.6) : 600);
96
102
 
97
103
  function clampControlPanelHeight(h: number): number {
98
104
  return Math.min(Math.max(h, 140), maxControlPanelHeight);
99
105
  }
100
106
 
101
107
  function handleVerticalResizerPointerDown(e: PointerEvent) {
102
- if (playgroundContentEl) dragContainerBottom = playgroundContentEl.getBoundingClientRect().bottom;
108
+ if (playgroundContentEl)
109
+ dragContainerBottom = playgroundContentEl.getBoundingClientRect().bottom;
103
110
  isVerticalResizing = true;
104
111
  (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
105
112
  }
@@ -133,7 +140,9 @@
133
140
  const sessionId = getCurrentSession()?.id;
134
141
  if (sessionId) {
135
142
  void playgroundService
136
- .getMessages(sessionId, playgroundService.getLastSequenceNumber() ?? undefined)
143
+ .getMessages(sessionId, {
144
+ since: playgroundService.getLastSequenceNumber() ?? undefined
145
+ })
137
146
  .then((response) => applyServerResponse(response))
138
147
  .catch((err) => logger.error('[Playground] Visibility catchup failed:', err));
139
148
  }
@@ -231,26 +240,79 @@
231
240
  async function loadSession(sessionId: string): Promise<void> {
232
241
  playgroundActions.setLoading(true);
233
242
  playgroundActions.setError(null);
243
+ const token = ++loadToken;
234
244
 
235
245
  try {
236
246
  const session = await playgroundService.getSession(sessionId);
247
+ if (token !== loadToken) return; // a newer session load superseded us
237
248
  playgroundActions.setCurrentSession(session);
238
249
 
239
- const response = await playgroundService.getMessages(sessionId);
250
+ // Load only the most recent page; older messages load on demand when the
251
+ // user scrolls up (loadOlderMessages). Clear right before applying the
252
+ // fresh page — not before the await — so switching sessions doesn't blank
253
+ // the view for the duration of the fetch.
254
+ const response = await playgroundService.getMessages(sessionId, {
255
+ latest: true,
256
+ limit: messagePageSize
257
+ });
258
+ if (token !== loadToken) return;
259
+ playgroundActions.clearMessages();
240
260
  applyServerResponse(response);
261
+ setHasOlder(deriveHasOlder(response));
241
262
 
242
263
  if (session.status !== 'idle') {
264
+ // Seed polling from the newest loaded message so it tails live updates
265
+ // instead of crawling forward from the start of the conversation.
243
266
  startPolling(sessionId, true);
244
267
  }
245
268
  } catch (err) {
269
+ if (token !== loadToken) return; // don't surface a superseded load's error
246
270
  const errorMessage = err instanceof Error ? err.message : 'Failed to load session';
247
271
  playgroundActions.setError(errorMessage);
248
272
  logger.error('Failed to load session:', err);
249
273
  } finally {
250
- playgroundActions.setLoading(false);
274
+ if (token === loadToken) playgroundActions.setLoading(false);
251
275
  }
252
276
  }
253
277
 
278
+ /**
279
+ * Load the page of messages immediately older than the oldest one currently
280
+ * shown. Triggered by scroll-up in MessageStream, which serializes calls and
281
+ * owns the in-flight/anchoring state. Bypasses applyServerResponse so a
282
+ * historical fetch never disturbs the live polling cursor or pipeline view.
283
+ */
284
+ async function loadOlderMessages(): Promise<void> {
285
+ const sessionId = getCurrentSession()?.id;
286
+ const before = getOldestSequenceNumber();
287
+ if (!sessionId || before === null) return;
288
+
289
+ try {
290
+ const response = await playgroundService.getMessages(sessionId, {
291
+ before,
292
+ limit: messagePageSize
293
+ });
294
+ // The session may have changed while the fetch was in flight — don't
295
+ // splice an old session's page into the new session's store.
296
+ if (getCurrentSession()?.id !== sessionId) return;
297
+ if (response.data && response.data.length > 0) {
298
+ playgroundActions.addMessages(response.data);
299
+ }
300
+ setHasOlder(deriveHasOlder(response));
301
+ } catch (err) {
302
+ logger.error('[Playground] Failed to load older messages:', err);
303
+ }
304
+ }
305
+
306
+ /**
307
+ * Whether older messages remain after a backward-pagination response. Prefer
308
+ * the server's explicit `hasOlder` flag; fall back to inferring from page
309
+ * fullness for backends that haven't adopted the field yet.
310
+ */
311
+ function deriveHasOlder(response: PlaygroundMessagesApiResponse): boolean {
312
+ if (typeof response.hasOlder === 'boolean') return response.hasOlder;
313
+ return (response.data?.length ?? 0) >= messagePageSize;
314
+ }
315
+
254
316
  async function handleCreateSession(): Promise<void> {
255
317
  playgroundActions.setLoading(true);
256
318
  playgroundActions.setError(null);
@@ -320,8 +382,10 @@
320
382
  playgroundActions.addMessage(message);
321
383
  // Only start polling if not already active — avoids resetting the cursor
322
384
  // mid-session and re-fetching messages that are already in the store.
385
+ // Seed from the newest loaded message so polling tails live updates
386
+ // rather than crawling forward from the start of the conversation.
323
387
  if (!playgroundService.isPolling()) {
324
- startPolling(sessionId);
388
+ startPolling(sessionId, true);
325
389
  }
326
390
  } catch (err) {
327
391
  const errorMessage = err instanceof Error ? err.message : 'Failed to send message';
@@ -370,10 +434,9 @@
370
434
  if (!sessionId || isRefreshing) return;
371
435
  isRefreshing = true;
372
436
  try {
373
- const response = await playgroundService.getMessages(
374
- sessionId,
375
- playgroundService.getLastSequenceNumber() ?? undefined
376
- );
437
+ const response = await playgroundService.getMessages(sessionId, {
438
+ since: playgroundService.getLastSequenceNumber() ?? undefined
439
+ });
377
440
  applyServerResponse(response);
378
441
  if (response.sessionStatus === 'running' && !playgroundService.isPolling()) {
379
442
  startPolling(sessionId, true);
@@ -392,10 +455,9 @@
392
455
  try {
393
456
  // Catch up immediately rather than waiting for the next poll interval.
394
457
  // Use the service's sequence cursor so we only fetch new messages.
395
- const response = await playgroundService.getMessages(
396
- sessionId,
397
- playgroundService.getLastSequenceNumber() ?? undefined
398
- );
458
+ const response = await playgroundService.getMessages(sessionId, {
459
+ since: playgroundService.getLastSequenceNumber() ?? undefined
460
+ });
399
461
  applyServerResponse(response);
400
462
  } catch (err) {
401
463
  logger.error('[Playground] Failed to refresh after interrupt:', err);
@@ -430,10 +492,7 @@
430
492
  </div>
431
493
  {/if}
432
494
 
433
- <div
434
- class="playground__content"
435
- bind:this={playgroundContentEl}
436
- >
495
+ <div class="playground__content" bind:this={playgroundContentEl}>
437
496
  {#if getIsLoading() && !getCurrentSession()}
438
497
  <div class="playground__loading">
439
498
  <Icon icon="mdi:loading" class="playground__loading-icon" />
@@ -444,11 +503,14 @@
444
503
  showTimestamps={config.showTimestamps ?? true}
445
504
  autoScroll={config.autoScroll ?? true}
446
505
  enableMarkdown={config.enableMarkdown ?? true}
447
- showLogsInline={config.logDisplayMode === 'inline'}
448
506
  onInterruptResolved={handleInterruptResolved}
449
507
  onCreateSession={getSessions().length === 0 ? handleCreateSession : undefined}
508
+ onLoadOlder={loadOlderMessages}
450
509
  />
451
510
 
511
+ <!-- Focusable ARIA splitter: keyboard/pointer handlers drive the resize -->
512
+ <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
513
+ <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
452
514
  <div
453
515
  class="playground__vertical-resizer"
454
516
  class:playground__vertical-resizer--active={isVerticalResizing}