Skip to content

Commit a76724d

Browse files
authored
refactor: replace snackbars with persistent info popups (#315)
ui.showSnackbar has a few issues that make it unreliable for surfacing errors and notifications: - Limited lifetime: a snackbar launched while the window is hidden is often never shown if more than a few seconds pass before the window becomes visible. - Only the last of several snackbars launched in a row is displayed. - Toolbox keeps the coroutine suspended for the snackbar's lifetime and cancels (rather than resumes) it on dismissal, propagating the cancel to the caller and skipping any follow-up code such as resetting the busy state. Switch to ui.showInfoPopup, which is backed by a persistent dialog state: it is still rendered once the window becomes visible, resumes normally on dismissal, and is serialized via a mutex so popups are shown one after another instead of overwriting each other. Since popups render after the window becomes visible, the ErrorReporter buffer-and-flush abstraction is no longer needed. Remove it along with the parallel errorBuffer in CoderRemoteProvider and the visibilityState plumbing threaded through the setup wizard steps; errors are now shown directly where they occur. - resolves https://linear.app/codercom/issue/DEVEX-408/
1 parent 8783eb8 commit a76724d

14 files changed

Lines changed: 129 additions & 286 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
### Changed
1111

1212
- skip the Coder TLS alternate hostname when fetching IDE metadata from JetBrains
13+
- notifications are now persistent popups instead of snackbars, so they survive a hidden window and no longer get dropped
1314

1415
## 0.9.0 - 2026-05-14
1516

src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt

Lines changed: 13 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -92,12 +92,6 @@ class CoderRemoteProvider(
9292
}
9393
}
9494
}
95-
private val visibilityState = MutableStateFlow(
96-
ProviderVisibilityState(
97-
applicationVisible = false,
98-
providerVisible = false
99-
)
100-
)
10195
private val linkHandler = CoderProtocolHandler(context, IdeFeedManager(context))
10296

10397
override val loadingEnvironmentsDescription: LocalizableString = context.i18n.ptrl("Loading workspaces...")
@@ -109,8 +103,6 @@ class CoderRemoteProvider(
109103
context.envPageManager.showPluginEnvironmentsPage()
110104
}
111105

112-
private val errorBuffer = mutableListOf<Throwable>()
113-
114106
private val router = PageRouter()
115107

