Skip to content

Commit 7040eed

Browse files
authored
fix: pin subagent model and unify desktop UI (#211)
## Related Issue Resolve #209 Resolve #210 ## Problem The subagent-model setting looked authoritative, but it configured only a default. An explicit primary-model request could still override it. The shared sidebar selected different banner assets by theme instead of using the approved dark banner across browser and desktop builds. The desktop updater did not match the approved prompt and progress design. Electron's differential download could also fail and fall back to a second full transfer, which restarted the visible progress. ## What changed - Add an accessible switch that writes the canonical v2 `defaultModel` and `force` fields while reading legacy `model` values. - Use `pythinker_banner_dark.svg` in the shared web, macOS, and Windows sidebar, and remove the unused light code-banner asset. - Match the reference updater prompt and progress layout with Pythinker branding. - Force one full update transfer and ignore late progress events after the update is ready. - Keep an available update downloadable when automatic checks are enabled. - Add regression coverage and rebuild the committed web bundle. ## Verification - `pnpm test apps/pythinker-web/test` — 84 files, 999 tests passed. - `pnpm test apps/desktop/tests` — 14 files, 170 tests passed. - Web and desktop typechecks, `tsgo`, and `pnpm lint` — passed with 0 errors. - `pnpm run build:web` and `pnpm run check:web` — 673 bundle entries verified at source hash `f88e4160eddd`. - Built-browser security checks — Mermaid and Monaco sanitization passed. - The signed macOS package passed strict `codesign` verification and contains a byte-identical copy of the rebuilt web bundle. - Available and downloading states were visually inspected against the supplied desktop references. ## Checklist - [x] I have read the [CONTRIBUTING](https://github.com/PyModel/pythinker-code/blob/main/CONTRIBUTING.md) document. - [x] I have linked related issues. - [x] I have added tests that prove the changes work. - [x] Ran `gen-changesets` skill. - [x] This PR needs no documentation update. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Added options to select and force a default model for subagents. - Added support for additional diagram types and editor languages. - Added automatic update-check controls and clearer update download progress. - **Improvements** - Updated the update dialog with release dates, branded styling, and responsive layout. - Standardized the sidebar with a consistent dark banner. - Improved update state handling and download reliability. - **Bug Fixes** - Prevented update progress events from showing incorrect download states. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent bd0eadd commit 7040eed

114 files changed

Lines changed: 562 additions & 652 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.changeset/pin-subagent-model.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@pymodel/pythinker-code": patch
3+
---
4+
5+
Add a setting to pin every subagent to the selected model, use the dark banner in every sidebar, and show Pythinker desktop updates as one continuous download.

apps/desktop/src/updater.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,7 @@ function hasUpdateConfig(): boolean {
264264
function configureExplicitConsent(): void {
265265
autoUpdater.autoDownload = false
266266
autoUpdater.autoInstallOnAppQuit = false
267+
autoUpdater.disableDifferentialDownload = true
267268
autoUpdater.channel = settings.channel === 'stable' ? STABLE_UPDATER_CHANNEL : settings.channel
268269
autoUpdater.allowPrerelease = settings.channel !== 'stable'
269270
autoUpdater.allowDowngrade = false
@@ -346,6 +347,7 @@ function wireUpdaterEvents(): void {
346347
})
347348
})
348349
autoUpdater.on('download-progress', (progress: ProgressInfo) => {
350+
if (state.status !== 'downloading') return
349351
updateState({
350352
status: 'downloading',
351353
percent: progress.percent,
@@ -444,7 +446,7 @@ export function setAutoUpdate(enabled: boolean): UpdateState {
444446
}
445447
if (enabled) {
446448
scheduleChecks()
447-
if (!wasEnabled) void checkForUpdatesNow()
449+
if (!wasEnabled && state.availableVersion === undefined) void checkForUpdatesNow()
448450
} else {
449451
clearTimers()
450452
}

apps/desktop/tests/updater.spec.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ vi.mock('electron-updater', () => ({
3030
checkForUpdates: vi.fn(),
3131
downloadUpdate: vi.fn(() => Promise.resolve([])),
3232
quitAndInstall: vi.fn(),
33+
disableDifferentialDownload: false,
3334
},
3435
},
3536
}))
@@ -69,6 +70,7 @@ afterEach(() => {
6970
autoUpdater.autoInstallOnAppQuit = undefined as unknown as boolean
7071
autoUpdater.allowPrerelease = undefined as unknown as boolean
7172
autoUpdater.allowDowngrade = undefined as unknown as boolean
73+
autoUpdater.disableDifferentialDownload = false
7274
;(autoUpdater as unknown as { _channel: string | null })._channel = null
7375
})
7476

@@ -241,6 +243,7 @@ describe('strict update consent', () => {
241243
expect(localAutoUpdater.allowDowngrade).toBe(false)
242244
expect(localAutoUpdater.autoDownload).toBe(false)
243245
expect(localAutoUpdater.autoInstallOnAppQuit).toBe(false)
246+
expect(localAutoUpdater.disableDifferentialDownload).toBe(true)
244247
expect(localAutoUpdater.checkForUpdates).not.toHaveBeenCalled()
245248
expect(localAutoUpdater.downloadUpdate).not.toHaveBeenCalled()
246249
expect(localAutoUpdater.quitAndInstall).not.toHaveBeenCalled()
@@ -339,6 +342,7 @@ describe('strict update consent', () => {
339342
const {
340343
getUpdateState: getLocalUpdateState,
341344
initUpdater: initLocalUpdater,
345+
startUpdateDownload: startLocalUpdateDownload,
342346
} = await import('../src/updater')
343347
vi.mocked(localApp.getPath).mockReturnValue(directory)
344348
Object.defineProperty(localApp, 'isPackaged', { configurable: true, value: true })
@@ -366,6 +370,7 @@ describe('strict update consent', () => {
366370
releaseDate: '2026-08-22T12:00:00.000Z',
367371
releaseNotes: [{ note: 'First change' }, { note: null }, { note: 'Second change' }],
368372
})
373+
startLocalUpdateDownload()
369374
progress?.({ percent: 42.5, transferred: 425, total: 1_000, bytesPerSecond: 85 })
370375

371376
expect(getLocalUpdateState()).toMatchObject({
@@ -381,6 +386,38 @@ describe('strict update consent', () => {
381386
})
382387
})
383388

389+
it('keeps an available update downloadable when automatic checks are enabled', async () => {
390+
vi.resetModules()
391+
const directory = temporaryDirectory()
392+
writeFileSync(join(directory, 'app-update.yml'), '', 'utf8')
393+
writeFileSync(join(directory, 'update-settings.json'), '{"autoUpdate":false}\n', 'utf8')
394+
const { app: localApp } = await import('electron')
395+
const { default: localElectronUpdater } = await import('electron-updater')
396+
const {
397+
getUpdateState: getLocalUpdateState,
398+
initUpdater: initLocalUpdater,
399+
setAutoUpdate: setLocalAutoUpdate,
400+
startUpdateDownload: startLocalUpdateDownload,
401+
} = await import('../src/updater')
402+
const localAutoUpdater = localElectronUpdater.autoUpdater
403+
vi.mocked(localApp.getPath).mockReturnValue(directory)
404+
Object.defineProperty(localApp, 'isPackaged', { configurable: true, value: true })
405+
Object.defineProperty(process, 'resourcesPath', { configurable: true, value: directory })
406+
407+
initLocalUpdater(() => undefined)
408+
const available = vi.mocked(localAutoUpdater.on).mock.calls.find(
409+
([event]) => event === 'update-available',
410+
)?.[1] as ((info: { version: string }) => void) | undefined
411+
available?.({ version: '1.2.3' })
412+
413+
expect(setLocalAutoUpdate(true)).toMatchObject({ status: 'available', availableVersion: '1.2.3' })
414+
expect(localAutoUpdater.checkForUpdates).not.toHaveBeenCalled()
415+
expect(startLocalUpdateDownload()).toMatchObject({ status: 'downloading' })
416+
expect(localAutoUpdater.downloadUpdate).toHaveBeenCalledOnce()
417+
setLocalAutoUpdate(false)
418+
expect(getLocalUpdateState()).toMatchObject({ status: 'downloading' })
419+
})
420+
384421
it('downloads and installs only through separate explicit actions', async () => {
385422
vi.resetModules()
386423
const directory = temporaryDirectory()
@@ -420,6 +457,55 @@ describe('strict update consent', () => {
420457
expect(readUpdateSettings(directory)).toMatchObject({ pendingInstallVersion: '1.2.3' })
421458
})
422459

460+
it('does not return to downloading after the update is ready', async () => {
461+
vi.resetModules()
462+
const directory = temporaryDirectory()
463+
writeFileSync(join(directory, 'app-update.yml'), '', 'utf8')
464+
writeFileSync(join(directory, 'update-settings.json'), '{"autoUpdate":false}\n', 'utf8')
465+
const { app: localApp } = await import('electron')
466+
const { default: localElectronUpdater } = await import('electron-updater')
467+
const {
468+
getUpdateState: getLocalUpdateState,
469+
initUpdater: initLocalUpdater,
470+
startUpdateDownload: startLocalUpdateDownload,
471+
} = await import('../src/updater')
472+
const localAutoUpdater = localElectronUpdater.autoUpdater
473+
vi.mocked(localApp.getPath).mockReturnValue(directory)
474+
Object.defineProperty(localApp, 'isPackaged', { configurable: true, value: true })
475+
Object.defineProperty(process, 'resourcesPath', { configurable: true, value: directory })
476+
477+
initLocalUpdater(() => undefined)
478+
const available = vi.mocked(localAutoUpdater.on).mock.calls.find(
479+
([event]) => event === 'update-available',
480+
)?.[1] as ((info: { version: string }) => void) | undefined
481+
const progress = vi.mocked(localAutoUpdater.on).mock.calls.find(
482+
([event]) => event === 'download-progress',
483+
)?.[1] as ((info: {
484+
percent: number
485+
transferred: number
486+
total: number
487+
bytesPerSecond: number
488+
}) => void) | undefined
489+
const downloaded = vi.mocked(localAutoUpdater.on).mock.calls.find(
490+
([event]) => event === 'update-downloaded',
491+
)?.[1] as ((info: { version: string }) => void) | undefined
492+
493+
available?.({ version: '1.2.3' })
494+
startLocalUpdateDownload()
495+
progress?.({ percent: 100, transferred: 1_000, total: 1_000, bytesPerSecond: 80 })
496+
downloaded?.({ version: '1.2.3' })
497+
progress?.({ percent: 1, transferred: 10, total: 1_000, bytesPerSecond: 20 })
498+
499+
expect(getLocalUpdateState()).toMatchObject({
500+
status: 'downloaded',
501+
availableVersion: '1.2.3',
502+
percent: 100,
503+
transferred: 1_000,
504+
total: 1_000,
505+
})
506+
expect(localAutoUpdater.downloadUpdate).toHaveBeenCalledOnce()
507+
})
508+
423509
it('does not let a scheduled check overwrite a downloaded update', async () => {
424510
vi.useFakeTimers()
425511
try {
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
{
2-
"sourceHash": "965c37d0a139928c9e6107941d28eb228a6be4d212d1bbd110338a83ef8646a2",
3-
"sourceFileCount": 404
2+
"sourceHash": "f88e4160eddd99fef98d31820eb0a5e660fc8209a97edb99e62b445d7f9b7365",
3+
"sourceFileCount": 403
44
}

0 commit comments

Comments
 (0)