mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-15 04:27:20 +03:00
feat(desktop): make the first-quit dialog a notice that points to Settings
This commit is contained in:
committed by
GitHub
Unverified
parent
aa7336d557
commit
31137a5782
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user