Skip to content

Commit 5f50b9d

Browse files
committed
Move AI publishing into a cancellable background process
1 parent 6df3a88 commit 5f50b9d

9 files changed

Lines changed: 803 additions & 60 deletions

File tree

Lines changed: 290 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,290 @@
1+
import { useAtom, useAtomValue } from 'jotai'
2+
import {
3+
AlertCircle,
4+
CheckCircle2,
5+
Clock,
6+
ExternalLink,
7+
Loader2,
8+
X,
9+
XCircle,
10+
} from 'lucide-react'
11+
import { Link, useBeforeUnload } from 'react-router'
12+
13+
import { AITaskStatus } from '~/api/tasks'
14+
import { CAPABILITY_META } from '~/features/ai/components/article-overview/capability-meta'
15+
import { statusIcon, taskStatusLabelKeys } from '~/features/tasks/constants'
16+
import {
17+
getProgress,
18+
getTaskProgressLabel,
19+
statusIconClassName,
20+
} from '~/features/tasks/utils/tasks'
21+
import {
22+
dismissPublishProcess,
23+
isPublishProcessActive,
24+
type PublishProcess,
25+
publishProcessDockOpenAtom,
26+
publishProcessesAtom,
27+
type PublishProcessPhase,
28+
type PublishProcessResource,
29+
} from '~/features/write/utils/publish-process-state'
30+
import { useI18n } from '~/i18n'
31+
import type { TranslationKey } from '~/i18n/types'
32+
import { confirmDialog } from '~/ui/feedback/confirm'
33+
import { Popover } from '~/ui/overlay/popover'
34+
import { Button } from '~/ui/primitives/button'
35+
import { cn } from '~/utils/cn'
36+
37+
const phaseLabelKeys: Record<PublishProcessPhase, TranslationKey> = {
38+
cancelled: 'write.publishProcess.cancelled',
39+
cancelling: 'write.publishProcess.cancelling',
40+
completed: 'write.publishProcess.completed',
41+
failed: 'write.publishProcess.failed',
42+
preparing: 'write.publishProcess.preparing',
43+
publishing: 'write.publishProcess.publishing',
44+
}
45+
46+
export function PublishProcessDock() {
47+
const { t } = useI18n()
48+
const processes = useAtomValue(publishProcessesAtom)
49+
const [open, setOpen] = useAtom(publishProcessDockOpenAtom)
50+
const hasActiveProcess = processes.some((process) =>
51+
isPublishProcessActive(process.phase),
52+
)
53+
54+
useBeforeUnload(
55+
(event) => {
56+
if (hasActiveProcess) event.preventDefault()
57+
},
58+
{ capture: true },
59+
)
60+
61+
if (processes.length === 0) return null
62+
63+
const current = getCurrentProcess(processes)
64+
const progress = getProcessProgress(current)
65+
const failed = current.phase === 'failed'
66+
const CurrentIcon = getPhaseIcon(current.phase)
67+
68+
return (
69+
<div className="fixed right-4 bottom-4 z-[1090] max-sm:left-4">
70+
<Popover onOpenChange={setOpen} open={open}>
71+
<Popover.Trigger
72+
aria-label={t('write.publishProcess.open')}
73+
className={cn(
74+
'flex h-10 max-w-[min(24rem,calc(100vw-2rem))] items-center gap-2 rounded-full border border-border bg-surface-overlay px-3 text-sm shadow-lg transition-colors hover:bg-surface-card focus-visible:ring-[3px] focus-visible:ring-accent/20',
75+
failed && 'border-red-500/40',
76+
)}
77+
>
78+
<CurrentIcon
79+
aria-hidden="true"
80+
className={cn(
81+
'size-4 shrink-0',
82+
current.phase === 'preparing' || current.phase === 'cancelling'
83+
? 'animate-spin text-blue-500'
84+
: current.phase === 'publishing'
85+
? 'animate-spin text-accent'
86+
: current.phase === 'completed'
87+
? 'text-emerald-500'
88+
: current.phase === 'failed'
89+
? 'text-red-500'
90+
: 'text-fg-subtle',
91+
)}
92+
/>
93+
<span aria-live="polite" className="min-w-0 truncate">
94+
{t(phaseLabelKeys[current.phase])}
95+
</span>
96+
{isPublishProcessActive(current.phase) ? (
97+
<span className="shrink-0 text-xs text-fg-muted tabular-nums">
98+
{progress}%
99+
</span>
100+
) : null}
101+
{processes.length > 1 ? (
102+
<span className="shrink-0 rounded-full bg-surface-inset px-1.5 text-xs text-fg-muted tabular-nums">
103+
{processes.length}
104+
</span>
105+
) : null}
106+
</Popover.Trigger>
107+
<Popover.Content
108+
align="end"
109+
className="max-h-[min(72vh,38rem)] overflow-hidden"
110+
side="top"
111+
sideOffset={8}
112+
width="lg"
113+
>
114+
<Popover.Header>{t('write.publishProcess.title')}</Popover.Header>
115+
<Popover.Body className="max-h-[min(64vh,34rem)] overflow-y-auto p-0">
116+
{processes.map((process) => (
117+
<ProcessDetail key={process.id} process={process} />
118+
))}
119+
</Popover.Body>
120+
</Popover.Content>
121+
</Popover>
122+
</div>
123+
)
124+
}
125+
126+
function ProcessDetail({ process }: { process: PublishProcess }) {
127+
const { t } = useI18n()
128+
const progress = getProcessProgress(process)
129+
const terminal = !isPublishProcessActive(process.phase)
130+
const editPath = `/${process.kind === 'post' ? 'posts' : 'notes'}/edit?id=${encodeURIComponent(process.refId)}`
131+
132+
const terminate = async () => {
133+
const confirmed = await confirmDialog({
134+
confirmText: t('write.publishProcess.terminate'),
135+
description: t('write.publishProcess.terminateDescription'),
136+
destructive: true,
137+
title: t('write.publishProcess.terminateTitle'),
138+
})
139+
if (confirmed) {
140+
const { cancelPublishProcess } = await import(
141+
'~/features/write/utils/prepare-post-publish'
142+
)
143+
await cancelPublishProcess(process.id)
144+
}
145+
}
146+
147+
return (
148+
<section className="border-b border-border p-3 last:border-b-0">
149+
<div className="flex items-start justify-between gap-2">
150+
<div className="min-w-0">
151+
<Link
152+
className="block truncate font-medium hover:text-accent hover:underline"
153+
to={editPath}
154+
>
155+
{process.title || t(`write.kind.${process.kind}`)}
156+
</Link>
157+
<p className="mt-0.5 text-xs text-fg-muted">
158+
{t(phaseLabelKeys[process.phase])}
159+
</p>
160+
</div>
161+
{process.phase === 'preparing' ? (
162+
<Button
163+
className="h-10 shrink-0 text-xs"
164+
onClick={() => void terminate()}
165+
variant="secondary"
166+
>
167+
{t('write.publishProcess.terminate')}
168+
</Button>
169+
) : terminal ? (
170+
<Button
171+
aria-label={t('common.close')}
172+
className="size-10 shrink-0"
173+
iconOnly
174+
onClick={() => dismissPublishProcess(process.id)}
175+
variant="ghost"
176+
>
177+
<X aria-hidden="true" className="size-4" />
178+
</Button>
179+
) : null}
180+
</div>
181+
182+
<div className="mt-3 flex items-center gap-2">
183+
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-border">
184+
<div
185+
className={cn(
186+
'h-full transition-[width] duration-300',
187+
process.phase === 'failed' ? 'bg-red-500' : 'bg-accent',
188+
)}
189+
style={{ width: `${progress}%` }}
190+
/>
191+
</div>
192+
<span className="w-9 text-right text-xs text-fg-muted tabular-nums">
193+
{progress}%
194+
</span>
195+
</div>
196+
197+
<ul className="mt-2 divide-y divide-border/70">
198+
{process.resources.map((resource) => (
199+
<ResourceProgress key={resource.resource} resource={resource} />
200+
))}
201+
</ul>
202+
203+
{process.error ? (
204+
<p className="mt-2 text-xs break-words text-red-600 dark:text-red-400">
205+
{process.error}
206+
</p>
207+
) : null}
208+
</section>
209+
)
210+
}
211+
212+
function ResourceProgress({ resource }: { resource: PublishProcessResource }) {
213+
const { t } = useI18n()
214+
const meta = CAPABILITY_META[resource.resource]
215+
const CapabilityIcon = meta.icon
216+
const StatusIcon =
217+
resource.status === 'queued' ? Clock : statusIcon[resource.status]
218+
const progress = getResourceProgress(resource)
219+
const progressLabel = resource.task
220+
? getTaskProgressLabel(resource.task)
221+
: null
222+
223+
return (
224+
<li className="flex min-h-10 items-center gap-2 py-1.5 text-xs">
225+
<CapabilityIcon
226+
aria-hidden="true"
227+
className="size-4 shrink-0 text-fg-muted"
228+
/>
229+
<span className="min-w-0 flex-1 truncate">{t(meta.labelKey)}</span>
230+
<span className="shrink-0 text-fg-muted tabular-nums">
231+
{progressLabel ?? `${progress}%`}
232+
</span>
233+
{resource.taskId ? (
234+
<Link
235+
aria-label={t('write.publishProcess.viewTask')}
236+
className="inline-flex size-10 shrink-0 items-center justify-center rounded-sm text-fg-muted transition-colors hover:bg-surface-inset hover:text-fg focus-visible:outline-hidden focus-visible:ring-[3px] focus-visible:ring-accent/15"
237+
to={`/tasks/${encodeURIComponent(resource.taskId)}`}
238+
>
239+
<ExternalLink aria-hidden="true" className="size-3.5" />
240+
</Link>
241+
) : null}
242+
<span className="inline-flex min-w-20 items-center justify-end gap-1.5 text-fg-muted">
243+
<StatusIcon
244+
aria-hidden="true"
245+
className={cn(
246+
'size-3.5',
247+
resource.status === 'queued'
248+
? 'text-fg-subtle'
249+
: statusIconClassName(resource.status),
250+
resource.status === AITaskStatus.Running && 'animate-spin',
251+
)}
252+
/>
253+
{resource.status === 'queued'
254+
? t('write.publishProcess.queued')
255+
: t(taskStatusLabelKeys[resource.status])}
256+
</span>
257+
</li>
258+
)
259+
}
260+
261+
function getResourceProgress(resource: PublishProcessResource) {
262+
if (resource.status === AITaskStatus.Completed) return 100
263+
const progress = resource.task ? getProgress(resource.task) : 0
264+
return Math.round(Math.min(100, Math.max(0, progress ?? 0)))
265+
}
266+
267+
function getProcessProgress(process: PublishProcess) {
268+
if (process.phase === 'completed' || process.phase === 'publishing')
269+
return 100
270+
const total = process.resources.reduce(
271+
(sum, resource) => sum + getResourceProgress(resource),
272+
0,
273+
)
274+
return Math.round(total / process.resources.length)
275+
}
276+
277+
function getPhaseIcon(phase: PublishProcessPhase) {
278+
if (phase === 'completed') return CheckCircle2
279+
if (phase === 'failed') return AlertCircle
280+
if (phase === 'cancelled') return XCircle
281+
return Loader2
282+
}
283+
284+
function getCurrentProcess(processes: PublishProcess[]) {
285+
for (let index = processes.length - 1; index >= 0; index -= 1) {
286+
const process = processes[index]
287+
if (isPublishProcessActive(process.phase)) return process
288+
}
289+
return processes.at(-1)!
290+
}

0 commit comments

Comments
 (0)