@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.
- package/README.md +5 -0
- package/dist/components/ConfigForm.svelte +41 -21
- package/dist/components/ConfigPanel.svelte +7 -1
- package/dist/components/NodeSwapPicker.svelte +5 -1
- package/dist/components/PipelineStatus.svelte +11 -2
- package/dist/components/SchemaForm.svelte +28 -16
- 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 +23 -12
- package/dist/components/interrupt/ChoicePrompt.svelte +5 -1
- package/dist/components/interrupt/InterruptBubble.svelte +4 -5
- package/dist/components/nodes/AtomNode.svelte +280 -0
- package/dist/components/nodes/AtomNode.svelte.d.ts +26 -0
- 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 +94 -27
- package/dist/components/playground/PlaygroundStudio.svelte +21 -7
- package/dist/components/playground/pipelineViewUtils.svelte.js +11 -4
- package/dist/helpers/proximityConnect.d.ts +4 -1
- package/dist/helpers/proximityConnect.js +17 -1
- package/dist/openapi/v1/openapi.yaml +6466 -0
- package/dist/playground/mount.js +2 -2
- package/dist/registry/builtinNodes.d.ts +1 -1
- package/dist/registry/builtinNodes.js +13 -0
- package/dist/schemas/v1/workflow.schema.json +86 -3
- package/dist/services/playgroundService.d.ts +23 -4
- package/dist/services/playgroundService.js +22 -9
- package/dist/stores/playgroundStore.svelte.d.ts +29 -2
- package/dist/stores/playgroundStore.svelte.js +120 -35
- package/dist/types/index.d.ts +38 -3
- package/dist/types/playground.d.ts +36 -2
- package/dist/utils/formMerge.d.ts +36 -0
- package/dist/utils/formMerge.js +70 -0
- package/dist/utils/nodeTypes.js +1 -0
- 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
|
-
|
|
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:
|
|
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(() => {
|
|
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(() => {
|
|
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(() => {
|
|
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
|
-
|
|
163
|
-
{
|
|
164
|
-
|
|
165
|
-
<
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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>;
|
|
@@ -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
|
-
|
|
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) {
|
|
76
|
-
|
|
77
|
-
|
|
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) {
|
|
159
|
-
|
|
164
|
+
.message-markdown :global(strong) {
|
|
165
|
+
font-weight: 600;
|
|
166
|
+
}
|
|
167
|
+
.message-markdown :global(em) {
|
|
168
|
+
font-style: italic;
|
|
169
|
+
}
|
|
160
170
|
</style>
|
|
@@ -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'] {
|