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>
        <button
          class="artifacts-tab"
          :class="{ 'is-active': activeTab === 'terminals' }"
          @click="activeTab = 'terminals'"
        >
          <i class="ph ph-terminal"></i>
          <span>Terminals</span>
          <span class="artifacts-tab-count">{{ activeTerminalCount }}</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
        class="files-tab-wrap"
        @touchstart="onFilesTouchStart"
        @touchmove="onFilesTouchMove"
        @touchend="onFilesTouchEnd"
      >
        <!-- Pull-to-refresh indicator (mobile only) -->
        <div
          class="ptr-indicator files-ptr"
          :class="{ 'ptr-active': filesPtrPulling && !filesRefreshing }"
          :style="filesPtrStyle"
        >
          <span
            class="ptr-spinner"
            :class="{ 'ptr-spinning': filesPtrTriggered }"
          ></span>
          <span class="ptr-label">{{ filesPtrLabel }}</span>
        </div>

        <!-- Desktop refresh button -->
        <button
          class="files-refresh-btn"
          title="Refresh files"
          :disabled="filesRefreshing"
          @click="refreshFiles"
        >
          <i class="ph ph-arrows-clockwise" :class="{ spinning: filesRefreshing }"></i>
        </button>

        <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 ref="filesListRef" class="artifacts-list files-list" :class="{ 'ptr-pulled': filesPtrPulling }">
            <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>
      </div>
    </template>

    <template v-if="activeTab === 'terminals'">
      <div v-if="!terminalsList.length" class="artifacts-empty">
        <i class="ph ph-terminal"></i>
        <span>No active terminals</span>
      </div>

      <template v-else>
        <div class="artifacts-list terminals-list">
          <button
            v-for="item in terminalsList"
            :key="item.name"
            class="artifact-item terminal-item"
            :class="{ 'is-active': selectedTerminalName === item.name, 'is-closed': item.closed }"
            @click="selectTerminal(item)"
          >
            <span class="artifact-icon">
              <i :class="item.closed ? 'ph ph-x-circle' : (item.status === 'busy' ? 'ph ph-play-circle' : 'ph ph-pause-circle')"></i>
            </span>
            <span class="artifact-info">
              <span class="artifact-title">{{ item.name }}</span>
              <span class="artifact-meta">{{ item.description || item.command || 'Terminal' }}</span>
            </span>
            <span
              class="terminal-status-badge"
              :class="item.status === 'busy' ? 'status-busy' : (item.closed ? 'status-closed' : 'status-idle')"
            >
              {{ item.closed ? 'Closed' : (item.status === 'busy' ? 'Busy' : 'Idle') }}
            </span>
          </button>
        </div>

        <div v-if="selectedTerminal" class="terminal-detail">
          <div class="terminal-detail-header">
            <div class="terminal-detail-title">{{ selectedTerminal.name }}</div>
            <div class="terminal-detail-meta">{{ selectedTerminal.description || selectedTerminal.command || 'Terminal' }}</div>
          </div>

          <div class="terminal-detail-grid">
            <div class="terminal-detail-row">
              <span class="terminal-detail-label">Status</span>
              <span class="terminal-detail-value" :class="selectedTerminal.status === 'busy' ? 'status-busy' : (selectedTerminal.closed ? 'status-closed' : 'status-idle')">
                {{ selectedTerminal.closed ? 'Closed' : (selectedTerminal.status === 'busy' ? 'Busy' : 'Idle') }}
              </span>
            </div>
            <div class="terminal-detail-row">
              <span class="terminal-detail-label">PID</span>
              <span class="terminal-detail-value">{{ selectedTerminal.pid || 'N/A' }}</span>
            </div>
            <div class="terminal-detail-row">
              <span class="terminal-detail-label">Command</span>
              <span class="terminal-detail-value terminal-detail-command">{{ selectedTerminal.command || 'N/A' }}</span>
            </div>
            <div class="terminal-detail-row">
              <span class="terminal-detail-label">CWD</span>
              <span class="terminal-detail-value">{{ selectedTerminal.cwd || 'N/A' }}</span>
            </div>
            <div v-if="selectedTerminal.uptime_seconds != null" class="terminal-detail-row">
              <span class="terminal-detail-label">Uptime</span>
              <span class="terminal-detail-value">{{ formatUptime(selectedTerminal.uptime_seconds) }}</span>
            </div>
            <div v-if="selectedTerminal.background != null" class="terminal-detail-row">
              <span class="terminal-detail-label">Background</span>
              <span class="terminal-detail-value">{{ selectedTerminal.background ? 'Yes' : 'No' }}</span>
            </div>
            <div v-if="selectedTerminal.closeReason" class="terminal-detail-row">
              <span class="terminal-detail-label">Reason</span>
              <span class="terminal-detail-value">{{ selectedTerminal.closeReason }}</span>
            </div>
          </div>

          <div v-if="selectedTerminal.output" class="terminal-output-wrap">
            <div class="terminal-output-header">Live output</div>
            <pre class="terminal-output">{{ selectedTerminal.output }}</pre>
          </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 selectedTerminalName = ref(null)

