11import { useEffect , type CSSProperties } from 'react'
22import { createPortal } from 'react-dom'
3- import { AlertCircle , CheckCircle2 , Download , ExternalLink , LoaderCircle , X } from 'lucide-react'
3+ import { AlertCircle , CheckCircle2 , Download , ExternalLink } from 'lucide-react'
44
55import type { AppUpdateState } from '../../../shared/appUpdate'
66import { useT } from '../../contexts/LocaleContext'
77import { MarkdownRenderer } from '../conversation/MarkdownRenderer'
88import { Button } from '../ui/Button'
9- import { IconButton } from '../ui/IconButton '
9+ import { ModalHeader } from '../ui/ModalHeader '
1010
1111interface AppUpdateDialogProps {
1212 state : AppUpdateState
@@ -23,7 +23,8 @@ export function AppUpdateDialog({
2323 const update = state . update
2424 const downloading = state . status === 'downloading'
2525 const downloaded = state . status === 'downloaded'
26- const error = state . status === 'error' ? state . error : undefined
26+ const failed = state . status === 'error'
27+ const error = failed ? state . error : undefined
2728 const canClose = ! downloading && ! downloaded
2829 const progress = state . progress
2930
@@ -38,6 +39,14 @@ export function AppUpdateDialog({
3839 return ( ) => window . removeEventListener ( 'keydown' , handleKeyDown )
3940 } , [ canClose , onClose ] )
4041
42+ const primaryLabel = downloading
43+ ? t ( 'update.downloading' )
44+ : downloaded
45+ ? t ( 'update.installing' )
46+ : failed
47+ ? t ( 'update.retry' )
48+ : t ( 'update.download' )
49+
4150 const dialog = (
4251 < div
4352 style = { backdropStyle }
@@ -53,62 +62,26 @@ export function AppUpdateDialog({
5362 aria-labelledby = "app-update-title"
5463 style = { dialogStyle }
5564 >
56- < div style = { closeStyle } >
57- < IconButton
58- label = { t ( 'update.closeAria' ) }
59- icon = { < X size = { 18 } strokeWidth = { 2 } aria-hidden = "true" /> }
60- onClick = { onClose }
61- disabled = { ! canClose }
62- />
63- </ div >
64-
65- < header style = { headerStyle } >
66- < div style = { eyebrowStyle } >
67- { downloaded ? (
68- < CheckCircle2 size = { 15 } strokeWidth = { 2 } aria-hidden = "true" />
69- ) : downloading ? (
70- < LoaderCircle size = { 15 } strokeWidth = { 2 } aria-hidden = "true" style = { spinStyle } />
71- ) : (
72- < Download size = { 15 } strokeWidth = { 2 } aria-hidden = "true" />
73- ) }
74- < span >
75- { update
76- ? t ( 'update.subtitle' , { version : update . latestVersion } )
77- : t ( 'update.checking' ) }
78- </ span >
79- </ div >
80- </ header >
81-
82- < h2 id = "app-update-title" style = { titleStyle } >
83- { t ( 'update.title' ) }
84- </ h2 >
65+ < ModalHeader
66+ icon = { downloaded ? < CheckCircle2 size = { 18 } aria-hidden /> : < Download size = { 18 } aria-hidden /> }
67+ title = { t ( 'update.title' ) }
68+ titleId = "app-update-title"
69+ description = {
70+ update ? t ( 'update.subtitle' , { version : update . latestVersion } ) : t ( 'update.checking' )
71+ }
72+ onClose = { canClose ? onClose : undefined }
73+ closeLabel = { t ( 'update.closeAria' ) }
74+ style = { headerStyle }
75+ />
8576
8677 < div style = { contentStyle } >
8778 { update ? (
88- < div style = { releaseNotesStyle } >
89- < div style = { releaseNotesHeaderStyle } >
90- < div style = { releaseNotesTitleStyle } > { t ( 'update.releaseNotes' ) } </ div >
91- { update . htmlUrl && (
92- < Button
93- variant = "ghost"
94- size = "sm"
95- style = { releaseNotesActionStyle }
96- iconLeft = { < ExternalLink size = { 13 } strokeWidth = { 2 } aria-hidden = "true" /> }
97- onClick = { ( ) => {
98- void window . api . shell . openExternal ( update . htmlUrl as string )
99- } }
100- >
101- { t ( 'update.viewRelease' ) }
102- </ Button >
103- ) }
104- </ div >
105- < div style = { releaseNotesBodyStyle } >
106- < MarkdownRenderer
107- content = { update . releaseNotes || t ( 'update.noReleaseNotes' ) }
108- linkMode = "external"
109- containOverflow
110- />
111- </ div >
79+ < div className = "app-update-notes" style = { notesStyle } >
80+ < MarkdownRenderer
81+ content = { update . releaseNotes || t ( 'update.noReleaseNotes' ) }
82+ linkMode = "external"
83+ containOverflow
84+ />
11285 </ div >
11386 ) : (
11487 < p style = { bodyStyle } > { t ( 'update.checkingBody' ) } </ p >
@@ -151,25 +124,34 @@ export function AppUpdateDialog({
151124 </ div >
152125
153126 < footer style = { footerStyle } >
154- < Button
155- variant = "secondary"
156- onClick = { onClose }
157- disabled = { ! canClose }
158- >
159- { t ( 'update.cancel' ) }
160- </ Button >
161- < Button
162- variant = "primary"
163- onClick = { onDownload }
164- disabled = { ! update || downloading || downloaded }
165- loading = { downloading }
166- >
167- { downloading
168- ? t ( 'update.downloading' )
169- : state . status === 'error'
170- ? t ( 'update.retry' )
171- : t ( 'update.download' ) }
172- </ Button >
127+ < div style = { footerLeadStyle } >
128+ { update ?. htmlUrl && (
129+ < Button
130+ variant = "ghost"
131+ iconLeft = { < ExternalLink size = { 14 } strokeWidth = { 2 } aria-hidden = "true" /> }
132+ onClick = { ( ) => {
133+ void window . api . shell . openExternal ( update . htmlUrl as string )
134+ } }
135+ >
136+ { t ( 'update.viewRelease' ) }
137+ </ Button >
138+ ) }
139+ </ div >
140+ < div style = { footerActionsStyle } >
141+ { failed && (
142+ < Button variant = "secondary" onClick = { onClose } >
143+ { t ( 'update.cancel' ) }
144+ </ Button >
145+ ) }
146+ < Button
147+ variant = "primary"
148+ onClick = { onDownload }
149+ disabled = { ! update || downloading || downloaded }
150+ loading = { downloading }
151+ >
152+ { primaryLabel }
153+ </ Button >
154+ </ div >
173155 </ footer >
174156 </ section >
175157 </ div >
@@ -215,46 +197,15 @@ const dialogStyle: CSSProperties = {
215197 boxShadow : '0 24px 80px rgba(0, 0, 0, 0.38)'
216198}
217199
218- /**
219- * The close control sits outside the header flow so its 32px footprint cannot
220- * set the header height. Its 18px glyph then lands on the eyebrow's line and on
221- * the dialog's 22px inset.
222- */
223- const closeStyle : CSSProperties = {
224- position : 'absolute' ,
225- top : 12 ,
226- right : 15 ,
227- zIndex : 1
228- }
229-
230- // No rule under the header: the gap and the title weight already mark the edge.
231200const headerStyle : CSSProperties = {
232- display : 'flex' ,
233- alignItems : 'center' ,
234- gap : 16 ,
235- padding : '20px 60px 0 22px'
236- }
237-
238- const eyebrowStyle : CSSProperties = {
239- display : 'inline-flex' ,
240- minWidth : 0 ,
241- alignItems : 'center' ,
242- gap : 7 ,
243- color : 'var(--text-secondary)' ,
244- fontSize : 'var(--type-secondary-size)' ,
245- lineHeight : 'var(--type-secondary-line-height)'
246- }
247-
248- const titleStyle : CSSProperties = {
249- margin : '6px 22px 0' ,
250- fontSize : 22 ,
251- lineHeight : '30px' ,
252- fontWeight : 680
201+ margin : '20px 22px 0'
253202}
254203
255204const contentStyle : CSSProperties = {
256- padding : '16px 22px 0' ,
257- overflowY : 'auto'
205+ display : 'flex' ,
206+ minHeight : 0 ,
207+ flexDirection : 'column' ,
208+ padding : '0 22px'
258209}
259210
260211const bodyStyle : CSSProperties = {
@@ -264,54 +215,22 @@ const bodyStyle: CSSProperties = {
264215 lineHeight : 'var(--type-body-line-height)'
265216}
266217
267- const releaseNotesStyle : CSSProperties = {
218+ const notesStyle : CSSProperties = {
219+ minHeight : 120 ,
220+ maxHeight : 360 ,
221+ overflowY : 'auto' ,
222+ padding : '12px 14px' ,
268223 border : '1px solid var(--border-subtle)' ,
269224 borderRadius : 8 ,
270225 background : 'var(--bg-secondary)' ,
271- overflow : 'hidden'
272- }
273-
274- /**
275- * View release belongs beside the content it opens. The rule below the row
276- * stays: it marks where the scrolling region begins, not where a section ends.
277- */
278- const releaseNotesHeaderStyle : CSSProperties = {
279- display : 'flex' ,
280- minHeight : 36 ,
281- alignItems : 'center' ,
282- justifyContent : 'space-between' ,
283- gap : 12 ,
284- padding : '0 6px 0 11px' ,
285- borderBottom : '1px solid var(--border-subtle)'
286- }
287-
288- const releaseNotesTitleStyle : CSSProperties = {
289- minWidth : 0 ,
290- overflow : 'hidden' ,
291- color : 'var(--text-primary)' ,
292- fontSize : 'var(--type-ui-size)' ,
293- lineHeight : 'var(--type-ui-line-height)' ,
294- fontWeight : 600 ,
295- textOverflow : 'ellipsis' ,
296- whiteSpace : 'nowrap'
297- }
298-
299- // A longer localized label must not squeeze the action out of the row.
300- const releaseNotesActionStyle : CSSProperties = {
301- flex : '0 0 auto'
302- }
303-
304- const releaseNotesBodyStyle : CSSProperties = {
305- maxHeight : 156 ,
306- overflowY : 'auto' ,
307- padding : '10px 11px' ,
308226 color : 'var(--text-secondary)' ,
309227 fontSize : 'var(--type-secondary-size)' ,
310- lineHeight : 'var(--type-secondary-line-height)' ,
228+ lineHeight : 'var(--type-secondary-prose- line-height)' ,
311229 overflowWrap : 'anywhere'
312230}
313231
314232const progressWrapStyle : CSSProperties = {
233+ flex : '0 0 auto' ,
315234 marginTop : 16
316235}
317236
@@ -325,7 +244,6 @@ const progressLabelStyle: CSSProperties = {
325244 lineHeight : 'var(--type-secondary-line-height)'
326245}
327246
328- // Transferred bytes ride with the status label instead of a third, dimmer line.
329247const progressBytesStyle : CSSProperties = {
330248 flex : '0 0 auto' ,
331249 fontVariantNumeric : 'tabular-nums'
@@ -366,14 +284,23 @@ const errorStyle: CSSProperties = {
366284 overflowWrap : 'anywhere'
367285}
368286
369- // No rule above the footer either; the gap carries the separation.
370287const footerStyle : CSSProperties = {
371288 display : 'flex' ,
372- justifyContent : 'flex-end' ,
373- gap : 8 ,
289+ flex : '0 0 auto' ,
290+ alignItems : 'center' ,
291+ justifyContent : 'space-between' ,
292+ gap : 12 ,
374293 padding : '18px 22px 20px'
375294}
376295
377- const spinStyle : CSSProperties = {
378- animation : 'spin 1s linear infinite'
296+ const footerLeadStyle : CSSProperties = {
297+ display : 'flex' ,
298+ minWidth : 0 ,
299+ marginLeft : - 10
300+ }
301+
302+ const footerActionsStyle : CSSProperties = {
303+ display : 'flex' ,
304+ flex : '0 0 auto' ,
305+ gap : 8
379306}
0 commit comments