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