@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.
@@ -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
- const hasMessages = $derived(errors.length > 0 || warnings.length > 0);
51
- const totalCount = $derived(errors.length + warnings.length);
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 {errors.length >
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
- : 'text-warning hover:bg-warning/10'}"
76
- title={`${totalCount} ${totalCount === 1 ? 'issue' : 'issues'}`}
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 errors.length > 0}
95
+ {#if errorCount > 0}
79
96
  <CircleAlert class="h-3.5 w-3.5" />
80
- <span class="font-medium">{errors.length} Error{errors.length !== 1 ? 's' : ''}</span>
97
+ <span class="font-medium">{errorCount} Error{errorCount !== 1 ? 's' : ''}</span>
81
98
  {/if}
82
99
 
83
- {#if warnings.length > 0}
84
- {#if errors.length > 0}
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
- >{warnings.length} Warning{warnings.length !== 1 ? 's' : ''}</span
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
- // The session is created alongside this adapter and outlives no other component, so
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
  }
@@ -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';