<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 class="artifacts-tabs">
<button
class="artifacts-tab"
:class="{ 'is-active': activeTab === 'artifacts' }"
@click="activeTab = 'artifacts'"
>
<i class="ph ph-files"></i>
<span>Artifacts</span>
<span class="artifacts-tab-count">{{ artifacts.length }}</span>
</button>
<button
class="artifacts-tab"
:class="{ 'is-active': activeTab === 'links' }"
@click="activeTab = 'links'"
>
<i class="ph ph-link"></i>
<span>Links</span>
<span class="artifacts-tab-count">{{ links.length }}</span>
</button>
<button
class="artifacts-tab"
:class="{ 'is-active': activeTab === 'files' }"
@click="activeTab = 'files'"
>
<i class="ph ph-folder"></i>
<span>Files</span>
<span class="artifacts-tab-count">{{ filesFlat.length }}</span>
</button>
</div>
<GnIconButton icon="ph ph-x" label="Close artifacts" class="artifacts-close" @click="emit('close')" />
</div>
<template v-if="activeTab === 'artifacts'">
<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>
</template>
<template v-if="activeTab === 'links'">
<div v-if="!links.length" class="artifacts-empty">
<i class="ph ph-link"></i>
<span>No links found yet</span>
</div>
<template v-else>
<div class="artifacts-list links-list">
<a
v-for="item in links"
:key="item.url"
class="artifact-item link-item"
:href="item.url"
target="_blank"
rel="noopener noreferrer"
>
<span class="artifact-icon"><i class="ph ph-link"></i></span>
<span class="artifact-info">
<span class="artifact-title">{{ item.text || item.url }}</span>
<span class="artifact-meta">{{ item.domain }} · {{ item.snippet }}</span>
</span>
</a>
</div>
<div class="link-detail">
<div class="link-detail-header">
<div class="link-detail-count">{{ links.length }} link{{ links.length === 1 ? '' : 's' }} in session</div>
</div>
<div class="link-actions">
<button class="artifact-action" title="Copy all links" @click="copyAllLinks">
<i class="ph ph-copy"></i>
</button>
<button class="artifact-action" title="Open all links" @click="openAllLinks">
<i class="ph ph-arrow-square-out"></i>
</button>
</div>
</div>
</template>
</template>
<template v-if="activeTab === 'files'">
<div v-if="!filesFlat.length" class="artifacts-empty">
<i class="ph ph-folder"></i>
<span>No files in session directory</span>
</div>
<template v-else>
<div class="artifacts-list files-list">
<div
v-for="item in fileTree"
:key="item.path"
class="artifact-item file-item"
:class="{ 'is-dir': item.is_dir }"
:style="fileIndent(item)"
>
<span class="artifact-icon">
<i :class="fileIcon(item)"></i>
</span>
<span class="artifact-info">
<span class="artifact-title">{{ item.name }}</span>
<span class="artifact-meta">{{ fileMeta(item) }}</span>
</span>
<div v-if="!item.is_dir" class="file-row-actions">
<a
class="artifact-action file-row-action"
:href="fileDownloadUrl(item.path)"
title="Download"
target="_blank"
rel="noopener noreferrer"
>
<i class="ph ph-download-simple"></i>
</a>
<a
v-if="isInlineFile(item.path)"
class="artifact-action file-row-action"
:href="fileDownloadUrl(item.path)"
title="Open inline"
target="_blank"
rel="noopener noreferrer"
>
<i class="ph ph-eye"></i>
</a>
</div>
</div>
</div>
</template>
</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 activeTab = ref('artifacts')
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 links = computed(() => {
const seen = new Set()
const out = []
const mdLinkRe = /\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g
const bareUrlRe = /https?:\/\/[^\s<>"{}|\\^`[\]]+/g
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
// Markdown links
while ((match = mdLinkRe.exec(text)) !== null) {
const url = match[2].trim()
const norm = url.toLowerCase()
if (seen.has(norm)) continue
seen.add(norm)
out.push({
url,
text: match[1].trim(),
snippet: snippetOf(text, match.index),
domain: domainOf(url),
})
}
// Bare URLs (skip those inside markdown links we already matched)
while ((match = bareUrlRe.exec(text)) !== null) {
const url = match[0].trim()
const norm = url.toLowerCase()
if (seen.has(norm)) continue
// Rough guard: skip if this bare URL is the href part of a markdown link
const before = text.slice(Math.max(0, match.index - 1), match.index)
if (before === '(' && text[match.index + match[0].length] === ')') {
const openBracket = text.lastIndexOf('[', match.index - 2)
if (openBracket !== -1 && text.slice(openBracket + 1, match.index - 2).indexOf(']') === -1) {
continue
}
}
seen.add(norm)
out.push({
url,
text: '',
snippet: snippetOf(text, match.index),
domain: domainOf(url),
})
}
}
return out
})
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 }
})
})
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'
}
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
}
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')
}
}
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',
json: 'ph ph-file-json',
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 '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`
}
function isInlineFile(path) {
const ext = path.split('.').pop()?.toLowerCase() || ''
const inlineExts = ['jpg', 'jpeg', 'png', 'gif', 'svg', 'webp', 'html', 'txt', 'md', 'pdf']
return inlineExts.includes(ext)
}
</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: #16161E;
border-left: 2px solid rgba(192, 202, 245, 0.24);
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: #FF9E64;
}
.artifacts-header {
height: 58px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 0 15px;
border-bottom: 2px solid rgba(192, 202, 245, 0.24);
}
.artifacts-empty {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
color: #787C99;
font-size: 13px;
}
.artifacts-empty i {
font-size: 28px;
}
.artifacts-tabs {
display: flex;
align-items: center;
flex: 1;
}
.artifacts-tab {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 46px;
padding: 12px 15px;
border: 0;
border-right: 2px solid rgba(192, 202, 245, 0.08);
background: transparent;
color: #A9B1D6;
font-size: 13px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
cursor: pointer;
transition: background 0.2s ease, color 0.2s ease;
}
.artifacts-tab:hover {
background: #7AA2F7;
color: #16161E;
}
.artifacts-tab.is-active {
background: #C0CAF5;
color: #16161E;
}
.artifacts-tab-count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 5px;
background: #1F2335;
color: #787C99;
font-size: 11px;
font-weight: 600;
}
.artifacts-tab.is-active .artifacts-tab-count {
background: rgba(22, 22, 30, 0.2);
color: #16161E;
}
.artifacts-list {
max-height: calc(58px * 4);
overflow-y: auto;
border-bottom: 2px solid rgba(192, 202, 245, 0.24);
}
.artifact-item {
width: 100%;
min-height: 50px;
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
border: 2px solid transparent;
background: rgba(192, 202, 245, 0.045);
color: #C0CAF5;
text-align: left;
cursor: pointer;
transition: background 0.2s ease, border-color 0.2s ease, color 0.2s ease;
}
.artifact-item:hover {
background: #7AA2F7;
color: #16161E;
}
.artifact-item.is-active {
border-color: #7AA2F7;
background: #7AA2F7;
color: #16161E;
}
.artifact-icon {
width: 28px;
height: 28px;
display: inline-flex;
align-items: center;
justify-content: center;
color: #FF9E64;
background: rgba(192, 202, 245, 0.085);
}
.artifact-item:hover .artifact-icon,
.artifact-item.is-active .artifact-icon {
color: #16161E;
background: rgba(22, 22, 30, 0.12);
}
.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: #787C99;
}
.artifact-item:hover .artifact-meta,
.artifact-item.is-active .artifact-meta {
color: rgba(22, 22, 30, 0.72);
}
.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: #C0CAF5;
}
.artifact-detail-filename {
margin-top: 3px;
font-size: 12px;
color: #787C99;
}
.artifact-actions {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(42px, 1fr));
gap: 6px;
padding: 0 15px 12px;
}
.artifact-action {
height: 32px;
display: inline-flex;
align-items: center;
justify-content: center;
border: 2px solid rgba(192, 202, 245, 0.24);
background: #1F2335;
color: #C0CAF5;
text-decoration: none;
cursor: pointer;
transition: background 0.2s ease, border-color 0.2s ease, color 0.2s ease;
}
.artifact-action:hover {
background: #7AA2F7;
border-color: #7AA2F7;
color: #16161E;
}
.artifact-preview {
flex: 1;
min-height: 0;
border-top: 2px solid rgba(192, 202, 245, 0.24);
background: #0f0f14;
}
.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: #0f0f14;
}
.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: #C0CAF5;
}
.artifact-source-error {
color: #F7768E;
}
.artifact-source-state {
display: flex;
align-items: center;
gap: 8px;
padding: 14px;
color: #787C99;
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: #787C99;
font-size: 13px;
}
.artifact-no-preview i {
font-size: 28px;
}
.links-list {
max-height: none;
flex: 1;
overflow-y: auto;
}
.link-item {
text-decoration: none;
}
.link-item .artifact-title {
color: #FF9E64;
}
.link-item:hover .artifact-title,
.link-item.is-active .artifact-title {
color: #16161E;
}
.link-detail {
padding: 12px 15px;
border-top: 2px solid rgba(192, 202, 245, 0.24);
}
.link-detail-header {
margin-bottom: 8px;
}
.link-detail-count {
font-size: 12px;
color: #787C99;
}
.link-actions {
display: flex;
gap: 6px;
}
.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: #A9B1D6;
}
.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;
}
@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 (prefers-reduced-motion: reduce) {
.artifacts-panel {
transition: none;
}
}
</style>