@flowdrop/flowdrop 1.13.0 → 1.15.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 (51) hide show
  1. package/README.md +5 -0
  2. package/dist/components/ConfigForm.svelte +41 -21
  3. package/dist/components/ConfigPanel.svelte +7 -1
  4. package/dist/components/NodeSwapPicker.svelte +5 -1
  5. package/dist/components/PipelineStatus.svelte +11 -2
  6. package/dist/components/SchemaForm.svelte +28 -16
  7. package/dist/components/SettingsPanel.svelte +5 -1
  8. package/dist/components/WorkflowEditor.svelte +5 -1
  9. package/dist/components/chat/AIChatPanel.svelte +1 -5
  10. package/dist/components/form/FormAutocomplete.svelte +23 -12
  11. package/dist/components/interrupt/ChoicePrompt.svelte +5 -1
  12. package/dist/components/interrupt/InterruptBubble.svelte +4 -5
  13. package/dist/components/nodes/AtomNode.svelte +280 -0
  14. package/dist/components/nodes/AtomNode.svelte.d.ts +26 -0
  15. package/dist/components/playground/ChatBubble.svelte +6 -8
  16. package/dist/components/playground/ChatInput.svelte +11 -5
  17. package/dist/components/playground/ControlPanel.svelte +42 -29
  18. package/dist/components/playground/ExecutionConsole.svelte +5 -1
  19. package/dist/components/playground/ExecutionConsole.svelte.d.ts +2 -0
  20. package/dist/components/playground/ExecutionList.svelte +7 -2
  21. package/dist/components/playground/LogRow.svelte +2 -1
  22. package/dist/components/playground/MessageBubble.svelte +1 -4
  23. package/dist/components/playground/MessageCard.svelte +2 -1
  24. package/dist/components/playground/MessageMarkdown.svelte +15 -5
  25. package/dist/components/playground/MessageNotice.svelte +2 -1
  26. package/dist/components/playground/MessageStream.svelte +138 -17
  27. package/dist/components/playground/MessageStream.svelte.d.ts +5 -0
  28. package/dist/components/playground/MessageTagChip.svelte +24 -6
  29. package/dist/components/playground/PipelineKanbanView.svelte +40 -11
  30. package/dist/components/playground/PipelinePanel.svelte +5 -1
  31. package/dist/components/playground/PipelineTableView.svelte +20 -6
  32. package/dist/components/playground/Playground.svelte +94 -27
  33. package/dist/components/playground/PlaygroundStudio.svelte +21 -7
  34. package/dist/components/playground/pipelineViewUtils.svelte.js +11 -4
  35. package/dist/helpers/proximityConnect.d.ts +4 -1
  36. package/dist/helpers/proximityConnect.js +17 -1
  37. package/dist/openapi/v1/openapi.yaml +6466 -0
  38. package/dist/playground/mount.js +2 -2
  39. package/dist/registry/builtinNodes.d.ts +1 -1
  40. package/dist/registry/builtinNodes.js +13 -0
  41. package/dist/schemas/v1/workflow.schema.json +86 -3
  42. package/dist/services/playgroundService.d.ts +23 -4
  43. package/dist/services/playgroundService.js +22 -9
  44. package/dist/stores/playgroundStore.svelte.d.ts +29 -2
  45. package/dist/stores/playgroundStore.svelte.js +120 -35
  46. package/dist/types/index.d.ts +38 -3
  47. package/dist/types/playground.d.ts +36 -2
  48. package/dist/utils/formMerge.d.ts +36 -0
  49. package/dist/utils/formMerge.js +70 -0
  50. package/dist/utils/nodeTypes.js +1 -0
  51. package/package.json +7 -1
