You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Follow-on to #1368. That PR made the channel allow-list store the platform's immutable channel ID and resolve human display names dynamically — and, per the rule "we don't persist a display name we can't map to a real channel ID," it drops an unmappable display name (with a loud warning) rather than persisting an inert allow-list entry.
That's correct for deterministic failures (channel not found, bot lacks channels:read, invalid token). But it's a blunt response to a transient failure (network blip, timeout, 429, 5xx): we drop a just-typed name that would have resolved a second later, because in that instant we can't obtain its ID. We can't keep the name (no ID = no stable ACL key), but immediately discarding it on a recoverable error is poor UX.
This issue proposes a standard "resolve-with-retry" UI paradigm for any input whose persisted form must be obtained from a remote that may be transiently unavailable — channels today, but also provider endpoints and search backends.
The paradigm: a "Resolving" pending state with bounded auto-retry
A reference that requires remote resolution to obtain its canonical/stable form moves through four visible states instead of resolve-or-drop:
State
Meaning
Rendered as
Persisted?
Resolved
canonical ID known
#display-name (display looked up live from the ID)
✅ the ID
Resolving
submitted, awaiting the probe
⠋ resolving #netclaw-test… (spinner)
❌ pending
Retrying
transient failure, auto-retry pending
⠋ #netclaw-test — network error, retry 2/4 in 3s… (spinner + live countdown)
Retrying, attempts exhausted → Unmappable ("couldn't reach after N tries")
Unmappable + [r] → Resolving (reset attempts)
Backoff: small bounded exponential (e.g. 1s → 2s → 4s → 8s cap, ~4 attempts), with the countdown shown so the wait is legible. A pending reference is treated as not in the ACL until it resolves — never an inert name on disk.
Requirements this exposes
Structured failure classification on the probe. Today the resolution result carries a free-text ErrorMessage; the editor cannot tell transient from deterministic without string-matching. Add a category to SlackChannelResolutionResult / DiscordChannelResolutionResult / MattermostChannelResolutionResult (e.g. ResolutionFailureKind { Transient, Deterministic } or a bool IsRetryable), set from the transport layer (HTTP 5xx/timeout/connection = transient; missing_scope/invalid_auth/not-found = deterministic; 429 = transient with Retry-After honored).
A tri-state channel reference in the editor model. Replace the flat List<string> of ids with something that can hold Resolved(id) / Pending(typedName, attempt, nextRetryAt) / Unmappable(typedName, reason), so a pending entry is renderable and cancellable but not persistable.
the Resolving/Retrying row uses a self-animating SpinnerNode (bubbles invalidation → RequestRedraw; no manual tick);
the countdown is an IAnimatedTextSegment like ElapsedTimeSegment (1 Hz tick → invalidation → redraw);
the retry loop is a tracked task + owned CancellationTokenSource, off the loop thread, publishing via RequestRedraw — no .GetAwaiter().GetResult();
navigating away / saving cancels-and-awaits the in-flight retry; pending refs are excluded from the saved ACL.
Where it plugs in
src/Netclaw.Cli/Tui/Config/ChannelsConfigViewModel.cs → ReconcileResolvedChannels is where the drop currently happens on any non-mapping outcome (including transient errors). This is the seam to introduce the pending/retry state.
ISlackProbe.ResolveChannelNamesAsync / IDiscordProbe.ResolveChannelIdsAsync / IMattermostProbe.ResolveChannelIdsAsync need the structured failure kind.
Generalize it
The same "remote-resolved input that may be transiently unavailable" shape appears for inference provider endpoint probes (ProviderStepViewModel) and search backend probes (SearchConfigEditorViewModel). Propose extracting a small reusable Termina convention/helper (a RemoteResolution<T> state + a standard Resolving/Retrying/Failed row view) rather than hand-rolling it per editor.
Acceptance criteria
A transient resolution failure does not drop the typed reference; it shows a spinner + bounded retry countdown and resolves automatically when the remote recovers, persisting the canonical ID.
Summary
Follow-on to #1368. That PR made the channel allow-list store the platform's immutable channel ID and resolve human display names dynamically — and, per the rule "we don't persist a display name we can't map to a real channel ID," it drops an unmappable display name (with a loud warning) rather than persisting an inert allow-list entry.
That's correct for deterministic failures (channel not found, bot lacks
channels:read, invalid token). But it's a blunt response to a transient failure (network blip, timeout, 429, 5xx): we drop a just-typed name that would have resolved a second later, because in that instant we can't obtain its ID. We can't keep the name (no ID = no stable ACL key), but immediately discarding it on a recoverable error is poor UX.This issue proposes a standard "resolve-with-retry" UI paradigm for any input whose persisted form must be obtained from a remote that may be transiently unavailable — channels today, but also provider endpoints and search backends.
The paradigm: a "Resolving" pending state with bounded auto-retry
A reference that requires remote resolution to obtain its canonical/stable form moves through four visible states instead of resolve-or-drop:
#display-name(display looked up live from the ID)⠋ resolving #netclaw-test…(spinner)⠋ #netclaw-test — network error, retry 2/4 in 3s…(spinner + live countdown)[r] Retryaffordance + reasonTransitions
[r]→ Resolving (reset attempts)Backoff: small bounded exponential (e.g. 1s → 2s → 4s → 8s cap, ~4 attempts), with the countdown shown so the wait is legible. A pending reference is treated as not in the ACL until it resolves — never an inert name on disk.
Requirements this exposes
ErrorMessage; the editor cannot tell transient from deterministic without string-matching. Add a category toSlackChannelResolutionResult/DiscordChannelResolutionResult/MattermostChannelResolutionResult(e.g.ResolutionFailureKind { Transient, Deterministic }or abool IsRetryable), set from the transport layer (HTTP 5xx/timeout/connection = transient;missing_scope/invalid_auth/not-found = deterministic; 429 = transient withRetry-Afterhonored).List<string>of ids with something that can holdResolved(id)/Pending(typedName, attempt, nextRetryAt)/Unmappable(typedName, reason), so a pending entry is renderable and cancellable but not persistable.termina-tui-patternsskill added in feat(init,config): simplified init, rebuilt config TUI, canonical channel-ID resolution #1368):SpinnerNode(bubbles invalidation →RequestRedraw; no manual tick);IAnimatedTextSegmentlikeElapsedTimeSegment(1 Hz tick → invalidation → redraw);CancellationTokenSource, off the loop thread, publishing viaRequestRedraw— no.GetAwaiter().GetResult();Where it plugs in
src/Netclaw.Cli/Tui/Config/ChannelsConfigViewModel.cs→ReconcileResolvedChannelsis where the drop currently happens on any non-mapping outcome (including transient errors). This is the seam to introduce the pending/retry state.ISlackProbe.ResolveChannelNamesAsync/IDiscordProbe.ResolveChannelIdsAsync/IMattermostProbe.ResolveChannelIdsAsyncneed the structured failure kind.Generalize it
The same "remote-resolved input that may be transiently unavailable" shape appears for inference provider endpoint probes (
ProviderStepViewModel) and search backend probes (SearchConfigEditorViewModel). Propose extracting a small reusable Termina convention/helper (aRemoteResolution<T>state + a standard Resolving/Retrying/Failed row view) rather than hand-rolling it per editor.Acceptance criteria
GetResult), verified by the same kind of deterministicPendingX-awaiting tests feat(init,config): simplified init, rebuilt config TUI, canonical channel-ID resolution #1368 added.Non-goals