diff --git a/webclient/src/components/artifacts/ArtifactsPanel.vue b/webclient/src/components/artifacts/ArtifactsPanel.vue
index 41296c0..346833c 100644
--- a/webclient/src/components/artifacts/ArtifactsPanel.vue
+++ b/webclient/src/components/artifacts/ArtifactsPanel.vue
@@ -423,20 +423,16 @@
diff --git a/webclient/src/components/artifacts/composables/useArtifactsView.js b/webclient/src/components/artifacts/composables/useArtifactsView.js
new file mode 100644
index 0000000..a3167b7
--- /dev/null
+++ b/webclient/src/components/artifacts/composables/useArtifactsView.js
@@ -0,0 +1,257 @@
+import { computed, ref, watch } from 'vue'
+
+import { useLightbox } from '@/composables/useLightbox.js'
+import { locale, t } from '@/i18n/index.js'
+import { itemContentType } from '@/utils/artifactContent.js'
+import {
+ absoluteContentUrl,
+ contentFileUrl,
+ versionedContentUrl,
+ viewerContentUrl,
+} from '@/utils/contentLinks.js'
+import {
+ CONTENT_TYPE_ICONS,
+ isFrameType,
+ isFullscreenType,
+ viewerNameFor,
+} from '@/utils/contentTypes.js'
+import { highlightSource, sourceLanguage } from '@/utils/sourceHighlight.js'
+
+/**
+ * The Artifacts tab: which published artifact is selected, its source or text
+ * preview, and every URL the pane offers (preview, raw, download, share).
+ */
+
+const TEXT_PREVIEW_LIMIT = 1_000_000
+
+export function useArtifactsView(chat) {
+ const { open: openLightbox, openContent } = useLightbox()
+
+ const selectedKey = ref(null)
+ const copiedKey = ref(null)
+ const showSource = ref(false)
+ const sourceText = ref('')
+ const sourceLoading = ref(false)
+ const sourceError = ref('')
+ // Text files are fetched into the preview rather than offered as a download.
+ const textContent = ref('')
+ const textLoading = ref(false)
+ const textError = ref('')
+ const textTruncated = ref(false)
+ const textPreviewLimitLabel = `${Math.round(TEXT_PREVIEW_LIMIT / 1024)} KB`
+
+ const artifacts = computed(() => chat.artifacts)
+ const selected = computed(() => artifacts.value.find(item => artifactKey(item) === selectedKey.value) || artifacts.value[0] || null)
+ const selectedSourceLanguage = computed(() => selected.value ? sourceLanguage(selected.value) : 'plaintext')
+ const highlightedSource = computed(() => highlightSource(sourceText.value, selectedSourceLanguage.value))
+ const highlightedText = computed(() => highlightSource(textContent.value, selectedSourceLanguage.value))
+
+ function openViewedImage(src) {
+ openLightbox(src)
+ }
+
+ async function loadText(item) {
+ if (!item || textLoading.value || textContent.value) return
+ textLoading.value = true
+ textError.value = ''
+ try {
+ const res = await fetch(versionedUrl(item))
+ if (!res.ok) throw new Error(`HTTP ${res.status}`)
+ const raw = await res.text()
+ textTruncated.value = raw.length > TEXT_PREVIEW_LIMIT
+ textContent.value = textTruncated.value ? raw.slice(0, TEXT_PREVIEW_LIMIT) : raw
+ } catch (err) {
+ textError.value = t('artifacts.sourceFailed', { error: err?.message || err })
+ } finally {
+ textLoading.value = false
+ }
+ }
+
+ // A different artifact selected → start its text over, and fetch it right
+ // away (the detail pane is always visible, unlike the card's collapsed body).
+ watch(selected, (item) => {
+ textContent.value = ''
+ textError.value = ''
+ textTruncated.value = false
+ if (item && !showSource.value && contentType(item) === 'text') loadText(item)
+ }, { immediate: true })
+
+ 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 = ''
+ }
+
+ // The backend detects the type; the name is the fallback for entries that
+ // arrive without one. Both cards read the same table now.
+ function contentType(item) {
+ return itemContentType(item)
+ }
+
+ function iconClass(item) {
+ return CONTENT_TYPE_ICONS[contentType(item)] || CONTENT_TYPE_ICONS.unknown
+ }
+
+ 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(locale.value, { 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)
+ return isFrameType(type)
+ ? viewerUrl(item, viewerNameFor(type))
+ : absoluteContentUrl(versionedUrl(item))
+ }
+
+ function canFullscreen(item) {
+ return isFullscreenType(contentType(item))
+ }
+
+ function openFullscreen(item) {
+ if (!item || !canFullscreen(item)) return
+ openContent({
+ url: absoluteContentUrl(versionedUrl(item)),
+ type: contentType(item),
+ title: item.title || item.filename,
+ })
+ }
+
+ function rawUrl(item) {
+ return contentFileUrl(item.url)
+ }
+
+ function sourceUrl(item) {
+ const type = contentType(item)
+ if (['html', 'svg'].includes(type)) return rawUrl(item)
+ if (['stl', 'model'].includes(type) && 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 = t('artifacts.sourceFailed', { error: err?.message || err })
+ } finally {
+ sourceLoading.value = false
+ }
+ }
+
+ function shareUrl(item) {
+ if (['stl', 'model'].includes(contentType(item))) 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 isFrameType(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'
+ }
+
+ return {
+ selectedKey,
+ copiedKey,
+ showSource,
+ sourceText,
+ sourceLoading,
+ sourceError,
+ textContent,
+ textLoading,
+ textError,
+ textTruncated,
+ textPreviewLimitLabel,
+ artifacts,
+ selected,
+ selectedSourceLanguage,
+ highlightedSource,
+ highlightedText,
+ openViewedImage,
+ artifactKey,
+ selectArtifact,
+ contentType,
+ iconClass,
+ labelFor,
+ versionedUrl,
+ previewUrl,
+ canFullscreen,
+ openFullscreen,
+ rawUrl,
+ hasSource,
+ toggleSource,
+ downloadUrl,
+ isFramePreview,
+ copyLink,
+ copyIcon,
+ }
+}
diff --git a/webclient/src/components/artifacts/composables/useBackgrounds.js b/webclient/src/components/artifacts/composables/useBackgrounds.js
new file mode 100644
index 0000000..d98de64
--- /dev/null
+++ b/webclient/src/components/artifacts/composables/useBackgrounds.js
@@ -0,0 +1,184 @@
+import { computed, ref, watch } from 'vue'
+
+import { locale, t } from '@/i18n/index.js'
+
+/**
+ * The Backgrounds tab: running/finished tasks and terminals in one list, the
+ * row that is selected in the detail pane, and the labels/ages it shows.
+ */
+
+const TASK_ICONS = {
+ terminal: 'ph ph-terminal-window',
+ ssh_exec: 'ph ph-plugs-connected',
+ spawn_agent: 'ph ph-user-circle-gear',
+ code_exec: 'ph ph-code',
+ peer: 'ph ph-chats-circle',
+}
+
+export function useBackgrounds(chat) {
+ // { kind: 'task' | 'terminal', key }
+ const selectedBg = ref(null)
+
+ const backgroundsList = computed(() => {
+ const tasks = Object.values(chat.backgroundTasks).map(t => ({
+ kind: 'task',
+ key: t.taskId,
+ running: t.status === 'running',
+ startedAt: t.startedAt ?? '',
+ task: t,
+ }))
+ const terminals = Object.values(chat.terminals)
+ .filter(t => !t.closed || t.output)
+ .map(t => ({
+ kind: 'terminal',
+ key: t.name,
+ running: t.status === 'busy' && !t.closed,
+ startedAt: '',
+ terminal: t,
+ }))
+ const all = [...tasks, ...terminals]
+ return all.sort((a, b) => {
+ // running first, then newest started on top
+ if (a.running !== b.running) return a.running ? -1 : 1
+ return String(b.startedAt ?? '').localeCompare(String(a.startedAt ?? ''))
+ })
+ })
+
+ const activeBackgroundCount = computed(() =>
+ backgroundsList.value.filter(i => i.running).length,
+ )
+
+ const selectedTask = computed(() =>
+ selectedBg.value?.kind === 'task' ? (chat.backgroundTasks[selectedBg.value.key] || null) : null,
+ )
+ const selectedTerminal = computed(() =>
+ selectedBg.value?.kind === 'terminal' ? (chat.terminals[selectedBg.value.key] || null) : null,
+ )
+
+ // A task can finish (TTL, cancel) while selected — drop a dangling selection.
+ watch(() => [selectedTask.value, selectedTerminal.value], ([task, term]) => {
+ if (selectedBg.value && !task && !term) selectedBg.value = null
+ })
+
+ // Selection refers to per-session ids — reset on session switch.
+ watch(() => chat.currentId, () => { selectedBg.value = null })
+
+ function selectBackground(item) {
+ const sel = { kind: item.kind, key: item.key }
+ selectedBg.value = selectedBg.value?.kind === sel.kind && selectedBg.value?.key === sel.key ? null : sel
+ }
+
+ function isBgSelected(item) {
+ return Boolean(
+ selectedBg.value &&
+ selectedBg.value.kind === item.kind &&
+ selectedBg.value.key === item.key,
+ )
+ }
+
+ function backgroundIcon(item) {
+ return item.kind === 'terminal'
+ ? 'ph ph-terminal-window'
+ : (TASK_ICONS[item.task.tool] || 'ph ph-arrows-out')
+ }
+
+ function backgroundTitle(item) {
+ return item.kind === 'terminal'
+ ? item.terminal.name
+ : (item.task.argsSummary || item.task.tool)
+ }
+
+ function backgroundMeta(item) {
+ return item.kind === 'terminal'
+ ? (item.terminal.description || item.terminal.command || t('artifacts.terminal'))
+ : t('artifacts.toolAndAge', { tool: item.task.tool, age: taskAge(item.task) })
+ }
+
+ function backgroundBadgeClass(item) {
+ if (item.kind === 'terminal') {
+ if (item.terminal.closed) return 'badge-error'
+ return item.terminal.status === 'busy' ? 'badge-success' : 'badge-secondary'
+ }
+ return taskBadgeBgClass(item.task)
+ }
+
+ function backgroundBadgeLabel(item) {
+ if (item.kind === 'terminal') {
+ if (item.terminal.closed) return t('artifacts.statusClosed')
+ return t(item.terminal.status === 'busy' ? 'artifacts.statusBusy' : 'artifacts.statusIdle')
+ }
+ return taskBadgeLabel(item.task)
+ }
+
+ function taskAge(task) {
+ const ts = task.finishedAt ?? task.startedAt
+ if (!ts) return ''
+ let seconds = (Date.now() - new Date(ts).getTime()) / 1000
+ if (!Number.isFinite(seconds) || seconds < 0) return t('common.justNow')
+ if (seconds < 60) return `${Math.round(seconds)}s`
+ if (seconds < 3600) return `${Math.floor(seconds / 60)}m`
+ if (seconds < 86400) return `${Math.floor(seconds / 3600)}h`
+ return `${Math.floor(seconds / 86400)}d`
+ }
+
+ function taskBadgeClass(task) {
+ if (task.status === 'running') return 'status-busy'
+ if (task.status === 'completed') return 'status-idle'
+ return 'status-closed' // failed / cancelled
+ }
+
+ // Kit badge variant for list badges (filled chip look).
+ function taskBadgeBgClass(task) {
+ if (task.status === 'running') return 'badge-success'
+ if (task.status === 'completed') return 'badge-secondary'
+ return 'badge-error' // failed / cancelled
+ }
+
+ function taskBadgeLabel(task) {
+ const map = {
+ running: t('artifacts.statusRunning'),
+ completed: t('artifacts.statusDone'),
+ failed: t('artifacts.statusFailed'),
+ cancelled: t('artifacts.statusStopped'),
+ }
+ return map[task.status] || task.status
+ }
+
+ function formatTime(value) {
+ if (!value) return t('common.notAvailable')
+ const date = new Date(value)
+ if (Number.isNaN(date.getTime())) return value
+ return date.toLocaleTimeString(locale.value, {
+ hour: '2-digit',
+ minute: '2-digit',
+ second: '2-digit',
+ })
+ }
+
+ 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`
+ }
+
+ return {
+ selectedBg,
+ backgroundsList,
+ activeBackgroundCount,
+ selectedTask,
+ selectedTerminal,
+ selectBackground,
+ isBgSelected,
+ backgroundIcon,
+ backgroundTitle,
+ backgroundMeta,
+ backgroundBadgeClass,
+ backgroundBadgeLabel,
+ taskBadgeClass,
+ taskBadgeLabel,
+ formatTime,
+ formatUptime,
+ }
+}
diff --git a/webclient/src/components/artifacts/composables/useDrawerWidth.js b/webclient/src/components/artifacts/composables/useDrawerWidth.js
new file mode 100644
index 0000000..3b8e8ed
--- /dev/null
+++ b/webclient/src/components/artifacts/composables/useDrawerWidth.js
@@ -0,0 +1,100 @@
+import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
+
+/**
+ * The docked panel's width: the persisted preference, the viewport clamp, and
+ * the pointer drag that resizes it. Below the desktop breakpoint the panel is
+ * a full-screen drawer and none of this applies.
+ */
+
+const DRAWER_DESKTOP_BREAKPOINT = 980
+const CHAT_MIN_WIDTH = 500
+const DRAWER_WIDTH_STORAGE_KEY = 'navi:artifacts-drawer-width'
+
+export function useDrawerWidth() {
+ const isResizing = ref(false)
+ const viewportWidth = ref(typeof window === 'undefined' ? 0 : window.innerWidth)
+
+ const drawerWidth = ref(readSavedDrawerWidth())
+
+ const isDesktop = computed(() => viewportWidth.value > DRAWER_DESKTOP_BREAKPOINT)
+ const panelStyle = computed(() => {
+ if (!isDesktop.value) return {}
+ return { '--artifacts-width': `${currentDrawerWidth()}px` }
+ })
+
+ 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()
+ })
+
+ return { isResizing, isDesktop, panelStyle, startResize }
+}
diff --git a/webclient/src/components/artifacts/composables/useSessionFiles.js b/webclient/src/components/artifacts/composables/useSessionFiles.js
new file mode 100644
index 0000000..af39947
--- /dev/null
+++ b/webclient/src/components/artifacts/composables/useSessionFiles.js
@@ -0,0 +1,204 @@
+import { computed, ref } from 'vue'
+
+import { useLightbox } from '@/composables/useLightbox.js'
+import { t } from '@/i18n/index.js'
+import { itemContentType } from '@/utils/artifactContent.js'
+import { absoluteContentUrl } from '@/utils/contentLinks.js'
+
+/**
+ * The Files tab: the session's directory tree with per-file download / view
+ * actions, the refresh button, and the mobile pull-to-refresh gesture.
+ */
+
+const FILES_PTR_THRESHOLD = 80
+const FILES_PTR_MAX = 120
+
+export function useSessionFiles(chat) {
+ const { openContent } = useLightbox()
+
+ 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 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 t('common.refreshing')
+ if (filesPtrTriggered.value) return t('common.releaseToRefresh')
+ return t('common.pullToRefresh')
+ })
+
+ 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 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 }
+ })
+ })
+
+ function contentType(item) {
+ return itemContentType(item)
+ }
+
+ 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',
+ // phosphor has no dedicated json glyph — file-code is its stand-in
+ json: 'ph ph-file-code',
+ 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 t('artifacts.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`
+ }
+
+ // "Open inline" used to append ?download=1 as well, so the eye button saved
+ // the file instead of showing it.
+ function filePreviewUrl(path) {
+ const sessionId = chat.currentId
+ if (!sessionId) return '#'
+ return `/api/sessions/${sessionId}/files/${encodeURIComponent(path)}`
+ }
+
+ function canViewFile(item) {
+ if (!item || item.is_dir) return false
+ return contentType(item) !== 'unknown'
+ }
+
+ // Every type with a viewer opens in the same modal the artifact preview uses:
+ // media plays there, text is fetched and highlighted, documents and 3D models
+ // get their iframe page.
+ function openFileInViewer(item) {
+ if (!item || item.is_dir) return
+ const type = contentType(item)
+ if (type === 'unknown') return
+ openContent({
+ url: absoluteContentUrl(filePreviewUrl(item.path)),
+ type,
+ title: item.name,
+ })
+ }
+
+ return {
+ filesRefreshing,
+ refreshFiles,
+ filesPtrPulling,
+ filesPtrOffset,
+ filesPtrTriggered,
+ filesListRef,
+ filesPtrStyle,
+ filesPtrLabel,
+ onFilesTouchStart,
+ onFilesTouchMove,
+ onFilesTouchEnd,
+ filesFlat,
+ fileTree,
+ fileIcon,
+ fileMeta,
+ fileIndent,
+ fileDownloadUrl,
+ canViewFile,
+ openFileInViewer,
+ }
+}
diff --git a/webclient/src/components/artifacts/composables/useSessionLinks.js b/webclient/src/components/artifacts/composables/useSessionLinks.js
new file mode 100644
index 0000000..b19208b
--- /dev/null
+++ b/webclient/src/components/artifacts/composables/useSessionLinks.js
@@ -0,0 +1,125 @@
+import { computed } from 'vue'
+
+/**
+ * Every link the assistant wrote in this session: markdown links first, then
+ * bare URLs. A markdown link wins over a bare URL for the same page and keeps
+ * its title; a bare URL that sits inside a markdown href is skipped.
+ */
+
+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()
+ }
+}
+
+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
+}
+
+export function useSessionLinks(chat) {
+ 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
+ })
+
+ 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')
+ }
+ }
+
+ return { links, copyAllLinks, openAllLinks }
+}
diff --git a/webclient/src/components/artifacts/styles/_backgrounds.scss b/webclient/src/components/artifacts/styles/_backgrounds.scss
new file mode 100644
index 0000000..09a7f93
--- /dev/null
+++ b/webclient/src/components/artifacts/styles/_backgrounds.scss
@@ -0,0 +1,145 @@
+/* ── Terminals tab ────────────────────────────────────────────────────────── */
+.terminals-list {
+ max-height: none;
+ flex: 1;
+ overflow-y: auto;
+}
+
+.terminal-item.is-closed {
+ opacity: 0.6;
+}
+
+.terminal-item .artifact-meta {
+ white-space: normal;
+ word-break: break-word;
+ line-height: 1.4;
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+}
+
+.terminal-detail {
+ flex: 1;
+ min-height: 0;
+ display: flex;
+ flex-direction: column;
+ border-top: 2px solid var(--gn-border-color-muted);
+ overflow-y: auto;
+}
+
+.terminal-detail-header {
+ padding: 12px 15px 8px;
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 8px;
+}
+
+.terminal-detail-title-wrap {
+ min-width: 0;
+}
+
+.artifacts-tab-count.has-running {
+ background: var(--gn-color-success);
+ color: var(--gn-color-black);
+}
+
+.terminal-detail-title {
+ font-size: 14px;
+ font-weight: 600;
+ color: var(--gn-color-text-light);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.terminal-detail-close {
+ flex-shrink: 0;
+}
+
+.terminal-detail-meta {
+ margin-top: 3px;
+ font-size: 12px;
+ color: var(--gn-color-text-dark);
+ word-break: break-word;
+ line-height: 1.5;
+}
+
+.terminal-detail-grid {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ padding: 0 15px 12px;
+ border-bottom: 2px solid color-mix(in srgb, var(--gn-color-text-light) 12%, transparent);
+}
+
+.terminal-detail-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ font-size: 12px;
+}
+
+.terminal-detail-label {
+ color: var(--gn-color-text-dark);
+ flex-shrink: 0;
+}
+
+.terminal-detail-value {
+ color: var(--gn-color-text-light);
+ text-align: right;
+ word-break: break-all;
+}
+
+/* Status colours inside detail rows — must be after .terminal-detail-value
+ so their color overrides the default var(--gn-color-text-light). */
+.terminal-detail-value.status-busy {
+ color: var(--gn-color-success);
+}
+
+.terminal-detail-value.status-idle {
+ color: var(--gn-color-secondary);
+}
+
+.terminal-detail-value.status-closed {
+ color: var(--gn-color-error);
+}
+
+.terminal-detail-command {
+ font-family: var(--gn-font-family-base);
+ font-size: 11px;
+ color: var(--gn-color-accent);
+}
+
+.terminal-output-wrap {
+ flex: 1;
+ min-height: 0;
+ display: flex;
+ flex-direction: column;
+ background: var(--gn-color-black);
+}
+
+.terminal-output-header {
+ padding: 8px 15px;
+ font-size: 11px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ color: var(--gn-color-text-dark);
+ border-bottom: 2px solid color-mix(in srgb, var(--gn-color-text-light) 12%, transparent);
+}
+
+.terminal-output {
+ flex: 1;
+ margin: 0;
+ padding: 14px;
+ overflow: auto;
+ white-space: pre-wrap;
+ word-break: break-word;
+ font-family: var(--gn-font-family-base);
+ font-size: 12px;
+ line-height: 1.6;
+ color: var(--gn-color-text-light);
+}
diff --git a/webclient/src/components/artifacts/styles/_detail.scss b/webclient/src/components/artifacts/styles/_detail.scss
new file mode 100644
index 0000000..f105e95
--- /dev/null
+++ b/webclient/src/components/artifacts/styles/_detail.scss
@@ -0,0 +1,123 @@
+.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: var(--gn-color-text-light);
+}
+
+.artifact-detail-filename {
+ margin-top: 3px;
+ font-size: 12px;
+ color: var(--gn-color-text-dark);
+}
+
+.artifact-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 4px;
+ padding: 0 15px 12px;
+}
+
+.artifact-preview {
+ flex: 1;
+ min-height: 0;
+ border-top: 2px solid var(--gn-border-color-muted);
+ background: var(--gn-color-black);
+}
+
+.artifact-frame,
+.artifact-image,
+.artifact-video {
+ width: 100%;
+ height: 100%;
+ border: 0;
+ display: block;
+}
+
+.artifact-image,
+.artifact-video {
+ object-fit: contain;
+}
+
+/* The preview itself is the affordance: clicking the photo opens the same
+ modal as the expand button. */
+.artifact-image.is-viewable {
+ cursor: zoom-in;
+}
+
+/* The native player keeps its own height; only the width is ours */
+.artifact-audio {
+ width: 100%;
+ display: block;
+}
+
+.artifact-text {
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+ overflow: auto;
+ background: var(--gn-color-black);
+}
+
+.artifact-text-note {
+ padding: 8px 14px;
+ border-top: 1px solid var(--gn-border-color-muted);
+ color: var(--gn-color-text-dark);
+ font-size: 12px;
+}
+
+.artifact-source {
+ height: 100%;
+ overflow: auto;
+ background: var(--gn-color-black);
+}
+
+.artifact-source-code,
+.artifact-source-error {
+ margin: 0;
+ padding: 14px;
+ white-space: pre;
+ font-family: var(--gn-font-family-base);
+ font-size: 12px;
+ line-height: 1.6;
+ color: var(--gn-color-text-light);
+}
+
+.artifact-source-error {
+ color: var(--gn-color-error);
+}
+
+.artifact-source-state {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 14px;
+ color: var(--gn-color-text-dark);
+ 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: var(--gn-color-text-dark);
+ font-size: 13px;
+}
+
+.artifact-no-preview i {
+ font-size: 28px;
+}
diff --git a/webclient/src/components/artifacts/styles/_files-toolbar.scss b/webclient/src/components/artifacts/styles/_files-toolbar.scss
new file mode 100644
index 0000000..6890ef8
--- /dev/null
+++ b/webclient/src/components/artifacts/styles/_files-toolbar.scss
@@ -0,0 +1,54 @@
+.files-toolbar {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ padding: 8px 12px 2px;
+}
+
+/* ── Files tab refresh button ─────────────────────────────────────────────── */
+.files-tab-wrap {
+ position: relative;
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+}
+
+.files-refresh-btn {
+ &:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ }
+}
+
+/* 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(--gn-color-text-dark);
+ 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; }
+}
diff --git a/webclient/src/components/artifacts/styles/_files.scss b/webclient/src/components/artifacts/styles/_files.scss
new file mode 100644
index 0000000..63429cc
--- /dev/null
+++ b/webclient/src/components/artifacts/styles/_files.scss
@@ -0,0 +1,36 @@
+.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: var(--gn-color-text-medium);
+}
+
+.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;
+}
diff --git a/webclient/src/components/artifacts/styles/_links.scss b/webclient/src/components/artifacts/styles/_links.scss
new file mode 100644
index 0000000..d1d1375
--- /dev/null
+++ b/webclient/src/components/artifacts/styles/_links.scss
@@ -0,0 +1,38 @@
+.links-list {
+ max-height: none;
+ flex: 1;
+ overflow-y: auto;
+}
+
+.link-item {
+ text-decoration: none;
+}
+
+.link-item .artifact-title {
+ color: var(--gn-color-accent);
+}
+
+.link-item:hover .artifact-title,
+.link-item.is-active .artifact-title {
+ /* black title text vanishes on the panel hover tint — keep the link lit */
+ color: var(--gn-color-text-light);
+}
+
+.link-detail {
+ padding: 12px 15px;
+ border-top: 2px solid var(--gn-border-color-muted);
+}
+
+.link-detail-header {
+ margin-bottom: 8px;
+}
+
+.link-detail-count {
+ font-size: 12px;
+ color: var(--gn-color-text-dark);
+}
+
+.link-actions {
+ display: flex;
+ gap: 6px;
+}
diff --git a/webclient/src/components/artifacts/styles/_lists.scss b/webclient/src/components/artifacts/styles/_lists.scss
new file mode 100644
index 0000000..ff3e991
--- /dev/null
+++ b/webclient/src/components/artifacts/styles/_lists.scss
@@ -0,0 +1,57 @@
+.artifact-item {
+ width: 100%;
+ min-height: 50px;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 8px 12px;
+ border: 0;
+ border-left: var(--gn-border-width-accent) solid transparent; /* active bar, same row geometry */
+ background: transparent;
+ color: var(--gn-color-text-light);
+ text-align: left;
+ cursor: pointer;
+ transition: background var(--gn-motion-fast) var(--gn-motion-ease), border-color var(--gn-motion-fast) var(--gn-motion-ease);
+}
+
+.artifact-item:hover {
+ background: color-mix(in srgb, var(--gn-color-text-light) 7%, transparent);
+}
+
+.artifact-item.is-active {
+ border-left-color: var(--gn-color-secondary);
+ background: var(--gn-surface-panel-muted);
+}
+
+.artifact-icon {
+ display: inline-flex;
+ flex-shrink: 0;
+ color: var(--gn-color-accent);
+ font-size: 20px;
+}
+
+.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: var(--gn-color-text-dark);
+}
diff --git a/webclient/src/components/artifacts/styles/_panel-responsive.scss b/webclient/src/components/artifacts/styles/_panel-responsive.scss
new file mode 100644
index 0000000..5da415d
--- /dev/null
+++ b/webclient/src/components/artifacts/styles/_panel-responsive.scss
@@ -0,0 +1,41 @@
+@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;
+ }
+}
diff --git a/webclient/src/components/artifacts/styles/_panel.scss b/webclient/src/components/artifacts/styles/_panel.scss
new file mode 100644
index 0000000..aab4a9a
--- /dev/null
+++ b/webclient/src/components/artifacts/styles/_panel.scss
@@ -0,0 +1,155 @@
+.artifacts-panel {
+ /* container queries below squeeze the tab row to the panel width —
+ the tabs must never wrap into a second row */
+ container-type: inline-size;
+
+ 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: var(--gn-color-black);
+ border-left: 2px solid var(--gn-border-color-muted);
+ 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: var(--gn-color-accent);
+}
+
+.artifacts-header {
+ /* min-height: kit chips may wrap to a second row on a narrow panel */
+ min-height: 58px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ padding: 0 15px;
+ border-bottom: 2px solid var(--gn-border-color-muted);
+}
+
+.artifacts-empty {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ color: var(--gn-color-text-dark);
+ font-size: 13px;
+}
+
+.artifacts-tabs {
+ display: flex;
+ align-items: center;
+ flex-wrap: nowrap; /* kit chip-group wraps — the tab row must stay one line */
+ gap: 8px;
+ flex: 1;
+ min-width: 0;
+}
+
+.artifacts-tab {
+ min-width: 0;
+ white-space: nowrap;
+}
+
+/* Squeeze steps sized from the widest tab (BACKGROUNDS + icon + count) */
+@container (max-width: 560px) {
+ .artifacts-tab {
+ padding: 4px 10px;
+ font-size: 11px;
+ gap: 6px;
+ }
+
+ .artifacts-tabs {
+ gap: 6px;
+ }
+}
+
+@container (max-width: 490px) {
+ .artifacts-tab {
+ padding: 3px 8px;
+ font-size: 10px;
+ gap: 5px;
+ }
+}
+
+@container (max-width: 400px) {
+ .artifacts-tab {
+ padding: 2px 6px;
+ }
+
+ .artifacts-tab-count {
+ padding-left: 3px;
+ }
+}
+
+/* Only the docked panel runs out of room with the label still attached; on a
+ phone the mobile block below has already dropped the label, so the icon is
+ all that is left and dropping it too would leave a bare counter. */
+@media (min-width: 981px) {
+ @container (max-width: 360px) {
+ .artifacts-tab i {
+ display: none;
+ }
+ }
+}
+
+.artifacts-tab-count {
+ padding-left: 4px;
+ opacity: 0.72;
+}
+
+.artifacts-tab-count.has-running {
+ color: var(--gn-color-success);
+ opacity: 1;
+}
+
+.artifacts-list {
+ max-height: calc(58px * 4);
+ overflow-y: auto;
+ border-bottom: 2px solid var(--gn-border-color-muted);
+}
diff --git a/webclient/src/components/artifacts/styles/_viewed.scss b/webclient/src/components/artifacts/styles/_viewed.scss
new file mode 100644
index 0000000..2502a3d
--- /dev/null
+++ b/webclient/src/components/artifacts/styles/_viewed.scss
@@ -0,0 +1,47 @@
+@use 'kit-deps' as *;
+
+/* ── Viewed images (grouped, Navi's own image reads) ─────────────────── */
+.viewed-images-group {
+ flex-shrink: 0;
+ border-bottom: 2px solid var(--gn-border-color-muted);
+ background: var(--gn-surface-panel-muted);
+}
+
+.viewed-images-header {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 8px 12px;
+ font-size: 12px;
+ font-weight: 500;
+ color: var(--gn-color-text-medium);
+}
+
+.viewed-images-header i {
+ font-size: 14px;
+ color: var(--gn-color-secondary);
+}
+
+.viewed-images-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
+ gap: 6px;
+ padding: 0 12px 12px;
+ max-height: 320px;
+ overflow-y: auto;
+}
+
+.viewed-image-thumb {
+ width: 100%;
+ height: 96px;
+ object-fit: cover;
+ border: 1px solid var(--gn-border-color-muted);
+ border-radius: $border-radius-md;
+ cursor: zoom-in;
+ transition: border-color var(--gn-motion-fast) var(--gn-motion-ease), opacity var(--gn-motion-fast) var(--gn-motion-ease);
+}
+
+.viewed-image-thumb:hover {
+ border-color: var(--gn-color-secondary);
+ opacity: 0.85;
+}
diff --git a/webclient/src/utils/artifactContent.js b/webclient/src/utils/artifactContent.js
new file mode 100644
index 0000000..b3494a1
--- /dev/null
+++ b/webclient/src/utils/artifactContent.js
@@ -0,0 +1,7 @@
+import { contentTypeFromName, normalizeContentType } from '@/utils/contentTypes.js'
+
+/** The one content-type decision for an artifact or a session file entry. */
+export function itemContentType(item) {
+ return normalizeContentType(item?.content_type)
+ || contentTypeFromName(item?.filename || item?.path || item?.url || '')
+}