Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/fork-features.yml
Original file line number Diff line number Diff line change
Expand Up @@ -853,7 +853,7 @@ features:
- Nightly release previews retain curated highlights and footer filtering while showing newest entries with counts and links to styal releases.
- Desktop activity authentication remains available for the host process lifetime, including the first quit after days of uptime and renewal after bearer expiry. Signing in for an activity check never revokes the desktop window's session.
- In-app shutdown dialogs appear only for active or unknown work and list affected thread titles under their project icons/names and host labels, with threads that continue after the restart in their own section. Which threads continue comes from the server's activity report, which applies each project's setting and excludes runs the user asked to stop, native maintenance commands, and threads at the limit of automatic continuations in a row, as delivery after the restart does; desktop quits say they continue when styal next starts, and desktop relaunches that turn off or replace a backend report every thread as interrupted. Each thread has a badge per turn, input, approval, or background-work reason from the same snapshot as the counts; older servers retain counts and explicitly report unavailable details, and a reason the client does not recognize is dropped rather than failing the check. Terminal activity is counted and inspection warnings appear only for activity that could not be checked.
- Manual desktop quits and restarts check every owned backend; active or unknown host activity requires confirmation, and cancellation preserves windows and downloaded updates. The readiness warning appears only when some work will be interrupted or could not be checked.
- Local desktop restarts skip confirmation only when every owned backend check succeeds and no work would be interrupted; resumable turns are allowed, while running or unknown terminal activity, waiting requests, non-continuable turns, provider installs and shared devices require confirmation. Quit still confirms active or resumable work because continuation waits until the next launch. Cancellation preserves windows and downloaded updates.
- Shutdown confirmation has one renderer-wide host, including on recovery screens; cancelling an update restart dismisses the request without reopening it. Failed presentation alone arms a subsequent Quit override that preserves cleanup, cannot be consumed by restart actions, and clears when the renderer recovers or displays a dialog.
- Available desktop updates download automatically while preserving accurate available, downloading, downloaded, install, and retry state across updater events and pre-install refreshes.
- Downloaded updates install on normal app quit; the sidebar offers a plain restart arrow without a completion checkmark, using the theme update surface and foreground once an installer is ready, while Settings presents automatic checks and downloads as status and reserves download actions for failure retries.
Expand Down
94 changes: 89 additions & 5 deletions apps/desktop/src/app/DesktopShutdownGuard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,93 @@ function harness(
}

