diff --git a/plugin/package-lock.json b/plugin/package-lock.json index 61b6bd1..726cb82 100644 --- a/plugin/package-lock.json +++ b/plugin/package-lock.json @@ -14,6 +14,7 @@ "react-dom": "^19.2.7", "react-markdown": "^10.1.0", "remark-gfm": "^4.0.1", + "sonner": "^2.0.7", "zod": "^4.4.3" }, "devDependencies": { @@ -5850,6 +5851,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/sonner": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz", + "integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==", + "license": "MIT", + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", diff --git a/plugin/package.json b/plugin/package.json index 1dfe4d9..1a84124 100644 --- a/plugin/package.json +++ b/plugin/package.json @@ -56,6 +56,7 @@ "react-dom": "^19.2.7", "react-markdown": "^10.1.0", "remark-gfm": "^4.0.1", + "sonner": "^2.0.7", "zod": "^4.4.3" } } diff --git a/plugin/src/client.ts b/plugin/src/client.ts index 253d845..cfd9c30 100644 --- a/plugin/src/client.ts +++ b/plugin/src/client.ts @@ -1,8 +1,8 @@ import { getApiUrlAndToken } from '@ds-wizard/plugin-sdk/requests' import type { - PipelineRunResponse, PipelineStatusResponse, + PipelineSummaryItem, TemplateDetail, TemplateOption, TemplateScope, @@ -103,6 +103,28 @@ export const getPipelineStatus = async (runId: string): Promise => { + const url = `${getApiBaseUrl()}/pipelines?questionnaireUuid=${encodeURIComponent(questionnaireUuid)}` + const response = await apiFetch(url) + const data = await readApiResponse(response, url) + + if (!response.ok) { + throw new Error( + 'detail' in data && data.detail + ? data.detail + : 'Failed to load the generation history.', + ) + } + + if (!Array.isArray(data)) { + throw new Error('Invalid generation history returned.') + } + + return data +} + type RunPipelineParams = { questionnaireUuid: string templateUuid: string @@ -119,7 +141,7 @@ export const runPipeline = async ({ llmApiKey = null, llmApiUrl = null, llmMaxWorkers = null, -}: RunPipelineParams): Promise => { +}: RunPipelineParams): Promise => { const url = `${getApiBaseUrl()}/pipelines/run` const response = await apiFetch(url, { method: 'POST', @@ -136,7 +158,7 @@ export const runPipeline = async ({ }), }) - const data = await readApiResponse(response, url) + const data = await readApiResponse(response, url) if (!response.ok) { if (response.status == 422) { @@ -150,7 +172,7 @@ export const runPipeline = async ({ ) } - if (!('runId' in data)) { + if (!isPipelineStatusResponse(data)) { throw new Error('The backend did not return a pipeline run identifier.') } diff --git a/plugin/src/components/CustomTemplateSection.tsx b/plugin/src/components/CustomTemplateSection.tsx index 9d7dc6f..b34842c 100644 --- a/plugin/src/components/CustomTemplateSection.tsx +++ b/plugin/src/components/CustomTemplateSection.tsx @@ -1,8 +1,8 @@ import { ChangeEvent, DragEvent, useEffect, useRef, useState } from 'react' +import { toast } from 'sonner' import { createTemplate, updateTemplate } from '@/client' import styles from '@/components/CustomTemplateSection.module.css' -import { FeedbackAlert } from '@/components/FeedbackAlert' import { TemplateStructureEditor } from '@/components/TemplateStructureEditor' import type { ApiTemplateContent, TemplateOption, TemplateScope } from '@/types' @@ -34,7 +34,6 @@ export function CustomTemplateSection({ editingTemplate ? JSON.stringify(editingTemplate.content, null, 2) : '', ) const [fileName, setFileName] = useState('') - const [error, setError] = useState(null) const [isSaving, setIsSaving] = useState(false) const [inputMode, setInputMode] = useState<'visual' | 'file'>('visual') const [isDraggingFile, setIsDraggingFile] = useState(false) @@ -46,7 +45,6 @@ export function CustomTemplateSection({ setTitle(editingTemplate?.title ?? '') setJson(editingTemplate ? JSON.stringify(editingTemplate.content, null, 2) : '') setFileName('') - setError(null) setInputMode('visual') }, [editingTemplate]) @@ -58,9 +56,8 @@ export function CustomTemplateSection({ if (!title.trim()) { setTitle(file.name.replace(/\.json$/i, '')) } - setError(null) } catch { - setError('Failed to read the selected JSON file.') + toast.error('Failed to read the selected JSON file.') } } @@ -114,12 +111,12 @@ export function CustomTemplateSection({ const trimmedJson = json.trim() if (!trimmedTitle) { - setError('Enter a template title.') + toast.error('Enter a template title.') return } if (!trimmedJson) { - setError('Insert or upload template JSON.') + toast.error('Insert or upload template JSON.') return } @@ -147,10 +144,11 @@ export function CustomTemplateSection({ setJson('') setFileName('') } - setError(null) onSaved(data) } catch (saveError) { - setError(saveError instanceof Error ? saveError.message : 'Template JSON is not valid.') + toast.error( + saveError instanceof Error ? saveError.message : 'Template JSON is not valid.', + ) } finally { setIsSaving(false) } @@ -265,8 +263,6 @@ export function CustomTemplateSection({ ) : null} - - {error ? {error} : null} ) } diff --git a/plugin/src/components/HistorySidebar.module.css b/plugin/src/components/HistorySidebar.module.css new file mode 100644 index 0000000..9d61c3b --- /dev/null +++ b/plugin/src/components/HistorySidebar.module.css @@ -0,0 +1,245 @@ +.root { + /* Content height of a two-line run item (title + timestamp) — every item matches it. */ + --ai-doc-history-item-body: calc(1.5 * 0.9rem + 0.1rem + 1.5 * 0.75rem); + --ai-doc-history-radius: var(--bs-border-radius); + --ai-doc-history-line: var(--ai-doc-color-slate-250); + /* Doubles as the list's inset, so nothing is faded until it actually scrolls. */ + --ai-doc-history-fade: 0.75rem; + + display: flex; + flex-direction: column; + width: 17rem; + flex-shrink: 0; + position: sticky; + top: 0; + align-self: flex-start; + height: 100%; + padding: 1.5rem 0 1.5rem 0.75rem; + /* Anchors the negative z-index of the divider below. */ + isolation: isolate; +} + +/* + * Divider towards the main panel. It sits behind everything in the sidebar: + * items leave their last pixel transparent so it shows through, and the + * selected one paints over it. It cannot ride above the list instead — the + * list is a stacking context (mask), so the selected item's z-index could + * never escape it. + */ +.root::after { + content: ''; + position: absolute; + top: 0; + right: 0; + bottom: 0; + width: 1px; + background: var(--ai-doc-history-line); + z-index: -1; +} + +.item, +.newButton { + display: flex; + align-items: center; + gap: 0.55rem; + position: relative; + width: 100%; + min-height: calc(var(--ai-doc-history-item-body) + 0.9rem + 2px); + padding: 0.45rem 0.7rem; + /* Keep focus/scrollIntoView from parking an item flush against the clip edge. */ + scroll-margin-block: calc(var(--ai-doc-history-radius) + 2px); + border: 1px solid var(--ai-doc-color-slate-200); + /* Last pixel is left to the divider until the item is selected. */ + border-right-color: transparent; + border-radius: var(--ai-doc-history-radius) 0 0 var(--ai-doc-history-radius); + background: var(--ai-doc-color-slate-50); + background-clip: padding-box; + color: var(--ai-doc-color-slate-800); + font-size: 0.9rem; + font-weight: 500; + text-align: left; + transition: + background-color 120ms ease, + border-color 120ms ease; +} + +.item:hover:not(.itemActive), +.newButton:hover:not(:disabled) { + background: var(--ai-doc-color-slate-100); + border-color: var(--ai-doc-color-slate-300); + border-right-color: transparent; +} + +/* Selected item reads as the open page: white and merged with the main panel. */ +.itemActive, +.newButton:disabled { + background: var(--ai-doc-color-white); + /* Border box, so the white runs under the transparent right border and hides the divider. */ + background-clip: border-box; + border-color: var(--ai-doc-history-line); + border-right-color: transparent; + color: var(--ai-doc-color-slate-950); + font-weight: 600; + z-index: 1; +} + +/* Two class selectors, to outrank the host's `button` cursor rule. */ +.item.itemActive, +.newButton.itemActive, +.newButton:disabled { + cursor: default; +} + +/* + * Concave corners that carry the divider into the selected item's top and + * bottom edge, so the tab flows into the content instead of butting against it. + * Each is a white patch over the divider with the arc drawn by a radial + * gradient centred on the corner the curve turns around. + */ +.itemActive::before, +.itemActive::after, +.newButton:disabled::before, +.newButton:disabled::after { + content: ''; + position: absolute; + right: -1px; + width: var(--ai-doc-history-radius); + height: calc(var(--ai-doc-history-radius) + 1px); +} + +.itemActive::before, +.newButton:disabled::before { + top: calc(-1px - var(--ai-doc-history-radius)); + background: radial-gradient( + circle at 0 0, + var(--ai-doc-color-white) calc(var(--ai-doc-history-radius) - 1px), + var(--ai-doc-history-line) calc(var(--ai-doc-history-radius) - 1px), + var(--ai-doc-history-line) var(--ai-doc-history-radius), + var(--ai-doc-color-white) var(--ai-doc-history-radius) + ); +} + +.itemActive::after, +.newButton:disabled::after { + bottom: calc(-1px - var(--ai-doc-history-radius)); + background: radial-gradient( + circle at 0 100%, + var(--ai-doc-color-white) calc(var(--ai-doc-history-radius) - 1px), + var(--ai-doc-history-line) calc(var(--ai-doc-history-radius) - 1px), + var(--ai-doc-history-line) var(--ai-doc-history-radius), + var(--ai-doc-color-white) var(--ai-doc-history-radius) + ); +} + +.list { + display: flex; + flex-direction: column; + flex: 1 1 0; + /* Room for the selected item's concave corners between neighbours. */ + gap: calc(var(--ai-doc-history-radius) + 2px); + padding: var(--ai-doc-history-fade) 0; + min-height: 0; + overflow-y: auto; + overscroll-behavior: contain; + /* + * The scrollbar would sit exactly where the items meet the divider, so it is + * hidden and overflow is signalled by fading both ends instead. The fade also + * softens concave corners clipped at the container edge. + */ + scrollbar-width: none; + -webkit-mask-image: linear-gradient( + to bottom, + transparent 0, + #000 var(--ai-doc-history-fade), + #000 calc(100% - var(--ai-doc-history-fade)), + transparent 100% + ); + mask-image: linear-gradient( + to bottom, + transparent 0, + #000 var(--ai-doc-history-fade), + #000 calc(100% - var(--ai-doc-history-fade)), + transparent 100% + ); +} + +.list::-webkit-scrollbar { + display: none; +} + +/* + * Overlay scrollbar replacing the hidden native one. It sits in the sidebar's + * left gutter rather than on the right, where it would land on the divider and + * the selected item. Position and size are set from the list's scroll metrics. + */ +.scrollThumb { + position: absolute; + right: 4px; + width: 4px; + border-radius: 2px; + background: var(--ai-doc-color-slate-300); + opacity: 0; + z-index: 2; + touch-action: none; + transition: + opacity 120ms ease, + background-color 120ms ease; +} + +/* Only shown while the pointer is over the list itself (or the thumb). */ +.list:hover ~ .scrollThumb, +.scrollThumb:hover, +.scrollThumb:active { + opacity: 1; +} + +.scrollThumb:hover, +.scrollThumb:active { + background: var(--ai-doc-color-slate-400); +} + +.itemBody { + display: flex; + flex-direction: column; + gap: 0.1rem; + min-width: 0; +} + +.itemTitle { + line-height: 1.5; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.itemTime { + font-size: 0.75rem; + font-weight: 400; + line-height: 1.5; + color: var(--ai-doc-color-slate-500); +} + +.statusIcon { + flex-shrink: 0; + font-size: 0.8rem; + color: var(--ai-doc-color-slate-500); +} + +.statusIcon.queued, +.statusIcon.running { + color: var(--bs-primary); +} + +.statusIcon.succeeded { + color: var(--ai-doc-color-emerald-700); +} + +.statusIcon.failed { + color: var(--ai-doc-color-red-700); +} + +.emptyState { + padding: 0.5rem 0.7rem; + color: var(--ai-doc-color-slate-500); + font-size: 0.85rem; +} diff --git a/plugin/src/components/HistorySidebar.tsx b/plugin/src/components/HistorySidebar.tsx new file mode 100644 index 0000000..f263124 --- /dev/null +++ b/plugin/src/components/HistorySidebar.tsx @@ -0,0 +1,184 @@ +import { + type PointerEvent as ReactPointerEvent, + useCallback, + useEffect, + useRef, + useState, +} from 'react' + +import styles from '@/components/HistorySidebar.module.css' +import type { RunRecord, UseGenerationHistoryResult } from '@/hooks/useGenerationHistory' + +type HistorySidebarProps = { + history: UseGenerationHistoryResult + selectedRunId: string | null + onSelectNew: () => void + onSelectRun: (runId: string) => void +} + +const STATUS_ICON: Record = { + queued: 'fas fa-spinner fa-spin', + running: 'fas fa-spinner fa-spin', + succeeded: 'fas fa-check-circle', + failed: 'fas fa-exclamation-circle', +} + +const RELATIVE_TIME_DIVISIONS: [Intl.RelativeTimeFormatUnit, number][] = [ + ['year', 60 * 60 * 24 * 365], + ['month', 60 * 60 * 24 * 30], + ['day', 60 * 60 * 24], + ['hour', 60 * 60], + ['minute', 60], +] + +const relativeTimeFormatter = new Intl.RelativeTimeFormat('en', { numeric: 'auto' }) + +const MIN_THUMB_HEIGHT = 24 + +type ScrollThumb = { top: number; height: number } + +const formatRelativeTime = (isoString: string): string => { + const date = new Date(isoString) + if (Number.isNaN(date.getTime())) { + return '' + } + + const diffSeconds = Math.round((date.getTime() - Date.now()) / 1000) + + for (const [unit, secondsInUnit] of RELATIVE_TIME_DIVISIONS) { + if (Math.abs(diffSeconds) >= secondsInUnit) { + return relativeTimeFormatter.format(Math.round(diffSeconds / secondsInUnit), unit) + } + } + + return 'a few seconds ago' +} + +export function HistorySidebar({ + history, + selectedRunId, + onSelectNew, + onSelectRun, +}: HistorySidebarProps) { + const { items, isLoading } = history + const activeItemRef = useRef(null) + const listRef = useRef(null) + const [thumb, setThumb] = useState(null) + + // The native scrollbar is hidden (it would sit on the divider), so the + // overlay thumb below is measured from the list instead. + const updateThumb = useCallback(() => { + const list = listRef.current + if (!list) { + return + } + + const { scrollTop, scrollHeight, clientHeight } = list + if (scrollHeight - clientHeight < 1) { + setThumb(null) + return + } + + const height = Math.max((clientHeight / scrollHeight) * clientHeight, MIN_THUMB_HEIGHT) + const top = + (scrollTop / (scrollHeight - clientHeight)) * (clientHeight - height) + list.offsetTop + setThumb({ top, height }) + }, []) + + useEffect(() => { + updateThumb() + }, [updateThumb, items.length]) + + useEffect(() => { + const list = listRef.current + if (!list) { + return + } + + const observer = new ResizeObserver(updateThumb) + observer.observe(list) + return () => observer.disconnect() + }, [updateThumb]) + + // The selected run can be off-screen after a new run starts or the list loads. + useEffect(() => { + activeItemRef.current?.scrollIntoView({ block: 'nearest' }) + }, [selectedRunId, items.length]) + + const handleThumbPointerDown = (event: ReactPointerEvent) => { + const list = listRef.current + if (!list || !thumb) { + return + } + + event.preventDefault() + const startY = event.clientY + const startScrollTop = list.scrollTop + const trackRange = list.clientHeight - thumb.height + const scrollRange = list.scrollHeight - list.clientHeight + + const handleMove = (moveEvent: PointerEvent) => { + const ratio = trackRange > 0 ? (moveEvent.clientY - startY) / trackRange : 0 + list.scrollTop = startScrollTop + ratio * scrollRange + } + + const handleUp = () => { + window.removeEventListener('pointermove', handleMove) + window.removeEventListener('pointerup', handleUp) + } + + window.addEventListener('pointermove', handleMove) + window.addEventListener('pointerup', handleUp) + } + + return ( +