@aphrody/koharu-ui 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/README.md +37 -2
  2. package/package.json +70 -4
  3. package/src/assets/NotoSansCJK-VF.otf +0 -0
  4. package/src/assets/OFL.txt +92 -0
  5. package/src/components/ClientOnly.tsx +21 -0
  6. package/src/components/ShenronExportPanel.tsx +213 -0
  7. package/src/components/app/AboutDialog.tsx +64 -0
  8. package/src/components/app/KoharuApp.tsx +58 -0
  9. package/src/components/app/StartupView.tsx +67 -0
  10. package/src/components/app/TitleBar.tsx +322 -0
  11. package/src/components/app/Updater.tsx +143 -0
  12. package/src/components/app/WindowChrome.tsx +137 -0
  13. package/src/components/controls/ColorSampling.tsx +70 -0
  14. package/src/components/controls/ColorWell.tsx +139 -0
  15. package/src/components/controls/CommitTextarea.tsx +63 -0
  16. package/src/components/controls/FontPicker.tsx +543 -0
  17. package/src/components/controls/ModelPicker.tsx +178 -0
  18. package/src/components/controls/OutputPicker.tsx +170 -0
  19. package/src/components/editor/ActivityCenter.tsx +234 -0
  20. package/src/components/editor/AgentPanel.tsx +352 -0
  21. package/src/components/editor/CanvasCommandBar.tsx +38 -0
  22. package/src/components/editor/CanvasOverlay.tsx +194 -0
  23. package/src/components/editor/CanvasWorkspace.tsx +947 -0
  24. package/src/components/editor/Editor.tsx +56 -0
  25. package/src/components/editor/InferenceControl.tsx +457 -0
  26. package/src/components/editor/Inspector.tsx +1073 -0
  27. package/src/components/editor/PageRail.tsx +581 -0
  28. package/src/components/editor/ResourceMonitor.tsx +151 -0
  29. package/src/components/editor/RightSidebar.tsx +50 -0
  30. package/src/components/editor/SelectionControls.tsx +184 -0
  31. package/src/components/editor/StatusBar.tsx +41 -0
  32. package/src/components/editor/ToolBar.tsx +182 -0
  33. package/src/components/editor/useCanvas.ts +194 -0
  34. package/src/components/preferences/GenerationPreferences.tsx +118 -0
  35. package/src/components/preferences/KoharuSettings.tsx +229 -0
  36. package/src/components/preferences/PipelinePreferences.tsx +173 -0
  37. package/src/components/preferences/PreferenceFields.tsx +149 -0
  38. package/src/components/preferences/ProviderPreferences.tsx +173 -0
  39. package/src/components/preferences/SettingsPage.tsx +336 -0
  40. package/src/components/preferences/TranslationPreferences.tsx +187 -0
  41. package/src/components/preferences/TypesettingPreferences.tsx +142 -0
  42. package/src/components/preferences/models.ts +103 -0
  43. package/src/components/start/StartView.tsx +277 -0
  44. package/src/index.ts +59 -0
  45. package/src/internal/components/accordion.tsx +72 -0
  46. package/src/internal/components/alert-dialog.tsx +189 -0
  47. package/src/internal/components/alert.tsx +76 -0
  48. package/src/internal/components/aspect-ratio.tsx +22 -0
  49. package/src/internal/components/attachment.tsx +207 -0
  50. package/src/internal/components/avatar.tsx +109 -0
  51. package/src/internal/components/badge.tsx +52 -0
  52. package/src/internal/components/breadcrumb.tsx +125 -0
  53. package/src/internal/components/bubble.tsx +128 -0
  54. package/src/internal/components/button-group.tsx +87 -0
  55. package/src/internal/components/button.tsx +58 -0
  56. package/src/internal/components/card.tsx +103 -0
  57. package/src/internal/components/checkbox.tsx +29 -0
  58. package/src/internal/components/collapsible.tsx +21 -0
  59. package/src/internal/components/combobox.tsx +298 -0
  60. package/src/internal/components/context-menu.tsx +272 -0
  61. package/src/internal/components/dialog.tsx +162 -0
  62. package/src/internal/components/direction.tsx +6 -0
  63. package/src/internal/components/drawer.tsx +230 -0
  64. package/src/internal/components/dropdown-menu.tsx +278 -0
  65. package/src/internal/components/empty.tsx +104 -0
  66. package/src/internal/components/field.tsx +238 -0
  67. package/src/internal/components/hover-card.tsx +52 -0
  68. package/src/internal/components/input-group.tsx +158 -0
  69. package/src/internal/components/input.tsx +20 -0
  70. package/src/internal/components/item.tsx +201 -0
  71. package/src/internal/components/kbd.tsx +26 -0
  72. package/src/internal/components/label.tsx +20 -0
  73. package/src/internal/components/marker.tsx +71 -0
  74. package/src/internal/components/menubar.tsx +280 -0
  75. package/src/internal/components/message.tsx +92 -0
  76. package/src/internal/components/native-select.tsx +61 -0
  77. package/src/internal/components/navigation-menu.tsx +168 -0
  78. package/src/internal/components/number-field.tsx +68 -0
  79. package/src/internal/components/pagination.tsx +130 -0
  80. package/src/internal/components/popover.tsx +91 -0
  81. package/src/internal/components/progress.tsx +83 -0
  82. package/src/internal/components/radio-group.tsx +38 -0
  83. package/src/internal/components/resizable.tsx +50 -0
  84. package/src/internal/components/scroll-area.tsx +88 -0
  85. package/src/internal/components/select.tsx +191 -0
  86. package/src/internal/components/separator.tsx +25 -0
  87. package/src/internal/components/sheet.tsx +140 -0
  88. package/src/internal/components/sidebar.tsx +723 -0
  89. package/src/internal/components/skeleton.tsx +13 -0
  90. package/src/internal/components/slider.tsx +56 -0
  91. package/src/internal/components/spinner.tsx +10 -0
  92. package/src/internal/components/switch.tsx +32 -0
  93. package/src/internal/components/table.tsx +116 -0
  94. package/src/internal/components/tabs.tsx +82 -0
  95. package/src/internal/components/textarea.tsx +18 -0
  96. package/src/internal/components/toast.tsx +235 -0
  97. package/src/internal/components/toggle-group.tsx +89 -0
  98. package/src/internal/components/toggle.tsx +45 -0
  99. package/src/internal/components/tooltip.tsx +67 -0
  100. package/src/internal/hooks/use-mobile.ts +19 -0
  101. package/src/internal/lib/utils.ts +6 -0
  102. package/src/internal/styles/globals.css +73 -0
  103. package/src/lib/backend.ts +85 -0
  104. package/src/lib/document.ts +46 -0
  105. package/src/lib/geometry.ts +272 -0
  106. package/src/lib/i18n.ts +54 -0
  107. package/src/lib/queries.ts +110 -0
  108. package/src/lib/store.ts +191 -0
  109. package/src/lib/translation.ts +35 -0
  110. package/src/locales/en-US/translation.json +536 -0
  111. package/src/locales/es-ES/translation.json +536 -0
  112. package/src/locales/ja-JP/translation.json +536 -0
  113. package/src/locales/ko-KR/translation.json +536 -0
  114. package/src/locales/pt-BR/translation.json +536 -0
  115. package/src/locales/ru-RU/translation.json +536 -0
  116. package/src/locales/tr-TR/translation.json +536 -0
  117. package/src/locales/zh-CN/translation.json +536 -0
  118. package/src/locales/zh-TW/translation.json +536 -0
  119. package/src/navigation.ts +28 -0
  120. package/src/providers.tsx +132 -0
  121. package/src/shenron-pipeline-env.d.ts +14 -0
  122. package/src/styles.css +246 -0
  123. package/tsconfig.json +21 -0
