@selvajs/ui 6.3.2 → 6.4.1
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/dist/components/compute/ComputeApp.svelte +32 -2
- package/dist/components/compute/ComputeApp.svelte.d.ts +7 -1
- package/dist/components/compute/ComputeMessagesDialog.svelte +118 -95
- package/dist/components/compute/ComputeMessagesDialog.svelte.d.ts +4 -0
- package/dist/components/compute/SolveMessages.svelte +367 -0
- package/dist/components/compute/SolveMessages.svelte.d.ts +28 -0
- package/dist/components/layout/AppShell.svelte +5 -1
- package/dist/components/layout/AppShell.svelte.d.ts +3 -0
- package/dist/components/layout/PageFooter.svelte +34 -14
- package/dist/components/layout/PageFooter.svelte.d.ts +3 -0
- package/dist/compute/useSolveSession.svelte.js +28 -4
- package/dist/i18n/messages.d.ts +9 -0
- package/dist/i18n/messages.js +18 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.js +4 -1
- package/dist/public.d.ts +2 -2
- package/package.json +16 -16
- package/src/lib/components/compute/ComputeApp.svelte +32 -2
- package/src/lib/components/compute/ComputeMessagesDialog.svelte +118 -95
- package/src/lib/components/compute/SolveMessages.svelte +367 -0
- package/src/lib/components/layout/AppShell.svelte +5 -1
- package/src/lib/components/layout/PageFooter.svelte +34 -14
- package/src/lib/compute/useSolveSession.svelte.ts +28 -4
- package/src/lib/i18n/messages.ts +28 -0
- package/src/lib/index.ts +10 -2
- package/src/lib/public.ts +4 -2
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
// The one place a solve talks to the user.
|
|
3
|
+
//
|
|
4
|
+
// Only a Selva Message component interrupts: the author wrote that text for whoever is using
|
|
5
|
+
// the app. Grasshopper's own warnings are about the definition, not the result, and go to the
|
|
6
|
+
// footer log instead — interrupting for them trains people to dismiss the one that mattered.
|
|
7
|
+
//
|
|
8
|
+
// While the solve runs the panel is fed by live events (messages and progress) and offers
|
|
9
|
+
// Abort. When it ends the reported result replaces them outright — it lists the same messages,
|
|
10
|
+
// remarks included — so nothing is ever shown twice.
|
|
11
|
+
//
|
|
12
|
+
// Of those, only warnings and errors get buttons. A remark states something without asking
|
|
13
|
+
// anything, so it appears on its own after the solve and fades.
|
|
14
|
+
|
|
15
|
+
import { untrack } from 'svelte';
|
|
16
|
+
import { CircleAlert, TriangleAlert, Info, LoaderCircle } from '@lucide/svelte';
|
|
17
|
+
import type { SolveDiagnostic } from '@selvajs/solve/shared';
|
|
18
|
+
import type { LiveSolveState, SolvePhase } from '@selvajs/solve/client';
|
|
19
|
+
import { Button } from '$lib/components/primitives/button';
|
|
20
|
+
|
|
21
|
+
interface Props {
|
|
22
|
+
/** What the running (or most recent) solve has said over the live channel. */
|
|
23
|
+
live: LiveSolveState;
|
|
24
|
+
/** The session's phase: `solving`, `review` (result held), `blocked` or `idle`. */
|
|
25
|
+
phase: SolvePhase;
|
|
26
|
+
/** The last result's messages, with level, source and `isGate`. */
|
|
27
|
+
diagnostics?: SolveDiagnostic[];
|
|
28
|
+
onabort: () => void;
|
|
29
|
+
onconfirm: () => void;
|
|
30
|
+
ondiscard: () => void;
|
|
31
|
+
labels?: Labels;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface Labels {
|
|
35
|
+
solving: string;
|
|
36
|
+
blockedTitle: string;
|
|
37
|
+
messagesTitle: string;
|
|
38
|
+
blockedDescription: string;
|
|
39
|
+
messagesDescription: string;
|
|
40
|
+
confirm: string;
|
|
41
|
+
discard: string;
|
|
42
|
+
abort: string;
|
|
43
|
+
dismiss: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const DEFAULT_LABELS: Labels = {
|
|
47
|
+
solving: 'Solving…',
|
|
48
|
+
blockedTitle: 'Solve stopped',
|
|
49
|
+
messagesTitle: 'Solve messages',
|
|
50
|
+
blockedDescription: 'The definition stopped this solve. No results were produced.',
|
|
51
|
+
messagesDescription: 'This solve reported the following. Continue to see the result.',
|
|
52
|
+
confirm: 'Continue',
|
|
53
|
+
discard: 'Discard',
|
|
54
|
+
abort: 'Abort',
|
|
55
|
+
dismiss: 'Dismiss'
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
let {
|
|
59
|
+
live,
|
|
60
|
+
phase: sessionPhase,
|
|
61
|
+
diagnostics = [],
|
|
62
|
+
onabort,
|
|
63
|
+
onconfirm,
|
|
64
|
+
ondiscard,
|
|
65
|
+
labels = DEFAULT_LABELS
|
|
66
|
+
}: Props = $props();
|
|
67
|
+
|
|
68
|
+
const REMARK_LINGER_MS = 6000;
|
|
69
|
+
|
|
70
|
+
const progress = $derived([...live.progress.values()]);
|
|
71
|
+
|
|
72
|
+
// A report only arrives when a step starts, so a bar that just shows `fraction` stands still
|
|
73
|
+
// through the step and then jumps. When the plugin knows how long the step took last time, the
|
|
74
|
+
// bar runs linearly to where the step will end, over that time. Steps are weighted by time, so
|
|
75
|
+
// linear gives one steady speed across the solve; any easing curve visibly speeds up and
|
|
76
|
+
// crawls within each step. A slower run waits at the step's end; a faster one snaps ahead.
|
|
77
|
+
let bars = $state.raw<Record<string, { width: number; ms: number }>>({});
|
|
78
|
+
// Any live event re-runs the effect below. Only a source with a new report may re-snap;
|
|
79
|
+
// the rest keep easing where they are.
|
|
80
|
+
let seen = new Map<string, unknown>();
|
|
81
|
+
let targets: Record<string, { width: number; ms: number }> = {};
|
|
82
|
+
$effect(() => {
|
|
83
|
+
const snapped: Record<string, { width: number; ms: number }> = {};
|
|
84
|
+
const eased: Record<string, { width: number; ms: number }> = {};
|
|
85
|
+
const current = untrack(() => bars);
|
|
86
|
+
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- bookkeeping for this effect; reactive reads here would re-trigger it
|
|
87
|
+
const nextSeen = new Map<string, unknown>();
|
|
88
|
+
for (const p of progress) {
|
|
89
|
+
if (p.fraction === undefined) continue;
|
|
90
|
+
const key = p.source ?? '';
|
|
91
|
+
nextSeen.set(key, p);
|
|
92
|
+
if (seen.get(key) === p && current[key]) {
|
|
93
|
+
snapped[key] = current[key];
|
|
94
|
+
// This run's cleanup cancelled the frame that would have started the ease.
|
|
95
|
+
if (targets[key] && current[key] !== targets[key]) eased[key] = targets[key];
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
snapped[key] = { width: p.fraction, ms: 150 };
|
|
99
|
+
if (p.nextFraction !== undefined && p.stepMs !== undefined) {
|
|
100
|
+
eased[key] = { width: p.nextFraction, ms: p.stepMs };
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
seen = nextSeen;
|
|
104
|
+
targets = eased;
|
|
105
|
+
bars = snapped;
|
|
106
|
+
if (Object.keys(eased).length === 0) return;
|
|
107
|
+
// Two frames: the snapped width must be painted before the eased one is set, or the
|
|
108
|
+
// browser folds both into one style change and there is nothing to transition from.
|
|
109
|
+
let frame = requestAnimationFrame(() => {
|
|
110
|
+
frame = requestAnimationFrame(() => (bars = { ...snapped, ...eased }));
|
|
111
|
+
});
|
|
112
|
+
return () => cancelAnimationFrame(frame);
|
|
113
|
+
});
|
|
114
|
+
// Grasshopper's own complaints stay in the footer log; this panel shows what the author wrote.
|
|
115
|
+
const authored = $derived(diagnostics.filter((d) => d.isGate));
|
|
116
|
+
|
|
117
|
+
// A remark states something without asking anything, so it never belongs next to
|
|
118
|
+
// Discard/Continue — the buttons would imply it is part of what the user is deciding. It gets
|
|
119
|
+
// the quiet treatment instead: shown alone after the solve, then faded.
|
|
120
|
+
const authoredGating = $derived(authored.filter((d) => d.level !== 'remark'));
|
|
121
|
+
const authoredRemarks = $derived(authored.filter((d) => d.level === 'remark'));
|
|
122
|
+
|
|
123
|
+
let dismissedFor = $state.raw<SolveDiagnostic[] | null>(null);
|
|
124
|
+
|
|
125
|
+
// The session's phase, except that a blocked solve the user dismissed reads as idle here.
|
|
126
|
+
type Phase = 'live' | 'review' | 'blocked' | 'idle';
|
|
127
|
+
const phase = $derived<Phase>(
|
|
128
|
+
sessionPhase === 'solving'
|
|
129
|
+
? 'live'
|
|
130
|
+
: sessionPhase === 'blocked' && dismissedFor === diagnostics
|
|
131
|
+
? 'idle'
|
|
132
|
+
: sessionPhase
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
// Remarks gate nothing, so after the solve they show briefly and fade.
|
|
136
|
+
//
|
|
137
|
+
// Keyed on the diagnostics array's identity, which every report renews, rather than on a
|
|
138
|
+
// solve id: only the live channel has one, and on the Compute path remarks arrive with the
|
|
139
|
+
// result. Once per array, so remarks that sat behind a review do not reappear after Continue.
|
|
140
|
+
let lingering = $state(false);
|
|
141
|
+
let lingeredFor: SolveDiagnostic[] | null = null;
|
|
142
|
+
$effect(() => {
|
|
143
|
+
if (phase === 'live') {
|
|
144
|
+
lingering = false;
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (authoredRemarks.length === 0 || lingeredFor === diagnostics) return;
|
|
148
|
+
lingeredFor = diagnostics;
|
|
149
|
+
if (phase !== 'idle') return;
|
|
150
|
+
lingering = true;
|
|
151
|
+
const timer = setTimeout(() => (lingering = false), REMARK_LINGER_MS);
|
|
152
|
+
return () => clearTimeout(timer);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// A solve that finishes quickly never shows the live panel: flashing it for 50 ms reads as a
|
|
156
|
+
// glitch, not as progress. Once shown, it stays long enough to read and ends on a full bar
|
|
157
|
+
// ("finishing"), so a solve just past the reveal does not blink the panel away mid-bar. A
|
|
158
|
+
// result that needs an answer (review, blocked) replaces it at once instead.
|
|
159
|
+
const LIVE_REVEAL_MS = 300;
|
|
160
|
+
const LIVE_MIN_VISIBLE_MS = 400;
|
|
161
|
+
const FINISH_MS = 200;
|
|
162
|
+
let revealed = $state(false);
|
|
163
|
+
let finishing = $state(false);
|
|
164
|
+
let revealedAt = 0;
|
|
165
|
+
$effect(() => {
|
|
166
|
+
if (phase === 'live') {
|
|
167
|
+
finishing = false;
|
|
168
|
+
const timer = setTimeout(() => {
|
|
169
|
+
revealed = true;
|
|
170
|
+
revealedAt = performance.now();
|
|
171
|
+
}, LIVE_REVEAL_MS);
|
|
172
|
+
return () => clearTimeout(timer);
|
|
173
|
+
}
|
|
174
|
+
if (!untrack(() => revealed) || phase !== 'idle') {
|
|
175
|
+
revealed = false;
|
|
176
|
+
finishing = false;
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
finishing = true;
|
|
180
|
+
const hold = Math.max(FINISH_MS, LIVE_MIN_VISIBLE_MS - (performance.now() - revealedAt));
|
|
181
|
+
const timer = setTimeout(() => {
|
|
182
|
+
revealed = false;
|
|
183
|
+
finishing = false;
|
|
184
|
+
}, hold);
|
|
185
|
+
return () => clearTimeout(timer);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// What the panel is showing, which lags the session while a finished solve's panel winds down.
|
|
189
|
+
const view = $derived<Phase>(finishing ? 'live' : phase);
|
|
190
|
+
|
|
191
|
+
// While solving, the live channel is all there is. Once the result lands it is authoritative
|
|
192
|
+
// — it carries remarks too — so the live list is dropped entirely and nothing is shown twice.
|
|
193
|
+
const rows = $derived<SolveDiagnostic[]>(
|
|
194
|
+
view === 'live' ? [...live.diagnostics] : view === 'idle' ? authoredRemarks : authoredGating
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
// Steps a few milliseconds long would swap the headline faster than it can be read. Each one
|
|
198
|
+
// stays up for at least this long; steps that end sooner are skipped in the label but still
|
|
199
|
+
// count in the bar. The label and its step count move together so they never disagree.
|
|
200
|
+
const HEADLINE_MIN_MS = 200;
|
|
201
|
+
let headline = $state.raw<(typeof progress)[number] | undefined>(undefined);
|
|
202
|
+
let headlineAt = 0;
|
|
203
|
+
$effect(() => {
|
|
204
|
+
const next = progress[0];
|
|
205
|
+
const shown = untrack(() => headline);
|
|
206
|
+
if (next?.label === shown?.label && next?.done === shown?.done) {
|
|
207
|
+
headline = next;
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
const show = () => {
|
|
211
|
+
headline = next;
|
|
212
|
+
headlineAt = performance.now();
|
|
213
|
+
};
|
|
214
|
+
const wait = HEADLINE_MIN_MS - (performance.now() - headlineAt);
|
|
215
|
+
if (!shown || wait <= 0) {
|
|
216
|
+
show();
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
const timer = setTimeout(show, wait);
|
|
220
|
+
return () => clearTimeout(timer);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
const visible = $derived(
|
|
224
|
+
view === 'review' || view === 'blocked'
|
|
225
|
+
? true
|
|
226
|
+
: view === 'live'
|
|
227
|
+
? revealed && (rows.length > 0 || progress.length > 0)
|
|
228
|
+
: rows.length > 0 && lingering
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
function dismiss() {
|
|
232
|
+
dismissedFor = diagnostics;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Escape resolves to the safe side — discard, keeping what is on screen — never to silently
|
|
236
|
+
// applying a result the user has not accepted.
|
|
237
|
+
function handleKeydown(e: KeyboardEvent) {
|
|
238
|
+
if (e.key !== 'Escape') return;
|
|
239
|
+
if (phase === 'review') ondiscard();
|
|
240
|
+
else if (phase === 'blocked') dismiss();
|
|
241
|
+
}
|
|
242
|
+
</script>
|
|
243
|
+
|
|
244
|
+
<svelte:window onkeydown={handleKeydown} />
|
|
245
|
+
|
|
246
|
+
{#if visible}
|
|
247
|
+
<div
|
|
248
|
+
class="bottom-4 p-3 shadow-lg fixed left-1/2 z-50 w-[min(32rem,calc(100vw-2rem))] -translate-x-1/2 rounded-lg border bg-background {view ===
|
|
249
|
+
'blocked'
|
|
250
|
+
? 'border-destructive/50'
|
|
251
|
+
: ''}"
|
|
252
|
+
role={view === 'live' || view === 'idle' ? 'status' : 'alert'}
|
|
253
|
+
data-testid="solve-messages"
|
|
254
|
+
data-view={view}
|
|
255
|
+
>
|
|
256
|
+
{#if view === 'live'}
|
|
257
|
+
{@const head = headline ?? progress[0]}
|
|
258
|
+
<div>
|
|
259
|
+
<div>
|
|
260
|
+
<div class="gap-2 flex items-center">
|
|
261
|
+
<LoaderCircle class="h-4 w-4 animate-spin shrink-0 text-muted-foreground" />
|
|
262
|
+
<span class="min-w-0 text-sm flex-1 truncate">{head?.label ?? labels.solving}</span>
|
|
263
|
+
{#if head?.done !== undefined && head?.total !== undefined}
|
|
264
|
+
<span class="text-xs shrink-0 text-muted-foreground tabular-nums">
|
|
265
|
+
{head.done} / {head.total}
|
|
266
|
+
</span>
|
|
267
|
+
{/if}
|
|
268
|
+
<Button
|
|
269
|
+
variant="secondary"
|
|
270
|
+
size="sm"
|
|
271
|
+
class="-my-1 h-7 px-3 text-xs shrink-0"
|
|
272
|
+
disabled={finishing}
|
|
273
|
+
onclick={onabort}
|
|
274
|
+
>
|
|
275
|
+
{labels.abort}
|
|
276
|
+
</Button>
|
|
277
|
+
</div>
|
|
278
|
+
{#each progress as p, i (p.source ?? '')}
|
|
279
|
+
<div class="mt-2" data-testid="solve-progress">
|
|
280
|
+
<!-- The first bar's label is the headline above; further bars label themselves. -->
|
|
281
|
+
{#if i > 0}
|
|
282
|
+
<div class="mb-1 gap-2 text-xs flex justify-between text-muted-foreground">
|
|
283
|
+
<span class="truncate">{p.label ?? p.source ?? ''}</span>
|
|
284
|
+
{#if p.done !== undefined && p.total !== undefined}
|
|
285
|
+
<span class="shrink-0 tabular-nums">{p.done} / {p.total}</span>
|
|
286
|
+
{/if}
|
|
287
|
+
</div>
|
|
288
|
+
{/if}
|
|
289
|
+
<div
|
|
290
|
+
class="h-1 overflow-hidden rounded-full bg-muted"
|
|
291
|
+
role="progressbar"
|
|
292
|
+
aria-label={p.label ?? p.source ?? labels.solving}
|
|
293
|
+
aria-valuemin={0}
|
|
294
|
+
aria-valuemax={100}
|
|
295
|
+
aria-valuenow={p.fraction !== undefined ? Math.round(p.fraction * 100) : undefined}
|
|
296
|
+
>
|
|
297
|
+
{#if p.fraction !== undefined}
|
|
298
|
+
{@const bar = finishing
|
|
299
|
+
? { width: 1, ms: FINISH_MS }
|
|
300
|
+
: (bars[p.source ?? ''] ?? { width: p.fraction, ms: 150 })}
|
|
301
|
+
<div
|
|
302
|
+
class="h-full rounded-full bg-primary transition-[width] ease-linear"
|
|
303
|
+
style="width: {bar.width * 100}%; transition-duration: {bar.ms}ms"
|
|
304
|
+
></div>
|
|
305
|
+
{:else}
|
|
306
|
+
<div class="animate-pulse h-full w-1/3 rounded-full bg-primary/60"></div>
|
|
307
|
+
{/if}
|
|
308
|
+
</div>
|
|
309
|
+
</div>
|
|
310
|
+
{/each}
|
|
311
|
+
</div>
|
|
312
|
+
</div>
|
|
313
|
+
{:else if view === 'review' || view === 'blocked'}
|
|
314
|
+
<div class="mb-2">
|
|
315
|
+
<div class="gap-2 text-sm font-medium flex items-center">
|
|
316
|
+
{#if view === 'blocked'}
|
|
317
|
+
<CircleAlert class="h-4 w-4 shrink-0 text-destructive" />
|
|
318
|
+
{labels.blockedTitle}
|
|
319
|
+
{:else}
|
|
320
|
+
<TriangleAlert class="h-4 w-4 text-amber-500 shrink-0" />
|
|
321
|
+
{labels.messagesTitle}
|
|
322
|
+
{/if}
|
|
323
|
+
</div>
|
|
324
|
+
<p class="text-xs text-muted-foreground">
|
|
325
|
+
{view === 'blocked' ? labels.blockedDescription : labels.messagesDescription}
|
|
326
|
+
</p>
|
|
327
|
+
</div>
|
|
328
|
+
{/if}
|
|
329
|
+
|
|
330
|
+
{#if rows.length > 0}
|
|
331
|
+
<ul
|
|
332
|
+
class="max-h-40 space-y-2 text-sm overflow-y-auto {view === 'live'
|
|
333
|
+
? 'mt-3 pt-3 border-t'
|
|
334
|
+
: ''}"
|
|
335
|
+
>
|
|
336
|
+
{#each rows as row, i (i)}
|
|
337
|
+
<li class="gap-2 flex">
|
|
338
|
+
{#if row.level === 'error'}
|
|
339
|
+
<CircleAlert class="mt-0.5 h-4 w-4 shrink-0 text-destructive" />
|
|
340
|
+
{:else if row.level === 'warning'}
|
|
341
|
+
<TriangleAlert class="mt-0.5 h-4 w-4 text-amber-500 shrink-0" />
|
|
342
|
+
{:else}
|
|
343
|
+
<Info class="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
|
|
344
|
+
{/if}
|
|
345
|
+
<div class="min-w-0 flex-1">
|
|
346
|
+
<p class="wrap-anywhere">{row.message}</p>
|
|
347
|
+
{#if row.source}
|
|
348
|
+
<p class="text-xs text-muted-foreground">{row.source}</p>
|
|
349
|
+
{/if}
|
|
350
|
+
</div>
|
|
351
|
+
</li>
|
|
352
|
+
{/each}
|
|
353
|
+
</ul>
|
|
354
|
+
{/if}
|
|
355
|
+
|
|
356
|
+
{#if view === 'review' || view === 'blocked'}
|
|
357
|
+
<div class="mt-3 gap-2 flex justify-end">
|
|
358
|
+
{#if view === 'blocked'}
|
|
359
|
+
<Button variant="outline" size="sm" onclick={dismiss}>{labels.dismiss}</Button>
|
|
360
|
+
{:else}
|
|
361
|
+
<Button variant="outline" size="sm" onclick={ondiscard}>{labels.discard}</Button>
|
|
362
|
+
<Button size="sm" onclick={onconfirm}>{labels.confirm}</Button>
|
|
363
|
+
{/if}
|
|
364
|
+
</div>
|
|
365
|
+
{/if}
|
|
366
|
+
</div>
|
|
367
|
+
{/if}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import type { Snippet } from 'svelte';
|
|
3
|
+
import type { SolveDiagnostic } from '@selvajs/solve/shared';
|
|
3
4
|
import PageHeader from './PageHeader.svelte';
|
|
4
5
|
import PageFooter from './PageFooter.svelte';
|
|
5
6
|
|
|
@@ -32,6 +33,8 @@
|
|
|
32
33
|
showFooter?: boolean;
|
|
33
34
|
errors?: string[];
|
|
34
35
|
warnings?: string[];
|
|
36
|
+
/** Structured messages for the footer badge and message centre. */
|
|
37
|
+
diagnostics?: SolveDiagnostic[];
|
|
35
38
|
/** Fully overrides the footer copyright line. `{name}` and `{year}` are substituted. */
|
|
36
39
|
footerText?: string;
|
|
37
40
|
footerChildren?: Snippet;
|
|
@@ -59,6 +62,7 @@
|
|
|
59
62
|
showFooter = false,
|
|
60
63
|
errors = [],
|
|
61
64
|
warnings = [],
|
|
65
|
+
diagnostics,
|
|
62
66
|
footerText,
|
|
63
67
|
footerChildren,
|
|
64
68
|
class: className = '',
|
|
@@ -138,7 +142,7 @@
|
|
|
138
142
|
|
|
139
143
|
{#if showFooter}
|
|
140
144
|
<div class="bottom-0 sticky z-10 shrink-0">
|
|
141
|
-
<PageFooter {errors} {warnings} copyrightName={_copyright} {footerText}>
|
|
145
|
+
<PageFooter {errors} {warnings} {diagnostics} copyrightName={_copyright} {footerText}>
|
|
142
146
|
{#if footerChildren}
|
|
143
147
|
{@render footerChildren()}
|
|
144
148
|
{/if}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import type { Snippet } from 'svelte';
|
|
3
|
-
import { CircleAlert, TriangleAlert } from '@lucide/svelte';
|
|
3
|
+
import { CircleAlert, TriangleAlert, Info } from '@lucide/svelte';
|
|
4
|
+
import type { SolveDiagnostic } from '@selvajs/solve/shared';
|
|
4
5
|
import { useFooter } from '$lib/contexts/footerContext.svelte';
|
|
5
6
|
import ComputeMessagesDialog from '../compute/ComputeMessagesDialog.svelte';
|
|
6
7
|
import FooterItemRenderer from './FooterItemRenderer.svelte';
|
|
@@ -8,6 +9,8 @@
|
|
|
8
9
|
interface Props {
|
|
9
10
|
errors?: string[];
|
|
10
11
|
warnings?: string[];
|
|
12
|
+
/** Structured messages, including remarks. Preferred over the two arrays above. */
|
|
13
|
+
diagnostics?: SolveDiagnostic[];
|
|
11
14
|
copyrightName?: string;
|
|
12
15
|
/** Fully overrides the copyright line. `{name}` and `{year}` are substituted. */
|
|
13
16
|
footerText?: string;
|
|
@@ -17,6 +20,7 @@
|
|
|
17
20
|
let {
|
|
18
21
|
errors = [],
|
|
19
22
|
warnings = [],
|
|
23
|
+
diagnostics,
|
|
20
24
|
copyrightName = 'Selva',
|
|
21
25
|
footerText,
|
|
22
26
|
children
|
|
@@ -47,8 +51,19 @@
|
|
|
47
51
|
|
|
48
52
|
let _currentYear = new Date().getFullYear();
|
|
49
53
|
|
|
50
|
-
|
|
51
|
-
|
|
54
|
+
// Counts come from `diagnostics` when the host supplies it, so remarks are included and the
|
|
55
|
+
// badge matches what the dialog lists.
|
|
56
|
+
const errorCount = $derived(
|
|
57
|
+
diagnostics ? diagnostics.filter((d) => d.level === 'error').length : errors.length
|
|
58
|
+
);
|
|
59
|
+
const warningCount = $derived(
|
|
60
|
+
diagnostics ? diagnostics.filter((d) => d.level === 'warning').length : warnings.length
|
|
61
|
+
);
|
|
62
|
+
const remarkCount = $derived(
|
|
63
|
+
diagnostics ? diagnostics.filter((d) => d.level === 'remark').length : 0
|
|
64
|
+
);
|
|
65
|
+
const totalCount = $derived(errorCount + warningCount + remarkCount);
|
|
66
|
+
const hasMessages = $derived(totalCount > 0);
|
|
52
67
|
|
|
53
68
|
const copyrightLine = $derived(
|
|
54
69
|
footerText
|
|
@@ -66,28 +81,33 @@
|
|
|
66
81
|
{/each}
|
|
67
82
|
|
|
68
83
|
{#if hasMessages}
|
|
69
|
-
<ComputeMessagesDialog {errors} {warnings}>
|
|
84
|
+
<ComputeMessagesDialog {errors} {warnings} {diagnostics}>
|
|
70
85
|
{#snippet trigger()}
|
|
71
86
|
<div
|
|
72
|
-
class="gap-1.5 px-2 py-1 rounded flex cursor-pointer items-center transition-colors hover:bg-muted {
|
|
87
|
+
class="gap-1.5 px-2 py-1 rounded flex cursor-pointer items-center transition-colors hover:bg-muted {errorCount >
|
|
73
88
|
0
|
|
74
89
|
? 'text-destructive hover:bg-destructive/10'
|
|
75
|
-
:
|
|
76
|
-
|
|
90
|
+
: warningCount > 0
|
|
91
|
+
? 'text-warning hover:bg-warning/10'
|
|
92
|
+
: 'hover:bg-muted'}"
|
|
93
|
+
title={`${totalCount} ${totalCount === 1 ? 'message' : 'messages'} from the last solve`}
|
|
77
94
|
>
|
|
78
|
-
{#if
|
|
95
|
+
{#if errorCount > 0}
|
|
79
96
|
<CircleAlert class="h-3.5 w-3.5" />
|
|
80
|
-
<span class="font-medium">{
|
|
97
|
+
<span class="font-medium">{errorCount} Error{errorCount !== 1 ? 's' : ''}</span>
|
|
81
98
|
{/if}
|
|
82
99
|
|
|
83
|
-
{#if
|
|
84
|
-
{#if
|
|
100
|
+
{#if warningCount > 0}
|
|
101
|
+
{#if errorCount > 0}
|
|
85
102
|
<span class="text-border">•</span>
|
|
86
103
|
{/if}
|
|
87
104
|
<TriangleAlert class="h-3.5 w-3.5" />
|
|
88
|
-
<span class="font-medium"
|
|
89
|
-
|
|
90
|
-
|
|
105
|
+
<span class="font-medium">{warningCount} Warning{warningCount !== 1 ? 's' : ''}</span>
|
|
106
|
+
{/if}
|
|
107
|
+
|
|
108
|
+
{#if errorCount === 0 && warningCount === 0}
|
|
109
|
+
<Info class="h-3.5 w-3.5" />
|
|
110
|
+
<span class="font-medium">{remarkCount} Note{remarkCount !== 1 ? 's' : ''}</span>
|
|
91
111
|
{/if}
|
|
92
112
|
</div>
|
|
93
113
|
{/snippet}
|
|
@@ -20,11 +20,16 @@ export function useSolveSession(args: SolveSessionArgs): SolveSession {
|
|
|
20
20
|
let version = $state(0);
|
|
21
21
|
|
|
22
22
|
$effect(() => {
|
|
23
|
-
|
|
24
|
-
// dropping the subscription is the whole cleanup.
|
|
25
|
-
return session.subscribe(() => {
|
|
23
|
+
const unsubscribe = session.subscribe(() => {
|
|
26
24
|
version += 1;
|
|
27
25
|
});
|
|
26
|
+
// The session dies with the owning component. Its event source may not (the cloud
|
|
27
|
+
// stream belongs to the tab), so the session must stop listening to it, not only we to
|
|
28
|
+
// the session.
|
|
29
|
+
return () => {
|
|
30
|
+
unsubscribe();
|
|
31
|
+
session.dispose();
|
|
32
|
+
};
|
|
28
33
|
});
|
|
29
34
|
|
|
30
35
|
/** Registers the reactive dependency, then returns the live value. */
|
|
@@ -46,6 +51,22 @@ export function useSolveSession(args: SolveSessionArgs): SolveSession {
|
|
|
46
51
|
get computeWarnings() {
|
|
47
52
|
return track(() => session.computeWarnings);
|
|
48
53
|
},
|
|
54
|
+
get diagnostics() {
|
|
55
|
+
return track(() => session.diagnostics);
|
|
56
|
+
},
|
|
57
|
+
get blocked() {
|
|
58
|
+
return track(() => session.blocked);
|
|
59
|
+
},
|
|
60
|
+
get awaitingAck() {
|
|
61
|
+
return track(() => session.awaitingAck);
|
|
62
|
+
},
|
|
63
|
+
get live() {
|
|
64
|
+
return track(() => session.live);
|
|
65
|
+
},
|
|
66
|
+
get phase() {
|
|
67
|
+
return track(() => session.phase);
|
|
68
|
+
},
|
|
69
|
+
abort: () => session.abort(),
|
|
49
70
|
get meshes() {
|
|
50
71
|
return track(() => session.meshes);
|
|
51
72
|
},
|
|
@@ -61,6 +82,8 @@ export function useSolveSession(args: SolveSessionArgs): SolveSession {
|
|
|
61
82
|
get isSolving() {
|
|
62
83
|
return track(() => session.isSolving);
|
|
63
84
|
},
|
|
85
|
+
acknowledge: () => session.acknowledge(),
|
|
86
|
+
discard: () => session.discard(),
|
|
64
87
|
setValue: (id, value, forceSolve) => session.setValue(id, value, forceSolve),
|
|
65
88
|
solve: () => session.solve(),
|
|
66
89
|
loadValues: (incoming) => session.loadValues(incoming),
|
|
@@ -68,6 +91,7 @@ export function useSolveSession(args: SolveSessionArgs): SolveSession {
|
|
|
68
91
|
report: (result) => session.report(result),
|
|
69
92
|
reportError: (message) => session.reportError(message),
|
|
70
93
|
subscribe: (listener) => session.subscribe(listener),
|
|
71
|
-
notify: () => session.notify()
|
|
94
|
+
notify: () => session.notify(),
|
|
95
|
+
dispose: () => session.dispose()
|
|
72
96
|
};
|
|
73
97
|
}
|
package/src/lib/i18n/messages.ts
CHANGED
|
@@ -54,6 +54,15 @@ export interface ViewerMessages {
|
|
|
54
54
|
|
|
55
55
|
// App layout / compute shell
|
|
56
56
|
loadingSchema: string;
|
|
57
|
+
solveBlocked: string;
|
|
58
|
+
solveBlockedTitle: string;
|
|
59
|
+
solveMessagesTitle: string;
|
|
60
|
+
solveMessagesDescription: string;
|
|
61
|
+
solveMessagesConfirm: string;
|
|
62
|
+
solveMessagesDiscard: string;
|
|
63
|
+
solveMessagesAbort: string;
|
|
64
|
+
solveMessagesDismiss: string;
|
|
65
|
+
solveSolving: string;
|
|
57
66
|
parametersTab: string;
|
|
58
67
|
moreTab: string;
|
|
59
68
|
expandPanel: string;
|
|
@@ -113,6 +122,15 @@ const en: ViewerMessages = {
|
|
|
113
122
|
noMetadata: 'No metadata',
|
|
114
123
|
|
|
115
124
|
loadingSchema: 'Loading schema...',
|
|
125
|
+
solveBlocked: 'The definition stopped this solve. No results were produced.',
|
|
126
|
+
solveBlockedTitle: 'Solve stopped',
|
|
127
|
+
solveMessagesTitle: 'Solve messages',
|
|
128
|
+
solveMessagesDescription: 'This solve reported the following. Continue to see the result.',
|
|
129
|
+
solveMessagesConfirm: 'Continue',
|
|
130
|
+
solveMessagesDiscard: 'Discard',
|
|
131
|
+
solveMessagesAbort: 'Abort',
|
|
132
|
+
solveMessagesDismiss: 'Dismiss',
|
|
133
|
+
solveSolving: 'Solving…',
|
|
116
134
|
parametersTab: 'Parameters',
|
|
117
135
|
moreTab: 'More',
|
|
118
136
|
expandPanel: 'Expand panel',
|
|
@@ -170,6 +188,16 @@ const de: ViewerMessages = {
|
|
|
170
188
|
noMetadata: 'Keine Metadaten',
|
|
171
189
|
|
|
172
190
|
loadingSchema: 'Schema wird geladen...',
|
|
191
|
+
solveBlocked: 'Die Definition hat diese Berechnung gestoppt. Es wurden keine Ergebnisse erzeugt.',
|
|
192
|
+
solveBlockedTitle: 'Berechnung gestoppt',
|
|
193
|
+
solveMessagesTitle: 'Meldungen zur Berechnung',
|
|
194
|
+
solveMessagesDescription:
|
|
195
|
+
'Diese Berechnung hat Folgendes gemeldet. Weiter, um das Ergebnis zu sehen.',
|
|
196
|
+
solveMessagesConfirm: 'Weiter',
|
|
197
|
+
solveMessagesDiscard: 'Verwerfen',
|
|
198
|
+
solveMessagesAbort: 'Abbrechen',
|
|
199
|
+
solveMessagesDismiss: 'Schliessen',
|
|
200
|
+
solveSolving: 'Berechnung läuft…',
|
|
173
201
|
parametersTab: 'Parameter',
|
|
174
202
|
moreTab: 'Mehr',
|
|
175
203
|
expandPanel: 'Bereich aufklappen',
|
package/src/lib/index.ts
CHANGED
|
@@ -6,6 +6,7 @@ export * from './components/layout';
|
|
|
6
6
|
|
|
7
7
|
export { default as AppLayout } from './components/compute/AppLayout.svelte';
|
|
8
8
|
export { default as ComputeApp } from './components/compute/ComputeApp.svelte';
|
|
9
|
+
export { default as SolveMessages } from './components/compute/SolveMessages.svelte';
|
|
9
10
|
|
|
10
11
|
export { default as ErrorScreen } from './components/ErrorScreen.svelte';
|
|
11
12
|
|
|
@@ -30,7 +31,14 @@ export {
|
|
|
30
31
|
type SolveSession,
|
|
31
32
|
type SolveSessionArgs,
|
|
32
33
|
type SolveDriver,
|
|
33
|
-
type SolveReporter
|
|
34
|
+
type SolveReporter,
|
|
35
|
+
type LiveSolveState,
|
|
36
|
+
type SolvePhase,
|
|
37
|
+
// A push driver (plugin-ui's WebSocket) decodes the plugin's verdict with these.
|
|
38
|
+
EMPTY_LIVE_SOLVE,
|
|
39
|
+
decodeOutcome,
|
|
40
|
+
finalizeResult,
|
|
41
|
+
type SolveOutcome
|
|
34
42
|
} from '@selvajs/solve/client';
|
|
35
43
|
|
|
36
44
|
// Pre-step producer transit storage
|
|
@@ -52,5 +60,5 @@ export { randomId } from './utils/randomId';
|
|
|
52
60
|
|
|
53
61
|
// UI-specific runtime types (not from schema)
|
|
54
62
|
export type { ActionButton } from './types/actionButton';
|
|
55
|
-
export type { SolveFn, SolveResult } from '@selvajs/solve/shared';
|
|
63
|
+
export type { SolveFn, SolveResult, SolveDiagnostic } from '@selvajs/solve/shared';
|
|
56
64
|
export { DEFAULT_PRESET_LABELS, type PresetLabels } from './types/presetLabels';
|
package/src/lib/public.ts
CHANGED
|
@@ -56,7 +56,9 @@ export {
|
|
|
56
56
|
type SolveSession,
|
|
57
57
|
type SolveSessionArgs,
|
|
58
58
|
type SolveDriver,
|
|
59
|
-
type SolveReporter
|
|
59
|
+
type SolveReporter,
|
|
60
|
+
type LiveSolveState,
|
|
61
|
+
type SolvePhase
|
|
60
62
|
} from '@selvajs/solve/client';
|
|
61
63
|
|
|
62
64
|
// Client-slot context: host apps render their own cell for client-sourced inputs,
|
|
@@ -80,5 +82,5 @@ export * from './schema/dynamic-value-list';
|
|
|
80
82
|
|
|
81
83
|
// UI-facing runtime types (not from schema)
|
|
82
84
|
export type { ActionButton } from './types/actionButton';
|
|
83
|
-
export type { SolveFn, SolveResult } from '@selvajs/solve/shared';
|
|
85
|
+
export type { SolveFn, SolveResult, SolveDiagnostic } from '@selvajs/solve/shared';
|
|
84
86
|
export { DEFAULT_PRESET_LABELS, type PresetLabels } from './types/presetLabels';
|