Skip to content

Commit fd84c70

Browse files
big-guyclaude
andauthored
feat: release-notes deep links across the updater UI (#35)
Adds a harnessReleaseNotesUrl(version) helper plus HARNESS_SITE_URL / HARNESS_SITE_RELEASES_URL constants, and wires version-anchored release-notes links throughout: - site/public/releases.html: every version <h2> is now a self-link to its #vX.Y.Z anchor with a hover-revealed link icon; release.sh generates the same markup (and id anchor) for new entries. - Settings → Updates: the current version and the updater status messages ("Harness ### available / downloading / ready to install") link to that version's release notes. - The top update-ready banner's "Harness ###" text links too. Dev tooling: a dev-gated updater:devSimulate handler + buttons in Settings → Updates to fake updater states (the auto-updater is a no-op in dev), and `version` added to the 'downloading' updater status so the link resolves mid-download. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 0c7d95d commit fd84c70

11 files changed

Lines changed: 165 additions & 65 deletions

File tree

scripts/release.sh

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,8 @@ div opening, before the first existing release section).
177177
178178
Rules:
179179
- Match the exact HTML structure, CSS classes, and formatting of existing entries.
180+
- The new <section class=\"release-section py-10\"> MUST include id=\"${TAG}\" (e.g. id=\"v1.2.3\") so it can be deep-linked from the in-app updater UI.
181+
- The version <h2> MUST wrap its text in a self-link matching existing entries, e.g.: <h2 class=\"text-3xl font-bold tracking-tight\"><a href=\"#${TAG}\" class=\"release-anchor\">${TAG}<span class=\"anchor-icon\">🔗</span></a></h2>
180182
- Rewrite commit messages into user-facing release notes. Write for users, not developers.
181183
- Group under h4 headings: \"New features\", \"Improvements\", \"Fixes\" — only include sections that have content.
182184
- Skip meta commits: version bumps, README updates, CI fixes, squash labels.

site/public/releases.html

Lines changed: 59 additions & 55 deletions
Large diffs are not rendered by default.

