diff --git a/packages/app/src/runtime/i18n/desktop-native.ts b/packages/app/src/runtime/i18n/desktop-native.ts index cea4ab0a17c..22cb8015087 100644 --- a/packages/app/src/runtime/i18n/desktop-native.ts +++ b/packages/app/src/runtime/i18n/desktop-native.ts @@ -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 diff --git a/packages/desktop/src/main/lifecycle/index.ts b/packages/desktop/src/main/lifecycle/index.ts index 427a61bf34c..77143d4d013 100644 --- a/packages/desktop/src/main/lifecycle/index.ts +++ b/packages/desktop/src/main/lifecycle/index.ts @@ -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" }) diff --git a/packages/desktop/src/main/lifecycle/machine.test.ts b/packages/desktop/src/main/lifecycle/machine.test.ts index b3c362d8c6d..87b72ef3a63 100644 --- a/packages/desktop/src/main/lifecycle/machine.test.ts +++ b/packages/desktop/src/main/lifecycle/machine.test.ts @@ -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({ diff --git a/packages/desktop/src/main/lifecycle/machine.ts b/packages/desktop/src/main/lifecycle/machine.ts index eae100ad5fd..2b890202c54 100644 --- a/packages/desktop/src/main/lifecycle/machine.ts +++ b/packages/desktop/src/main/lifecycle/machine.ts @@ -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, { type: "quit.confirmed" }>, + event: Exclude, { 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 /** Starts the plugins a handoff stopped again; never rejects. */ readonly resume: () => Promise - /** Asks the user the first-quit question and stores the answer; never rejects. */ - readonly confirm: () => Promise + /** Shows the first-quit notice and remembers it was shown; never rejects. */ + readonly notice: () => Promise 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") diff --git a/packages/desktop/src/main/service/quit-behavior.ts b/packages/desktop/src/main/service/quit-behavior.ts index 0c072a5374e..6f8777d7c10 100644 --- a/packages/desktop/src/main/service/quit-behavior.ts +++ b/packages/desktop/src/main/service/quit-behavior.ts @@ -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) }