@@ -0,0 +1,26 @@
1
+ import type { ConfigValues, NodeMetadata, NodeExtensions } from '../../types/index.js';
2
+ interface AtomNodeData {
3
+ label: string;
4
+ config: ConfigValues;
5
+ metadata: NodeMetadata;
6
+ nodeId?: string;
7
+ extensions?: NodeExtensions;
8
+ onConfigOpen?: (node: {
9
+ id: string;
10
+ type: string;
11
+ data: {
12
+ label: string;
13
+ config: ConfigValues;
14
+ metadata: NodeMetadata;
15
+ };
16
+ }) => void;
17
+ }
18
+ interface Props {
19
+ data: AtomNodeData;
20
+ selected?: boolean;
21
+ isProcessing?: boolean;
22
+ isError?: boolean;
23
+ }
24
+ declare const AtomNode: import("svelte").Component<Props, {}, "">;
25
+ type AtomNode = ReturnType<typeof AtomNode>;
26
+ export default AtomNode;
@@ -10,12 +10,7 @@
10
10
  import HierarchyTrail from './HierarchyTrail.svelte';
11
11
  import MessageTagStrip from './MessageTagStrip.svelte';
12
12
  import MessageMarkdown from './MessageMarkdown.svelte';
13
- import {
14
- formatDuration,
15
- formatTimestamp,
16
- getRoleIcon,
17
- getRoleLabel
18
- } from './messageDisplay.js';
13
+ import { formatDuration, formatTimestamp, getRoleIcon, getRoleLabel } from './messageDisplay.js';
19
14
  import { m } from '../../messages/index.js';
20
15
 
21
16
  interface Props {
@@ -55,7 +50,8 @@
55
50
  class="message-bubble__timestamp"
56
51
  datetime={message.timestamp}
57
52
  aria-label="sent at {formatTimestamp(message.timestamp)}"
58
- >{formatTimestamp(message.timestamp)}</time>
53
+ >{formatTimestamp(message.timestamp)}</time
54
+ >
59
55
  {/if}
60
56
  </div>
61
57
 
@@ -157,7 +153,9 @@
157
153
  background-color: var(--fd-card);
158
154
  border: 1px solid var(--fd-border);
159
155
  color: var(--fd-card-foreground);
160
- box-shadow: 0 1px 3px 0 oklch(0% 0 0 / 0.06), 0 1px 2px -1px oklch(0% 0 0 / 0.04);
156
+ box-shadow:
157
+ 0 1px 3px 0 oklch(0% 0 0 / 0.06),
158
+ 0 1px 2px -1px oklch(0% 0 0 / 0.04);
161
159
  border-bottom-left-radius: var(--fd-radius-sm);
162
160
  }
163
161
 
@@ -58,23 +58,27 @@
58
58
  let runEnabled = $state(true);
59
59
 
60
60
  let inputValue = $state('');
61
- let inputField: HTMLTextAreaElement | undefined;
61
+ let inputField: HTMLTextAreaElement | undefined = $state();
62
62
 
63
63
  // Count of enableRun messages seen so far — plain let, not $state.
64
64
  // Written with untrack to make the bookkeeping intent explicit.
65
65
  let seenEnableRunCount = 0;
66
66
 
67
67
  $effect(() => {
68
- const count = getMessages().filter(m => hasEnableRunFlag(m.metadata)).length;
68
+ const count = getMessages().filter((m) => hasEnableRunFlag(m.metadata)).length;
69
69
  if (count > seenEnableRunCount) {
70
- untrack(() => { seenEnableRunCount = count; });
70
+ untrack(() => {
71
+ seenEnableRunCount = count;
72
+ });
71
73
  runEnabled = true;
72
74
  }
73
75
  });
74
76
 
75
77
  $effect(() => {
76
78
  if (getCurrentSession()?.id) {
77
- untrack(() => { seenEnableRunCount = 0; });
79
+ untrack(() => {
80
+ seenEnableRunCount = 0;
81
+ });
78
82
  runEnabled = true;
79
83
  }
80
84
  });
@@ -87,7 +91,9 @@
87
91
  if (wasExecuting && !nowExecuting && inputField) {
88
92
  tick().then(() => inputField?.focus({ preventScroll: true }));
89
93
  }
90
- untrack(() => { wasExecuting = nowExecuting; });
94
+ untrack(() => {
95
+ wasExecuting = nowExecuting;
96
+ });
91
97
  });
92
98
 