src/main/desktop-shell.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -186,8 +186,10 @@ export function startDesktopShell(deps: DesktopShellStartDeps): DesktopShellStar
186186
log('updater', 'checking for update')
187187
store.dispatch({ type: 'updater/statusChanged', payload: { state: 'checking' } })
188188
})
189+
let pendingUpdateVersion = ''
189190
autoUpdater.on('update-available', (info) => {
190191
log('updater', `update available ${info.version}${manualInstallRequired ? ' (manual install)' : ''}`)
192+
pendingUpdateVersion = info.version
191193
store.dispatch({
192194
type: 'updater/statusChanged',
193195
payload: {
@@ -215,7 +217,7 @@ export function startDesktopShell(deps: DesktopShellStartDeps): DesktopShellStar
215217
autoUpdater.on('download-progress', (p) => {
216218
store.dispatch({
217219
type: 'updater/statusChanged',
218-
payload: { state: 'downloading', percent: p.percent }
220+
payload: { state: 'downloading', percent: p.percent, version: pendingUpdateVersion }
219221
})
220222
})
221223
autoUpdater.on('update-downloaded', (info) => {
@@ -482,6 +484,25 @@ export function startDesktopShell(deps: DesktopShellStartDeps): DesktopShellStar
482484
}
483485
})
484486

487+
if (!app.isPackaged) {
488+
transport.onRequest('updater:devSimulate', (_ctx, state: string) => {
489+
const version = app.getVersion()
490+
if (state === 'available') {
491+
store.dispatch({ type: 'updater/statusChanged', payload: { state: 'available', version } })
492+
} else if (state === 'downloading') {
493+
store.dispatch({
494+
type: 'updater/statusChanged',
495+
payload: { state: 'downloading', percent: 42, version }
496+
})
497+
} else if (state === 'downloaded') {
498+
store.dispatch({ type: 'updater/statusChanged', payload: { state: 'downloaded', version } })
499+
} else {
500+
store.dispatch({ type: 'updater/statusChanged', payload: { state: 'not-available' } })
501+
}
502+
return true
503+
})
504+
}
505+
485506
transport.onRequest('updater:quitAndInstall', (_ctx) => {
486507
log('updater', 'quitAndInstall requested — tearing down before handing off to Squirrel')
487508
try {

src/renderer/App.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import { ErrorBoundary } from './components/ErrorBoundary'
4242
import { type GroupKey } from './worktree-sort'
4343
import { useViewport } from './hooks/useViewport'
4444
import { MobileApp } from './components/MobileApp'
45+
import { harnessReleaseNotesUrl } from '../shared/constants'
4546

4647
function isPendingId(id: string | null | undefined): id is string {
4748
return typeof id === 'string' && id.startsWith('pending:')
@@ -1026,7 +1027,13 @@ const setQuestStep = useCallback((next: QuestStep) => {
10261027
{updaterStatus?.state === 'downloaded' && !updateBannerDismissed && (
10271028
<div className="bg-success/15 border-b border-success/30 pl-20 pr-4 py-2.5 drag-region flex items-center gap-3 shrink-0">
10281029
<span className="text-success text-sm flex-1">
1029-
Harness {updaterStatus.version} is ready to install. Restart to update.
1030+
<a
1031+
onClick={() => backend.openExternal(harnessReleaseNotesUrl(updaterStatus.version))}
1032+
className="underline hover:text-success cursor-pointer no-drag"
1033+
>
1034+
Harness {updaterStatus.version}
1035+
</a>{' '}
1036+
is ready to install. Restart to update.
10301037
</span>
10311038
<button
10321039
onClick={handleUpdateRestart}

src/renderer/build-backend.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,8 @@ export function buildBackend(
334334
readRecentLog: (maxLines?: number) => req('debug:readRecentLog', maxLines),
335335
checkForUpdates: () => req('updater:checkForUpdates'),
336336
quitAndInstall: () => req('updater:quitAndInstall'),
337+
devSimulateUpdate: (state: 'available' | 'downloading' | 'downloaded' | 'clear') =>
338+
req('updater:devSimulate', state),
337339

338340
// Always-local: shell.openExternal opens on the local user's
339341
// machine, never on the remote backend. Same with debug log paths.

src/renderer/components/Settings.tsx

Lines changed: 58 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { useState, useEffect, useCallback, useRef, useMemo } from 'react'
22
import { ArrowLeft, Check, X, Eye, EyeOff, Star, RefreshCw, Download, RotateCw, GitPullRequest, DownloadCloud, Keyboard, RotateCcw, Terminal as TerminalIcon, Palette, BookOpen, Code2, GitBranch, Plus, Trash2, LifeBuoy, Bug, Lightbulb, FlaskConical, Copy, ExternalLink, CalendarDays, FileText, FolderOpen } from 'lucide-react'
33
import { openReportIssue } from './ReportIssueScreen'
4-
import { HARNESS_ISSUES_URL, HARNESS_RELEASES_URL } from '../../shared/constants'
4+
import { HARNESS_ISSUES_URL, HARNESS_RELEASES_URL, harnessReleaseNotesUrl } from '../../shared/constants'
55
import { useSettings, useUpdater, useRepoConfigs, useHooks } from '../store'
66
import { useBackend } from '../backend'
77
import type { UpdaterStatus, MergeStrategy, RepoConfig } from '../types'
@@ -833,22 +833,47 @@ export function Settings({ onClose, onOpenGuide, onOpenMyWeek, initialSection }:
833833
return (
834834
<div className="flex items-center gap-2 text-xs text-warning">
835835
<Download size={12} />
836-
Version {updaterStatus.version} available — downloading...
836+
<span>
837+
<a
838+
onClick={() => backend.openExternal(harnessReleaseNotesUrl(updaterStatus.version))}
839+
className="underline hover:text-fg-bright cursor-pointer"
840+
>
841+
Harness {updaterStatus.version}
842+
</a>{' '}
843+
available — downloading...
844+
</span>
837845
</div>
838846
)
839847
case 'downloading':
840848
return (
841849
<div className="flex items-center gap-2 text-xs text-warning">
842850
<Download size={12} />
843-
Downloading update... {Math.round(updaterStatus.percent)}%
851+
<span>
852+
Downloading{' '}
853+
<a
854+
onClick={() => backend.openExternal(harnessReleaseNotesUrl(updaterStatus.version))}
855+
className="underline hover:text-fg-bright cursor-pointer"
856+
>
857+
Harness {updaterStatus.version}
858+
</a>
859+
... {Math.round(updaterStatus.percent)}%
860+
</span>
844861
</div>
845862
)
846863
case 'downloaded':
847864
return (
848865
<div className="flex flex-col gap-2">
849866
<div className="flex items-center gap-2 text-xs text-success">
850867
<Check size={12} />
851-
Version {updaterStatus.version} ready to install
868+
<span>
869+
<a
870+
onClick={() => backend.openExternal(harnessReleaseNotesUrl(updaterStatus.version))}
871+
className="underline hover:text-fg-bright cursor-pointer"
872+
>
873+
Harness {updaterStatus.version}
874+
</a>{' '}
875+
ready to install
876+
</span>
852877
</div>
853878
<button
854879
onClick={handleRestart}
@@ -2034,7 +2059,16 @@ export function Settings({ onClose, onOpenGuide, onOpenMyWeek, initialSection }:
20342059
<div>
20352060
<div className="text-sm text-fg">Current version</div>
20362061
<div className="text-xs text-dim font-mono mt-0.5">
2037-
{version || '...'}
2062+
{version ? (
2063+
<a
2064+
onClick={() => backend.openExternal(harnessReleaseNotesUrl(version))}
2065+
className="underline hover:text-fg-bright cursor-pointer"
2066+
>
2067+
{version}
2068+
</a>
2069+
) : (
2070+
'...'
2071+
)}
20382072
</div>
20392073
</div>
20402074
<button
@@ -2053,6 +2087,25 @@ export function Settings({ onClose, onOpenGuide, onOpenMyWeek, initialSection }:
20532087
</div>
20542088
)}
20552089

2090+
{import.meta.env.DEV && (
2091+
<div className="mt-3 pt-3 border-t border-border">
2092+
<div className="text-[10px] uppercase tracking-wide text-faint mb-1.5">
2093+
Dev: simulate updater state
2094+
</div>
2095+
<div className="flex gap-1.5">
2096+
{(['available', 'downloading', 'downloaded', 'clear'] as const).map((s) => (
2097+
<button
2098+
key={s}
2099+
onClick={() => backend.devSimulateUpdate(s)}
2100+
className="px-2 py-1 bg-surface hover:bg-surface-hover rounded text-[11px] text-fg transition-colors cursor-pointer"
2101+
>
2102+
{s}
2103+
</button>
2104+
))}
2105+
</div>
2106+
</div>
2107+
)}
2108+
20562109
<div className="mt-4 pt-3 border-t border-border">
20572110
<label className="flex items-start gap-2 cursor-pointer">
20582111
<input

src/renderer/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -387,6 +387,8 @@ export interface ElectronAPI {
387387
readRecentLog(maxLines?: number): Promise<string>
388388
checkForUpdates(): Promise<{ ok: boolean; available?: boolean; version?: string; releaseDate?: string; error?: string }>
389389
quitAndInstall(): Promise<boolean>
390+
/** Dev-only: fakes an updater status so the update UI can be checked in `npm run dev`. */
391+
devSimulateUpdate(state: 'available' | 'downloading' | 'downloaded' | 'clear'): Promise<boolean>
390392

391393
getPerfMetrics(): Promise<PerfMetrics>
392394
perfLogSlowRender(id: string, ms: number, phase: string): void

src/renderer/vite-env.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
/// <reference types="vite/client" />

src/shared/constants.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,11 @@ export const HARNESS_REPO_URL = `https://github.com/${HARNESS_REPO_OWNER}/${HARN
44
export const HARNESS_NEW_ISSUE_URL = `${HARNESS_REPO_URL}/issues/new`
55
export const HARNESS_ISSUES_URL = `${HARNESS_REPO_URL}/issues`
66
export const HARNESS_RELEASES_URL = `${HARNESS_REPO_URL}/releases`
7+
8+
export const HARNESS_SITE_URL = 'https://harness.mikelyons.org'
9+
export const HARNESS_SITE_RELEASES_URL = `${HARNESS_SITE_URL}/releases.html`
10+
11+
export function harnessReleaseNotesUrl(version: string): string {
12+
const v = version.startsWith('v') ? version : `v${version}`
13+
return `${HARNESS_SITE_RELEASES_URL}#${v}`
14+
}

src/shared/state/updater.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,9 @@ describe('updaterReducer', () => {
2828
expect(state.status).toEqual({ state: 'available', version: '1.13.0' })
2929
state = updaterReducer(state, {
3030
type: 'updater/statusChanged',
31-
payload: { state: 'downloading', percent: 42.5 }
31+
payload: { state: 'downloading', percent: 42.5, version: '1.13.0' }
3232
})
33-
expect(state.status).toEqual({ state: 'downloading', percent: 42.5 })
33+
expect(state.status).toEqual({ state: 'downloading', percent: 42.5, version: '1.13.0' })
3434
state = updaterReducer(state, {
3535
type: 'updater/statusChanged',
3636
payload: { state: 'downloaded', version: '1.13.0' }

0 commit comments

Comments
 (0)