Initial commit

This commit is contained in:
2025-12-14 21:59:59 +00:00
commit 9c2a1a795a
126 changed files with 21728 additions and 0 deletions
+18
View File
@@ -0,0 +1,18 @@
<script setup lang="ts">
defineProps<{
icon?: string
colorClass?: string
bgClass?: string
borderClass?: string
}>()
</script>
<template>
<span
class="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full border"
:class="[bgClass || 'bg-slate-500/20', colorClass || 'text-slate-300', borderClass || 'border-slate-500/40']"
>
<i v-if="icon" class="fas" :class="icon" />
<slot />
</span>
</template>
+21
View File
@@ -0,0 +1,21 @@
<script setup lang="ts">
defineProps<{
gradient?: string
borderClass?: string
highlight?: boolean
}>()
</script>
<template>
<div
class="p-4 rounded-xl border-2 transition-all"
:class="[
gradient || 'bg-gradient-to-br from-slate-800/50 to-slate-900/50',
borderClass || 'border-slate-700/50',
highlight ? 'ring-2 ring-claude-500/20' : '',
'hover:border-opacity-70'
]"
>
<slot />
</div>
</template>
+89
View File
@@ -0,0 +1,89 @@
<script setup lang="ts">
import type { FilterType, ObservationType, ConceptType } from '@/types'
import { OBSERVATION_TYPES, CONCEPT_TYPES } from '@/types/observation'
defineProps<{
currentFilter: FilterType
currentTypeFilter: ObservationType | null
currentConceptFilter: ConceptType | null
observationCount: number
promptCount: number
}>()
const emit = defineEmits<{
'update:filter': [filter: FilterType]
'update:typeFilter': [type: ObservationType | null]
'update:conceptFilter': [concept: ConceptType | null]
}>()
const tabs: { key: FilterType; label: string; icon: string }[] = [
{ key: 'all', label: 'All', icon: 'fa-layer-group' },
{ key: 'observations', label: 'Observations', icon: 'fa-brain' },
{ key: 'summaries', label: 'Summaries', icon: 'fa-clipboard-list' },
{ key: 'prompts', label: 'Prompts', icon: 'fa-comment' }
]
</script>
<template>
<div class="glass rounded-xl p-4 mb-4 border border-white/10">
<!-- Main Filter Tabs -->
<div class="flex items-center gap-2 flex-wrap">
<button
v-for="tab in tabs"
:key="tab.key"
class="px-3 py-1.5 rounded-lg text-sm font-medium transition-colors"
:class="[
currentFilter === tab.key
? 'bg-claude-500 text-white'
: 'bg-white/5 text-slate-400 hover:bg-white/10 hover:text-white'
]"
@click="emit('update:filter', tab.key)"
>
<i class="fas mr-1.5" :class="tab.icon" />
{{ tab.label }}
</button>
<!-- Stats -->
<div class="ml-auto flex items-center gap-3 text-xs text-slate-500">
<span>{{ observationCount }} obs</span>
<span>·</span>
<span>{{ promptCount }} prompts</span>
</div>
</div>
<!-- Sub-filters (when observations selected) -->
<div v-if="currentFilter === 'observations' || currentFilter === 'all'" class="mt-3 pt-3 border-t border-white/10">
<div class="flex items-center gap-3 flex-wrap">
<!-- Type Filter -->
<div class="flex items-center gap-1">
<span class="text-xs text-slate-500 mr-1">Type:</span>
<select
class="bg-white/5 border border-white/10 rounded px-2 py-1 text-xs text-slate-300 focus:outline-none focus:border-claude-500"
:value="currentTypeFilter || ''"
@change="emit('update:typeFilter', ($event.target as HTMLSelectElement).value as ObservationType || null)"
>
<option value="">All Types</option>
<option v-for="type in OBSERVATION_TYPES" :key="type" :value="type">
{{ type }}
</option>
</select>
</div>
<!-- Concept Filter -->
<div class="flex items-center gap-1">
<span class="text-xs text-slate-500 mr-1">Concept:</span>
<select
class="bg-white/5 border border-white/10 rounded px-2 py-1 text-xs text-slate-300 focus:outline-none focus:border-claude-500"
:value="currentConceptFilter || ''"
@change="emit('update:conceptFilter', ($event.target as HTMLSelectElement).value as ConceptType || null)"
>
<option value="">All Concepts</option>
<option v-for="concept in CONCEPT_TYPES" :key="concept" :value="concept">
{{ concept }}
</option>
</select>
</div>
</div>
</div>
</div>
</template>
+55
View File
@@ -0,0 +1,55 @@
<script setup lang="ts">
defineProps<{
isConnected: boolean
isProcessing: boolean
}>()
const emit = defineEmits<{
refresh: []
}>()
</script>
<template>
<header class="glass border-b border-white/10 sticky top-0 z-50">
<div class="max-w-7xl mx-auto px-4 py-4">
<div class="flex items-center justify-between">
<!-- Logo & Title -->
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-xl bg-gradient-to-br from-claude-500 to-claude-700 flex items-center justify-center shadow-lg">
<i class="fas fa-brain text-xl text-white" />
</div>
<div>
<h1 class="text-xl font-bold text-white">Claude <span class="text-claude-400">Mnemonic</span></h1>
<p class="text-xs text-slate-400">Persistent Memory System</p>
</div>
</div>
<!-- Status & Actions -->
<div class="flex items-center gap-4">
<!-- Connection Status -->
<div class="flex items-center gap-2">
<span
class="w-2 h-2 rounded-full"
:class="[
isConnected ? 'bg-green-500' : 'bg-red-500',
isProcessing ? 'animate-pulse' : ''
]"
/>
<span class="text-sm text-slate-400">
{{ isConnected ? (isProcessing ? 'Processing' : 'Connected') : 'Disconnected' }}
</span>
</div>
<!-- Refresh Button -->
<button
class="p-2 rounded-lg bg-white/5 hover:bg-white/10 transition-colors text-slate-400 hover:text-white"
title="Refresh"
@click="emit('refresh')"
>
<i class="fas fa-rotate" />
</button>
</div>
</div>
</div>
</header>
</template>
+33
View File
@@ -0,0 +1,33 @@
<script setup lang="ts">
defineProps<{
icon: string
gradient?: string
size?: 'sm' | 'md' | 'lg'
}>()
</script>
<template>
<div
class="flex items-center justify-center rounded-xl bg-gradient-to-br shadow-lg flex-shrink-0"
:class="[
gradient || 'from-claude-500 to-claude-700',
{
'w-8 h-8': size === 'sm',
'w-12 h-12': size === 'md' || !size,
'w-16 h-16': size === 'lg'
}
]"
>
<i
class="fas text-white"
:class="[
icon,
{
'text-base': size === 'sm',
'text-2xl': size === 'md' || !size,
'text-3xl': size === 'lg'
}
]"
/>
</div>
</template>
+146
View File
@@ -0,0 +1,146 @@
<script setup lang="ts">
import type { ObservationFeedItem } from '@/types'
import { TYPE_CONFIG, CONCEPT_CONFIG } from '@/types/observation'
import { formatRelativeTime } from '@/utils/formatters'
import Card from './Card.vue'
import IconBox from './IconBox.vue'
import Badge from './Badge.vue'
import { computed } from 'vue'
const props = defineProps<{
observation: ObservationFeedItem
highlight?: boolean
}>()
const config = computed(() => TYPE_CONFIG[props.observation.type] || TYPE_CONFIG.change)
const concepts = computed(() => {
const raw = props.observation.concepts
if (Array.isArray(raw)) return raw
return []
})
const facts = computed(() => {
const raw = props.observation.facts
if (Array.isArray(raw)) return raw
return []
})
const filesRead = computed(() => {
const raw = props.observation.files_read
if (Array.isArray(raw)) return raw
return []
})
const filesModified = computed(() => {
const raw = props.observation.files_modified
if (Array.isArray(raw)) return raw
return []
})
const hasFiles = computed(() => filesRead.value.length > 0 || filesModified.value.length > 0)
// Split path into project root and relative path for styling
// e.g., /Users/foo/project/src/file.go → { root: 'project', path: 'src/file.go' }
const splitPath = (path: string, components = 3) => {
const parts = path.split('/').filter(Boolean)
if (parts.length <= components) {
return { root: '', path: path }
}
const relevant = parts.slice(-components)
return {
root: relevant[0],
path: relevant.slice(1).join('/')
}
}
</script>
<template>
<Card
:gradient="`bg-gradient-to-br from-amber-500/10 to-orange-500/5`"
:border-class="config.borderClass"
:highlight="highlight"
class="mb-4 hover:border-amber-400/50"
>
<div class="flex items-start gap-4">
<!-- Icon -->
<IconBox :icon="config.icon" :gradient="config.gradient" />
<!-- Content -->
<div class="flex-1 min-w-0">
<!-- Header -->
<div class="flex items-center gap-2 mb-2 flex-wrap">
<Badge
:icon="config.icon"
:color-class="config.colorClass"
:bg-class="config.bgClass"
:border-class="config.borderClass"
>
{{ observation.type.toUpperCase() }}
</Badge>
<span class="text-xs text-slate-500">{{ formatRelativeTime(observation.created_at) }}</span>
<span v-if="observation.project" class="text-xs text-slate-500 flex items-center gap-1">
<span class="text-slate-600">·</span>
<i class="fas fa-folder text-slate-600 text-[10px]" />
<span class="text-amber-600/80 font-mono">{{ observation.project.split('/').pop() }}</span>
</span>
</div>
<!-- Title & Subtitle -->
<h3 class="text-lg font-semibold text-amber-100 mb-1">
{{ observation.title || 'Untitled' }}
</h3>
<p v-if="observation.subtitle || observation.narrative" class="text-sm text-slate-300 mb-2">
{{ observation.subtitle || observation.narrative }}
</p>
<!-- Concepts -->
<div v-if="concepts.length > 0" class="flex flex-wrap gap-1.5 mt-2">
<Badge
v-for="concept in concepts"
:key="concept"
:icon="CONCEPT_CONFIG[concept as keyof typeof CONCEPT_CONFIG]?.icon || 'fa-tag'"
:color-class="CONCEPT_CONFIG[concept as keyof typeof CONCEPT_CONFIG]?.colorClass"
:bg-class="CONCEPT_CONFIG[concept as keyof typeof CONCEPT_CONFIG]?.bgClass"
:border-class="CONCEPT_CONFIG[concept as keyof typeof CONCEPT_CONFIG]?.borderClass"
>
{{ concept }}
</Badge>
</div>
<!-- Facts -->
<div v-if="facts.length > 0" class="mt-3 space-y-1.5">
<div class="text-xs text-slate-500 uppercase tracking-wide mb-1">Key Facts</div>
<div v-for="(fact, index) in facts" :key="index" class="flex items-start gap-2 text-sm text-slate-300">
<i class="fas fa-check text-amber-500/70 mt-0.5 flex-shrink-0 text-xs" />
<span>{{ fact }}</span>
</div>
</div>
<!-- Files -->
<div v-if="hasFiles" class="mt-3 pt-3 border-t border-slate-700/50">
<div class="space-y-1 text-xs">
<div v-if="filesRead.length > 0" class="flex items-start gap-1.5">
<i class="fas fa-eye text-slate-600 mt-0.5" />
<span class="text-slate-600">Read:</span>
<div class="flex flex-wrap gap-x-2 gap-y-0.5">
<span v-for="(file, index) in filesRead" :key="file" :title="file" class="cursor-default font-mono">
<span class="text-amber-600">{{ splitPath(file).root }}</span><span v-if="splitPath(file).root && splitPath(file).path">/</span><span class="text-slate-400 hover:text-slate-300">{{ splitPath(file).path }}</span><span v-if="index < filesRead.length - 1" class="text-slate-600">,</span>
</span>
</div>
</div>
<div v-if="filesModified.length > 0" class="flex items-start gap-1.5">
<i class="fas fa-pen text-slate-600 mt-0.5" />
<span class="text-slate-600">Modified:</span>
<div class="flex flex-wrap gap-x-2 gap-y-0.5">
<span v-for="(file, index) in filesModified" :key="file" :title="file" class="cursor-default font-mono">
<span class="text-amber-600">{{ splitPath(file).root }}</span><span v-if="splitPath(file).root && splitPath(file).path">/</span><span class="text-slate-400 hover:text-slate-300">{{ splitPath(file).path }}</span><span v-if="index < filesModified.length - 1" class="text-slate-600">,</span>
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</Card>
</template>
+142
View File
@@ -0,0 +1,142 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { fetchProjects } from '@/utils/api'
const props = defineProps<{
currentProject: string | null
}>()
const emit = defineEmits<{
'update:project': [project: string | null]
}>()
const projects = ref<string[]>([])
const searchQuery = ref('')
const isOpen = ref(false)
const loading = ref(false)
const filteredProjects = computed(() => {
if (!searchQuery.value) return projects.value
const query = searchQuery.value.toLowerCase()
return projects.value.filter(p => p.toLowerCase().includes(query))
})
const selectedProjectName = computed(() => {
if (!props.currentProject) return 'All Projects'
// Extract just the directory name from the full path
const parts = props.currentProject.split('/')
return parts[parts.length - 1] || props.currentProject
})
async function loadProjects() {
loading.value = true
try {
projects.value = await fetchProjects()
} catch (err) {
console.error('[ProjectFilter] Failed to load projects:', err)
} finally {
loading.value = false
}
}
function selectProject(project: string | null) {
emit('update:project', project)
isOpen.value = false
searchQuery.value = ''
}
function toggleDropdown() {
isOpen.value = !isOpen.value
if (isOpen.value) {
loadProjects()
}
}
// Close dropdown when clicking outside
function handleClickOutside(event: MouseEvent) {
const target = event.target as HTMLElement
if (!target.closest('.project-filter')) {
isOpen.value = false
}
}
onMounted(() => {
document.addEventListener('click', handleClickOutside)
loadProjects()
})
</script>
<template>
<div class="project-filter relative">
<!-- Trigger Button -->
<button
@click="toggleDropdown"
class="flex items-center gap-2 px-4 py-2 bg-slate-800 hover:bg-slate-700 border border-slate-700 rounded-lg text-sm text-slate-200 transition-colors w-full"
>
<i class="fas fa-folder text-claude-400" />
<span class="truncate flex-1 text-left">{{ selectedProjectName }}</span>
<i
class="fas fa-chevron-down text-slate-500 transition-transform"
:class="{ 'rotate-180': isOpen }"
/>
</button>
<!-- Dropdown -->
<div
v-if="isOpen"
class="absolute z-50 w-full mt-2 bg-slate-800 border border-slate-700 rounded-lg shadow-xl overflow-hidden"
>
<!-- Search Input -->
<div class="p-2 border-b border-slate-700">
<div class="relative">
<i class="fas fa-search absolute left-3 top-1/2 -translate-y-1/2 text-slate-500 text-sm" />
<input
v-model="searchQuery"
type="text"
placeholder="Search projects..."
class="w-full pl-9 pr-3 py-2 bg-slate-900 border border-slate-600 rounded text-sm text-slate-200 placeholder-slate-500 focus:outline-none focus:border-claude-500"
/>
</div>
</div>
<!-- Project List -->
<div class="max-h-64 overflow-y-auto">
<!-- All Projects Option -->
<button
@click="selectProject(null)"
class="w-full px-4 py-2 text-left text-sm hover:bg-slate-700 transition-colors flex items-center gap-2"
:class="{ 'bg-slate-700 text-claude-400': !currentProject, 'text-slate-300': currentProject }"
>
<i class="fas fa-globe text-slate-500" />
<span>All Projects</span>
<i v-if="!currentProject" class="fas fa-check ml-auto text-claude-400" />
</button>
<!-- Loading State -->
<div v-if="loading" class="px-4 py-3 text-slate-500 text-sm text-center">
<i class="fas fa-spinner fa-spin mr-2" />
Loading projects...
</div>
<!-- No Results -->
<div v-else-if="filteredProjects.length === 0" class="px-4 py-3 text-slate-500 text-sm text-center">
No projects found
</div>
<!-- Project Items -->
<button
v-else
v-for="project in filteredProjects"
:key="project"
@click="selectProject(project)"
class="w-full px-4 py-2 text-left text-sm hover:bg-slate-700 transition-colors flex items-center gap-2"
:class="{ 'bg-slate-700 text-claude-400': currentProject === project, 'text-slate-300': currentProject !== project }"
>
<i class="fas fa-folder text-slate-500" />
<span class="truncate">{{ project.split('/').pop() }}</span>
<i v-if="currentProject === project" class="fas fa-check ml-auto text-claude-400" />
</button>
</div>
</div>
</div>
</template>
+58
View File
@@ -0,0 +1,58 @@
<script setup lang="ts">
import type { PromptFeedItem } from '@/types'
import { formatRelativeTime, truncate } from '@/utils/formatters'
import Card from './Card.vue'
import Badge from './Badge.vue'
import { computed } from 'vue'
const props = defineProps<{
prompt: PromptFeedItem
highlight?: boolean
}>()
const displayText = computed(() => {
const text = props.prompt.prompt_text || ''
return truncate(text, 500)
})
</script>
<template>
<Card
gradient="bg-gradient-to-br from-emerald-500/10 to-green-500/5"
border-class="border-emerald-500/30"
:highlight="highlight"
class="mb-4 hover:border-emerald-400/50"
>
<div class="flex items-start gap-3">
<!-- User Icon -->
<div class="w-10 h-10 rounded-full bg-emerald-500/20 flex items-center justify-center flex-shrink-0">
<i class="fas fa-user text-emerald-400" />
</div>
<!-- Content -->
<div class="flex-1 min-w-0">
<!-- Header -->
<div class="flex items-center gap-2 flex-wrap mb-2">
<!-- Memory Match Badge -->
<Badge
v-if="prompt.matched_observations > 0"
icon="fa-brain"
color-class="text-amber-300"
bg-class="bg-amber-500/20"
border-class="border-amber-500/40"
>
{{ prompt.matched_observations }} memory match{{ prompt.matched_observations !== 1 ? 'es' : '' }}
</Badge>
<!-- Prompt Number & Time -->
<span class="text-xs text-slate-500">
#{{ prompt.prompt_number }} · {{ formatRelativeTime(prompt.created_at) }}
</span>
</div>
<!-- Prompt Text -->
<p class="text-sm text-slate-300 whitespace-pre-wrap">{{ displayText }}</p>
</div>
</div>
</Card>
</template>
+149
View File
@@ -0,0 +1,149 @@
<script setup lang="ts">
import type { Stats } from '@/types'
import ProjectFilter from './ProjectFilter.vue'
defineProps<{
stats: Stats | null
observationCount: number
promptCount: number
summaryCount: number
currentProject: string | null
}>()
defineEmits<{
'update:project': [project: string | null]
}>()
function formatNumber(n: number): string {
if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M'
if (n >= 1000) return (n / 1000).toFixed(1) + 'K'
return n.toString()
}
</script>
<template>
<aside class="w-72 flex-shrink-0 space-y-4">
<!-- Project Filter -->
<div class="bg-slate-800/50 rounded-lg p-4 border border-slate-700/50">
<div class="flex items-center gap-2 mb-3">
<i class="fas fa-filter text-claude-400" />
<h3 class="text-sm font-semibold text-white">Filter by Project</h3>
</div>
<ProjectFilter
:current-project="currentProject"
@update:project="$emit('update:project', $event)"
/>
</div>
<!-- Memory Stats -->
<div class="bg-slate-800/50 rounded-lg p-4 border border-slate-700/50">
<div class="flex items-center gap-2 mb-3">
<i class="fas fa-brain text-purple-400" />
<h3 class="text-sm font-semibold text-white">Memory Contents</h3>
</div>
<div class="space-y-3">
<!-- Observations -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<i class="fas fa-lightbulb text-amber-400 w-4" />
<span class="text-slate-400 text-sm">Observations</span>
</div>
<span class="text-white font-medium">{{ formatNumber(observationCount) }}</span>
</div>
<!-- Prompts -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<i class="fas fa-comment text-blue-400 w-4" />
<span class="text-slate-400 text-sm">Prompts</span>
</div>
<span class="text-white font-medium">{{ formatNumber(promptCount) }}</span>
</div>
<!-- Summaries -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<i class="fas fa-clipboard-list text-green-400 w-4" />
<span class="text-slate-400 text-sm">Summaries</span>
</div>
<span class="text-white font-medium">{{ formatNumber(summaryCount) }}</span>
</div>
</div>
</div>
<!-- Retrieval Stats -->
<div v-if="stats?.retrieval" class="bg-slate-800/50 rounded-lg p-4 border border-slate-700/50">
<div class="flex items-center gap-2 mb-3">
<i class="fas fa-search text-cyan-400" />
<h3 class="text-sm font-semibold text-white">Retrieval Stats</h3>
</div>
<div class="space-y-3">
<!-- Total Requests -->
<div class="flex items-center justify-between">
<span class="text-slate-400 text-sm">Total Requests</span>
<span class="text-white font-medium">{{ formatNumber(stats.retrieval.TotalRequests) }}</span>
</div>
<!-- Observations Served -->
<div class="flex items-center justify-between">
<span class="text-slate-400 text-sm">Obs Served</span>
<span class="text-white font-medium">{{ formatNumber(stats.retrieval.ObservationsServed) }}</span>
</div>
<!-- Search Requests -->
<div class="flex items-center justify-between">
<span class="text-slate-400 text-sm">Searches</span>
<span class="text-white font-medium">{{ formatNumber(stats.retrieval.SearchRequests) }}</span>
</div>
<!-- Context Injections -->
<div class="flex items-center justify-between">
<span class="text-slate-400 text-sm">Injections</span>
<span class="text-white font-medium">{{ formatNumber(stats.retrieval.ContextInjections) }}</span>
</div>
<!-- Verified Stale -->
<div v-if="stats.retrieval.VerifiedStale > 0" class="flex items-center justify-between">
<span class="text-slate-400 text-sm">Verified Stale</span>
<span class="text-amber-400 font-medium">{{ formatNumber(stats.retrieval.VerifiedStale) }}</span>
</div>
<!-- Deleted Invalid -->
<div v-if="stats.retrieval.DeletedInvalid > 0" class="flex items-center justify-between">
<span class="text-slate-400 text-sm">Deleted Invalid</span>
<span class="text-red-400 font-medium">{{ formatNumber(stats.retrieval.DeletedInvalid) }}</span>
</div>
</div>
</div>
<!-- Session Info -->
<div v-if="stats" class="bg-slate-800/50 rounded-lg p-4 border border-slate-700/50">
<div class="flex items-center gap-2 mb-3">
<i class="fas fa-clock text-slate-400" />
<h3 class="text-sm font-semibold text-white">Worker Info</h3>
</div>
<div class="space-y-3">
<!-- Uptime -->
<div class="flex items-center justify-between">
<span class="text-slate-400 text-sm">Uptime</span>
<span class="text-white font-medium text-xs">{{ stats.uptime }}</span>
</div>
<!-- Sessions Today -->
<div class="flex items-center justify-between">
<span class="text-slate-400 text-sm">Sessions Today</span>
<span class="text-white font-medium">{{ stats.sessionsToday }}</span>
</div>
<!-- Connected Clients -->
<div class="flex items-center justify-between">
<span class="text-slate-400 text-sm">Connected Clients</span>
<span class="text-white font-medium">{{ stats.connectedClients }}</span>
</div>
</div>
</div>
</aside>
</template>
+84
View File
@@ -0,0 +1,84 @@
<script setup lang="ts">
import type { Stats } from '@/types'
import { formatUptime } from '@/utils/formatters'
import { computed } from 'vue'
const props = defineProps<{
stats: Stats | null
queueDepth: number
}>()
const uptime = computed(() => {
if (!props.stats?.uptime) return '-'
return formatUptime(props.stats.uptime)
})
const status = computed(() => {
if (!props.stats) return 'Loading'
if (props.queueDepth > 0) return 'Processing'
if (props.stats.activeSessions > 0) return 'Active'
return 'Idle'
})
const statusColor = computed(() => {
if (status.value === 'Processing') return 'text-yellow-400'
if (status.value === 'Active') return 'text-green-400'
return 'text-slate-400'
})
</script>
<template>
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<!-- Uptime -->
<div class="glass rounded-xl p-4 border border-white/10">
<div class="flex items-center justify-between">
<div>
<p class="text-xs text-slate-400 uppercase tracking-wide">Uptime</p>
<p class="text-2xl font-bold text-claude-400">{{ uptime }}</p>
</div>
<div class="w-10 h-10 rounded-full bg-claude-500/20 flex items-center justify-center">
<i class="fas fa-clock text-claude-400" />
</div>
</div>
</div>
<!-- Active Sessions -->
<div class="glass rounded-xl p-4 border border-white/10">
<div class="flex items-center justify-between">
<div>
<p class="text-xs text-slate-400 uppercase tracking-wide">Active Sessions</p>
<p class="text-2xl font-bold text-blue-400">{{ stats?.activeSessions ?? 0 }}</p>
</div>
<div class="w-10 h-10 rounded-full bg-blue-500/20 flex items-center justify-center">
<i class="fas fa-terminal text-blue-400" />
</div>
</div>
</div>
<!-- Queue Depth -->
<div class="glass rounded-xl p-4 border border-white/10">
<div class="flex items-center justify-between">
<div>
<p class="text-xs text-slate-400 uppercase tracking-wide">Queue Depth</p>
<p class="text-2xl font-bold text-purple-400">{{ queueDepth }}</p>
</div>
<div class="w-10 h-10 rounded-full bg-purple-500/20 flex items-center justify-center">
<i class="fas fa-layer-group text-purple-400" />
</div>
</div>
</div>
<!-- Status -->
<div class="glass rounded-xl p-4 border border-white/10">
<div class="flex items-center justify-between">
<div>
<p class="text-xs text-slate-400 uppercase tracking-wide">Status</p>
<p class="text-2xl font-bold" :class="statusColor">{{ status }}</p>
</div>
<div class="w-10 h-10 rounded-full bg-green-500/20 flex items-center justify-center">
<i class="fas fa-signal text-green-400" />
</div>
</div>
</div>
</div>
</template>
+72
View File
@@ -0,0 +1,72 @@
<script setup lang="ts">
import type { SummaryFeedItem } from '@/types'
import { formatRelativeTime } from '@/utils/formatters'
import Card from './Card.vue'
import IconBox from './IconBox.vue'
defineProps<{
summary: SummaryFeedItem
highlight?: boolean
}>()
</script>
<template>
<Card
gradient="bg-gradient-to-br from-blue-500/10 to-indigo-500/5"
border-class="border-blue-500/30"
:highlight="highlight"
class="mb-4 hover:border-blue-400/50"
>
<div class="flex items-start gap-4">
<!-- Icon -->
<IconBox icon="fa-clipboard-list" gradient="from-blue-500 to-indigo-700" />
<!-- Content -->
<div class="flex-1 min-w-0">
<!-- Header -->
<div class="flex items-center gap-2 mb-3">
<span class="text-xs px-2 py-1 rounded-full bg-blue-500/20 text-blue-300 font-semibold uppercase tracking-wide">
<i class="fas fa-clipboard-list mr-1" /> Summary
</span>
<span class="text-xs text-slate-500">{{ formatRelativeTime(summary.created_at) }}</span>
</div>
<!-- Request -->
<div v-if="summary.request" class="mb-3">
<h4 class="text-xs text-slate-500 uppercase tracking-wide mb-1">Request</h4>
<p class="text-sm text-blue-100">{{ summary.request }}</p>
</div>
<!-- Sections -->
<div class="grid gap-3 text-sm">
<!-- Completed -->
<div v-if="summary.completed" class="bg-green-500/10 rounded-lg p-3 border border-green-500/20">
<div class="flex items-center gap-2 mb-1">
<i class="fas fa-check-circle text-green-400" />
<span class="text-xs text-green-300 uppercase tracking-wide font-medium">Completed</span>
</div>
<p class="text-slate-300">{{ summary.completed }}</p>
</div>
<!-- Learned -->
<div v-if="summary.learned" class="bg-purple-500/10 rounded-lg p-3 border border-purple-500/20">
<div class="flex items-center gap-2 mb-1">
<i class="fas fa-graduation-cap text-purple-400" />
<span class="text-xs text-purple-300 uppercase tracking-wide font-medium">Learned</span>
</div>
<p class="text-slate-300">{{ summary.learned }}</p>
</div>
<!-- Next Steps -->
<div v-if="summary.next_steps" class="bg-cyan-500/10 rounded-lg p-3 border border-cyan-500/20">
<div class="flex items-center gap-2 mb-1">
<i class="fas fa-arrow-right text-cyan-400" />
<span class="text-xs text-cyan-300 uppercase tracking-wide font-medium">Next Steps</span>
</div>
<p class="text-slate-300">{{ summary.next_steps }}</p>
</div>
</div>
</div>
</div>
</Card>
</template>
+48
View File
@@ -0,0 +1,48 @@
<script setup lang="ts">
import type { FeedItem } from '@/types'
import ObservationCard from './ObservationCard.vue'
import PromptCard from './PromptCard.vue'
import SummaryCard from './SummaryCard.vue'
defineProps<{
items: FeedItem[]
loading: boolean
}>()
</script>
<template>
<div class="timeline">
<!-- Loading State -->
<div v-if="loading && items.length === 0" class="text-center py-12">
<i class="fas fa-spinner fa-spin text-3xl text-claude-500 mb-4" />
<p class="text-slate-400">Loading timeline...</p>
</div>
<!-- Empty State -->
<div v-else-if="items.length === 0" class="text-center py-12">
<i class="fas fa-inbox text-3xl text-slate-600 mb-4" />
<p class="text-slate-400">No items to display</p>
</div>
<!-- Items -->
<template v-else>
<template v-for="(item, index) in items" :key="`${item.itemType}-${item.id}`">
<ObservationCard
v-if="item.itemType === 'observation'"
:observation="item"
:highlight="index === 0"
/>
<PromptCard
v-else-if="item.itemType === 'prompt'"
:prompt="item"
:highlight="index === 0"
/>
<SummaryCard
v-else-if="item.itemType === 'summary'"
:summary="item"
:highlight="index === 0"
/>
</template>
</template>
</div>
</template>
+10
View File
@@ -0,0 +1,10 @@
export { default as Badge } from './Badge.vue'
export { default as Card } from './Card.vue'
export { default as IconBox } from './IconBox.vue'
export { default as Header } from './Header.vue'
export { default as StatsCards } from './StatsCards.vue'
export { default as FilterTabs } from './FilterTabs.vue'
export { default as Timeline } from './Timeline.vue'
export { default as ObservationCard } from './ObservationCard.vue'
export { default as PromptCard } from './PromptCard.vue'
export { default as SummaryCard } from './SummaryCard.vue'