describe("DesktopShutdownGuard", () => {
it.effect("restarts resumable work without consent but still lets the user cancel Quit", () => {
const h = harness({
activity: () =>
Effect.succeed({
...idle,
activeSessions: 1,
continuableSessions: 1,
threads: [
{
threadId: ThreadId.make("release"),
title: "Prepare release",
reasons: ["running"],
continuable: true,
},
],
}),
});
return Effect.gen(function* () {
const guard = yield* Guard.DesktopShutdownGuard;
assert.isTrue(yield* guard.confirm("restart"));
assert.equal(h.checks(), 2);
assert.isEmpty(h.messages);

assert.isFalse(yield* guard.confirm("quit"));
assert.equal(h.checks(), 4);
assert.lengthOf(h.messages, 1);
assert.include(h.messages[0]!, "Quit with running work?");
assert.include(h.messages[0]!, "2 threads will continue when styal next starts.");
assert.deepEqual(
h.details[0]?.map((item) => item.continuesAfterRestart),
[true, true],
);

assert.isTrue(yield* guard.confirm("restart"));
assert.equal(h.checks(), 6);
assert.lengthOf(h.messages, 1);

assert.isFalse(yield* guard.confirm("restart", { continueThreads: false }));
assert.lengthOf(h.messages, 2);
assert.include(h.messages[1]!, "2 threads will be interrupted.");
}).pipe(Effect.provide(h.layer));
});

it.effect.each([
{ name: "waiting requests or secrets", blocker: { waitingSessions: 1 } },
{ name: "non-continuable turns", blocker: { activeSessions: 2 } },
{ name: "terminal commands", blocker: { terminalsRequiringConfirmation: 1 } },
{
name: "unknown terminal activity",
blocker: { terminalsRequiringConfirmation: 1, terminalsWithUnknownActivity: 1 },
},
{ name: "provider installs", blocker: { providerInstalls: 1 } },
{ name: "shared devices", blocker: { environmentsUsingDevices: 1 } },
])("asks before restarting resumable turns alongside $name", ({ blocker }) => {
const h = harness({
activity: (port) =>
Effect.succeed({
...idle,
activeSessions: 1,
continuableSessions: 1,
...(port === "3774" ? blocker : {}),
}),
});
return Effect.gen(function* () {
const guard = yield* Guard.DesktopShutdownGuard;
assert.isFalse(yield* guard.confirm("restart"));
assert.equal(h.checks(), 2);
assert.lengthOf(h.messages, 1);
assert.include(h.messages[0]!, "Make sure you're ready before continuing.");
}).pipe(Effect.provide(h.layer));
});

it.effect("does not infer a safe restart from the hosts that answered successfully", () => {
const h = harness({
activity: (port) =>
Effect.succeed(
port === "3773" ? { ...idle, activeSessions: 1, continuableSessions: 1 } : {},
),
});
return Effect.gen(function* () {
const guard = yield* Guard.DesktopShutdownGuard;
assert.isFalse(yield* guard.confirm("restart"));
assert.include(h.messages[0]!, "Could not check every local environment");
assert.equal(h.checks(), 2);
}).pipe(Effect.provide(h.layer));
});

it.effect("names affected threads from each owned backend and preserves cancellation", () => {
const h = harness({
activity: (port) =>
Expand Down Expand Up @@ -358,7 +445,7 @@ describe("shutdown confirmation copy", () => {
[true, false],
);
});
it("drops the warning when every thread continues, and says when for quit", () => {
it("skips restart confirmation when every thread continues, and says when for quit", () => {
const check = {
...idle,
activeSessions: 1,
Expand All @@ -368,10 +455,7 @@ describe("shutdown confirmation copy", () => {
const quit = Guard.shutdownConfirmation([check], "quit");
assert.strictEqual(quit?.message, "Quit with running work?");
assert.strictEqual(quit?.detail, "1 thread will continue when styal next starts.");
assert.strictEqual(
Guard.shutdownConfirmation([check], "restart")?.detail,
"1 thread will continue after the restart.",
);
assert.isNull(Guard.shutdownConfirmation([check], "restart"));
});
it("keeps the warning when a continuing thread shares the restart with a waiting one", () => {
const confirmation = Guard.shutdownConfirmation(
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/app/DesktopShutdownGuard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ export function shutdownConfirmation(
continueRunningThreads: continueThreads,
continuesWhen: action === "quit" ? "when styal next starts" : "after the restart",
});
if (concern === null) return null;
if (concern === null || (action === "restart" && !interrupts)) return null;
const detail = incomplete
? [
...lines,
Expand Down
4 changes: 3 additions & 1 deletion docs/user/styal-differences.md
Original file line number Diff line number Diff line change
Expand Up @@ -337,7 +337,9 @@ works. This overview focuses on desktop and web; the mobile app is a work in pro
- **Confirm only when quitting may interrupt work.** Desktop quits and restarts check agents and
terminal work across the environments hosted by the app, including work started from other devices
and agents in other environments using its shared devices.
Idle hosts proceed without a dialog; active work or an unavailable check requires an in-app confirmation.
Local restarts proceed without a dialog when all active threads will resume and no other work
would be interrupted. Quit still asks about active work. Terminal commands, other interruptions,
and an unavailable check require confirmation.
The **Update server** action checks the connected server the same way before it restarts.
Compared with [T3 Code revision d4d5d12](https://github.com/pingdotgg/t3code/tree/d4d5d12e8ba086cfbf79ca3adeb4156b46ead665),
whose desktop lifecycle does not check host activity before quitting, and
Expand Down
7 changes: 5 additions & 2 deletions docs/user/updating.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,8 +144,11 @@ next time you look at its window, with a link to that version's release notes.

Before quitting or restarting, styal checks agents and terminal commands in the
environments the desktop app hosts, including WSL and work started from other
devices. If work is active or cannot be checked, styal lists it and lets you
cancel or continue; cancelling keeps the downloaded update. If the confirmation
devices. A restart proceeds directly when every check succeeds and all active
threads can continue. Terminal commands, waiting requests and unchecked activity
still require confirmation. Quit asks about active work even when threads can
continue, because they resume only when styal next starts. You can cancel or
continue; cancelling keeps the downloaded update. If the confirmation
cannot be displayed and the app stays unresponsive, choose **Quit** again after
five seconds to exit. This can interrupt running work.

Expand Down
Loading