@@ -0,0 +1,178 @@
1
+ 'use client'
2
+
3
+ import { Check, ChevronLeft, ChevronRight, HardDrive, LoaderCircle, Search, X } from 'lucide-react'
4
+ import { useState } from 'react'
5
+ import { useTranslation } from 'react-i18next'
6
+
7
+ import { modelKey, modelSelection, providerName } from '@aphrody/koharu-ui/lib/translation'
8
+ import type { Model, ModelSelection, ProviderPreference } from '@aphrody/koharu-bridge/protocol'
9
+ import { Button } from '../../internal/components/button'
10
+ import {
11
+ InputGroup,
12
+ InputGroupAddon,
13
+ InputGroupButton,
14
+ InputGroupInput,
15
+ } from '../../internal/components/input-group'
16
+ import { ScrollArea } from '../../internal/components/scroll-area'
17
+
18
+ export function ModelPicker({
19
+ value,
20
+ models,
21
+ providers,
22
+ loading = false,
23
+ disabled = false,
24
+ busyModel,
25
+ onBack,
26
+ onSelect,
27
+ }: {
28
+ value: ModelSelection | null
29
+ models: Model[]
30
+ providers: ProviderPreference[]
31
+ loading?: boolean
32
+ disabled?: boolean
33
+ busyModel?: string | null
34
+ onBack: () => void
35
+ onSelect: (model: ModelSelection) => void
36
+ }) {
37
+ const { t } = useTranslation()
38
+ const [query, setQuery] = useState('')
39
+ const [pendingKey, setPendingKey] = useState<string | null>(null)
40
+ const pendingModel = models.find((model) => modelKey(model) === pendingKey)
41
+ const selectedKey = value ? modelKey(value) : null
42
+ const saving = Boolean(busyModel)
43
+ const normalizedQuery = query.trim().toLocaleLowerCase()
44
+ const options = pendingModel
45
+ ? pendingModel.quantizations.map((quantization) => ({ model: pendingModel, quantization }))
46
+ : models
47
+ .filter((model) => {
48
+ if (!normalizedQuery) return true
49
+ return [model.name, model.model, providerName(providers, model.provider)].some(
50
+ (candidate) => candidate?.toLocaleLowerCase().includes(normalizedQuery),
51
+ )
52
+ })
53
+ .map((model) => ({ model, quantization: null }))
54
+
55
+ return (
56
+ <div className='min-w-0 overflow-hidden'>
57
+ <div className='mb-1 flex h-7 items-center border-b border-border/60 px-0.5 pb-1'>
58
+ <Button
59
+ type='button'
60
+ variant='ghost'
61
+ size='icon-xs'
62
+ aria-label={t('common.back')}
63
+ className='rounded-md text-muted-foreground hover:bg-primary/10 hover:text-foreground'
64
+ disabled={saving}
65
+ onClick={() => (pendingModel ? setPendingKey(null) : onBack())}
66
+ >
67
+ <ChevronLeft className='size-3.5' />
68
+ </Button>
69
+ <span className='ml-1 min-w-0 flex-1 truncate text-[11px] font-medium'>
70
+ {pendingModel?.name ?? t('modelPicker.title')}
71
+ </span>
72
+ {pendingModel && busyModel === pendingKey && (
73
+ <LoaderCircle className='mr-1 size-3.5 shrink-0 animate-spin text-primary' />
74
+ )}
75
+ </div>
76
+
77
+ {pendingModel ? (
78
+ <div className='border-b border-border/60 px-2 py-1.5 text-[11px] text-muted-foreground'>
79
+ {t('settings.translation.selectQuantization')}
80
+ </div>
81
+ ) : (
82
+ <div className='border-b border-border/60 p-1'>
83
+ <InputGroup className='h-7 border-0 bg-muted/50 shadow-none focus-within:bg-background has-[>[data-align=inline-start]]:[&>input]:pl-0'>
84
+ <InputGroupAddon className='pr-1 pl-1.5'>
85
+ <Search className='size-3' />
86
+ </InputGroupAddon>
87
+ <InputGroupInput
88
+
89
+ value={query}
90
+ aria-label={t('modelPicker.search')}
91
+ placeholder={t('modelPicker.search')}
92
+ className='h-7 px-0 text-[11px]'
93
+ onChange={(event) => setQuery(event.currentTarget.value)}
94
+ />
95
+ {query && (
96
+ <InputGroupAddon align='inline-end' className='pr-0.5'>
97
+ <InputGroupButton aria-label={t('common.clearSearch')} onClick={() => setQuery('')}>
98
+ <X />
99
+ </InputGroupButton>
100
+ </InputGroupAddon>
101
+ )}
102
+ </InputGroup>
103
+ </div>
104
+ )}
105
+
106
+ <ScrollArea
107
+ key={pendingKey}
108
+ className='max-h-64 min-w-0 overflow-hidden'
109
+ viewportClassName='h-auto max-h-64 min-w-0 overscroll-contain'
110
+ >
111
+ <div className='grid min-w-0 gap-0.5 py-0.5'>
112
+ {options.map(({ model, quantization }, index) => {
113
+ const key = modelKey(model)
114
+ const expandable = !quantization && model.quantizations.length > 0
115
+ const downloaded =
116
+ quantization?.downloaded ??
117
+ model.quantizations.some((candidate) => candidate.downloaded)
118
+ const selected =
119
+ key === selectedKey &&
120
+ (!quantization ||
121
+ quantization.id === (value?.quantization ?? model.quantizations[0]?.id))
122
+ const provider = providerName(providers, model.provider)
123
+ return (
124
+ <Button
125
+ key={quantization?.id ?? key}
126
+
127
+ type='button'
128
+ variant='ghost'
129
+ aria-label={
130
+ quantization
131
+ ? undefined
132
+ : t('modelPicker.useModel', { model: model.name, provider })
133
+ }
134
+ aria-pressed={selected}
135
+ disabled={disabled || saving}
136
+ className='h-auto min-h-9 w-full max-w-full min-w-0 justify-start gap-2 overflow-hidden rounded-lg px-2 py-1 text-left font-normal hover:bg-primary/10'
137
+ onClick={() => {
138
+ if (expandable) setPendingKey(key)
139
+ else onSelect(modelSelection(model, quantization?.id ?? null))
140
+ }}
141
+ >
142
+ <span className='min-w-0 flex-1 overflow-hidden'>
143
+ <span className='block truncate text-[11px] text-foreground'>
144
+ {quantization?.name ?? model.name}
145
+ </span>
146
+ {!quantization && (
147
+ <span className='block truncate text-[9px] text-muted-foreground'>
148
+ {provider}
149
+ </span>
150
+ )}
151
+ </span>
152
+ {downloaded && <HardDrive className='size-3.5 shrink-0 text-muted-foreground' />}
153
+ {!quantization && busyModel === key ? (
154
+ <LoaderCircle className='size-3.5 shrink-0 animate-spin text-primary' />
155
+ ) : (
156
+ selected && <Check className='size-3.5 shrink-0 text-primary' />
157
+ )}
158
+ {expandable && <ChevronRight className='size-3.5 shrink-0 text-muted-foreground' />}
159
+ </Button>
160
+ )
161
+ })}
162
+ {options.length === 0 && (
163
+ <div className='flex items-center justify-center gap-2 px-2.5 py-5 text-center text-[11px] text-muted-foreground'>
164
+ {loading && models.length === 0 && <LoaderCircle className='size-3.5 animate-spin' />}
165
+ {t(
166
+ models.length > 0
167
+ ? 'modelPicker.noResults'
168
+ : loading
169
+ ? 'modelPicker.loading'
170
+ : 'modelPicker.empty',
171
+ )}
172
+ </div>
173
+ )}
174
+ </div>
175
+ </ScrollArea>
176
+ </div>
177
+ )
178
+ }
@@ -0,0 +1,170 @@
1
+ 'use client'
2
+
3
+ import { ChevronLeft } from 'lucide-react'
4
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
5
+ import { useTranslation } from 'react-i18next'
6
+
7
+ import { orderedLanguageChoices } from '@aphrody/koharu-ui/lib/translation'
8
+ import type { LanguageChoice } from '@aphrody/koharu-bridge/protocol'
9
+ import { Button } from '../../internal/components/button'
10
+ import {
11
+ Select,
12
+ SelectContent,
13
+ SelectItem,
14
+ SelectTrigger,
15
+ SelectValue,
16
+ } from '../../internal/components/select'
17
+ import { Textarea } from '../../internal/components/textarea'
18
+
19
+ export type OutputDraft = {
20
+ targetLanguage: string
21
+ instructions: string
22
+ }
23
+
24
+ export function OutputPicker({
25
+ targetLanguage,
26
+ instructions,
27
+ languages,
28
+ disabled = false,
29
+ saving = false,
30
+ onBack,
31
+ onChange,
32
+ }: {
33
+ targetLanguage: string
34
+ instructions: string | null
35
+ languages: LanguageChoice[]
36
+ disabled?: boolean
37
+ saving?: boolean
38
+ onBack: () => void
39
+ onChange: (draft: OutputDraft) => void
40
+ }) {
41
+ const { t } = useTranslation()
42
+ const [draft, setDraft] = useState<OutputDraft>({
43
+ targetLanguage,
44
+ instructions: instructions ?? '',
45
+ })
46
+ const changed =
47
+ draft.targetLanguage !== targetLanguage || draft.instructions !== (instructions ?? '')
48
+ const saveTimer = useRef<ReturnType<typeof setTimeout>>(undefined)
49
+ const latest = useRef({ changed, disabled, draft, onChange, saving })
50
+ const submitted = useRef<OutputDraft | null>(null)
51
+ latest.current = { changed, disabled, draft, onChange, saving }
52
+ const languageChoices = useMemo(() => orderedLanguageChoices(languages), [languages])
53
+
54
+ const submit = useCallback(
55
+ (next: OutputDraft) => {
56
+ submitted.current = next
57
+ onChange(next)
58
+ },
59
+ [onChange],
60
+ )
61
+
62
+ useEffect(() => {
63
+ if (!changed || saving || disabled || !draft.targetLanguage) return
64
+ saveTimer.current = setTimeout(() => submit(draft), 350)
65
+ return () => {
66
+ clearTimeout(saveTimer.current)
67
+ saveTimer.current = undefined
68
+ }
69
+ }, [changed, disabled, draft, saving, submit])
70
+
71
+ useEffect(
72
+ () => () => {
73
+ clearTimeout(saveTimer.current)
74
+ const current = latest.current
75
+ if (
76
+ current.changed &&
77
+ !current.saving &&
78
+ !current.disabled &&
79
+ current.draft.targetLanguage &&
80
+ !sameDraft(submitted.current, current.draft)
81
+ ) {
82
+ current.onChange(current.draft)
83
+ }
84
+ },
85
+ [],
86
+ )
87
+
88
+ const back = () => {
89
+ clearTimeout(saveTimer.current)
90
+ saveTimer.current = undefined
91
+ if (
92
+ changed &&
93
+ !saving &&
94
+ !disabled &&
95
+ draft.targetLanguage &&
96
+ !sameDraft(submitted.current, draft)
97
+ ) {
98
+ submit(draft)
99
+ }
100
+ onBack()
101
+ }
102
+
103
+ return (
104
+ <div className='min-w-0 overflow-hidden'>
105
+ <div className='mb-1 flex h-7 items-center border-b border-border/60 px-0.5 pb-1'>
106
+ <Button
107
+ type='button'
108
+ variant='ghost'
109
+ size='icon-xs'
110
+ aria-label={t('common.back')}
111
+ className='rounded-md text-muted-foreground hover:bg-primary/10 hover:text-foreground'
112
+ disabled={saving}
113
+ onClick={back}
114
+ >
115
+ <ChevronLeft className='size-3.5' />
116
+ </Button>
117
+ <span className='ml-1 text-[11px] font-medium'>{t('outputPicker.title')}</span>
118
+ </div>
119
+
120
+ <div className='grid gap-2 p-1'>
121
+ <label className='grid gap-1 text-[9px] text-muted-foreground'>
122
+ {t('model.targetLanguage')}
123
+ <Select
124
+ value={draft.targetLanguage}
125
+ items={Object.fromEntries(
126
+ languageChoices.map((language) => [language.tag, language.name]),
127
+ )}
128
+ disabled={disabled}
129
+ onValueChange={(targetLanguage) =>
130
+ targetLanguage && setDraft((current) => ({ ...current, targetLanguage }))
131
+ }
132
+ >
133
+ <SelectTrigger
134
+ aria-label={t('outputPicker.targetLanguage')}
135
+ className='h-7 w-full text-[11px]'
136
+ >
137
+ <SelectValue />
138
+ </SelectTrigger>
139
+ <SelectContent>
140
+ {languageChoices.map((language) => (
141
+ <SelectItem key={language.tag} value={language.tag}>
142
+ {language.name}
143
+ </SelectItem>
144
+ ))}
145
+ </SelectContent>
146
+ </Select>
147
+ </label>
148
+
149
+ <label className='grid gap-1 text-[9px] text-muted-foreground'>
150
+ {t('model.instructions')}
151
+ <Textarea
152
+ value={draft.instructions}
153
+ disabled={disabled}
154
+ aria-label={t('outputPicker.instructions')}
155
+ placeholder={t('outputPicker.instructionsPlaceholder')}
156
+ className='max-h-20 min-h-20 resize-none overflow-y-auto text-[11px] leading-4'
157
+ onChange={(event) => {
158
+ const instructions = event.currentTarget.value
159
+ setDraft((current) => ({ ...current, instructions }))
160
+ }}
161
+ />
162
+ </label>
163
+ </div>
164
+ </div>
165
+ )
166
+ }
167
+
168
+ function sameDraft(left: OutputDraft | null, right: OutputDraft): boolean {
169
+ return left?.targetLanguage === right.targetLanguage && left.instructions === right.instructions
170
+ }
@@ -0,0 +1,234 @@
1
+ 'use client'
2
+
3
+ import { useMutationState } from '@tanstack/react-query'
4
+ import { CircleAlert, Download, LoaderCircle, Square, X } from 'lucide-react'
5
+ import { useTranslation } from 'react-i18next'
6
+
7
+ import { call } from '@aphrody/koharu-ui/lib/backend'
8
+ import { usePages } from '@aphrody/koharu-ui/lib/queries'
9
+ import { useKoharuStore } from '@aphrody/koharu-ui/lib/store'
10
+ import { commands, type Download as DownloadState, type Job } from '@aphrody/koharu-bridge/protocol'
11
+ import { Button } from '../../internal/components/button'
12
+
13
+ export function ActivityCenter() {
14
+ const { t } = useTranslation()
15
+ const jobs = useKoharuStore((state) => state.jobs)
16
+ const downloads = useKoharuStore((state) => state.downloads)
17
+ const activities = useMutationState({
18
+ filters: { status: 'pending' },
19
+ select: (mutation) => ({
20
+ id: mutation.mutationId,
21
+ label: mutation.options.meta?.activity,
22
+ }),
23
+ }).filter(
24
+ (activity): activity is { id: number; label: string } => typeof activity.label === 'string',
25
+ )
26
+ const visibleJobs = Object.values(jobs).filter(
27
+ (job) => job.state === 'running' || job.state === 'failed',
28
+ )
29
+ const runningDownloads = Object.values(downloads).filter(
30
+ (download) => download.state === 'running',
31
+ )
32
+ const failedDownloads = Object.values(downloads).filter((download) => download.state === 'failed')
33
+ const visibleDownloads = [...runningDownloads, ...failedDownloads]
34
+ if (activities.length === 0 && visibleJobs.length === 0 && visibleDownloads.length === 0)
35
+ return null
36
+
37
+ return (
38
+ <aside
39
+ aria-label={t('activity.title')}
40
+ className='absolute right-3 bottom-9 z-30 flex w-72 max-w-[calc(100%-1.5rem)] flex-col rounded-2xl border border-border/50 bg-popover shadow-md'
41
+ >
42
+ <div className='border-b px-3 py-2'>
43
+ <span className='text-[10px] font-semibold tracking-[0.1em] uppercase'>
44
+ {t('activity.title')}
45
+ </span>
46
+ </div>
47
+ {activities.map((activity) => (
48
+ <div
49
+ key={activity.id}
50
+ role='status'
51
+ className='grid grid-cols-[1rem_minmax(0,1fr)] items-center gap-x-2.5 border-b p-3 last:border-b-0'
52
+ >
53
+ <LoaderCircle
54
+ className='size-3.5 animate-spin justify-self-center text-primary motion-reduce:animate-none'
55
+ aria-hidden='true'
56
+ />
57
+ <span className='truncate text-[12px] font-medium'>{t(activity.label)}</span>
58
+ </div>
59
+ ))}
60
+ {runningDownloads.length > 1 ? (
61
+ <DownloadGroup downloads={runningDownloads} />
62
+ ) : (
63
+ runningDownloads.map((download) => <DownloadItem key={download.id} download={download} />)
64
+ )}
65
+ {failedDownloads.map((download) => (
66
+ <DownloadItem key={download.id} download={download} />
67
+ ))}
68
+ {visibleJobs.map((job) => (
69
+ <JobItem key={job.id} job={job} />
70
+ ))}
71
+ </aside>
72
+ )
73
+ }
74
+
75
+ function DownloadGroup({ downloads }: { downloads: DownloadState[] }) {
76
+ const { t } = useTranslation()
77
+ const hasKnownTotal = downloads.every((download) => download.total > 0)
78
+ const percent = hasKnownTotal
79
+ ? progress(
80
+ downloads.reduce((sum, download) => sum + download.completed, 0),
81
+ downloads.reduce((sum, download) => sum + download.total, 0),
82
+ )
83
+ : null
84
+
85
+ return (
86
+ <div className='border-b p-3 last:border-b-0'>
87
+ <div className='grid grid-cols-[1rem_minmax(0,1fr)_2.25rem_1.5rem] items-start gap-x-2.5'>
88
+ <Download className='mt-0.5 size-3.5 justify-self-center text-primary' />
89
+ <span className='truncate text-[11px]'>
90
+ {t('activity.downloadingFiles', { count: downloads.length })}
91
+ </span>
92
+ <span className='text-right text-[10px] tabular-nums'>
93
+ {percent !== null ? `${percent}%` : null}
94
+ </span>
95
+ <span aria-hidden='true' />
96
+ <div className='col-start-2 col-end-4'>
97
+ <Progress value={percent} />
98
+ </div>
99
+ </div>
100
+ </div>
101
+ )
102
+ }
103
+
104
+ function JobItem({ job }: { job: Job }) {
105
+ const { t } = useTranslation()
106
+ const dismiss = useKoharuStore((state) => state.dismissJob)
107
+ // The pipeline reports which page it is on; the rail has already loaded the
108
+ // labels, so this resolves from cache rather than fetching.
109
+ const pages = usePages(job.page !== null).data
110
+ // Numbered by position in the rail, so the row says where in the run this
111
+ // is as well as which file: a scan filename alone rarely tells you.
112
+ const pageIndex = job.page ? (pages?.findIndex((page) => page.id === job.page) ?? -1) : -1
113
+ const page = pageIndex >= 0 ? pages?.[pageIndex] : undefined
114
+ const pageLabel = page
115
+ ? t('activity.pageLabel', { number: pageIndex + 1, label: page.label })
116
+ : undefined
117
+ if (job.state === 'failed') {
118
+ return (
119
+ <Failure
120
+ message={job.error || t('activity.processingFailed')}
121
+ onDismiss={() => dismiss(job.id)}
122
+ />
123
+ )
124
+ }
125
+ const percent = progress(job.completed, job.total)
126
+ return (
127
+ <div className='border-b p-3 last:border-b-0'>
128
+ <div className='grid grid-cols-[1rem_minmax(0,1fr)_2.25rem_1.5rem] items-start gap-x-2.5'>
129
+ <span className='mt-1.5 size-1.5 justify-self-center rounded-full bg-primary' />
130
+ <div className='min-w-0'>
131
+ <span className='block truncate text-[12px] font-medium capitalize'>
132
+ {job.stage
133
+ ? t(`phase.${job.stage}`, { defaultValue: job.stage })
134
+ : t('activity.processing')}
135
+ </span>
136
+ </div>
137
+ <span className='pt-0.5 text-right text-[10px] tabular-nums'>
138
+ {percent !== null ? `${percent}%` : null}
139
+ </span>
140
+ <Button
141
+ size='icon-xs'
142
+ variant='ghost'
143
+ className='-mt-1'
144
+ aria-label={t('activity.stop')}
145
+ onClick={() => void call(commands.stopJob, job.id).catch(() => undefined)}
146
+ >
147
+ <Square className='size-2.5 fill-current' />
148
+ </Button>
149
+ {pageLabel ? (
150
+ <p className='col-start-2 col-end-4 mt-0.5 truncate text-[10px] text-muted-foreground'>
151
+ {pageLabel}
152
+ </p>
153
+ ) : null}
154
+ {job.model ? (
155
+ <p className='col-start-2 col-end-4 truncate text-[10px] text-muted-foreground'>
156
+ {job.model}
157
+ </p>
158
+ ) : null}
159
+ <div className='col-start-2 col-end-4'>
160
+ <Progress value={percent} />
161
+ </div>
162
+ </div>
163
+ </div>
164
+ )
165
+ }
166
+
167
+ function DownloadItem({ download }: { download: DownloadState }) {
168
+ const { t } = useTranslation()
169
+ const dismiss = useKoharuStore((state) => state.dismissDownload)
170
+ if (download.state === 'failed') {
171
+ return (
172
+ <Failure
173
+ message={download.error || t('activity.downloadFailed')}
174
+ onDismiss={() => dismiss(download.id)}
175
+ />
176
+ )
177
+ }
178
+ const percent = progress(download.completed, download.total)
179
+ return (
180
+ <div className='border-b p-3 last:border-b-0'>
181
+ <div className='grid grid-cols-[1rem_minmax(0,1fr)_2.25rem_1.5rem] items-start gap-x-2.5'>
182
+ <Download className='mt-0.5 size-3.5 justify-self-center text-primary' />
183
+ <span className='truncate text-[11px]'>{download.name || t('activity.modelDownload')}</span>
184
+ <span className='text-right text-[10px] tabular-nums'>
185
+ {percent !== null ? `${percent}%` : null}
186
+ </span>
187
+ <span aria-hidden='true' />
188
+ <div className='col-start-2 col-end-4'>
189
+ <Progress value={percent} />
190
+ </div>
191
+ </div>
192
+ </div>
193
+ )
194
+ }
195
+
196
+ function Failure({ message, onDismiss }: { message: string; onDismiss: () => void }) {
197
+ const { t } = useTranslation()
198
+ return (
199
+ <div className='grid grid-cols-[1rem_minmax(0,1fr)_2.25rem_1.5rem] items-start gap-x-2.5 border-b p-3 text-[11px] last:border-b-0'>
200
+ <CircleAlert className='mt-0.5 size-3.5 justify-self-center text-destructive' />
201
+ <span className='col-start-2 col-end-4 min-w-0 text-destructive'>{message}</span>
202
+ <Button
203
+ size='icon-xs'
204
+ variant='ghost'
205
+ className='-mt-1'
206
+ aria-label={t('activity.dismiss')}
207
+ onClick={onDismiss}
208
+ >
209
+ <X />
210
+ </Button>
211
+ </div>
212
+ )
213
+ }
214
+
215
+ function Progress({ value }: { value: number | null }) {
216
+ return (
217
+ <div
218
+ role='progressbar'
219
+ aria-valuemin={0}
220
+ aria-valuemax={100}
221
+ aria-valuenow={value ?? undefined}
222
+ className='mt-2 h-1 overflow-hidden rounded-full bg-muted'
223
+ >
224
+ <div
225
+ className={`h-full rounded-full bg-primary ${value === null ? 'w-1/2' : ''}`}
226
+ style={value === null ? undefined : { width: `${value}%` }}
227
+ />
228
+ </div>
229
+ )
230
+ }
231
+
232
+ function progress(completed: number, total: number): number | null {
233
+ return total > 0 ? Math.min(100, Math.round((completed / total) * 100)) : null
234
+ }