feat(desktop): make the first-quit dialog a notice that points to Settings

This commit is contained in:
opencode-agent[bot]
2026-11-09 20:55:45 +00:00
committed by GitHub
Unverified
parent aa7336d557
commit 31137a5782
5 changed files with 49 additions and 57 deletions
@@ -319,9 +319,8 @@ export const DESKTOP_NATIVE_ENGLISH = {
"desktop.quit.serviceNotice.message": "OpenCode keeps running in the background",
"desktop.quit.serviceNotice.detail":
"After you quit, the OpenCode server keeps running so agents, terminals, and MCP servers can finish their work. You can change this anytime in Settings.",
"desktop.quit.serviceNotice.keepRunning": "Keep Running",
"desktop.quit.serviceNotice.stopOnQuit": "Stop When I Quit",
"After you quit, the OpenCode server keeps running so agents, terminals, and MCP servers can finish their work. You can change this in Settings → General.",
"desktop.quit.serviceNotice.ok": "OK",
} as const
export type DesktopNativeKey = keyof typeof DESKTOP_NATIVE_ENGLISH
+3 -3
View File
@@ -67,9 +67,9 @@ const runtime = Layer.effect(
}),
),
),
confirm: () =>
notice: () =>
runPromise(
Effect.tryPromise(QuitBehavior.confirmFirstQuit).pipe(
Effect.tryPromise(QuitBehavior.showQuitNotice).pipe(
Effect.catch((error) => Effect.logError("first-quit service notice failed", { error })),
),
),
@@ -128,7 +128,7 @@ const runtime = Layer.effect(
const beforeQuit = (event: Event) => {
if (preventsQuit(driver.state())) event.preventDefault()
driver.dispatch({ type: "before.quit", confirm: QuitBehavior.isQuitNoticePending() })
driver.dispatch({ type: "before.quit", notify: QuitBehavior.isQuitNoticePending() })
}
const willQuit = () => driver.dispatch({ type: "will.quit" })
@@ -147,50 +147,50 @@ describe("app lifecycle machine", () => {
])
})
test("the first quit holds Electron while the user answers, then stops the plugins exactly once", () => {
const asked = transition(initialState(), { type: "before.quit", confirm: true })
test("the first quit holds Electron while the notice is open, then stops the plugins exactly once", () => {
const asked = transition(initialState(), { type: "before.quit", notify: true })
expect(asked).toEqual({
state: { status: "confirming", attempt: 1 },
commands: [{ type: "confirm-quit", attempt: 1 }],
state: { status: "notifying", attempt: 1 },
commands: [{ type: "show-quit-notice", attempt: 1 }],
})
expect(preventsQuit(asked.state)).toBe(true)
expect(isQuitting(asked.state)).toBe(false)
// Cmd+Q pressed again, or the window closed again, while the dialog is open asks nothing new.
expect(transition(asked.state, { type: "before.quit", confirm: true })).toEqual({
expect(transition(asked.state, { type: "before.quit", notify: true })).toEqual({
state: asked.state,
commands: [],
})
expect(transition(asked.state, { type: "quit.confirmed", attempt: 0 })).toEqual({
expect(transition(asked.state, { type: "notice.dismissed", attempt: 0 })).toEqual({
state: asked.state,
commands: [],
})
const answered = transition(asked.state, { type: "quit.confirmed", attempt: 1 })
expect(answered).toEqual({
const dismissed = transition(asked.state, { type: "notice.dismissed", attempt: 1 })
expect(dismissed).toEqual({
state: { status: "stopping", attempt: 2, exit: "quit" },
commands: [{ type: "stop-plugins", attempt: 2 }],
})
expect(stopsForQuit(answered.state)).toBe(true)
expect(transition(answered.state, { type: "quit.confirmed", attempt: 1 }).state).toBe(answered.state)
expect(stopsForQuit(dismissed.state)).toBe(true)
expect(transition(dismissed.state, { type: "notice.dismissed", attempt: 1 }).state).toBe(dismissed.state)
})
test.each([
{ name: "the OS ends the session", event: { type: "session.ending" } },
{ name: "a signal quits", event: { type: "exit.requested", mode: "quit" } },
{ name: "Electron quits without before-quit", event: { type: "will.quit" } },
] satisfies { name: string; event: Event }[])("$name while the user is answering: the quit goes on", (row) => {
const asked = transition(initialState(), { type: "before.quit", confirm: true }).state
] satisfies { name: string; event: Event }[])("$name while the notice is open: the quit goes on", (row) => {
const asked = transition(initialState(), { type: "before.quit", notify: true }).state
const next = transition(asked, row.event)
expect(next.commands).toEqual([{ type: "stop-plugins", attempt: 2 }])
expect(stopsForQuit(next.state)).toBe(true)
expect(transition(next.state, { type: "quit.confirmed", attempt: 1 }).state).toBe(next.state)
expect(transition(next.state, { type: "notice.dismissed", attempt: 1 }).state).toBe(next.state)
})
test("a restart while the user is answering is rejected as busy", () => {
const asked = transition(initialState(), { type: "before.quit", confirm: true }).state
test("a restart while the notice is open is rejected as busy", () => {
const asked = transition(initialState(), { type: "before.quit", notify: true }).state
const caller = reply()
expect(transition(asked, { type: "restart.requested", keep, handoff, reply: caller })).toEqual({
+20 -20
View File
@@ -14,8 +14,8 @@ export type Reply = { readonly resolve: () => void; readonly reject: (cause: unk
*/
export type State =
| { readonly status: "running"; readonly attempt: number }
/** The user quit for the first time and is choosing whether the background service outlives the app. */
| { readonly status: "confirming"; readonly attempt: number }
/** The user quit for the first time and is reading that the background service keeps running after the app. */
| { readonly status: "notifying"; readonly attempt: number }
/** Every plugin but `keep` is stopping for a restart handoff; without `handoff` the app relaunches next. */
| {
readonly status: "suspending"
@@ -42,9 +42,9 @@ export type Event =
| { readonly type: "exit.requested"; readonly mode: ExitMode }
/** Windows logoff or shutdown (`session-end` on each window); Electron emits no `before-quit` for it. */
| { readonly type: "session.ending" }
/** `confirm` asks the user first, once, whether the background service should keep running after quit. */
| { readonly type: "before.quit"; readonly confirm?: boolean }
| { readonly type: "quit.confirmed"; readonly attempt: number }
/** `notify` first tells the user, once, that the background service keeps running after quit. */
| { readonly type: "before.quit"; readonly notify?: boolean }
| { readonly type: "notice.dismissed"; readonly attempt: number }
| { readonly type: "will.quit" }
export type RejectReason =
@@ -53,7 +53,7 @@ export type RejectReason =
| { readonly kind: "quitting" }
export type Command =
| { readonly type: "confirm-quit"; readonly attempt: number }
| { readonly type: "show-quit-notice"; readonly attempt: number }
| { readonly type: "stop-plugins"; readonly attempt: number }
| { readonly type: "run-handoff"; readonly attempt: number; readonly handoff: Handoff }
| { readonly type: "resume-plugins"; readonly attempt: number }
@@ -81,19 +81,19 @@ export function transition(state: State, event: Event): Transition {
// A quit that starts while the handoff runs is the handoff's own (quitAndInstall quits before its promise settles),
// and the plugins it must not wait for are already stopped.
if (event.type === "before.quit") {
if (state.status === "handing-off" || state.status === "confirming") return unchanged(state)
if (state.status === "handing-off" || state.status === "notifying") return unchanged(state)
if (state.status === "running" && event.confirm)
if (state.status === "running" && event.notify)
return {
state: { status: "confirming", attempt: state.attempt + 1 },
commands: [{ type: "confirm-quit", attempt: state.attempt + 1 }],
state: { status: "notifying", attempt: state.attempt + 1 },
commands: [{ type: "show-quit-notice", attempt: state.attempt + 1 }],
}
return exitRequested(state, "quit")
}
if (event.type === "quit.confirmed")
return state.status === "confirming" && state.attempt === event.attempt
if (event.type === "notice.dismissed")
return state.status === "notifying" && state.attempt === event.attempt
? exitRequested(state, "quit")
: unchanged(state)
@@ -104,7 +104,7 @@ export function transition(state: State, event: Event): Transition {
/** The app starts no plugin, and the windows that close are restored on the next launch. */
export const isQuitting = (state: State) =>
state.status !== "running" && state.status !== "resuming" && state.status !== "confirming"
state.status !== "running" && state.status !== "resuming" && state.status !== "notifying"
/** The scope a stop leaves running: the restart caller's, until the handoff settled. */
export const keptScope = (state: State) =>
@@ -113,7 +113,7 @@ export const keptScope = (state: State) =>
/** The macOS dock reopens windows until a real quit starts; a restart handoff can still fail and keep the app. */
export const restoresWindows = (state: State) => state.status !== "stopping" && state.status !== "quitting"
/** Electron's `before-quit` waits for the user's answer and for the plugins to stop first. */
/** Electron's `before-quit` waits for the user to dismiss the notice and for the plugins to stop first. */
export const preventsQuit = (state: State) => state.status !== "handing-off" && state.status !== "quitting"
/**
@@ -175,7 +175,7 @@ function willQuit(state: State): Transition {
function stepSettled(
state: State,
event: Exclude<Extract<Event, { readonly attempt: number }>, { type: "quit.confirmed" }>,
event: Exclude<Extract<Event, { readonly attempt: number }>, { type: "notice.dismissed" }>,
): Transition {
if (state.status === "quitting" || state.attempt !== event.attempt) return unchanged(state)
@@ -240,8 +240,8 @@ export function createDriver(input: {
readonly stop: () => Promise<void>
/** Starts the plugins a handoff stopped again; never rejects. */
readonly resume: () => Promise<void>
/** Asks the user the first-quit question and stores the answer; never rejects. */
readonly confirm: () => Promise<void>
/** Shows the first-quit notice and remembers it was shown; never rejects. */
readonly notice: () => Promise<void>
readonly exit: (mode: ExitMode) => void
}) {
const current = { state: initialState() }
@@ -259,10 +259,10 @@ export function createDriver(input: {
return void input.stop().then(done, done)
}
if (command.type === "confirm-quit") {
const done = () => dispatch({ type: "quit.confirmed", attempt: command.attempt })
if (command.type === "show-quit-notice") {
const done = () => dispatch({ type: "notice.dismissed", attempt: command.attempt })
return void input.confirm().then(done, done)
return void input.notice().then(done, done)
}
if (command.type === "run-handoff")
@@ -13,36 +13,29 @@ export function getKeepServiceRunning() {
export function setKeepServiceRunning(enabled: boolean) {
getStore().set(KEEP_SERVICE_RUNNING_KEY, enabled)
// Choosing in Settings answers the first-quit question too.
// Someone who chose in Settings already knows; the first-quit notice has nothing to add.
getStore().set(SERVICE_QUIT_NOTICE_SHOWN_KEY, true)
}
/** The first quit explains that the service keeps running, and offers to stop it instead. */
/** The first quit tells the user that the service keeps running, and where to change that. */
export function isQuitNoticePending() {
return getStore().get(SERVICE_QUIT_NOTICE_SHOWN_KEY) !== true
}
const KEEP = 0
const STOP = 1
/** Asks once and stores the answer. Dismissing the dialog keeps the default: the service keeps running. */
export async function confirmFirstQuit() {
/** Shown once; the quit continues whatever the user does with it. */
export async function showQuitNotice() {
const win = getLastFocusedWindow()
const options: Electron.MessageBoxOptions = {
type: "info",
buttons: [nativeT("desktop.quit.serviceNotice.keepRunning"), nativeT("desktop.quit.serviceNotice.stopOnQuit")],
defaultId: KEEP,
cancelId: KEEP,
buttons: [nativeT("desktop.quit.serviceNotice.ok")],
defaultId: 0,
cancelId: 0,
message: nativeT("desktop.quit.serviceNotice.message"),
detail: nativeT("desktop.quit.serviceNotice.detail"),
}
// The last window is already closed when closing it quits the app (Windows, Linux), so the dialog may have no parent.
const { response } = await (win && win.isVisible()
? dialog.showMessageBox(win, options)
: dialog.showMessageBox(options))
setKeepServiceRunning(response !== STOP)
await (win && win.isVisible() ? dialog.showMessageBox(win, options) : dialog.showMessageBox(options))
getStore().set(SERVICE_QUIT_NOTICE_SHOWN_KEY, true)
}