<template>
<aside
class="artifacts-panel"
:class="{ 'is-open': open, 'is-resizing': isResizing }"
:style="panelStyle"
aria-label="Session artifacts"
>
<div
class="artifacts-resize-handle"
role="separator"
aria-orientation="vertical"
title="Resize artifacts"
@pointerdown="startResize"
/>
<div class="artifacts-header">
<div>
<div class="artifacts-title">Artifacts</div>
<div class="artifacts-count">{{ artifacts.length }} published</div>
</div>
<GnIconButton icon="ph-x" label="Close artifacts" class="artifacts-close" @click="emit('close')" />
</div>
<div v-if="!artifacts.length" class="artifacts-empty">
<i class="ph ph-files"></i>
<span>No published files</span>
</div>
<template v-else>
<div class="artifacts-list">
<button
v-for="item in artifacts"
:key="item.id || item.filename"
class="artifact-item"
:class="{ 'is-active': selectedKey === artifactKey(item) }"
@click="selectArtifact(item)"
>
<span class="artifact-icon"><i :class="iconClass(item)"></i></span>
<span class="artifact-info">
<span class="artifact-title">{{ item.title || item.filename }}</span>
<span class="artifact-meta">{{ labelFor(item) }}</span>
</span>
</button>
</div>
<div v-if="selected" class="artifact-detail">
<div class="artifact-detail-header">
<div class="artifact-detail-title">{{ selected.title || selected.filename }}</div>
<div class="artifact-detail-filename">{{ selected.filename }}</div>
</div>
<div class="artifact-actions">
<a class="artifact-action" :href="previewUrl(selected)" target="_blank" rel="noopener noreferrer" title="Open preview">
<i class="ph ph-arrow-square-out"></i>
</a>
<a class="artifact-action" :href="rawUrl(selected)" target="_blank" rel="noopener noreferrer" title="Open raw file">
<i class="ph ph-file"></i>
</a>
<a class="artifact-action" :href="downloadUrl(selected)" title="Download">
<i class="ph ph-download-simple"></i>
</a>
<button
v-if="hasSource(selected)"
class="artifact-action"
:title="showSource ? 'Show preview' : 'Show source'"
@click="toggleSource(selected)"
>
<i :class="showSource ? 'ph ph-eye' : 'ph ph-code'"></i>
</button>
<button class="artifact-action" title="Copy file link" @click="copyLink(selected)">
<i :class="copyIcon(selected)"></i>
</button>
</div>
<div class="artifact-preview">
<div v-if="showSource" class="artifact-source">
<div v-if="sourceLoading" class="artifact-source-state">
<span class="spinner"></span>
<span>Loading source...</span>
</div>
<pre v-else-if="sourceError" class="artifact-source-error">{{ sourceError }}</pre>
<pre v-else class="artifact-source-code"><code class="hljs" :class="`language-${selectedSourceLanguage}`" v-html="highlightedSource"></code></pre>
</div>
<iframe
v-else-if="isFramePreview(selected)"
:src="previewUrl(selected)"
class="artifact-frame"
sandbox="allow-scripts allow-same-origin"
/>
<img
v-else-if="contentType(selected) === 'image'"
:src="versionedUrl(selected)"
:alt="selected.title || selected.filename"
class="artifact-image"
/>
<video
v-else-if="contentType(selected) === 'video'"
:src="versionedUrl(selected)"
class="artifact-video"
controls
playsinline
/>
<div v-else class="artifact-no-preview">
<i class="ph ph-file"></i>
<span>Preview unavailable</span>
</div>
</div>
</div>
</template>
</aside>
</template>
<script setup>
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useChatStore } from '@/stores/chat.js'
import { absoluteContentUrl, contentFileUrl, versionedContentUrl, viewerContentUrl } from '@/utils/contentLinks.js'
import { highlightSource, sourceLanguage } from '@/utils/sourceHighlight.js'
defineProps({
open: { type: Boolean, default: false }
})
const emit = defineEmits(['close'])
const chat = useChatStore()
const selectedKey = ref(null)
const copiedKey = ref(null)
const showSource = ref(false)
const sourceText = ref('')
const sourceLoading = ref(false)
const sourceError = ref('')
const isResizing = ref(false)
const viewportWidth = ref(typeof window === 'undefined' ? 0 : window.innerWidth)
const DRAWER_DESKTOP_BREAKPOINT = 980
const CHAT_MIN_WIDTH = 500
const DRAWER_WIDTH_STORAGE_KEY = 'navi:artifacts-drawer-width'
const drawerWidth = ref(readSavedDrawerWidth())
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 isDesktop = computed(() => viewportWidth.value > DRAWER_DESKTOP_BREAKPOINT)
const panelStyle = computed(() => {
if (!isDesktop.value) return {}
return { '--artifacts-width': `${currentDrawerWidth()}px` }
})
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 = ''
}
function contentType(item) {
return item.content_type || 'unknown'
}
function iconClass(item) {
const map = {
stl: 'ph ph-cube',
html: 'ph ph-browser',
svg: 'ph ph-bezier-curve',
pdf: 'ph ph-file-pdf',
image: 'ph ph-image',
video: 'ph ph-video',
}
return map[contentType(item)] || 'ph ph-paperclip'
}
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()
})
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([], { 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)
if (['stl', 'html', 'svg', 'pdf'].includes(type)) return viewerUrl(item, type)
return absoluteContentUrl(versionedUrl(item))
}
function rawUrl(item) {
return contentFileUrl(item.url)
}
function sourceUrl(item) {
const type = contentType(item)
if (['html', 'svg'].includes(type)) return rawUrl(item)
if (type === 'stl' && 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 = `Failed to load source: ${err?.message || err}`
} finally {
sourceLoading.value = false
}
}
function shareUrl(item) {
if (contentType(item) === 'stl') 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 ['stl', 'html', 'svg', 'pdf'].includes(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'
}
</script>
<style scoped>
.artifacts-panel {
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(--surface, #1e1e2e);
border-left: 1px solid var(--border, #2a2a3e);
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(--accent, #4ec9b0);
}
.artifacts-header {
height: 64px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 0 14px;
border-bottom: 1px solid var(--border, #2a2a3e);
}
.artifacts-title {
font-size: 14px;
font-weight: 600;
color: var(--text, #cdd6f4);
}
.artifacts-count {
margin-top: 2px;
font-size: 12px;
color: var(--text-muted, #6c7086);
}
.artifacts-empty {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
color: var(--text-muted, #6c7086);
font-size: 13px;
}
.artifacts-empty i {
font-size: 28px;
}
.artifacts-list {
max-height: calc(58px * 4);
overflow-y: auto;
border-bottom: 1px solid var(--border, #2a2a3e);
}
.artifact-item {
width: 100%;
min-height: 58px;
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
border: 0;
border-left: 3px solid transparent;
background: transparent;
color: var(--text, #cdd6f4);
text-align: left;
cursor: pointer;
}
.artifact-item:hover {
background: rgba(255, 255, 255, 0.03);
}
.artifact-item.is-active {
border-left-color: var(--accent, #4ec9b0);
background: rgba(78, 201, 176, 0.08);
}
.artifact-icon {
width: 28px;
height: 28px;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--accent, #4ec9b0);
background: var(--accent-muted, #313244);
}
.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(--text-muted, #6c7086);
}
.artifact-detail {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.artifact-detail-header {
padding: 12px 14px 8px;
}
.artifact-detail-title {
font-size: 14px;
font-weight: 600;
color: var(--text, #cdd6f4);
}
.artifact-detail-filename {
margin-top: 3px;
font-size: 12px;
color: var(--text-muted, #6c7086);
}
.artifact-actions {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(42px, 1fr));
gap: 6px;
padding: 0 14px 12px;
}
.artifact-action {
height: 32px;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid var(--border, #2a2a3e);
background: var(--surface-elevated, #242438);
color: var(--text, #cdd6f4);
text-decoration: none;
cursor: pointer;
}
.artifact-action:hover {
border-color: var(--accent, #4ec9b0);
color: var(--accent, #4ec9b0);
}
.artifact-preview {
flex: 1;
min-height: 0;
border-top: 1px solid var(--border, #2a2a3e);
background: #11111b;
}
.artifact-frame,
.artifact-image,
.artifact-video {
width: 100%;
height: 100%;
border: 0;
display: block;
}
.artifact-image,
.artifact-video {
object-fit: contain;
}
.artifact-source {
height: 100%;
overflow: auto;
background: #11111b;
}
.artifact-source-code,
.artifact-source-error {
margin: 0;
padding: 14px;
white-space: pre;
font-family: "IBM Plex Mono", monospace;
font-size: 12px;
line-height: 1.6;
color: var(--text, #cdd6f4);
}
.artifact-source-error {
color: var(--error, #f7768e);
}
.artifact-source-state {
display: flex;
align-items: center;
gap: 8px;
padding: 14px;
color: var(--text-muted, #6c7086);
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(--text-muted, #6c7086);
font-size: 13px;
}
.artifact-no-preview i {
font-size: 28px;
}
@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);
}
}
@media (min-width: 981px) {
.artifacts-close {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.artifacts-panel {
transition: none;
}
}
</style>