diff --git a/webclient/src/components/artifacts/ArtifactsPanel.vue b/webclient/src/components/artifacts/ArtifactsPanel.vue index 41296c0..346833c 100644 --- a/webclient/src/components/artifacts/ArtifactsPanel.vue +++ b/webclient/src/components/artifacts/ArtifactsPanel.vue @@ -423,20 +423,16 @@ diff --git a/webclient/src/components/artifacts/composables/useArtifactsView.js b/webclient/src/components/artifacts/composables/useArtifactsView.js new file mode 100644 index 0000000..a3167b7 --- /dev/null +++ b/webclient/src/components/artifacts/composables/useArtifactsView.js @@ -0,0 +1,257 @@ +import { computed, ref, watch } from 'vue' + +import { useLightbox } from '@/composables/useLightbox.js' +import { locale, t } from '@/i18n/index.js' +import { itemContentType } from '@/utils/artifactContent.js' +import { + absoluteContentUrl, + contentFileUrl, + versionedContentUrl, + viewerContentUrl, +} from '@/utils/contentLinks.js' +import { + CONTENT_TYPE_ICONS, + isFrameType, + isFullscreenType, + viewerNameFor, +} from '@/utils/contentTypes.js' +import { highlightSource, sourceLanguage } from '@/utils/sourceHighlight.js' + +/** + * The Artifacts tab: which published artifact is selected, its source or text + * preview, and every URL the pane offers (preview, raw, download, share). + */ + +const TEXT_PREVIEW_LIMIT = 1_000_000 + +export function useArtifactsView(chat) { + const { open: openLightbox, openContent } = useLightbox() + + const selectedKey = ref(null) + const copiedKey = ref(null) + const showSource = ref(false) + const sourceText = ref('') + const sourceLoading = ref(false) + const sourceError = ref('') + // Text files are fetched into the preview rather than offered as a download. + const textContent = ref('') + const textLoading = ref(false) + const textError = ref('') + const textTruncated = ref(false) + const textPreviewLimitLabel = `${Math.round(TEXT_PREVIEW_LIMIT / 1024)} KB` + + const artifacts = computed(() => chat.artifacts) + const selected = computed(() => artifacts.value.find(item => artifactKey(item) === selectedKey.value) || artifacts.value[0] || null) + const selectedSourceLanguage = computed(() => selected.value ? sourceLanguage(selected.value) : 'plaintext') + const highlightedSource = computed(() => highlightSource(sourceText.value, selectedSourceLanguage.value)) + const highlightedText = computed(() => highlightSource(textContent.value, selectedSourceLanguage.value)) + + function openViewedImage(src) { + openLightbox(src) + } + + async function loadText(item) { + if (!item || textLoading.value || textContent.value) return + textLoading.value = true + textError.value = '' + try { + const res = await fetch(versionedUrl(item)) + if (!res.ok) throw new Error(`HTTP ${res.status}`) + const raw = await res.text() + textTruncated.value = raw.length > TEXT_PREVIEW_LIMIT + textContent.value = textTruncated.value ? raw.slice(0, TEXT_PREVIEW_LIMIT) : raw + } catch (err) { + textError.value = t('artifacts.sourceFailed', { error: err?.message || err }) + } finally { + textLoading.value = false + } + } + + // A different artifact selected → start its text over, and fetch it right + // away (the detail pane is always visible, unlike the card's collapsed body). + watch(selected, (item) => { + textContent.value = '' + textError.value = '' + textTruncated.value = false + if (item && !showSource.value && contentType(item) === 'text') loadText(item) + }, { immediate: true }) + + watch( + artifacts, + (items) => { + if (!items.length) { + selectedKey.value = null + return + } + if (!selectedKey.value || !items.some(item => artifactKey(item) === selectedKey.value)) { + selectedKey.value = artifactKey(items[0]) + } + }, + { immediate: true } + ) + + function artifactKey(item) { + return item.id || item.filename + } + + function selectArtifact(item) { + selectedKey.value = artifactKey(item) + showSource.value = false + sourceText.value = '' + sourceError.value = '' + } + + // The backend detects the type; the name is the fallback for entries that + // arrive without one. Both cards read the same table now. + function contentType(item) { + return itemContentType(item) + } + + function iconClass(item) { + return CONTENT_TYPE_ICONS[contentType(item)] || CONTENT_TYPE_ICONS.unknown + } + + function labelFor(item) { + const type = contentType(item).toUpperCase() + const date = item.updated_at || item.created_at + if (!date) return type + return `${type} · ${formatDate(date)}` + } + + function formatDate(value) { + const date = new Date(value) + if (Number.isNaN(date.getTime())) return value + return date.toLocaleTimeString(locale.value, { hour: '2-digit', minute: '2-digit' }) + } + + function versionedUrl(item) { + return versionedContentUrl(item.url, item.updated_at || item.created_at || '') + } + + function viewerUrl(item, viewerType) { + return viewerContentUrl(viewerType, versionedUrl(item)) + } + + function previewUrl(item) { + const type = contentType(item) + return isFrameType(type) + ? viewerUrl(item, viewerNameFor(type)) + : absoluteContentUrl(versionedUrl(item)) + } + + function canFullscreen(item) { + return isFullscreenType(contentType(item)) + } + + function openFullscreen(item) { + if (!item || !canFullscreen(item)) return + openContent({ + url: absoluteContentUrl(versionedUrl(item)), + type: contentType(item), + title: item.title || item.filename, + }) + } + + function rawUrl(item) { + return contentFileUrl(item.url) + } + + function sourceUrl(item) { + const type = contentType(item) + if (['html', 'svg'].includes(type)) return rawUrl(item) + if (['stl', 'model'].includes(type) && item.source_url) return contentFileUrl(item.source_url) + return '' + } + + function hasSource(item) { + return Boolean(sourceUrl(item)) + } + + async function toggleSource(item) { + showSource.value = !showSource.value + if (showSource.value) await loadSource(item) + } + + async function loadSource(item) { + const url = sourceUrl(item) + if (!url || sourceText.value || sourceLoading.value) return + sourceLoading.value = true + sourceError.value = '' + try { + const res = await fetch(url) + if (!res.ok) throw new Error(`HTTP ${res.status}`) + sourceText.value = await res.text() + } catch (err) { + sourceError.value = t('artifacts.sourceFailed', { error: err?.message || err }) + } finally { + sourceLoading.value = false + } + } + + function shareUrl(item) { + if (['stl', 'model'].includes(contentType(item))) return previewUrl(item) + return absoluteContentUrl(versionedUrl(item)) + } + + function downloadUrl(item) { + const url = contentFileUrl(item.download_url || item.url) + if (item.download_url || !url) return url + return `${url}${url.includes('?') ? '&' : '?'}download=1` + } + + function isFramePreview(item) { + return isFrameType(contentType(item)) + } + + async function copyLink(item) { + try { + await navigator.clipboard.writeText(shareUrl(item)) + copiedKey.value = artifactKey(item) + setTimeout(() => { + if (copiedKey.value === artifactKey(item)) copiedKey.value = null + }, 1500) + } catch { + // Clipboard can fail on non-secure origins; ignore. + } + } + + function copyIcon(item) { + return copiedKey.value === artifactKey(item) ? 'ph ph-check' : 'ph ph-copy' + } + + return { + selectedKey, + copiedKey, + showSource, + sourceText, + sourceLoading, + sourceError, + textContent, + textLoading, + textError, + textTruncated, + textPreviewLimitLabel, + artifacts, + selected, + selectedSourceLanguage, + highlightedSource, + highlightedText, + openViewedImage, + artifactKey, + selectArtifact, + contentType, + iconClass, + labelFor, + versionedUrl, + previewUrl, + canFullscreen, + openFullscreen, + rawUrl, + hasSource, + toggleSource, + downloadUrl, + isFramePreview, + copyLink, + copyIcon, + } +} diff --git a/webclient/src/components/artifacts/composables/useBackgrounds.js b/webclient/src/components/artifacts/composables/useBackgrounds.js new file mode 100644 index 0000000..d98de64 --- /dev/null +++ b/webclient/src/components/artifacts/composables/useBackgrounds.js @@ -0,0 +1,184 @@ +import { computed, ref, watch } from 'vue' + +import { locale, t } from '@/i18n/index.js' + +/** + * The Backgrounds tab: running/finished tasks and terminals in one list, the + * row that is selected in the detail pane, and the labels/ages it shows. + */ + +const TASK_ICONS = { + terminal: 'ph ph-terminal-window', + ssh_exec: 'ph ph-plugs-connected', + spawn_agent: 'ph ph-user-circle-gear', + code_exec: 'ph ph-code', + peer: 'ph ph-chats-circle', +} + +export function useBackgrounds(chat) { + // { kind: 'task' | 'terminal', key } + const selectedBg = ref(null) + + const backgroundsList = computed(() => { + const tasks = Object.values(chat.backgroundTasks).map(t => ({ + kind: 'task', + key: t.taskId, + running: t.status === 'running', + startedAt: t.startedAt ?? '', + task: t, + })) + const terminals = Object.values(chat.terminals) + .filter(t => !t.closed || t.output) + .map(t => ({ + kind: 'terminal', + key: t.name, + running: t.status === 'busy' && !t.closed, + startedAt: '', + terminal: t, + })) + const all = [...tasks, ...terminals] + return all.sort((a, b) => { + // running first, then newest started on top + if (a.running !== b.running) return a.running ? -1 : 1 + return String(b.startedAt ?? '').localeCompare(String(a.startedAt ?? '')) + }) + }) + + const activeBackgroundCount = computed(() => + backgroundsList.value.filter(i => i.running).length, + ) + + const selectedTask = computed(() => + selectedBg.value?.kind === 'task' ? (chat.backgroundTasks[selectedBg.value.key] || null) : null, + ) + const selectedTerminal = computed(() => + selectedBg.value?.kind === 'terminal' ? (chat.terminals[selectedBg.value.key] || null) : null, + ) + + // A task can finish (TTL, cancel) while selected — drop a dangling selection. + watch(() => [selectedTask.value, selectedTerminal.value], ([task, term]) => { + if (selectedBg.value && !task && !term) selectedBg.value = null + }) + + // Selection refers to per-session ids — reset on session switch. + watch(() => chat.currentId, () => { selectedBg.value = null }) + + function selectBackground(item) { + const sel = { kind: item.kind, key: item.key } + selectedBg.value = selectedBg.value?.kind === sel.kind && selectedBg.value?.key === sel.key ? null : sel + } + + function isBgSelected(item) { + return Boolean( + selectedBg.value && + selectedBg.value.kind === item.kind && + selectedBg.value.key === item.key, + ) + } + + function backgroundIcon(item) { + return item.kind === 'terminal' + ? 'ph ph-terminal-window' + : (TASK_ICONS[item.task.tool] || 'ph ph-arrows-out') + } + + function backgroundTitle(item) { + return item.kind === 'terminal' + ? item.terminal.name + : (item.task.argsSummary || item.task.tool) + } + + function backgroundMeta(item) { + return item.kind === 'terminal' + ? (item.terminal.description || item.terminal.command || t('artifacts.terminal')) + : t('artifacts.toolAndAge', { tool: item.task.tool, age: taskAge(item.task) }) + } + + function backgroundBadgeClass(item) { + if (item.kind === 'terminal') { + if (item.terminal.closed) return 'badge-error' + return item.terminal.status === 'busy' ? 'badge-success' : 'badge-secondary' + } + return taskBadgeBgClass(item.task) + } + + function backgroundBadgeLabel(item) { + if (item.kind === 'terminal') { + if (item.terminal.closed) return t('artifacts.statusClosed') + return t(item.terminal.status === 'busy' ? 'artifacts.statusBusy' : 'artifacts.statusIdle') + } + return taskBadgeLabel(item.task) + } + + function taskAge(task) { + const ts = task.finishedAt ?? task.startedAt + if (!ts) return '' + let seconds = (Date.now() - new Date(ts).getTime()) / 1000 + if (!Number.isFinite(seconds) || seconds < 0) return t('common.justNow') + if (seconds < 60) return `${Math.round(seconds)}s` + if (seconds < 3600) return `${Math.floor(seconds / 60)}m` + if (seconds < 86400) return `${Math.floor(seconds / 3600)}h` + return `${Math.floor(seconds / 86400)}d` + } + + function taskBadgeClass(task) { + if (task.status === 'running') return 'status-busy' + if (task.status === 'completed') return 'status-idle' + return 'status-closed' // failed / cancelled + } + + // Kit badge variant for list badges (filled chip look). + function taskBadgeBgClass(task) { + if (task.status === 'running') return 'badge-success' + if (task.status === 'completed') return 'badge-secondary' + return 'badge-error' // failed / cancelled + } + + function taskBadgeLabel(task) { + const map = { + running: t('artifacts.statusRunning'), + completed: t('artifacts.statusDone'), + failed: t('artifacts.statusFailed'), + cancelled: t('artifacts.statusStopped'), + } + return map[task.status] || task.status + } + + function formatTime(value) { + if (!value) return t('common.notAvailable') + const date = new Date(value) + if (Number.isNaN(date.getTime())) return value + return date.toLocaleTimeString(locale.value, { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }) + } + + function formatUptime(seconds) { + if (seconds < 60) return `${Math.round(seconds)}s` + if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${Math.round(seconds % 60)}s` + const h = Math.floor(seconds / 3600) + const m = Math.floor((seconds % 3600) / 60) + return `${h}h ${m}m` + } + + return { + selectedBg, + backgroundsList, + activeBackgroundCount, + selectedTask, + selectedTerminal, + selectBackground, + isBgSelected, + backgroundIcon, + backgroundTitle, + backgroundMeta, + backgroundBadgeClass, + backgroundBadgeLabel, + taskBadgeClass, + taskBadgeLabel, + formatTime, + formatUptime, + } +} diff --git a/webclient/src/components/artifacts/composables/useDrawerWidth.js b/webclient/src/components/artifacts/composables/useDrawerWidth.js new file mode 100644 index 0000000..3b8e8ed --- /dev/null +++ b/webclient/src/components/artifacts/composables/useDrawerWidth.js @@ -0,0 +1,100 @@ +import { computed, onBeforeUnmount, onMounted, ref } from 'vue' + +/** + * The docked panel's width: the persisted preference, the viewport clamp, and + * the pointer drag that resizes it. Below the desktop breakpoint the panel is + * a full-screen drawer and none of this applies. + */ + +const DRAWER_DESKTOP_BREAKPOINT = 980 +const CHAT_MIN_WIDTH = 500 +const DRAWER_WIDTH_STORAGE_KEY = 'navi:artifacts-drawer-width' + +export function useDrawerWidth() { + const isResizing = ref(false) + const viewportWidth = ref(typeof window === 'undefined' ? 0 : window.innerWidth) + + const drawerWidth = ref(readSavedDrawerWidth()) + + const isDesktop = computed(() => viewportWidth.value > DRAWER_DESKTOP_BREAKPOINT) + const panelStyle = computed(() => { + if (!isDesktop.value) return {} + return { '--artifacts-width': `${currentDrawerWidth()}px` } + }) + + function minDrawerWidth() { + return Math.round(Math.min(600, viewportWidth.value * 0.42)) + } + + function maxDrawerWidth() { + return Math.max(minDrawerWidth(), viewportWidth.value - CHAT_MIN_WIDTH) + } + + function clampDrawerWidth(width) { + return Math.min(Math.max(Math.round(width), minDrawerWidth()), maxDrawerWidth()) + } + + function currentDrawerWidth() { + return clampDrawerWidth(drawerWidth.value ?? minDrawerWidth()) + } + + function syncViewportWidth() { + viewportWidth.value = window.innerWidth + if (drawerWidth.value !== null) drawerWidth.value = currentDrawerWidth() + } + + function startResize(event) { + if (!isDesktop.value) return + event.preventDefault() + isResizing.value = true + document.body.style.cursor = 'col-resize' + document.body.style.userSelect = 'none' + window.addEventListener('pointermove', resizeDrawer) + window.addEventListener('pointerup', stopResize, { once: true }) + } + + function resizeDrawer(event) { + drawerWidth.value = clampDrawerWidth(viewportWidth.value - event.clientX) + } + + function stopResize() { + isResizing.value = false + saveDrawerWidth() + document.body.style.cursor = '' + document.body.style.userSelect = '' + window.removeEventListener('pointermove', resizeDrawer) + window.removeEventListener('pointerup', stopResize) + } + + function readSavedDrawerWidth() { + if (typeof window === 'undefined') return null + try { + const value = Number(window.localStorage.getItem(DRAWER_WIDTH_STORAGE_KEY)) + return Number.isFinite(value) && value > 0 ? value : null + } catch { + return null + } + } + + function saveDrawerWidth() { + if (typeof window === 'undefined' || drawerWidth.value === null) return + try { + window.localStorage.setItem(DRAWER_WIDTH_STORAGE_KEY, String(currentDrawerWidth())) + } catch { + // Storage may be unavailable in restricted browser contexts. + } + } + + onMounted(() => { + window.addEventListener('resize', syncViewportWidth) + }) + + onBeforeUnmount(() => { + window.removeEventListener('resize', syncViewportWidth) + window.removeEventListener('pointermove', resizeDrawer) + window.removeEventListener('pointerup', stopResize) + if (isResizing.value) stopResize() + }) + + return { isResizing, isDesktop, panelStyle, startResize } +} diff --git a/webclient/src/components/artifacts/composables/useSessionFiles.js b/webclient/src/components/artifacts/composables/useSessionFiles.js new file mode 100644 index 0000000..af39947 --- /dev/null +++ b/webclient/src/components/artifacts/composables/useSessionFiles.js @@ -0,0 +1,204 @@ +import { computed, ref } from 'vue' + +import { useLightbox } from '@/composables/useLightbox.js' +import { t } from '@/i18n/index.js' +import { itemContentType } from '@/utils/artifactContent.js' +import { absoluteContentUrl } from '@/utils/contentLinks.js' + +/** + * The Files tab: the session's directory tree with per-file download / view + * actions, the refresh button, and the mobile pull-to-refresh gesture. + */ + +const FILES_PTR_THRESHOLD = 80 +const FILES_PTR_MAX = 120 + +export function useSessionFiles(chat) { + const { openContent } = useLightbox() + + const filesRefreshing = ref(false) + + async function refreshFiles() { + if (filesRefreshing.value || !chat.currentId) return + filesRefreshing.value = true + try { + await chat.fetchFiles(chat.currentId) + } finally { + filesRefreshing.value = false + } + } + + // ── Pull-to-refresh for files list (mobile) ───────────────────────────── + const filesPtrPulling = ref(false) + const filesPtrOffset = ref(0) + const filesPtrTriggered = ref(false) + const filesListRef = ref(null) + + const filesPtrStyle = computed(() => ({ + transform: `translateY(${Math.min(filesPtrOffset.value, FILES_PTR_MAX)}px)`, + opacity: filesPtrPulling.value ? Math.min(filesPtrOffset.value / FILES_PTR_THRESHOLD, 1) : 0, + })) + + const filesPtrLabel = computed(() => { + if (filesRefreshing.value) return t('common.refreshing') + if (filesPtrTriggered.value) return t('common.releaseToRefresh') + return t('common.pullToRefresh') + }) + + let _filesPtrStartY = 0 + let _filesPtrStartScroll = 0 + + function onFilesTouchStart(e) { + const list = filesListRef.value + if (!list) return + _filesPtrStartScroll = list.scrollTop || 0 + if (_filesPtrStartScroll > 2) return + _filesPtrStartY = e.touches[0]?.clientY ?? 0 + filesPtrPulling.value = false + filesPtrOffset.value = 0 + filesPtrTriggered.value = false + } + + function onFilesTouchMove(e) { + if (_filesPtrStartScroll > 2 || !_filesPtrStartY) return + const y = e.touches[0]?.clientY ?? 0 + const delta = y - _filesPtrStartY + if (delta <= 0) { + filesPtrPulling.value = false + return + } + if (delta > 4) { + e.preventDefault() + } + filesPtrPulling.value = true + filesPtrOffset.value = delta + filesPtrTriggered.value = delta >= FILES_PTR_THRESHOLD + } + + function onFilesTouchEnd() { + if (filesPtrTriggered.value && !filesRefreshing.value) { + refreshFiles() + } + filesPtrPulling.value = false + filesPtrOffset.value = 0 + filesPtrTriggered.value = false + _filesPtrStartY = 0 + _filesPtrStartScroll = 0 + } + + const filesFlat = computed(() => chat.files) + const fileTree = computed(() => { + const list = filesFlat.value + return list.map(item => { + const parts = item.path.split('/') + const name = parts[parts.length - 1] + const depth = parts.length - 1 + return { ...item, name, depth } + }) + }) + + function contentType(item) { + return itemContentType(item) + } + + function fileIcon(item) { + if (item.is_dir) return 'ph ph-folder' + const ext = item.name.split('.').pop()?.toLowerCase() || '' + const map = { + js: 'ph ph-file-js', + ts: 'ph ph-file-ts', + py: 'ph ph-file-py', + html: 'ph ph-file-html', + css: 'ph ph-file-css', + // phosphor has no dedicated json glyph — file-code is its stand-in + json: 'ph ph-file-code', + md: 'ph ph-file-text', + txt: 'ph ph-file-text', + jpg: 'ph ph-file-image', + jpeg: 'ph ph-file-image', + png: 'ph ph-file-image', + gif: 'ph ph-file-image', + svg: 'ph ph-file-image', + webp: 'ph ph-file-image', + mp4: 'ph ph-file-video', + webm: 'ph ph-file-video', + mov: 'ph ph-file-video', + mp3: 'ph ph-file-audio', + wav: 'ph ph-file-audio', + ogg: 'ph ph-file-audio', + pdf: 'ph ph-file-pdf', + zip: 'ph ph-file-zip', + tar: 'ph ph-file-zip', + gz: 'ph ph-file-zip', + rar: 'ph ph-file-zip', + } + return map[ext] || 'ph ph-file' + } + + function fileMeta(item) { + if (item.is_dir) return t('artifacts.directory') + const size = item.size + if (size < 1024) return `${size} B` + if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB` + return `${(size / (1024 * 1024)).toFixed(1)} MB` + } + + function fileIndent(item) { + return { paddingLeft: `${12 + item.depth * 16}px` } + } + + function fileDownloadUrl(path) { + const sessionId = chat.currentId + if (!sessionId) return '#' + return `/api/sessions/${sessionId}/files/${encodeURIComponent(path)}?download=1` + } + + // "Open inline" used to append ?download=1 as well, so the eye button saved + // the file instead of showing it. + function filePreviewUrl(path) { + const sessionId = chat.currentId + if (!sessionId) return '#' + return `/api/sessions/${sessionId}/files/${encodeURIComponent(path)}` + } + + function canViewFile(item) { + if (!item || item.is_dir) return false + return contentType(item) !== 'unknown' + } + + // Every type with a viewer opens in the same modal the artifact preview uses: + // media plays there, text is fetched and highlighted, documents and 3D models + // get their iframe page. + function openFileInViewer(item) { + if (!item || item.is_dir) return + const type = contentType(item) + if (type === 'unknown') return + openContent({ + url: absoluteContentUrl(filePreviewUrl(item.path)), + type, + title: item.name, + }) + } + + return { + filesRefreshing, + refreshFiles, + filesPtrPulling, + filesPtrOffset, + filesPtrTriggered, + filesListRef, + filesPtrStyle, + filesPtrLabel, + onFilesTouchStart, + onFilesTouchMove, + onFilesTouchEnd, + filesFlat, + fileTree, + fileIcon, + fileMeta, + fileIndent, + fileDownloadUrl, + canViewFile, + openFileInViewer, + } +} diff --git a/webclient/src/components/artifacts/composables/useSessionLinks.js b/webclient/src/components/artifacts/composables/useSessionLinks.js new file mode 100644 index 0000000..b19208b --- /dev/null +++ b/webclient/src/components/artifacts/composables/useSessionLinks.js @@ -0,0 +1,125 @@ +import { computed } from 'vue' + +/** + * Every link the assistant wrote in this session: markdown links first, then + * bare URLs. A markdown link wins over a bare URL for the same page and keeps + * its title; a bare URL that sits inside a markdown href is skipped. + */ + +function stripTrailingPunctuation(url) { + return url.replace(/[.,;:!?)\]}+]+$/, '') +} + +function normalizeUrlForDedup(url) { + try { + const cleaned = stripTrailingPunctuation(url) + const parsed = new URL(cleaned, window.location.origin) + const host = parsed.hostname.toLowerCase().replace(/^www\./, '') + let pathname = parsed.pathname.toLowerCase() + pathname = pathname.replace(/\/$/, '') + return `${host}${pathname}` + } catch { + return stripTrailingPunctuation(url).toLowerCase() + } +} + +function domainOf(url) { + try { + return new URL(url).hostname.replace(/^www\./, '') + } catch { + return url.split('/')[2] || url + } +} + +function snippetOf(text, index, maxLen = 60) { + const start = Math.max(0, index - maxLen) + const end = Math.min(text.length, index + maxLen) + let s = text.slice(start, end).replace(/\s+/g, ' ').trim() + if (start > 0) s = '…' + s + if (end < text.length) s = s + '…' + return s +} + +export function useSessionLinks(chat) { + const links = computed(() => { + const seen = new Set() + const out = [] + // Ranges occupied by markdown link hrefs — bare URLs inside them are skipped + const protectedRanges = [] + + // Pass 1: Markdown links have priority — they carry a title / caption. + // We scan all messages first so a bare URL from a newer message never + // shadows a markdown link from an older one. + for (let i = chat.messages.length - 1; i >= 0; i--) { + const m = chat.messages[i] + if (m.role !== 'assistant') continue + const text = m.text || '' + let match + + const mdLinkRe = /\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g + while ((match = mdLinkRe.exec(text)) !== null) { + let url = match[2].trim() + const norm = normalizeUrlForDedup(url) + if (seen.has(norm)) continue + seen.add(norm) + out.push({ + url, + text: match[1].trim(), + snippet: snippetOf(text, match.index), + domain: domainOf(url), + }) + // Record the exact href range so bare-URL extraction skips it + const urlOffsetInMatch = match[0].lastIndexOf(match[2]) + const urlStart = match.index + urlOffsetInMatch + protectedRanges.push({ start: urlStart, end: urlStart + match[2].length }) + } + } + + // Pass 2: Bare URLs — skip if already seen or inside a markdown link href. + for (let i = chat.messages.length - 1; i >= 0; i--) { + const m = chat.messages[i] + if (m.role !== 'assistant') continue + const text = m.text || '' + let match + + const bareUrlRe = /https?:\/\/[^\s<>"{}|\\^`[\]]+/g + while ((match = bareUrlRe.exec(text)) !== null) { + let url = match[0].trim() + // Strip trailing punctuation BEFORE dedup so "path." and "path" match + url = stripTrailingPunctuation(url) + const norm = normalizeUrlForDedup(url) + if (seen.has(norm)) continue + // Skip bare URLs that fall inside a markdown link href + const matchStart = match.index + const matchEnd = match.index + match[0].length + const isInsideMarkdown = protectedRanges.some( + r => matchStart >= r.start && matchEnd <= r.end + ) + if (isInsideMarkdown) continue + seen.add(norm) + out.push({ + url, + text: '', + snippet: snippetOf(text, match.index), + domain: domainOf(url), + }) + } + } + + return out + }) + + function copyAllLinks() { + const lines = links.value.map(l => l.url) + if (!lines.length) return + navigator.clipboard.writeText(lines.join('\n')).catch(() => {}) + } + + function openAllLinks() { + for (const l of links.value.slice(0, 8)) { + window.open(l.url, '_blank', 'noopener,noreferrer') + } + } + + return { links, copyAllLinks, openAllLinks } +} diff --git a/webclient/src/components/artifacts/styles/_backgrounds.scss b/webclient/src/components/artifacts/styles/_backgrounds.scss new file mode 100644 index 0000000..09a7f93 --- /dev/null +++ b/webclient/src/components/artifacts/styles/_backgrounds.scss @@ -0,0 +1,145 @@ +/* ── Terminals tab ────────────────────────────────────────────────────────── */ +.terminals-list { + max-height: none; + flex: 1; + overflow-y: auto; +} + +.terminal-item.is-closed { + opacity: 0.6; +} + +.terminal-item .artifact-meta { + white-space: normal; + word-break: break-word; + line-height: 1.4; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.terminal-detail { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + border-top: 2px solid var(--gn-border-color-muted); + overflow-y: auto; +} + +.terminal-detail-header { + padding: 12px 15px 8px; + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 8px; +} + +.terminal-detail-title-wrap { + min-width: 0; +} + +.artifacts-tab-count.has-running { + background: var(--gn-color-success); + color: var(--gn-color-black); +} + +.terminal-detail-title { + font-size: 14px; + font-weight: 600; + color: var(--gn-color-text-light); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.terminal-detail-close { + flex-shrink: 0; +} + +.terminal-detail-meta { + margin-top: 3px; + font-size: 12px; + color: var(--gn-color-text-dark); + word-break: break-word; + line-height: 1.5; +} + +.terminal-detail-grid { + display: flex; + flex-direction: column; + gap: 6px; + padding: 0 15px 12px; + border-bottom: 2px solid color-mix(in srgb, var(--gn-color-text-light) 12%, transparent); +} + +.terminal-detail-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + font-size: 12px; +} + +.terminal-detail-label { + color: var(--gn-color-text-dark); + flex-shrink: 0; +} + +.terminal-detail-value { + color: var(--gn-color-text-light); + text-align: right; + word-break: break-all; +} + +/* Status colours inside detail rows — must be after .terminal-detail-value + so their color overrides the default var(--gn-color-text-light). */ +.terminal-detail-value.status-busy { + color: var(--gn-color-success); +} + +.terminal-detail-value.status-idle { + color: var(--gn-color-secondary); +} + +.terminal-detail-value.status-closed { + color: var(--gn-color-error); +} + +.terminal-detail-command { + font-family: var(--gn-font-family-base); + font-size: 11px; + color: var(--gn-color-accent); +} + +.terminal-output-wrap { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + background: var(--gn-color-black); +} + +.terminal-output-header { + padding: 8px 15px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--gn-color-text-dark); + border-bottom: 2px solid color-mix(in srgb, var(--gn-color-text-light) 12%, transparent); +} + +.terminal-output { + flex: 1; + margin: 0; + padding: 14px; + overflow: auto; + white-space: pre-wrap; + word-break: break-word; + font-family: var(--gn-font-family-base); + font-size: 12px; + line-height: 1.6; + color: var(--gn-color-text-light); +} diff --git a/webclient/src/components/artifacts/styles/_detail.scss b/webclient/src/components/artifacts/styles/_detail.scss new file mode 100644 index 0000000..f105e95 --- /dev/null +++ b/webclient/src/components/artifacts/styles/_detail.scss @@ -0,0 +1,123 @@ +.artifact-detail { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; +} + +.artifact-detail-header { + padding: 12px 15px 8px; +} + +.artifact-detail-title { + font-size: 14px; + font-weight: 600; + color: var(--gn-color-text-light); +} + +.artifact-detail-filename { + margin-top: 3px; + font-size: 12px; + color: var(--gn-color-text-dark); +} + +.artifact-actions { + display: flex; + flex-wrap: wrap; + gap: 4px; + padding: 0 15px 12px; +} + +.artifact-preview { + flex: 1; + min-height: 0; + border-top: 2px solid var(--gn-border-color-muted); + background: var(--gn-color-black); +} + +.artifact-frame, +.artifact-image, +.artifact-video { + width: 100%; + height: 100%; + border: 0; + display: block; +} + +.artifact-image, +.artifact-video { + object-fit: contain; +} + +/* The preview itself is the affordance: clicking the photo opens the same + modal as the expand button. */ +.artifact-image.is-viewable { + cursor: zoom-in; +} + +/* The native player keeps its own height; only the width is ours */ +.artifact-audio { + width: 100%; + display: block; +} + +.artifact-text { + height: 100%; + display: flex; + flex-direction: column; + overflow: auto; + background: var(--gn-color-black); +} + +.artifact-text-note { + padding: 8px 14px; + border-top: 1px solid var(--gn-border-color-muted); + color: var(--gn-color-text-dark); + font-size: 12px; +} + +.artifact-source { + height: 100%; + overflow: auto; + background: var(--gn-color-black); +} + +.artifact-source-code, +.artifact-source-error { + margin: 0; + padding: 14px; + white-space: pre; + font-family: var(--gn-font-family-base); + font-size: 12px; + line-height: 1.6; + color: var(--gn-color-text-light); +} + +.artifact-source-error { + color: var(--gn-color-error); +} + +.artifact-source-state { + display: flex; + align-items: center; + gap: 8px; + padding: 14px; + color: var(--gn-color-text-dark); + font-size: 12px; +} + +.artifact-no-preview { + height: 100%; + min-height: 220px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; + color: var(--gn-color-text-dark); + font-size: 13px; +} + +.artifact-no-preview i { + font-size: 28px; +} diff --git a/webclient/src/components/artifacts/styles/_files-toolbar.scss b/webclient/src/components/artifacts/styles/_files-toolbar.scss new file mode 100644 index 0000000..6890ef8 --- /dev/null +++ b/webclient/src/components/artifacts/styles/_files-toolbar.scss @@ -0,0 +1,54 @@ +.files-toolbar { + display: flex; + align-items: center; + justify-content: flex-end; + padding: 8px 12px 2px; +} + +/* ── Files tab refresh button ─────────────────────────────────────────────── */ +.files-tab-wrap { + position: relative; + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.files-refresh-btn { + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } +} + +/* Hide refresh button on mobile */ +@media (max-width: 980px) { + .files-refresh-btn { display: none; } +} + +/* ── Pull-to-refresh for files tab (mobile) ──────────────────────────────── */ +.files-ptr { + position: absolute; + top: 0; + left: 0; + right: 0; + z-index: 10; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 8px; + font-size: 12px; + color: var(--gn-color-text-dark); + pointer-events: none; + transition: opacity 0.15s, transform 0.15s; + will-change: transform, opacity; +} + +.ptr-pulled { + transition: transform 0.15s; +} + +@media (min-width: 981px) { + .files-ptr { display: none; } +} diff --git a/webclient/src/components/artifacts/styles/_files.scss b/webclient/src/components/artifacts/styles/_files.scss new file mode 100644 index 0000000..63429cc --- /dev/null +++ b/webclient/src/components/artifacts/styles/_files.scss @@ -0,0 +1,36 @@ +.files-list { + max-height: none; + flex: 1; + overflow-y: auto; +} + +.file-item { + cursor: default; +} + +.file-item.is-dir { + cursor: default; +} + +.file-item.is-dir .artifact-title { + color: var(--gn-color-text-medium); +} + +.file-row-actions { + display: flex; + gap: 4px; + flex-shrink: 0; + opacity: 0; + transition: opacity 0.15s ease; +} + +.file-item:hover .file-row-actions { + opacity: 1; +} + +.file-row-action { + width: 28px; + height: 28px; + min-height: 0; + padding: 0; +} diff --git a/webclient/src/components/artifacts/styles/_links.scss b/webclient/src/components/artifacts/styles/_links.scss new file mode 100644 index 0000000..d1d1375 --- /dev/null +++ b/webclient/src/components/artifacts/styles/_links.scss @@ -0,0 +1,38 @@ +.links-list { + max-height: none; + flex: 1; + overflow-y: auto; +} + +.link-item { + text-decoration: none; +} + +.link-item .artifact-title { + color: var(--gn-color-accent); +} + +.link-item:hover .artifact-title, +.link-item.is-active .artifact-title { + /* black title text vanishes on the panel hover tint — keep the link lit */ + color: var(--gn-color-text-light); +} + +.link-detail { + padding: 12px 15px; + border-top: 2px solid var(--gn-border-color-muted); +} + +.link-detail-header { + margin-bottom: 8px; +} + +.link-detail-count { + font-size: 12px; + color: var(--gn-color-text-dark); +} + +.link-actions { + display: flex; + gap: 6px; +} diff --git a/webclient/src/components/artifacts/styles/_lists.scss b/webclient/src/components/artifacts/styles/_lists.scss new file mode 100644 index 0000000..ff3e991 --- /dev/null +++ b/webclient/src/components/artifacts/styles/_lists.scss @@ -0,0 +1,57 @@ +.artifact-item { + width: 100%; + min-height: 50px; + display: flex; + align-items: center; + gap: 10px; + padding: 8px 12px; + border: 0; + border-left: var(--gn-border-width-accent) solid transparent; /* active bar, same row geometry */ + background: transparent; + color: var(--gn-color-text-light); + text-align: left; + cursor: pointer; + transition: background var(--gn-motion-fast) var(--gn-motion-ease), border-color var(--gn-motion-fast) var(--gn-motion-ease); +} + +.artifact-item:hover { + background: color-mix(in srgb, var(--gn-color-text-light) 7%, transparent); +} + +.artifact-item.is-active { + border-left-color: var(--gn-color-secondary); + background: var(--gn-surface-panel-muted); +} + +.artifact-icon { + display: inline-flex; + flex-shrink: 0; + color: var(--gn-color-accent); + font-size: 20px; +} + +.artifact-info { + min-width: 0; + flex: 1; + display: flex; + flex-direction: column; + gap: 2px; +} + +.artifact-title, +.artifact-detail-title, +.artifact-detail-filename { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.artifact-title { + font-size: 13px; + font-weight: 500; +} + +.artifact-meta { + font-size: 11px; + color: var(--gn-color-text-dark); +} diff --git a/webclient/src/components/artifacts/styles/_panel-responsive.scss b/webclient/src/components/artifacts/styles/_panel-responsive.scss new file mode 100644 index 0000000..5da415d --- /dev/null +++ b/webclient/src/components/artifacts/styles/_panel-responsive.scss @@ -0,0 +1,41 @@ +@media (max-width: 980px) { + .artifacts-panel { + position: fixed; + top: 0; + right: 0; + bottom: 0; + z-index: 120; + margin-right: 0; + transform: translateX(100%); + width: 100vw; + min-width: 100vw; + max-width: 100vw; + } + + .artifacts-resize-handle { + display: none; + } + + .artifacts-panel.is-open { + transform: translateX(0); + } + + /* Hide tab text labels on mobile to fit the close button */ + .artifacts-header { + gap: 6px; + padding: 0 10px; + } + .artifacts-tab { + padding: 8px 10px; + gap: 4px; + } + .artifacts-tab > span:not(.artifacts-tab-count) { + display: none; + } +} + +@media (prefers-reduced-motion: reduce) { + .artifacts-panel { + transition: none; + } +} diff --git a/webclient/src/components/artifacts/styles/_panel.scss b/webclient/src/components/artifacts/styles/_panel.scss new file mode 100644 index 0000000..aab4a9a --- /dev/null +++ b/webclient/src/components/artifacts/styles/_panel.scss @@ -0,0 +1,155 @@ +.artifacts-panel { + /* container queries below squeeze the tab row to the panel width — + the tabs must never wrap into a second row */ + container-type: inline-size; + + width: var(--artifacts-width, min(600px, 42vw)); + min-width: var(--artifacts-width, min(600px, 42vw)); + max-width: var(--artifacts-width, min(600px, 42vw)); + display: flex; + flex-direction: column; + background: var(--gn-color-black); + border-left: 2px solid var(--gn-border-color-muted); + min-height: 0; + margin-right: calc(-1 * var(--artifacts-width, min(600px, 42vw))); + opacity: 0; + pointer-events: none; + transform: translateX(100%); + position: relative; + transition: + margin-right 0.22s ease, + opacity 0.22s ease, + transform 0.22s ease; +} + +.artifacts-panel.is-open { + margin-right: 0; + opacity: 1; + pointer-events: auto; + transform: translateX(0); +} + +.artifacts-panel.is-resizing { + transition: none; +} + +.artifacts-resize-handle { + position: absolute; + top: 0; + left: -4px; + bottom: 0; + width: 8px; + z-index: 2; + cursor: col-resize; + touch-action: none; +} + +.artifacts-resize-handle::after { + content: ""; + position: absolute; + top: 0; + left: 3px; + width: 1px; + height: 100%; + background: transparent; + transition: background 0.15s ease; +} + +.artifacts-resize-handle:hover::after, +.artifacts-panel.is-resizing .artifacts-resize-handle::after { + background: var(--gn-color-accent); +} + +.artifacts-header { + /* min-height: kit chips may wrap to a second row on a narrow panel */ + min-height: 58px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 0 15px; + border-bottom: 2px solid var(--gn-border-color-muted); +} + +.artifacts-empty { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; + color: var(--gn-color-text-dark); + font-size: 13px; +} + +.artifacts-tabs { + display: flex; + align-items: center; + flex-wrap: nowrap; /* kit chip-group wraps — the tab row must stay one line */ + gap: 8px; + flex: 1; + min-width: 0; +} + +.artifacts-tab { + min-width: 0; + white-space: nowrap; +} + +/* Squeeze steps sized from the widest tab (BACKGROUNDS + icon + count) */ +@container (max-width: 560px) { + .artifacts-tab { + padding: 4px 10px; + font-size: 11px; + gap: 6px; + } + + .artifacts-tabs { + gap: 6px; + } +} + +@container (max-width: 490px) { + .artifacts-tab { + padding: 3px 8px; + font-size: 10px; + gap: 5px; + } +} + +@container (max-width: 400px) { + .artifacts-tab { + padding: 2px 6px; + } + + .artifacts-tab-count { + padding-left: 3px; + } +} + +/* Only the docked panel runs out of room with the label still attached; on a + phone the mobile block below has already dropped the label, so the icon is + all that is left and dropping it too would leave a bare counter. */ +@media (min-width: 981px) { + @container (max-width: 360px) { + .artifacts-tab i { + display: none; + } + } +} + +.artifacts-tab-count { + padding-left: 4px; + opacity: 0.72; +} + +.artifacts-tab-count.has-running { + color: var(--gn-color-success); + opacity: 1; +} + +.artifacts-list { + max-height: calc(58px * 4); + overflow-y: auto; + border-bottom: 2px solid var(--gn-border-color-muted); +} diff --git a/webclient/src/components/artifacts/styles/_viewed.scss b/webclient/src/components/artifacts/styles/_viewed.scss new file mode 100644 index 0000000..2502a3d --- /dev/null +++ b/webclient/src/components/artifacts/styles/_viewed.scss @@ -0,0 +1,47 @@ +@use 'kit-deps' as *; + +/* ── Viewed images (grouped, Navi's own image reads) ─────────────────── */ +.viewed-images-group { + flex-shrink: 0; + border-bottom: 2px solid var(--gn-border-color-muted); + background: var(--gn-surface-panel-muted); +} + +.viewed-images-header { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + font-size: 12px; + font-weight: 500; + color: var(--gn-color-text-medium); +} + +.viewed-images-header i { + font-size: 14px; + color: var(--gn-color-secondary); +} + +.viewed-images-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); + gap: 6px; + padding: 0 12px 12px; + max-height: 320px; + overflow-y: auto; +} + +.viewed-image-thumb { + width: 100%; + height: 96px; + object-fit: cover; + border: 1px solid var(--gn-border-color-muted); + border-radius: $border-radius-md; + cursor: zoom-in; + transition: border-color var(--gn-motion-fast) var(--gn-motion-ease), opacity var(--gn-motion-fast) var(--gn-motion-ease); +} + +.viewed-image-thumb:hover { + border-color: var(--gn-color-secondary); + opacity: 0.85; +} diff --git a/webclient/src/utils/artifactContent.js b/webclient/src/utils/artifactContent.js new file mode 100644 index 0000000..b3494a1 --- /dev/null +++ b/webclient/src/utils/artifactContent.js @@ -0,0 +1,7 @@ +import { contentTypeFromName, normalizeContentType } from '@/utils/contentTypes.js' + +/** The one content-type decision for an artifact or a session file entry. */ +export function itemContentType(item) { + return normalizeContentType(item?.content_type) + || contentTypeFromName(item?.filename || item?.path || item?.url || '') +}