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="t('artifacts.panelLabel')"
  >
    <div
      class="artifacts-resize-handle"
      role="separator"
      aria-orientation="vertical"
      :title="t('artifacts.resize')"
      @pointerdown="startResize"
    />

    <div class="artifacts-header">
      <!-- Kit chips as tabs (aria-pressed = kit selected state) -->
      <div class="artifacts-tabs chip-group">
        <button
          class="chip artifacts-tab"
          :aria-pressed="activeTab === 'artifacts'"
          @click="activeTab = 'artifacts'"
        >
          <i class="ph ph-files"></i>
          <span>{{ t('artifacts.tabArtifacts') }}</span>
          <span class="artifacts-tab-count">{{ artifacts.length }}</span>
        </button>
        <button
          class="chip artifacts-tab"
          :aria-pressed="activeTab === 'links'"
          @click="activeTab = 'links'"
        >
          <i class="ph ph-link"></i>
          <span>{{ t('artifacts.tabLinks') }}</span>
          <span class="artifacts-tab-count">{{ links.length }}</span>
        </button>
        <button
          class="chip artifacts-tab"
          :aria-pressed="activeTab === 'files'"
          @click="activeTab = 'files'"
        >
          <i class="ph ph-folder"></i>
          <span>{{ t('artifacts.tabFiles') }}</span>
          <span class="artifacts-tab-count">{{ filesFlat.length }}</span>
        </button>
        <button
          class="chip artifacts-tab"
          :aria-pressed="activeTab === 'backgrounds'"
          @click="activeTab = 'backgrounds'"
        >
          <i class="ph ph-arrows-out"></i>
          <span>{{ t('artifacts.tabBackgrounds') }}</span>
          <span class="artifacts-tab-count" :class="{ 'has-running': activeBackgroundCount > 0 }">{{ activeBackgroundCount }}</span>
        </button>
      </div>
      <GnIconButton icon="ph ph-x" :label="t('artifacts.close')" class="artifacts-close" @click="emit('close')" />
    </div>

    <template v-if="activeTab === 'artifacts'">
      <!-- Images Navi itself looked at (image_view & other vision tools) —
           grouped above the published files; click opens the lightbox -->
      <div v-if="chat.viewedImages.length" class="viewed-images-group">
        <div class="viewed-images-header">
          <i class="ph ph-eye"></i>
          <span class="viewed-images-title">{{ t('artifacts.viewedImages') }}</span>
          <span class="artifacts-tab-count">{{ chat.viewedImages.length }}</span>
        </div>
        <div class="viewed-images-grid">
          <img
            v-for="(src, i) in chat.viewedImages"
            :key="i"
            :src="src"
            class="viewed-image-thumb"
            :alt="t('artifacts.viewedImageAlt', { n: i + 1 })"
            @click="openViewedImage(src)"
          />
        </div>
      </div>

      <div v-if="!artifacts.length && !chat.viewedImages.length" class="artifacts-empty">
        <GnEmptyState icon="ph ph-files" :title="t('artifacts.emptyArtifacts')" />
      </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="btn-icon" :href="previewUrl(selected)" target="_blank" rel="noopener noreferrer" :title="t('artifacts.openPreview')" :aria-label="t('artifacts.openPreview')">
            <i class="ph ph-arrow-square-out"></i>
          </a>
          <GnIconButton
            v-if="canFullscreen(selected)"
            icon="ph ph-arrows-out"
            :label="t('artifacts.openFullscreen')"
            @click="openFullscreen(selected)"
          />
          <a class="btn-icon" :href="rawUrl(selected)" target="_blank" rel="noopener noreferrer" :title="t('artifacts.openRaw')" :aria-label="t('artifacts.openRaw')">
            <i class="ph ph-file"></i>
          </a>
          <a class="btn-icon" :href="downloadUrl(selected)" target="_blank" rel="noopener noreferrer" :title="t('common.download')" :aria-label="t('common.download')">
            <i class="ph ph-download-simple"></i>
          </a>
          <GnIconButton
            v-if="hasSource(selected)"
            :icon="showSource ? 'ph ph-eye' : 'ph ph-code'"
            :label="t(showSource ? 'artifacts.showPreview' : 'artifacts.showSource')"
            @click="toggleSource(selected)"
          />
          <GnIconButton :icon="copyIcon(selected)" :label="t('artifacts.copyFileLink')" @click="copyLink(selected)" />
        </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>{{ t('artifacts.loadingSource') }}</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 is-viewable"
            :title="t('artifacts.openFullscreen')"
            @click="openFullscreen(selected)"
          />
          <video
            v-else-if="contentType(selected) === 'video'"
            :src="versionedUrl(selected)"
            class="artifact-video"
            controls
            playsinline
          />
          <audio
            v-else-if="contentType(selected) === 'audio'"
            :src="versionedUrl(selected)"
            class="artifact-audio"
            controls
            preload="metadata"
          />
          <div v-else-if="contentType(selected) === 'text'" class="artifact-text">
            <div v-if="textLoading" class="artifact-source-state">
              <span class="spinner"></span>
              <span>{{ t('artifacts.loadingPreview') }}</span>
            </div>
            <pre v-else-if="textError" class="artifact-source-error">{{ textError }}</pre>
            <template v-else>
              <pre class="artifact-source-code"><code class="hljs" :class="`language-${selectedSourceLanguage}`" v-html="highlightedText"></code></pre>
              <div v-if="textTruncated" class="artifact-text-note">
                {{ t('artifacts.textPreviewLimited', { size: textPreviewLimitLabel }) }}
              </div>
            </template>
          </div>
          <div v-else class="artifact-no-preview">
            <i class="ph ph-file"></i>
            <span>{{ t('artifacts.previewUnavailable') }}</span>
          </div>
        </div>
      </div>
    </template>
    </template>

    <template v-if="activeTab === 'links'">
      <div v-if="!links.length" class="artifacts-empty">
        <GnEmptyState icon="ph ph-link" :title="t('artifacts.emptyLinks')" />
      </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">{{ t('artifacts.linksInSession', { n: links.length }) }}</div>
          </div>
          <div class="link-actions">
            <GnIconButton icon="ph ph-copy" :label="t('artifacts.copyAllLinks')" @click="copyAllLinks" />
            <GnIconButton icon="ph ph-arrow-square-out" :label="t('artifacts.openAllLinks')" @click="openAllLinks" />
          </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: a static toolbar row, the floating
             absolute button used to sit on top of the first list rows -->
        <div class="files-toolbar">
          <GnIconButton
            class="files-refresh-btn"
            icon="ph ph-arrows-clockwise"
            :label="t('artifacts.refreshFiles')"
            :disabled="filesRefreshing"
            @click="refreshFiles"
          />
        </div>

        <div v-if="!filesFlat.length" class="artifacts-empty">
          <GnEmptyState icon="ph ph-folder" :title="t('artifacts.emptyFiles')" />
        </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="btn-icon file-row-action"
                  :href="fileDownloadUrl(item.path)"
                  :title="t('common.download')"
                  :aria-label="t('common.download')"
                  target="_blank"
                  rel="noopener noreferrer"
                >
                  <i class="ph ph-download-simple"></i>
                </a>
                <button
                  v-if="canViewFile(item)"
                  class="btn-icon file-row-action"
                  type="button"
                  :title="t('artifacts.viewFile')"
                  :aria-label="t('artifacts.viewFile')"
                  @click="openFileInViewer(item)"
                >
                  <i class="ph ph-eye"></i>
                </button>
              </div>
            </div>
          </div>
        </template>
      </div>
    </template>

    <template v-if="activeTab === 'backgrounds'">
      <div v-if="!backgroundsList.length" class="artifacts-empty">
        <GnEmptyState icon="ph ph-arrows-out" :title="t('artifacts.emptyBackgrounds')" />
      </div>

      <template v-else>
        <div class="artifacts-list terminals-list">
          <button
            v-for="item in backgroundsList"
            :key="`${item.kind}:${item.key}`"
            class="artifact-item terminal-item"
            :class="{ 'is-active': isBgSelected(item), 'is-closed': !item.running }"
            @click="selectBackground(item)"
          >
            <span class="artifact-icon">
              <i :class="backgroundIcon(item)"></i>
            </span>
            <span class="artifact-info">
              <span class="artifact-title">{{ backgroundTitle(item) }}</span>
              <span class="artifact-meta">{{ backgroundMeta(item) }}</span>
            </span>
            <span class="badge" :class="backgroundBadgeClass(item)">
              {{ backgroundBadgeLabel(item) }}
            </span>
          </button>
        </div>

        <div v-if="selectedTask" class="terminal-detail">
          <div class="terminal-detail-header">
            <div class="terminal-detail-title-wrap">
              <div class="terminal-detail-title">{{ selectedTask.taskId }}</div>
              <div class="terminal-detail-meta">{{ selectedTask.argsSummary || selectedTask.tool }}</div>
            </div>
            <GnIconButton class="terminal-detail-close" icon="ph ph-x" :label="t('artifacts.closeDetails')" @click="selectedBg = null" />
          </div>

          <div class="terminal-detail-grid">
            <div class="terminal-detail-row">
              <span class="terminal-detail-label">{{ t('col.tool') }}</span>
              <span class="terminal-detail-value">{{ selectedTask.tool }}</span>
            </div>
            <div class="terminal-detail-row">
              <span class="terminal-detail-label">{{ t('col.status') }}</span>
              <span class="terminal-detail-value" :class="taskBadgeClass(selectedTask)">
                {{ taskBadgeLabel(selectedTask) }}
              </span>
            </div>
            <div class="terminal-detail-row">
              <span class="terminal-detail-label">{{ t('col.started') }}</span>
              <span class="terminal-detail-value">{{ formatTime(selectedTask.startedAt) }}</span>
            </div>
            <div v-if="selectedTask.finishedAt" class="terminal-detail-row">
              <span class="terminal-detail-label">{{ t('col.finished') }}</span>
              <span class="terminal-detail-value">{{ formatTime(selectedTask.finishedAt) }}</span>
            </div>
            <div v-if="selectedTask.subagentTokens != null" class="terminal-detail-row">
              <span class="terminal-detail-label">{{ t('col.subagentTokens') }}</span>
              <span class="terminal-detail-value">{{ selectedTask.subagentTokens }}</span>
            </div>
          </div>

          <div v-if="selectedTask.preview || selectedTask.status === 'running'" class="terminal-output-wrap">
            <div class="terminal-output-header">{{ t(selectedTask.status === 'running' ? 'artifacts.latestProgress' : 'common.result') }}</div>
            <pre class="terminal-output">{{ selectedTask.preview || t('artifacts.noOutput') }}</pre>
          </div>
        </div>

        <div v-if="selectedTerminal" class="terminal-detail">
          <div class="terminal-detail-header">
            <div>
              <div class="terminal-detail-title">{{ selectedTerminal.name }}</div>
              <div class="terminal-detail-meta">{{ selectedTerminal.description || selectedTerminal.command || t('artifacts.terminal') }}</div>
            </div>
            <GnIconButton class="terminal-detail-close" icon="ph ph-x" :label="t('artifacts.closeDetails')" @click="selectedBg = null" />
          </div>

          <div class="terminal-detail-grid">
            <div class="terminal-detail-row">
              <span class="terminal-detail-label">{{ t('col.status') }}</span>
              <span class="terminal-detail-value" :class="selectedTerminal.status === 'busy' ? 'status-busy' : (selectedTerminal.closed ? 'status-closed' : 'status-idle')">
                {{ t(selectedTerminal.closed ? 'artifacts.statusClosed' : (selectedTerminal.status === 'busy' ? 'artifacts.statusBusy' : 'artifacts.statusIdle')) }}
              </span>
            </div>
            <div class="terminal-detail-row">
              <span class="terminal-detail-label">{{ t('col.pid') }}</span>
              <span class="terminal-detail-value">{{ selectedTerminal.pid || t('common.notAvailable') }}</span>
            </div>
            <div class="terminal-detail-row">
              <span class="terminal-detail-label">{{ t('col.command') }}</span>
              <span class="terminal-detail-value terminal-detail-command">{{ selectedTerminal.command || t('common.notAvailable') }}</span>
            </div>
            <div class="terminal-detail-row">
              <span class="terminal-detail-label">{{ t('col.cwd') }}</span>
              <span class="terminal-detail-value">{{ selectedTerminal.cwd || t('common.notAvailable') }}</span>
            </div>
            <div v-if="selectedTerminal.uptime_seconds != null" class="terminal-detail-row">
              <span class="terminal-detail-label">{{ t('col.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">{{ t('col.background') }}</span>
              <span class="terminal-detail-value">{{ t(selectedTerminal.background ? 'common.yes' : 'common.no') }}</span>
            </div>
            <div v-if="selectedTerminal.closeReason" class="terminal-detail-row">
              <span class="terminal-detail-label">{{ t('col.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">{{ t('common.liveOutput') }}</div>
            <pre class="terminal-output">{{ selectedTerminal.output }}</pre>
          </div>
        </div>
      </template>
    </template>
  </aside>
</template>

<script setup>
import { ref } from 'vue'

import { t } from '@/i18n/index.js'
import { useChatStore } from '@/stores/chat.js'

import { useArtifactsView } from './composables/useArtifactsView.js'
import { useBackgrounds } from './composables/useBackgrounds.js'
import { useDrawerWidth } from './composables/useDrawerWidth.js'
import { useSessionFiles } from './composables/useSessionFiles.js'
import { useSessionLinks } from './composables/useSessionLinks.js'

defineProps({
  open: { type: Boolean, default: false }
})

const emit = defineEmits(['close'])
const chat = useChatStore()

// Panel geometry: docked width, viewport clamp, the drag handle.
const { isResizing, panelStyle, startResize } = useDrawerWidth()
// Artifacts tab: the selected item, its previews and source view.
const {
  selectedKey,
  showSource,
  sourceLoading,
  sourceError,
  selectedSourceLanguage,
  highlightedSource,
  textLoading,
  textError,
  highlightedText,
  textTruncated,
  textPreviewLimitLabel,
  artifacts,
  selected,
  openViewedImage,
  artifactKey,
  selectArtifact,
  contentType,
  iconClass,
  labelFor,
  versionedUrl,
  previewUrl,
  canFullscreen,
  openFullscreen,
  rawUrl,
  hasSource,
  toggleSource,
  downloadUrl,
  isFramePreview,
  copyLink,
  copyIcon,
} = useArtifactsView(chat)
// Links tab: every link the assistant wrote.
const { links, copyAllLinks, openAllLinks } = useSessionLinks(chat)
// Files tab: the directory tree, refresh button and pull-to-refresh.
const {
  filesRefreshing,
  refreshFiles,
  filesPtrPulling,
  filesPtrStyle,
  filesPtrTriggered,
  filesPtrLabel,
  onFilesTouchStart,
  onFilesTouchMove,
  onFilesTouchEnd,
  filesListRef,
  filesFlat,
  fileTree,
  fileIcon,
  fileMeta,
  fileIndent,
  fileDownloadUrl,
  canViewFile,
  openFileInViewer,
} = useSessionFiles(chat)
// Backgrounds tab: tasks and terminals in one list, with their detail pane.
const {
  selectedBg,
  backgroundsList,
  activeBackgroundCount,
  selectedTask,
  selectedTerminal,
  selectBackground,
  isBgSelected,
  backgroundIcon,
  backgroundTitle,
  backgroundMeta,
  backgroundBadgeClass,
  backgroundBadgeLabel,
  taskBadgeClass,
  taskBadgeLabel,
  formatTime,
  formatUptime,
} = useBackgrounds(chat)

const activeTab = ref('artifacts')
</script>

<style scoped lang="scss">
@use 'kit-deps' as *;
@use './styles/panel' as *;
@use './styles/viewed' as *;
@use './styles/lists' as *;
@use './styles/detail' as *;
@use './styles/links' as *;
@use './styles/files' as *;
@use './styles/panel-responsive' as *;
@use './styles/files-toolbar' as *;
@use './styles/backgrounds' as *;
</style>