diff --git a/apps/desktop/src/updates/DesktopUpdates.test.ts b/apps/desktop/src/updates/DesktopUpdates.test.ts
index 32224c7a5ca..13b84f7960c 100644
--- a/apps/desktop/src/updates/DesktopUpdates.test.ts
+++ b/apps/desktop/src/updates/DesktopUpdates.test.ts
@@ -274,7 +274,7 @@ describe("DesktopUpdates", () => {
}).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));
});
- it.effect("updates and broadcasts state from updater events", () => {
+ it.effect("downloads and broadcasts an available update immediately", () => {
const harness = makeHarness();
return Effect.scoped(
@@ -286,10 +286,10 @@ describe("DesktopUpdates", () => {
yield* flushCallbacks;
const state = yield* updates.getState;
- assert.equal(state.status, "available");
+ assert.equal(state.status, "downloading");
assert.equal(state.availableVersion, "1.2.4");
assert.isNotNull(state.checkedAt);
- assert.equal(harness.sentStates.at(-1)?.status, "available");
+ assert.equal(harness.sentStates.at(-1)?.status, "downloading");
}),
).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));
});
@@ -321,7 +321,7 @@ describe("DesktopUpdates", () => {
yield* flushCallbacks;
const state = yield* updates.getState;
- assert.equal(state.status, "available");
+ assert.equal(state.status, "downloading");
assert.deepEqual(state.releaseNotes, [
{
version: "1.2.4-nightly.20260709.766",
@@ -407,7 +407,7 @@ describe("DesktopUpdates", () => {
);
});
- it.effect("recovers download state after an unexpected setup failure", () => {
+ it.effect("leaves an automatic download available to retry after setup failure", () => {
let disableDifferentialCalls = 0;
const harness = makeHarness({
setDisableDifferentialDownload: Effect.suspend(() => {
@@ -425,10 +425,6 @@ describe("DesktopUpdates", () => {
harness.emit("update-available", { version: "1.2.4" });
yield* flushCallbacks;
- const result = yield* updates.download;
- assert.isTrue(result.accepted);
- assert.isFalse(result.completed);
-
const failedState = yield* updates.getState;
assert.equal(failedState.status, "available");
assert.equal(failedState.errorContext, "download");
@@ -451,6 +447,9 @@ describe("DesktopUpdates", () => {
return Effect.void;
}
if (disableDifferentialCalls === 2) {
+ return Effect.die(new Error("automatic download setup failed"));
+ }
+ if (disableDifferentialCalls === 3) {
return Deferred.succeed(actionStarted, undefined).pipe(Effect.andThen(Effect.never));
}
return Effect.void;
@@ -470,7 +469,10 @@ describe("DesktopUpdates", () => {
const interruptedState = yield* updates.getState;
assert.equal(interruptedState.status, "available");
- assert.isNull(interruptedState.message);
+ assert.equal(
+ interruptedState.message,
+ "Desktop update download action failed unexpectedly.",
+ );
const retry = yield* updates.download;
assert.isTrue(retry.accepted);
diff --git a/apps/desktop/src/updates/DesktopUpdates.ts b/apps/desktop/src/updates/DesktopUpdates.ts
index 7357907e178..06070ceaa84 100644
--- a/apps/desktop/src/updates/DesktopUpdates.ts
+++ b/apps/desktop/src/updates/DesktopUpdates.ts
@@ -575,7 +575,8 @@ export const make = Effect.gen(function* () {
const checkedAt = yield* currentIsoTimestamp;
const releaseNotes = normalizeDesktopUpdateReleaseNotes(info.releaseNotes, info.version);
- yield* setState(
+ yield* Ref.set(
+ updateStateRef,
reduceDesktopUpdateStateOnUpdateAvailable(state, info.version, checkedAt, releaseNotes),
);
yield* Ref.set(lastLoggedDownloadMilestoneRef, -1);
@@ -583,6 +584,7 @@ export const make = Effect.gen(function* () {
version: info.version,
releaseNoteGroups: releaseNotes.length,
});
+ yield* downloadAvailableUpdate;
}),
),
Effect.catchCause((cause) => {
diff --git a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx
index 191f30438dd..838d2a1d9f7 100644
--- a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx
+++ b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx
@@ -243,7 +243,7 @@ function SidebarUpdateControl() {
type="button"
aria-label={tooltip}
aria-disabled={disabled || isActionPending || undefined}
- disabled={disabled || isActionPending}
+ disabled={isActionPending || (!isUpdateState && disabled)}
className={cn(
"inline-flex size-8 items-center justify-center rounded-full outline-hidden ring-ring transition-colors enabled:cursor-pointer focus-visible:ring-2 disabled:cursor-not-allowed disabled:opacity-60",
isUpdateState
@@ -254,6 +254,8 @@ function SidebarUpdateControl() {
>
{action === "install" ? (
+ ) : isDownloading ? (
+
) : isUpdateState ? (
) : (
diff --git a/docs/user/updating.md b/docs/user/updating.md
index d6c6dfd1c85..d303be38a52 100644
--- a/docs/user/updating.md
+++ b/docs/user/updating.md
@@ -20,6 +20,12 @@ connection will disappear briefly and work that is still running may be interrup
The update does not remove saved threads, settings, or project files.
+## Desktop App Updates
+
+The desktop app downloads its own updates in the background. Once an update is ready, select
+**Restart to update** when you are ready to close T3 Code. T3 Code always asks for confirmation
+before restarting, and a failed background download remains available to retry.
+
## Choose the Action You See
| Action | What to do |