93
99
  function handleSend(): void {
@@ -159,35 +159,37 @@
159
159
  </button>
160
160
  {#if getSessions().length > 0}
161
161
  <div class="control-panel__session-popover-divider"></div>
162
- {#each getSessions() as session (session.id)}
163
- {@const isActive = getCurrentSession()?.id === session.id}
164
- <div class="control-panel__session-popover-row">
165
- <button
166
- type="button"
167
- role="menuitem"
168
- class="control-panel__session-popover-item"
169
- class:control-panel__session-popover-item--active={isActive}
170
- onclick={() => handleSelect(session.id)}
171
- >
172
- {#if isActive}
173
- <Icon icon="mdi:check" class="control-panel__session-popover-check" />
174
- {:else}
175
- <Icon icon="mdi:message-outline" />
176
- {/if}
177
- <span>{session.name}</span>
178
- </button>
179
- <button
180
- type="button"
181
- role="menuitem"
182
- class="control-panel__session-popover-delete"
183
- onclick={(e) => handleDelete(e, session.id)}
184
- title={cp.deleteSession}
185
- aria-label={cp.deleteSession}
186
- >
187
- <Icon icon="mdi:delete-outline" />
188
- </button>
189
- </div>
190
- {/each}
162
+ <div class="control-panel__session-popover-list">
163
+ {#each getSessions() as session (session.id)}
164
+ {@const isActive = getCurrentSession()?.id === session.id}
165
+ <div class="control-panel__session-popover-row">
166
+ <button
167
+ type="button"
168
+ role="menuitem"
169
+ class="control-panel__session-popover-item"
170
+ class:control-panel__session-popover-item--active={isActive}
171
+ onclick={() => handleSelect(session.id)}
172
+ >
173
+ {#if isActive}
174
+ <Icon icon="mdi:check" class="control-panel__session-popover-check" />
175
+ {:else}
176
+ <Icon icon="mdi:message-outline" />
177
+ {/if}
178
+ <span>{session.name}</span>
179
+ </button>
180
+ <button
181
+ type="button"
182
+ role="menuitem"
183
+ class="control-panel__session-popover-delete"
184
+ onclick={(e) => handleDelete(e, session.id)}
185
+ title={cp.deleteSession}
186
+ aria-label={cp.deleteSession}
187
+ >
188
+ <Icon icon="mdi:delete-outline" />
189
+ </button>
190
+ </div>
191
+ {/each}
192
+ </div>
191
193
  {/if}
192
194
  </div>
193
195
  {/if}
@@ -333,6 +335,9 @@
333
335
  z-index: 50;
334
336
  min-width: 220px;
335
337
  max-width: 300px;
338
+ max-height: min(60vh, 420px);
339
+ display: flex;
340
+ flex-direction: column;
336
341
  padding: var(--fd-space-xs);
337
342
  background-color: var(--fd-background);
338
343
  border: 1px solid var(--fd-border);
@@ -344,6 +349,13 @@
344
349
  height: 1px;
345
350
  background-color: var(--fd-border-muted);
346
351
  margin: var(--fd-space-xs) 0;
352
+ flex-shrink: 0;
353
+ }
354
+
355
+ .control-panel__session-popover-list {
356
+ flex: 1 1 auto;
357
+ min-height: 0;
358
+ overflow-y: auto;
347
359
  }
348
360
 
349
361
  .control-panel__session-popover-row {
@@ -396,6 +408,7 @@
396
408
  color: var(--fd-primary);
397
409
  font-weight: 500;
398
410
  width: 100%;
411
+ flex: 0 0 auto;
399
412
  }
400
413
 
401
414
  .control-panel__session-popover-item--new :global(svg) {
@@ -22,6 +22,8 @@
22
22
  onInterruptResolved?: () => void;
23
23
  /** Optional callback that, when provided, shows a "New session" CTA in the welcome state */
24
24
  onCreateSession?: () => void;
25
+ /** Called when the user scrolls near the top to load older messages */
26
+ onLoadOlder?: () => void | Promise<void>;
25
27
  }
26
28
 
27
29
  let {
@@ -31,7 +33,8 @@
31
33
  allowLogs = true,
32
34
  compactSystemMessages = true,
33
35
  onInterruptResolved,
34
- onCreateSession
36
+ onCreateSession,
37
+ onLoadOlder
35
38
  }: Props = $props();
36
39
 
37
40
  const ec = $derived(m().playground.executionConsole);
@@ -50,6 +53,7 @@
50
53
  {allowLogs}
51
54
  {compactSystemMessages}
52
55
  {onInterruptResolved}
56
+ {onLoadOlder}
53
57
  welcome={welcomeState}
54
58
  emptySession={readyState}
55
59
  />
@@ -8,6 +8,8 @@ interface Props {
8
8
  onInterruptResolved?: () => void;
9
9
  /** Optional callback that, when provided, shows a "New session" CTA in the welcome state */
10
10
  onCreateSession?: () => void;
11
+ /** Called when the user scrolls near the top to load older messages */
12
+ onLoadOlder?: () => void | Promise<void>;
11
13
  }
12
14
  declare const ExecutionConsole: import("svelte").Component<Props, {}, "">;
13
15
  type ExecutionConsole = ReturnType<typeof ExecutionConsole>;
@@ -119,8 +119,13 @@
119
119
  }
120
120
 
121
121
  @keyframes pulse {
122
- 0%, 100% { opacity: 1; }
123
- 50% { opacity: 0.4; }
122
+ 0%,
123
+ 100% {
124
+ opacity: 1;
125
+ }
126
+ 50% {
127
+ opacity: 0.4;
128
+ }
124
129
  }
125
130
 
126
131
  :global(.execution-list__status-icon) {
@@ -55,7 +55,8 @@
55
55
  class="log-row__timestamp"
56
56
  datetime={message.timestamp}
57
57
  aria-label="sent at {formatTimestamp(message.timestamp)}"
58
- >{formatTimestamp(message.timestamp)}</time>
58
+ >{formatTimestamp(message.timestamp)}</time
59
+ >
59
60
  {/if}
60
61
  </div>
61
62
 
@@ -10,10 +10,7 @@
10
10
  -->
11
11
 
12
12
  <script lang="ts">
13
- import {
14
- resolveMessageDisplay,
15
- type PlaygroundMessage
16
- } from '../../types/playground.js';
13
+ import { resolveMessageDisplay, type PlaygroundMessage } from '../../types/playground.js';
17
14
  import ChatBubble from './ChatBubble.svelte';
18
15
  import LogRow from './LogRow.svelte';
19
16
  import MessageNotice from './MessageNotice.svelte';
@@ -43,7 +43,8 @@
43
43
  class="message-card__timestamp"
44
44
  datetime={message.timestamp}
45
45
  aria-label="sent at {formatTimestamp(message.timestamp)}"
46
- >{formatTimestamp(message.timestamp)}</time>
46
+ >{formatTimestamp(message.timestamp)}</time
47
+ >
47
48
  {/if}
48
49
  </header>
49
50
  {/if}
@@ -72,9 +72,15 @@
72
72
  margin-top: 0;
73
73
  }
74
74
 
75
- .message-markdown :global(h1) { font-size: var(--fd-text-xl); }
76
- .message-markdown :global(h2) { font-size: var(--fd-text-lg); }
77
- .message-markdown :global(h3) { font-size: var(--fd-text-base); }
75
+ .message-markdown :global(h1) {
76
+ font-size: var(--fd-text-xl);
77
+ }
78
+ .message-markdown :global(h2) {
79
+ font-size: var(--fd-text-lg);
80
+ }
81
+ .message-markdown :global(h3) {
82
+ font-size: var(--fd-text-base);
83
+ }
78
84
 
79
85
  .message-markdown :global(ul),
80
86
  .message-markdown :global(ol) {
@@ -155,6 +161,10 @@
155
161
  font-weight: 600;
156
162
  }
157
163
 
158
- .message-markdown :global(strong) { font-weight: 600; }
159
- .message-markdown :global(em) { font-style: italic; }
164
+ .message-markdown :global(strong) {
165
+ font-weight: 600;
166
+ }
167
+ .message-markdown :global(em) {
168
+ font-style: italic;
169
+ }
160
170
  </style>
@@ -42,7 +42,8 @@
42
42
  class="system-notice__timestamp"
43
43
  datetime={message.timestamp}
44
44
  aria-label="sent at {formatTimestamp(message.timestamp)}"
45
- >{formatTimestamp(message.timestamp)}</time>
45
+ >{formatTimestamp(message.timestamp)}</time
46
+ >
46
47
  {/if}
47
48
  </div>
48
49
 
@@ -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'] {