Newer
Older
navi-1 / webclient / src / components / artifacts / ArtifactsPanel.vue
<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>