@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.
- package/README.md +5 -0
- package/dist/components/ConfigPanel.svelte +7 -1
- package/dist/components/NodeSwapPicker.svelte +5 -1
- package/dist/components/PipelineStatus.svelte +11 -2
- package/dist/components/SettingsPanel.svelte +5 -1
- package/dist/components/WorkflowEditor.svelte +5 -1
- package/dist/components/chat/AIChatPanel.svelte +1 -5
- package/dist/components/form/FormAutocomplete.svelte +2 -5
- package/dist/components/interrupt/ChoicePrompt.svelte +5 -1
- package/dist/components/interrupt/InterruptBubble.svelte +4 -5
- package/dist/components/playground/ChatBubble.svelte +6 -8
- package/dist/components/playground/ChatInput.svelte +11 -5
- package/dist/components/playground/ControlPanel.svelte +42 -29
- package/dist/components/playground/ExecutionConsole.svelte +5 -1
- package/dist/components/playground/ExecutionConsole.svelte.d.ts +2 -0
- package/dist/components/playground/ExecutionList.svelte +7 -2
- package/dist/components/playground/LogRow.svelte +2 -1
- package/dist/components/playground/MessageBubble.svelte +1 -4
- package/dist/components/playground/MessageCard.svelte +2 -1
- package/dist/components/playground/MessageMarkdown.svelte +15 -5
- package/dist/components/playground/MessageNotice.svelte +2 -1
- package/dist/components/playground/MessageStream.svelte +138 -17
- package/dist/components/playground/MessageStream.svelte.d.ts +5 -0
- package/dist/components/playground/MessageTagChip.svelte +24 -6
- package/dist/components/playground/PipelineKanbanView.svelte +40 -11
- package/dist/components/playground/PipelinePanel.svelte +5 -1
- package/dist/components/playground/PipelineTableView.svelte +20 -6
- package/dist/components/playground/Playground.svelte +84 -22
- package/dist/components/playground/PlaygroundStudio.svelte +21 -7
- package/dist/components/playground/pipelineViewUtils.svelte.js +11 -4
- package/dist/openapi/v1/openapi.yaml +6403 -0
- package/dist/schemas/v1/workflow.schema.json +36 -0
- package/dist/services/playgroundService.d.ts +23 -4
- package/dist/services/playgroundService.js +22 -9
- package/dist/stores/playgroundStore.svelte.d.ts +22 -1
- package/dist/stores/playgroundStore.svelte.js +109 -32
- package/dist/types/playground.d.ts +36 -2
- 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
|
|
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(() => {
|
|
206
|
+
untrack(() => {
|
|
207
|
+
previousMessageCount = currentCount;
|
|
208
|
+
});
|
|
158
209
|
return;
|
|
159
210
|
}
|
|
160
211
|
|
|
161
212
|
const hasNewMessage = currentCount > previousMessageCount;
|
|
162
|
-
untrack(() => {
|
|
213
|
+
untrack(() => {
|
|
214
|
+
previousMessageCount = currentCount;
|
|
215
|
+
});
|
|
163
216
|
|
|
164
|
-
|
|
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
|
|
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
|
-
|
|
268
|
-
|
|
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) {
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
:global(.log-
|
|
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
|
-
|
|
284
|
-
|
|
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']
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
.message-tag-chip[data-color='
|
|
74
|
-
|
|
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
|
-
{
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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 {
|
|
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(
|
|
108
|
-
|
|
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
|
|
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}
|
|
145
|
-
|
|
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
|
|
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}
|
|
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)
|
|
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)
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
375
|
-
|
|
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
|
-
|
|
397
|
-
|
|
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}
|