Gliner/UI/GlinerWorkbench.razor
@using System.Linq
@using Sandbox
@using Sandbox.UI
@using GlinerPoc.Neural
@using GlinerPoc.Preprocessing
@inherits PanelComponent
@namespace GlinerPoc.UI

@{
	// Workbench redesign (UI only). The inference contract is untouched: this
	// panel still builds an immutable GlinerClassificationRequest snapshot from
	// the entry texts, calls Service.ClassifyAsync, and renders the returned
	// GlinerDecisionResult. No tokenizer/schema/score semantics live here.
}

<root>
	<GlinerKeyCatcherPanel OnKey="@OnRootKey" @ref="KeyCatcher">
		<ChildContent>

		<!-- ================= HEADER ================= -->
		<div class="header">
			<div class="header-titles">
				<div class="title">GLiNER 2.5 Small — Decision Workbench</div>
				<div class="subtitle">Evaluate options and choose the best action using GLiNER 2.5</div>
			</div>

			<div class="header-controls">
				<div class="backend-block">
					<span class="backend-caption">Backend:</span>
					<div class="seg">
						<button class="seg-btn @( BackendIsGpu ? "active" : "" ) @( CanSwitchBackend ? "" : "disabled" )"
								onclick="@(() => OnSelectBackend( GlinerBackendKind.NativeGpuFp32 ))">@( DefaultIsGpu ? "GPU · Default" : "GPU" )</button>
						<button class="seg-btn @( BackendIsCpu ? "active" : "" ) @( CanSwitchBackend ? "" : "disabled" )"
								onclick="@(() => OnSelectBackend( GlinerBackendKind.NativeScalarCpu ))">CPU</button>
					</div>
				</div>

				<div class="status-block">
					<div class="status-line">
						<div class="dot @StatusClass"></div>
						<span class="status-text @StatusClass">@StatusText</span>
					</div>
					<div class="status-sub">@StatusSub</div>
				</div>
			</div>
		</div>

		<!-- ================= BODY ================= -->
		<div class="body">

			<!-- ---------- LEFT: INPUT ---------- -->
			<div class="card input-card">
				<div class="card-scroll">

					<div class="section">
						<div class="section-title">1. Context</div>
						<div class="helper">Describe the current situation, state or available information.</div>
						<TextEntry @ref="ContextBox" class="context-input" Multiline="@true" />
						<div class="field-foot">
							<span>@ContextLength characters</span>
							<span class="field-foot-note">encoded budget: @GlinerProcessor.MaxEncodedTokens tokens</span>
						</div>
					</div>

					<div class="section">
						<div class="section-title">2. Task / Question</div>
						<div class="helper">What decision should be made?</div>
						<TextEntry @ref="TaskBox" class="task-input" />
						@if ( Service is { IsReady: true } && string.IsNullOrWhiteSpace( TaskBox?.Text ) && !RequestInFlight )
						{
							<div class="field-warn">A task is required to run the decision.</div>
						}
					</div>

					<div class="section">
						<div class="section-head">
							<div class="section-title">3. Candidates</div>
							<div class="count-chip @( PopulatedCount < 2 ? "chip-warn" : "" )">@PopulatedCount / 8</div>
						</div>
						<div class="helper">Add between 2 and 8 candidates (labels and optional descriptions).</div>

						<div class="candidate-row">
							<div class="candidate-index">1</div>
							<TextEntry @ref="Label1" class="candidate-label" Placeholder="label 1" />
							<TextEntry @ref="Desc1" class="candidate-desc" Placeholder="description (optional)" />
							<div class="icon-button @( CanRemoveCandidate ? "" : "disabled" )" onclick="@(() => OnRemoveCandidate( 0 ))">✕</div>
						</div>
						<div class="candidate-row">
							<div class="candidate-index">2</div>
							<TextEntry @ref="Label2" class="candidate-label" Placeholder="label 2" />
							<TextEntry @ref="Desc2" class="candidate-desc" Placeholder="description (optional)" />
							<div class="icon-button @( CanRemoveCandidate ? "" : "disabled" )" onclick="@(() => OnRemoveCandidate( 1 ))">✕</div>
						</div>
						<div class="candidate-row">
							<div class="candidate-index">3</div>
							<TextEntry @ref="Label3" class="candidate-label" Placeholder="label 3" />
							<TextEntry @ref="Desc3" class="candidate-desc" Placeholder="description (optional)" />
							<div class="icon-button @( CanRemoveCandidate ? "" : "disabled" )" onclick="@(() => OnRemoveCandidate( 2 ))">✕</div>
						</div>
						<div class="candidate-row">
							<div class="candidate-index">4</div>
							<TextEntry @ref="Label4" class="candidate-label" Placeholder="label 4" />
							<TextEntry @ref="Desc4" class="candidate-desc" Placeholder="description (optional)" />
							<div class="icon-button @( CanRemoveCandidate ? "" : "disabled" )" onclick="@(() => OnRemoveCandidate( 3 ))">✕</div>
						</div>
						@if ( VisibleCandidateCount >= 5 )
						{
							<div class="candidate-row">
								<div class="candidate-index">5</div>
								<TextEntry @ref="Label5" class="candidate-label" Placeholder="label 5" />
								<TextEntry @ref="Desc5" class="candidate-desc" Placeholder="description (optional)" />
								<div class="icon-button @( CanRemoveCandidate ? "" : "disabled" )" onclick="@(() => OnRemoveCandidate( 4 ))">✕</div>
							</div>
						}
						@if ( VisibleCandidateCount >= 6 )
						{
							<div class="candidate-row">
								<div class="candidate-index">6</div>
								<TextEntry @ref="Label6" class="candidate-label" Placeholder="label 6" />
								<TextEntry @ref="Desc6" class="candidate-desc" Placeholder="description (optional)" />
								<div class="icon-button @( CanRemoveCandidate ? "" : "disabled" )" onclick="@(() => OnRemoveCandidate( 5 ))">✕</div>
							</div>
						}
						@if ( VisibleCandidateCount >= 7 )
						{
							<div class="candidate-row">
								<div class="candidate-index">7</div>
								<TextEntry @ref="Label7" class="candidate-label" Placeholder="label 7" />
								<TextEntry @ref="Desc7" class="candidate-desc" Placeholder="description (optional)" />
								<div class="icon-button @( CanRemoveCandidate ? "" : "disabled" )" onclick="@(() => OnRemoveCandidate( 6 ))">✕</div>
							</div>
						}
						@if ( VisibleCandidateCount >= 8 )
						{
							<div class="candidate-row">
								<div class="candidate-index">8</div>
								<TextEntry @ref="Label8" class="candidate-label" Placeholder="label 8" />
								<TextEntry @ref="Desc8" class="candidate-desc" Placeholder="description (optional)" />
								<div class="icon-button @( CanRemoveCandidate ? "" : "disabled" )" onclick="@(() => OnRemoveCandidate( 7 ))">✕</div>
							</div>
						}

						<div class="add-button @( CanAddCandidate ? "" : "disabled" )" onclick="@OnAddCandidate">+ Add Candidate</div>

						@if ( HasDuplicateLabels )
						{
							<div class="field-warn">Duplicate labels — each entry is still scored, but ranking may be ambiguous.</div>
						}
					</div>
				</div>

				<div class="action-bar">
					@if ( !RequestInFlight )
					{
						<button class="primary-button @( CanRun ? "" : "disabled" )" onclick="@OnRun">▶ Run Decision</button>
					}
					else
					{
						<button class="primary-button running" disabled>Running… @(ElapsedSeconds)s</button>
						<button class="secondary-button @( CanCancel ? "" : "disabled" )" onclick="@OnCancel">Cancel</button>
					}
					<button class="secondary-button" onclick="@OnClear">↻ Clear All</button>

					@if ( !RequestInFlight && Service is { IsReady: true } && !CanRun )
					{
						<span class="run-block-note">@RunBlockReason</span>
					}
					else if ( !RequestInFlight && CanRun )
					{
						<span class="action-hint-note">Ctrl+Enter</span>
					}
				</div>
			</div>

			<!-- ---------- RIGHT: RESULTS ---------- -->
			<div class="card results-card">
				<div class="results-head">
					<div class="card-title">Results</div>
					<div class="helper">Model ranking and selected decision.</div>
				</div>

				<div class="card-scroll results-body">

					@if ( !string.IsNullOrEmpty( VisibleError ) )
					{
						<div class="banner @( ErrorIsCancel ? "banner-warn" : "banner-error" )">@VisibleError</div>
					}

					@if ( HasResult )
					{
						<!-- selected action -->
						<div class="selected-card">
							<div class="selected-main">
								<div class="selected-caption">Selected Action</div>
								<div class="selected-label">@Result.SelectedLabel</div>
								@if ( !string.IsNullOrWhiteSpace( SelectedDescription ) )
								{
									<div class="selected-desc">@SelectedDescription</div>
								}
							</div>
							<div class="selected-side">
								<div class="selected-score-caption">Model Score</div>
								<div class="selected-score">@Result.Scores[Result.SelectedIndex].ToString( "0.####" )</div>
								<div class="bar-track selected-track">
									<div class="bar-fill fill-green" style="width: 100%"></div>
								</div>
							</div>
						</div>

						<!-- ranking -->
						<div class="ranking-head">
							<div class="card-subtitle">Candidate Ranking</div>
							<div class="seg seg-small">
								<button class="seg-btn @( !ShowRawLogits ? "active" : "" )" onclick="@(() => ShowRawLogits = false)">Simple View</button>
								<button class="seg-btn @( ShowRawLogits ? "active" : "" )" onclick="@(() => ShowRawLogits = true)">Show Raw Logits</button>
							</div>
						</div>

						<div class="ranking-table">
							<div class="ranking-row head">
								<div class="rank-no">#</div>
								<div class="rank-label">Candidate</div>
								<div class="rank-desc">Description</div>
								<div class="rank-score">Score</div>
								@if ( ShowRawLogits )
								{
									<div class="rank-logit">Raw Logit</div>
								}
								<div class="rank-bar"></div>
							</div>

							@for ( int rank = 0; rank < RankedOrder.Length; rank++ )
							{
								var idx = RankedOrder[rank];
								var pct = ScoreBarPercent( idx );
								<div class="ranking-row @( idx == Result.SelectedIndex ? "winner" : "" )">
									<div class="rank-no">@( rank + 1 )</div>
									<div class="rank-label">@Result.Labels[idx]</div>
									<div class="rank-desc">@RankDescription( idx )</div>
									<div class="rank-score">@Result.Scores[idx].ToString( "0.####" )</div>
									@if ( ShowRawLogits )
									{
										<div class="rank-logit">@Result.RawLogits[idx].ToString( "0.###" )</div>
									}
									<div class="rank-bar">
										<div class="bar-track">
											<div class="bar-fill @( idx == Result.SelectedIndex ? "fill-green" : "fill-blue" )" style="@($"width: {pct:0.#}%")"></div>
										</div>
									</div>
								</div>
							}
						</div>
					}
					else if ( RequestInFlight )
					{
						<div class="state-note running-note">
							<div class="state-title">Running…</div>
							<div class="state-sub">Executing @Service?.BackendName on the native runtime — this may take a moment on CPU.</div>
						</div>
					}
					else
					{
						<div class="state-note">
							<div class="state-title">No decision yet</div>
							<div class="state-sub">Enter a context, task/question and at least two candidates, then run the model.</div>
						</div>
					}

					<!-- diagnostics -->
					<div class="diag">
						<div class="diag-head" onclick="@(() => DiagnosticsOpen = !DiagnosticsOpen)">
							<span class="diag-arrow">@( DiagnosticsOpen ? "▼" : "▶" )</span>
							<span>Diagnostics</span>
						</div>
						@if ( DiagnosticsOpen )
						{
							<div class="diag-grid">
								<div class="diag-cell">
									<div class="diag-label">Backend</div>
									<div class="diag-value">@( HasResult ? Service?.LastResultBackend : Service?.BackendName )</div>
								</div>
								<div class="diag-cell">
									<div class="diag-label">Encoded Tokens</div>
									<div class="diag-value">@( HasResult ? Result.EncodedTokenCount.ToString() : "—" )</div>
								</div>
								<div class="diag-cell">
									<div class="diag-label">Preprocess Time</div>
									<div class="diag-value">@( HasResult ? $"{Result.PreprocessMs:0.#} ms" : "—" )</div>
								</div>
								<div class="diag-cell">
									<div class="diag-label">Inference Time</div>
									<div class="diag-value">@( HasResult ? FormatMs( Result.TotalNeuralMs - Result.PreprocessMs ) : "—" )</div>
								</div>
								<div class="diag-cell">
									<div class="diag-label">Total Time</div>
									<div class="diag-value">@( HasResult ? FormatMs( Result.TotalNeuralMs ) : "—" )</div>
								</div>
								<div class="diag-cell">
									<div class="diag-label">GPU Weights</div>
									<div class="diag-value">@GpuMemoryText</div>
								</div>
							</div>
							@if ( Service is { State: GlinerServiceState.Ready } )
							{
								<div class="diag-foot">@($"init {Service.InitializationMs:0} ms · generation {Service.Generation}")</div>
							}
						}
					</div>
				</div>

				<div class="results-foot">
					<span>GLiNER 2.5 Small</span><span class="foot-sep">|</span><span>s&box</span>
				</div>
			</div>
		</div>
		</ChildContent>
	</GlinerKeyCatcherPanel>
</root>

@code
{
	private GlinerDecisionService Service;
	private TextEntry ContextBox;
	private TextEntry TaskBox;
	private GlinerKeyCatcherPanel KeyCatcher;
	private TextEntry Label1, Label2, Label3, Label4, Label5, Label6, Label7, Label8;
	private TextEntry Desc1, Desc2, Desc3, Desc4, Desc5, Desc6, Desc7, Desc8;
	private string UiError = "";
	private GlinerDecisionResult Result;
	private bool RequestInFlight;
	private int ElapsedSeconds;
	private double RunStart;

	// Workbench UX state (UI only — no inference semantics).
	private int VisibleCandidateCount = 4;
	private bool ShowRawLogits;
	private bool DiagnosticsOpen = true;
	private GlinerClassificationRequest LastRequest;
	private int[] RankedOrder = Array.Empty<int>();

	// Poll-loop change detection (razor panels don't tick OnUpdate).
	private int _pollContextLen = -1, _pollTaskLen = -1, _pollPopulated = -1,
		_pollElapsed = -1, _pollVisible = -1;
	private string _pollSignature = "";
	private bool _pollFirst = true;

	[Property]
	public bool AutoRunOnReady { get; set; }

	private const int MinCandidates = 2;
	private const int MaxCandidates = 8;

	// ---- run gating (same rules as before: ready, idle, >=2 labels) ----------
	private bool CanRun => Service is { IsReady: true } && !RequestInFlight && PopulatedCount >= MinCandidates;
	private bool CanCancel => RequestInFlight;
	private bool CanAddCandidate => VisibleCandidateCount < MaxCandidates;
	private bool CanRemoveCandidate => VisibleCandidateCount > MinCandidates;
	private bool HasResult => Result is not null;
	private bool BackendIsGpu => Service?.SelectedBackend == GlinerBackendKind.NativeGpuFp32;
	private bool BackendIsCpu => Service?.SelectedBackend == GlinerBackendKind.NativeScalarCpu;
	private bool DefaultIsGpu => Service?.DefaultBackend == GlinerBackendKind.NativeGpuFp32;
	// 8E.10: switching only while idle — selector disabled while Running
	private bool CanSwitchBackend => Service is { IsReady: true } && !RequestInFlight;

	private int ContextLength => ContextBox?.Text?.Length ?? 0;

	private string RunBlockReason
	{
		get
		{
			if ( string.IsNullOrWhiteSpace( TaskBox?.Text ) )
			{
				return "Enter a task to run the decision.";
			}
			if ( PopulatedCount < MinCandidates )
			{
				return $"At least {MinCandidates} candidates are required.";
			}
			return "";
		}
	}

	private bool HasDuplicateLabels
	{
		get
		{
			var seen = new HashSet<string>( StringComparer.OrdinalIgnoreCase );
			foreach ( var l in VisibleLabels() )
			{
				if ( !seen.Add( l ) )
				{
					return true;
				}
			}
			return false;
		}
	}

	private string SelectedDescription
	{
		get
		{
			if ( Result is null || LastRequest is null )
			{
				return "";
			}
			foreach ( var c in LastRequest.Candidates )
			{
				if ( string.Equals( c.Label?.Trim(), Result.SelectedLabel?.Trim(), StringComparison.OrdinalIgnoreCase ) )
				{
					return c.Description ?? "";
				}
			}
			return "";
		}
	}

	private string RankDescription( int idx )
	{
		if ( Result is null || LastRequest is null )
		{
			return "";
		}
		if ( idx < 0 || idx >= Result.Labels.Length )
		{
			return "";
		}
		var label = Result.Labels[idx];
		foreach ( var c in LastRequest.Candidates )
		{
			if ( string.Equals( c.Label?.Trim(), label?.Trim(), StringComparison.OrdinalIgnoreCase ) )
			{
				return c.Description ?? "";
			}
		}
		return "";
	}

	private string GpuMemoryText
	{
		get
		{
			var status = Service?.GpuStatus ?? "";
			if ( Service is { GpuBackendAvailable: true } )
			{
				var m = System.Text.RegularExpressions.Regex.Match( status, @"([\d.]+) MiB" );
				return m.Success ? $"{m.Groups[1].Value} MiB resident" : "resident";
			}
			return status.StartsWith( "failed" ) ? "unavailable" : "N/A";
		}
	}

	// 8E.18: honest timing units — ms for fast (GPU) results, s for scalar
	private static string FormatMs( double ms )
		=> ms < 1000.0 ? $"{ms:0.#} ms" : $"{ms / 1000.0:0.##} s";

	private double ScoreBarPercent( int idx )
	{
		if ( Result is null || Result.Scores.Length == 0 )
		{
			return 0;
		}
		float max = 0f;
		foreach ( var s in Result.Scores )
		{
			if ( s > max )
			{
				max = s;
			}
		}
		if ( max <= 0f )
		{
			return 0;
		}
		var pct = Result.Scores[idx] / max * 100.0;
		return Result.Scores[idx] > 0f && pct < 2.0 ? 2.0 : pct;
	}

	private IEnumerable<string> VisibleLabels()
	{
		var boxes = new[] { Label1, Label2, Label3, Label4, Label5, Label6, Label7, Label8 };
		for ( int i = 0; i < VisibleCandidateCount && i < boxes.Length; i++ )
		{
			var t = boxes[i]?.Text?.Trim();
			if ( !string.IsNullOrEmpty( t ) )
			{
				yield return t;
			}
		}
	}

	private int PopulatedCount => VisibleLabels().Count();

	// ---- header status -------------------------------------------------------

	private string StatusText => Service switch
	{
		null => "Service missing",
		_ => Service.State switch
		{
			GlinerServiceState.Uninitialized => "Offline",
			GlinerServiceState.Initializing => "Loading",
			GlinerServiceState.Ready => "Ready",
			GlinerServiceState.Running => "Running",
			GlinerServiceState.Cancelling => "Cancelling",
			GlinerServiceState.Faulted => "Error",
			_ => Service.State.ToString()
		}
	};

	private string StatusClass =>
		Service is { State: GlinerServiceState.Faulted } ? "error" :
		Service is { IsReady: true } ? ( ( Service.GpuStatus ?? "" ).StartsWith( "failed" ) ? "warn" : "ready" ) :
		Service is { State: GlinerServiceState.Running or GlinerServiceState.Cancelling or GlinerServiceState.Initializing } ? "busy" : "error";

	private string StatusSub
	{
		get
		{
			if ( Service is null )
			{
				return "";
			}
			switch ( Service.State )
			{
				case GlinerServiceState.Ready:
					var gpu = Service.GpuBackendAvailable
						? ( BackendIsGpu ? $" · {GpuMemoryText}" : $" · GPU {GpuMemoryText}" )
						: "";
					return $"{Service.BackendName}{gpu}";
				case GlinerServiceState.Initializing:
					return "loading GLiNER 2.5 Small…";
				case GlinerServiceState.Faulted:
					return Truncate( Service.ErrorMessage, 52 );
				default:
					return "";
			}
		}
	}

	private static string Truncate( string s, int max )
		=> string.IsNullOrEmpty( s ) ? "" : s.Length <= max ? s : s[..( max - 1 )] + "…";

	private string VisibleError => !string.IsNullOrEmpty( UiError ) ? UiError : Service?.ErrorMessage ?? "";

	private bool ErrorIsCancel => UiError == "Request cancelled.";

	// ---- backend --------------------------------------------------------------

	private void OnSelectBackend( GlinerBackendKind kind )
	{
		if ( !CanSwitchBackend || Service is null )
		{
			return;
		}
		_ = Service.SelectBackend( kind );
		StateHasChanged();
	}

	// ---- candidate rows --------------------------------------------------------

	private void OnAddCandidate()
	{
		if ( !CanAddCandidate )
		{
			return;
		}
		VisibleCandidateCount++;
		StateHasChanged();
	}

	private void OnRemoveCandidate( int index )
	{
		if ( !CanRemoveCandidate )
		{
			return;
		}
		var labels = new[] { Label1, Label2, Label3, Label4, Label5, Label6, Label7, Label8 };
		var descs = new[] { Desc1, Desc2, Desc3, Desc4, Desc5, Desc6, Desc7, Desc8 };

		// shift rows above the removed one up; identity/order stays user-visible
		for ( int i = index; i < VisibleCandidateCount - 1; i++ )
		{
			if ( labels[i] is not null )
			{
				labels[i].Text = labels[i + 1]?.Text ?? "";
			}
			if ( descs[i] is not null )
			{
				descs[i].Text = descs[i + 1]?.Text ?? "";
			}
		}

		VisibleCandidateCount--;
		// vacated tail rows unrender on rebuild — drop the refs so stale panels
		// are never read
		if ( VisibleCandidateCount < 8 ) { Label8 = null; Desc8 = null; }
		if ( VisibleCandidateCount < 7 ) { Label7 = null; Desc7 = null; }
		if ( VisibleCandidateCount < 6 ) { Label6 = null; Desc6 = null; }
		if ( VisibleCandidateCount < 5 ) { Label5 = null; Desc5 = null; }

		StateHasChanged();
	}

	// ---- run / cancel / clear --------------------------------------------------

	private async void OnRun()
	{
		if ( !CanRun || Service is null )
		{
			return;
		}

		// Immutable request snapshot built from current UI text NOW.
		var boxes = new[] { Label1, Label2, Label3, Label4, Label5, Label6, Label7, Label8 };
		var descs = new[] { Desc1, Desc2, Desc3, Desc4, Desc5, Desc6, Desc7, Desc8 };
		var candidates = new List<GlinerCandidate>();
		for ( int i = 0; i < boxes.Length; i++ )
		{
			string label = boxes[i]?.Text?.Trim();
			if ( string.IsNullOrEmpty( label ) )
			{
				continue;
			}
			string desc = descs[i]?.Text;
			candidates.Add( new GlinerCandidate( label, string.IsNullOrEmpty( desc ) ? null : desc ) );
		}
		var request = new GlinerClassificationRequest(
			ContextBox?.Text ?? "", TaskBox?.Text ?? "", candidates );

		UiError = "";
		Result = null;
		RequestInFlight = true;
		RunStart = Time.Now;
		ElapsedSeconds = 0;
		StateHasChanged();

		try
		{
			var result = await Service.ClassifyAsync( request );
			if ( result is null )
			{
				UiError = "Request cancelled.";
			}
			else
			{
				Result = result;
				LastRequest = request;
				RankedOrder = ComputeRanking( result );
			}
		}
		catch ( Exception e )
		{
			UiError = Service?.ErrorMessage ?? e.Message;
		}
		finally
		{
			RequestInFlight = false;
			StateHasChanged();
		}
	}

	// Ranking derived from the model's own scores: descending score, with the
	// selected candidate pinned to rank #1 on ties. Pure presentation.
	private static int[] ComputeRanking( GlinerDecisionResult result )
	{
		var order = new int[result.Labels.Length];
		for ( int i = 0; i < order.Length; i++ )
		{
			order[i] = i;
		}
		Array.Sort( order, ( a, b ) =>
		{
			int c = result.Scores[b].CompareTo( result.Scores[a] );
			if ( c != 0 )
			{
				return c;
			}
			if ( a == result.SelectedIndex )
			{
				return -1;
			}
			if ( b == result.SelectedIndex )
			{
				return 1;
			}
			return a.CompareTo( b );
		} );
		return order;
	}

	private void OnCancel()
	{
		Service?.Cancel();
	}

	private void OnClear()
	{
		if ( RequestInFlight )
		{
			return; // never mutate an active request
		}
		foreach ( var box in new[] { ContextBox, TaskBox, Label1, Label2, Label3, Label4,
			Label5, Label6, Label7, Label8, Desc1, Desc2, Desc3, Desc4,
			Desc5, Desc6, Desc7, Desc8 } )
		{
			if ( box is not null )
			{
				box.Text = "";
			}
		}
		Result = null;
		LastRequest = null;
		RankedOrder = Array.Empty<int>();
		UiError = "";
		VisibleCandidateCount = 4;
		StateHasChanged();
	}

	// ---- lifecycle --------------------------------------------------------------

	protected override void OnStart()
	{
		Service = GetComponent<GlinerDecisionService>();
		if ( Service is null )
		{
			UiError = "GlinerDecisionService is missing from this GameObject.";
			return;
		}

		// Phase 7 mouse policy: this is a dedicated UI scene, so the UI owns
		// the cursor. Play mode defaults to a hidden/locked cursor, which made
		// every panel unclickable. Save the previous mode and restore it when
		// the workbench goes away.
		_previousVisibility = Mouse.Visibility;
		_mouseSaved = true;
		Mouse.Visibility = MouseVisibility.Visible;
		Log.Info( $"[GLI:UI] Mouse.Visibility {_previousVisibility} -> Visible" );

		_ = Service.InitializeAsync();
		_ = AutoRunWatcherAsync();
		_ = RefreshLoopAsync();
	}

	private MouseVisibility _previousVisibility;
	private bool _mouseSaved;

	private void RestoreMouse()
	{
		if ( _mouseSaved )
		{
			Mouse.Visibility = _previousVisibility;
			_mouseSaved = false;
			Log.Info( $"[GLI:UI] Mouse.Visibility restored -> {_previousVisibility}" );
		}
	}

	protected override void OnDisabled() => RestoreMouse();

	protected override void OnDestroy() => RestoreMouse();

	protected override void OnTreeFirstBuilt()
	{
		// Default example so testing is immediate (phase 7.28). Named refs are
		// bound by this point; users can freely edit or clear everything.
		if ( string.IsNullOrEmpty( ContextBox?.Text ) )
		{
			ContextBox.Text = "The character is badly injured. No enemy is currently visible. Healing is available. The weapon is loaded.";
		}
		if ( string.IsNullOrEmpty( TaskBox?.Text ) )
		{
			TaskBox.Text = "Choose the next action.";
		}
		var labels = new[] { Label1, Label2, Label3, Label4 };
		var names = new[] { "heal", "attack", "reload", "retreat" };
		var descs = new[] { Desc1, Desc2, Desc3, Desc4 };
		var descTexts = new[] { "Restore health", "Engage an enemy", "Reload the current weapon", "Move away from danger" };
		for ( int i = 0; i < labels.Length; i++ )
		{
			if ( string.IsNullOrEmpty( labels[i]?.Text ) )
			{
				labels[i].Text = names[i];
			}
			if ( string.IsNullOrEmpty( descs[i]?.Text ) )
			{
				descs[i].Text = descTexts[i];
			}
		}
	}

	protected override void OnTreeBuilt()
	{
		// Ctrl+Enter runs the decision. Key events bubble from the focused
		// entry through the key-catcher wrapper panel; the handler is (re-)
		// attached here because tree rebuilds recreate the catcher panel.
		if ( KeyCatcher is not null )
		{
			KeyCatcher.OnKey = OnRootKey;
		}
	}

	private void OnRootKey( ButtonEvent e )
	{
		if ( e.Pressed && e.HasCtrl && ( e.Button == "enter" || e.Button == "return" ) )
		{
			Log.Info( "[GLI:UI] Ctrl+Enter shortcut -> run" );
			OnRun();
		}
	}

	// OnUpdate does not tick on razor panel components in 26.09.22 (observed);
	// the optional auto-run waits for readiness via this async watcher.
	private async Task AutoRunWatcherAsync()
	{
		while ( Service is null || !Service.IsReady )
		{
			await Task.Delay( 100 );
		}
		Log.Info( "[GLI:UI] watcher: service ready" );
		while ( ContextBox is null )
		{
			await Task.Delay( 100 );
		}
		Log.Info( "[GLI:UI] watcher: context ref bound" );
		ContextBox.Focus();
		await Task.Delay( 200 );
		Log.Info( $"[GLI:UI] TextEntry focus validation: ContextBox.HasFocus={ContextBox.HasFocus}" );
		await Task.Delay( 300 ); // let the first UI build settle
		if ( AutoRunOnReady )
		{
			OnRun();
		}
	}

	// Refresh loop: keeps counters, status and the running elapsed timer honest
	// without an OnUpdate tick. StateHasChanged only fires when something the
	// user can see actually changed (BuildHash gates the rebuild anyway).
	private async Task RefreshLoopAsync()
	{
		while ( true )
		{
			await Task.Delay( 200 );

			if ( RequestInFlight )
			{
				ElapsedSeconds = (int) ( Time.Now - RunStart );
			}

			var sig = $"{( Service?.State.ToString() ?? "null" )}|{Service?.GpuStatus}|{Service?.SelectedBackend}";
			var contextLen = ContextLength;
			var taskLen = TaskBox?.Text?.Length ?? 0;
			var populated = PopulatedCount;
			var elapsed = ElapsedSeconds;
			var visible = VisibleCandidateCount;

			if ( _pollFirst ||
				sig != _pollSignature ||
				contextLen != _pollContextLen || taskLen != _pollTaskLen ||
				populated != _pollPopulated || elapsed != _pollElapsed ||
				visible != _pollVisible )
			{
				_pollFirst = false;
				_pollSignature = sig;
				_pollContextLen = contextLen;
				_pollTaskLen = taskLen;
				_pollPopulated = populated;
				_pollElapsed = elapsed;
				_pollVisible = visible;
				StateHasChanged();
			}
		}
	}

	protected override int BuildHash()
	{
		return HashCode.Combine(
			HashCode.Combine(
				Service?.State ?? GlinerServiceState.Uninitialized, RequestInFlight, UiError,
				HasResult, ElapsedSeconds, Service?.SelectedBackend ?? GlinerBackendKind.NativeScalarCpu,
				Service?.GpuStatus ?? "" ),
			HashCode.Combine(
				VisibleCandidateCount, ShowRawLogits, DiagnosticsOpen,
				ContextLength, TaskBox?.Text?.Length ?? 0, PopulatedCount,
				HasDuplicateLabels, LastRequest ) );
	}
}