// ── Terminals tab state ───────────────────────────────────────────────────
const terminalsList = computed(() => {
  return Object.values(chat.terminals)
    .filter(t => !t.closed || t.output)
    .sort((a, b) => (a.name || '').localeCompare(b.name || ''))
})
const selectedTerminal = computed(() => {
  if (!selectedTerminalName.value) return null
  return chat.terminals[selectedTerminalName.value] || null
})
const activeTerminalCount = computed(() => {
  return Object.values(chat.terminals).filter(t => !t.closed).length
})

function selectTerminal(item) {
  selectedTerminalName.value = item.name
}

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`
}

// ── Files tab refresh state ───────────────────────────────────────────────
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 FILES_PTR_THRESHOLD = 80
const FILES_PTR_MAX = 120
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 'Refreshing...'
  if (filesPtrTriggered.value) return 'Release to refresh'
  return 'Pull to refresh'
})

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 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)

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()
  }
}

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
})

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);
  }

  /* 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;
  }
}

/* ── Files tab refresh button ─────────────────────────────────────────────── */
.files-tab-wrap {
  position: relative;
  flex: 1;
  display: flex;
  flex-direction: column;
  overflow: hidden;
}

.files-refresh-btn {
  position: absolute;
  top: 8px;
  right: 8px;
  z-index: 2;
  width: 28px;
  height: 28px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  border: 2px solid rgba(192, 202, 245, 0.24);
  background: #1F2335;
  color: #C0CAF5;
  font-size: 14px;
  cursor: pointer;
  transition: background 0.2s ease, border-color 0.2s ease, color 0.2s ease;
}

.files-refresh-btn:hover {
  background: #7AA2F7;
  border-color: #7AA2F7;
  color: #16161E;
}

.files-refresh-btn:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}

.files-refresh-btn i.spinning {
  display: inline-block;
  animation: refresh-spin 0.8s linear infinite;
}

@keyframes refresh-spin {
  to { transform: rotate(360deg); }
}

/* 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(--color-text-dark, #787c99);
  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; }
}
/* ── Terminals tab ────────────────────────────────────────────────────────── */
.terminals-list {
  max-height: none;
  flex: 1;
  overflow-y: auto;
}

.terminal-item.is-closed {
  opacity: 0.6;
}

.terminal-status-badge {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  padding: 2px 8px;
  font-size: 10px;
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.05em;
  border: 2px solid transparent;
}

.status-busy {
  background: #73DACA;
  color: #16161E;
  border-color: #73DACA;
}

.status-idle {
  background: #7AA2F7;
  color: #16161E;
  border-color: #7AA2F7;
}

.status-closed {
  background: #F7768E;
  color: #16161E;
  border-color: #F7768E;
}

.terminal-detail {
  flex: 1;
  min-height: 0;
  display: flex;
  flex-direction: column;
  border-top: 2px solid rgba(192, 202, 245, 0.24);
  overflow-y: auto;
}

.terminal-detail-header {
  padding: 12px 15px 8px;
}

.terminal-detail-title {
  font-size: 14px;
  font-weight: 600;
  color: #C0CAF5;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.terminal-detail-meta {
  margin-top: 3px;
  font-size: 12px;
  color: #787C99;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.terminal-detail-grid {
  display: flex;
  flex-direction: column;
  gap: 6px;
  padding: 0 15px 12px;
  border-bottom: 2px solid rgba(192, 202, 245, 0.12);
}

.terminal-detail-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
  font-size: 12px;
}

.terminal-detail-label {
  color: #787C99;
  flex-shrink: 0;
}

.terminal-detail-value {
  color: #C0CAF5;
  text-align: right;
  word-break: break-all;
}

.terminal-detail-command {
  font-family: "IBM Plex Mono", monospace;
  font-size: 11px;
  color: #FF9E64;
}

.terminal-output-wrap {
  flex: 1;
  min-height: 0;
  display: flex;
  flex-direction: column;
  background: #0f0f14;
}

.terminal-output-header {
  padding: 8px 15px;
  font-size: 11px;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.05em;
  color: #787C99;
  border-bottom: 2px solid rgba(192, 202, 245, 0.12);
}

.terminal-output {
  flex: 1;
  margin: 0;
  padding: 14px;
  overflow: auto;
  white-space: pre-wrap;
  word-break: break-word;
  font-family: "IBM Plex Mono", monospace;
  font-size: 12px;
  line-height: 1.6;
  color: #C0CAF5;
}
</style>