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 |