116108
/**
@@ -158,13 +150,17 @@ class CoderRemoteProvider(
158150
if (elapsed > POLL_INTERVAL * 2) {
159151
context.logger.info("wake-up from an OS sleep was detected")
160152
} else {
161-
context.logger.error(ex, "workspace polling error encountered")
162153
if ((ex is APIResponseException && ex.isTokenExpired) || ex is OAuthTokenResponseException) {
163154
close()
164155
context.envPageManager.showPluginEnvironmentsPage()
165-
errorBuffer.add(ex)
156+
context.logAndShowError(
157+
"Error encountered while setting up Coder",
158+
"Your Coder session has expired. Please re-authenticate and try again.",
159+
ex
160+
)
166161
break
167162
}
163+
context.logger.error(ex, "workspace polling error encountered")
168164
}
169165
}
170166

@@ -331,9 +327,6 @@ class CoderRemoteProvider(
331327
* and a manual refresh button.
332328
*/
333329
override fun setVisible(visibility: ProviderVisibilityState) {
334-
visibilityState.update {
335-
visibility
336-
}
337330
if (visibility.providerVisible) {
338331
context.cs.launch(CoroutineName("Notify Plugin Visibility")) {
339332
triggerProviderVisible.send(true)
@@ -378,7 +371,7 @@ class CoderRemoteProvider(
378371
// showPluginEnvironmentsPage() pull it through getOverrideUiPage.
379372
val credentials = newToken?.let { Credentials.Token(it) } ?: Credentials.MTls
380373
val wizard = CoderSetupWizardPage.connectStep(
381-
context, settingsPage, visibilityState,
374+
context, settingsPage,
382375
url = newUrl,
383376
credentials = credentials,
384377
onConnect = onConnect.andThen(deferredLinkHandler(params, newUrl)),
@@ -458,7 +451,7 @@ class CoderRemoteProvider(
458451
val oauthSessionContext = pendingOAuthConnection.session
459452
val tokenResponse = OAuth2Client(context).exchangeCode(oauthSessionContext, code)
460453
val wizard = CoderSetupWizardPage.connectStep(
461-
context, settingsPage, visibilityState,
454+
context, settingsPage,
462455
url = pendingOAuthConnection.url,
463456
credentials = Credentials.OAuth(oauthSessionContext.copy(tokenResponse = tokenResponse)),
464457
onConnect = onConnect,
@@ -555,36 +548,30 @@ class CoderRemoteProvider(
555548
try {
556549
val url = context.deploymentUrl
557550
val credentials = autoSetupCredentials(url) ?: return CoderSetupWizardPage.deploymentUrlStep(
558-
context, settingsPage, visibilityState,
551+
context, settingsPage,
559552
onConnect = onConnect,
560553
onTokenRefreshed = ::onTokenRefreshed,
561554
)
562555
return CoderSetupWizardPage.connectStep(
563-
context, settingsPage, visibilityState,
556+
context, settingsPage,
564557
url = url,
565558
credentials = credentials,
566559
onConnect = onConnect,
567560
onTokenRefreshed = ::onTokenRefreshed,
568561
)
569562
} catch (ex: Exception) {
570-
errorBuffer.add(ex)
563+
context.logAndShowError("Error encountered while setting up Coder", "Failed to set up Coder", ex)
571564
} finally {
572565
firstRun = false
573566
}
574567
}
575568

576569
// Login flow.
577-
val setupWizardPage = CoderSetupWizardPage.deploymentUrlStep(
578-
context, settingsPage, visibilityState,
570+
return CoderSetupWizardPage.deploymentUrlStep(
571+
context, settingsPage,
579572
onConnect = onConnect,
580573
onTokenRefreshed = ::onTokenRefreshed,
581574
)
582-
// We might have navigated here due to a polling error.
583-
errorBuffer.forEach {
584-
setupWizardPage.notify("Error encountered", it)
585-
}
586-
errorBuffer.clear()
587-
return setupWizardPage
588575
}
589576

590577
/**

src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt

Lines changed: 22 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@ import kotlinx.coroutines.CoroutineName
1818
import kotlinx.coroutines.CoroutineScope
1919
import kotlinx.coroutines.launch
2020
import java.net.URL
21-
import java.util.UUID
2221

2322
@Suppress("UnstableApiUsage")
2423
data class CoderToolboxContext(
@@ -34,8 +33,9 @@ data class CoderToolboxContext(
3433
val settingsStore: CoderSettingsStore,
3534
val secrets: CoderSecretsStore,
3635
val proxySettings: ToolboxProxySettings,
37-
val connectionMonitoringService: ConnectionMonitoringService,
3836
) {
37+
val connectionMonitoringService: ConnectionMonitoringService = ConnectionMonitoringService(this)
38+
3939
/**
4040
* Try to find a URL.
4141
*
@@ -54,49 +54,50 @@ data class CoderToolboxContext(
5454

5555
fun logAndShowError(title: String, error: String) {
5656
logger.error(error)
57-
showSnackbar(title, error)
57+
showInfoPopup(title, error)
5858
}
5959

6060
fun logAndShowError(title: String, error: String, exception: Exception) {
6161
logger.error(exception, error)
62-
showSnackbar(title, error)
62+
showInfoPopup(title, error)
6363
}
6464

6565
fun logAndShowWarning(title: String, warning: String) {
6666
logger.warn(warning)
67-
showSnackbar(title, warning)
67+
showInfoPopup(title, warning)
6868
}
6969

7070
fun logAndShowInfo(title: String, info: String) {
7171
logger.info(info)
72-
showSnackbar(title, info)
72+
showInfoPopup(title, info)
7373
}
7474

7575
/**
76-
* Displays a snackbar on a child of the plugin coroutine scope rather than on the
77-
* caller's coroutine, without waiting for it.
76+
* Displays an informational popup on a child of the plugin coroutine scope rather than on
77+
* the caller's coroutine, without waiting for it.
78+
*
79+
* Unlike [ToolboxUi.showSnackbar], a popup is backed by a persistent dialog state: it is
80+
* still rendered once the window becomes visible even if it was requested while the window
81+
* was hidden, it is not silently dropped when several are requested, and dismissing it
82+
* resumes the [ToolboxUi.showInfoPopup] coroutine normally instead of cancelling it.
7883
*
79-
* Toolbox keeps the [ToolboxUi.showSnackbar] coroutine suspended for the entire lifetime
80-
* of the snackbar and cancels it (rather than resuming it) when the snackbar goes away.
81-
* Calling it directly on the caller's coroutine would therefore either block the caller
82-
* until the snackbar is gone or, on dismissal, abruptly cancel the caller (e.g. the URI
83-
* handler) - skipping any code that runs after the error is shown, such as resetting the
84-
* busy state. Launching it fire-and-forget on the plugin scope lets the caller continue
85-
* immediately while the snackbar lives independently.
84+
* It is launched fire-and-forget so the caller is not suspended until the user closes the
85+
* popup - the caller (e.g. the URI handler) can run any follow-up code, such as resetting
86+
* the busy state, immediately. The popups are serialized via [popupMutex] so they are
87+
* shown one after another rather than overwriting each other.
8688
*/
87-
fun showSnackbar(title: String, text: String) {
88-
cs.launch(CoroutineName("snackbar")) {
89+
fun showInfoPopup(title: String, text: String) {
90+
cs.launch(CoroutineName("popup")) {
8991
try {
90-
ui.showSnackbar(
91-
UUID.randomUUID().toString(),
92+
ui.showInfoPopup(
9293
i18n.pnotr(title),
9394
i18n.pnotr(text),
9495
i18n.ptrl("OK")
9596
)
9697
} catch (_: CancellationException) {
97-
// Expected when the snackbar is dismissed or the plugin scope shuts down.
98+
// Expected when the plugin scope shuts down while the popup is open.
9899
} catch (ex: Exception) {
99-
logger.error(ex, "Failed to display snackbar with title '$title'")
100+
logger.error(ex, "Failed to display popup with title '$title'")
100101
}
101102
}
102103
}

src/main/kotlin/com/coder/toolbox/CoderToolboxExtension.kt

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ package com.coder.toolbox
33
import com.coder.toolbox.settings.Environment
44
import com.coder.toolbox.store.CoderSecretsStore
55
import com.coder.toolbox.store.CoderSettingsStore
6-
import com.coder.toolbox.util.ConnectionMonitoringService
76
import com.jetbrains.toolbox.api.core.PluginSecretStore
87
import com.jetbrains.toolbox.api.core.PluginSettingsStore
98
import com.jetbrains.toolbox.api.core.ServiceLocator
@@ -45,12 +44,6 @@ class CoderToolboxExtension : RemoteDevExtension {
4544
CoderSettingsStore(serviceLocator.getService<PluginSettingsStore>(), Environment(), logger),
4645
CoderSecretsStore(serviceLocator.getService<PluginSecretStore>()),
4746
serviceLocator.getService<ToolboxProxySettings>(),
48-
ConnectionMonitoringService(
49-
cs,
50-
ui,
51-
logger,
52-
i18n
53-
)
5447
)
5548
)
5649
}

src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ import kotlinx.coroutines.delay
1717
import kotlinx.coroutines.launch
1818
import kotlinx.coroutines.time.withTimeout
1919
import java.net.URL
20-
import java.util.UUID
2120
import kotlin.time.Duration
2221
import kotlin.time.Duration.Companion.minutes
2322
import kotlin.time.Duration.Companion.seconds
@@ -273,8 +272,7 @@ open class CoderProtocolHandler(
273272
context.logger.info("Successfully installed $selectedIde on $environmentId.")
274273
return selectedIde
275274
} else {
276-
context.ui.showSnackbar(
277-
UUID.randomUUID().toString(),
275+
context.ui.showInfoPopup(
278276
context.i18n.pnotr("$selectedIde could not be installed"),
279277
context.i18n.pnotr("$selectedIde could not be installed on time. Check the logs for more details"),
280278
context.i18n.ptrl("OK")
Lines changed: 7 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,14 @@
11
package com.coder.toolbox.util
22

3+
import com.coder.toolbox.CoderToolboxContext
34
import com.coder.toolbox.sdk.v2.models.Workspace
45
import com.coder.toolbox.sdk.v2.models.WorkspaceAgent
56
import com.coder.toolbox.sdk.v2.models.WorkspaceAgentLifecycleState
67
import com.coder.toolbox.sdk.v2.models.WorkspaceAgentStatus
78
import com.coder.toolbox.sdk.v2.models.WorkspaceStatus
8-
import com.jetbrains.toolbox.api.core.diagnostics.Logger
9-
import com.jetbrains.toolbox.api.localization.LocalizableStringFactory
10-
import com.jetbrains.toolbox.api.ui.ToolboxUi
11-
import kotlinx.coroutines.CoroutineScope
12-
import kotlinx.coroutines.launch
13-
import java.util.UUID
149

1510
class ConnectionMonitoringService(
16-
private val cs: CoroutineScope,
17-
private val ui: ToolboxUi,
18-
private val logger: Logger,
19-
private val i18n: LocalizableStringFactory
11+
private val context: CoderToolboxContext
2012
) {
2113
private var alreadyNotified = false
2214

@@ -34,25 +26,12 @@ class ConnectionMonitoringService(
3426

3527
when {
3628
isWorkspaceRunning && isAgentReady && hasConnectionIssue -> {
37-
cs.launch {
38-
logAndShowWarning(
39-
title = "Unstable connection detected",
40-
warning = "Unstable connection between Coder server and workspace detected. Your active sessions may disconnect"
41-
)
42-
}
29+
context.logAndShowWarning(
30+
"Unstable connection detected",
31+
"Unstable connection between Coder server and workspace detected. Your active sessions may disconnect"
32+
)
4333
alreadyNotified = true
4434
}
4535
}
4636
}
47-
48-
49-
private suspend fun logAndShowWarning(title: String, warning: String) {
50-
logger.warn(warning)
51-
ui.showSnackbar(
52-
UUID.randomUUID().toString(),
53-
i18n.ptrl(title),
54-
i18n.ptrl(warning),
55-
i18n.ptrl("OK")
56-
)
57-
}
58-
}
37+
}

src/main/kotlin/com/coder/toolbox/views/CoderSetupWizardPage.kt

Lines changed: 3 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -8,20 +8,17 @@ import com.coder.toolbox.views.state.Credentials
88
import com.coder.toolbox.views.state.PendingOAuthConnection
99
import com.coder.toolbox.views.state.WizardModel
1010
import com.coder.toolbox.views.state.WizardStep
11-
import com.jetbrains.toolbox.api.remoteDev.ProviderVisibilityState
1211
import com.jetbrains.toolbox.api.ui.actions.RunnableActionDescription
1312
import com.jetbrains.toolbox.api.ui.components.UiField
1413
import kotlinx.coroutines.Job
1514
import kotlinx.coroutines.flow.MutableStateFlow
16-
import kotlinx.coroutines.flow.StateFlow
1715
import kotlinx.coroutines.flow.update
1816
import kotlinx.coroutines.launch
1917
import java.net.URL
2018

2119
class CoderSetupWizardPage private constructor(
2220
private val context: CoderToolboxContext,
2321
private val settingsPage: CoderSettingsPage,
24-
visibilityState: StateFlow<ProviderVisibilityState>,
2522
private var autoLogin: Boolean = false,
2623
onConnect: SuspendBiConsumer<CoderRestClient, CoderCLIManager>,
2724
onTokenRefreshed: (suspend (url: URL, oauthSessionCtx: CoderOAuthSessionContext) -> Unit)? = null
@@ -31,17 +28,15 @@ class CoderSetupWizardPage private constructor(
3128
context.ui.showUiPage(settingsPage)
3229
}
3330

34-
private val deploymentUrlStep = DeploymentUrlStep(context, model, visibilityState)
31+
private val deploymentUrlStep = DeploymentUrlStep(context, model)
3532
private val tokenStep = TokenStep(context, model)
3633
private val connectStep = ConnectStep(
3734
context,
3835
model,
39-
visibilityState,
4036
navigateBack = this::navigateBackFromConnect,
4137
onConnect = onConnect,
4238
onTokenRefreshed = onTokenRefreshed
4339
)
44-
private val errorReporter = ErrorReporter.create(context, visibilityState, this.javaClass)
4540
private var stateCollectJob: Job? = null
4641

4742
/**
@@ -58,7 +53,6 @@ class CoderSetupWizardPage private constructor(
5853
displaySteps()
5954
}
6055
}
61-
errorReporter.flush()
6256
}
6357

6458
private fun displaySteps() {
@@ -153,35 +147,27 @@ class CoderSetupWizardPage private constructor(
153147
stateCollectJob?.cancel()
154148
}
155149

156-
/**
157-
* Show an error as a popup on this page.
158-
*/
159-
fun notify(message: String, ex: Throwable) = errorReporter.report(message, ex)
160-
161-
162150
companion object {
163151
fun deploymentUrlStep(
164152
context: CoderToolboxContext,
165153
settingsPage: CoderSettingsPage,
166-
visibilityState: StateFlow<ProviderVisibilityState>,
167154
onConnect: SuspendBiConsumer<CoderRestClient, CoderCLIManager>,
168155
onTokenRefreshed: (suspend (url: URL, oauthSessionCtx: CoderOAuthSessionContext) -> Unit)? = null,
169156
): CoderSetupWizardPage = CoderSetupWizardPage(
170-
context, settingsPage, visibilityState,
157+
context, settingsPage,
171158
onConnect = onConnect,
172159
onTokenRefreshed = onTokenRefreshed,
173160
).apply { model.goToFirst() }
174161

175162
fun connectStep(
176163
context: CoderToolboxContext,
177164
settingsPage: CoderSettingsPage,
178-
visibilityState: StateFlow<ProviderVisibilityState>,
179165
url: URL,
180166
credentials: Credentials,
181167
onConnect: SuspendBiConsumer<CoderRestClient, CoderCLIManager>,
182168
onTokenRefreshed: (suspend (url: URL, oauthSessionCtx: CoderOAuthSessionContext) -> Unit)? = null,
183169
): CoderSetupWizardPage = CoderSetupWizardPage(
184-
context, settingsPage, visibilityState,
170+
context, settingsPage,
185171
autoLogin = true,
186172
onConnect = onConnect,
187173
onTokenRefreshed = onTokenRefreshed,

0 commit comments

Comments
 (0)