feat(app): add Electron desktop UI and evolve memory/retrieval layer
Introduce an Electron app with session browser, memory list, and usage views (vanilla JS + Vue scaffolding). On the data layer: add content_type and is_meta to messages for transcript control-plane filtering, introduce FTS5-backed memory recall with safe tokenization, support memory archival via forget() through the renamed --attune runtime, and expose anchors on memory records.
This commit is contained in:
@@ -0,0 +1,308 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import {
|
||||
state,
|
||||
FOLDER_SVG,
|
||||
setRoute,
|
||||
setView,
|
||||
setProject,
|
||||
setProjectSearch
|
||||
} from '../store.js';
|
||||
import { formatProjectLabel } from '../utils.js';
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
|
||||
// --- Counts ---
|
||||
|
||||
const sessionCount = computed(() => state.sessions.length);
|
||||
const activeCount = computed(() => state.memories.filter(m => !m.archived).length);
|
||||
const archivedCount = computed(() => state.memories.filter(m => m.archived).length);
|
||||
const totalMemoryCount = computed(() => state.memories.length);
|
||||
|
||||
// --- Projects list ---
|
||||
|
||||
const sidebarProjects = computed(() => {
|
||||
const items = state.route === 'sessions' ? state.sessions : state.memories;
|
||||
const filtered = items.filter(item => {
|
||||
if (state.route === 'sessions') return true;
|
||||
return state.view === 'archived' ? item.archived : !item.archived;
|
||||
});
|
||||
let projects = [...new Set(filtered.map(item => item.project).filter(Boolean))];
|
||||
if (state.projectSearch) {
|
||||
const q = state.projectSearch.toLowerCase();
|
||||
projects = projects.filter(p => p.toLowerCase().includes(q));
|
||||
}
|
||||
projects.sort((a, b) => formatProjectLabel(a).localeCompare(formatProjectLabel(b)));
|
||||
|
||||
// Count per project
|
||||
const counts = {};
|
||||
for (const item of filtered) {
|
||||
if (item.project) counts[item.project] = (counts[item.project] || 0) + 1;
|
||||
}
|
||||
|
||||
return projects.map(p => ({
|
||||
slug: p,
|
||||
label: formatProjectLabel(p),
|
||||
count: counts[p] || 0
|
||||
}));
|
||||
});
|
||||
|
||||
// --- Active state helpers ---
|
||||
|
||||
function isSessionsActive() {
|
||||
return state.route === 'sessions' && state.projectFilter === 'all';
|
||||
}
|
||||
|
||||
function isMemoryViewActive(view) {
|
||||
return state.route === 'memory' && state.view === view && state.projectFilter === 'all';
|
||||
}
|
||||
|
||||
function isUsageActive() {
|
||||
return state.route === 'usage';
|
||||
}
|
||||
|
||||
function isProjectActive(slug) {
|
||||
return state.projectFilter === slug;
|
||||
}
|
||||
|
||||
// --- Navigation handlers ---
|
||||
|
||||
function handleSidebarRoute(routeName) {
|
||||
setRoute(routeName);
|
||||
if (routeName === 'sessions') {
|
||||
router.push('/sessions');
|
||||
} else if (routeName === 'usage') {
|
||||
router.push('/usage');
|
||||
} else {
|
||||
router.push('/memory');
|
||||
}
|
||||
}
|
||||
|
||||
function handleSidebarView(view) {
|
||||
setView(view);
|
||||
router.push('/memory');
|
||||
}
|
||||
|
||||
function handleSidebarProject(slug) {
|
||||
setProject(slug);
|
||||
if (state.route === 'sessions') router.push('/sessions');
|
||||
else router.push('/memory');
|
||||
}
|
||||
|
||||
function handleProjectSearch(e) {
|
||||
setProjectSearch(e.target.value);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-brand">
|
||||
<svg viewBox="0 0 20 20" fill="none">
|
||||
<circle cx="10" cy="10" r="8" stroke="currentColor" stroke-width="1.2" opacity="0.6"/>
|
||||
<path d="M10 4 L10 16" stroke="url(#obelisk-grad)" stroke-width="2.5" stroke-linecap="round"/>
|
||||
<defs>
|
||||
<linearGradient id="obelisk-grad" x1="10" y1="4" x2="10" y2="16" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#a78bfa"/>
|
||||
<stop offset="1" stop-color="#6366f1"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
<span class="name">Obelisk</span>
|
||||
</div>
|
||||
|
||||
<!-- Library section -->
|
||||
<div class="sidebar-section">
|
||||
<div class="sidebar-section-title"><span>Library</span></div>
|
||||
|
||||
<button
|
||||
class="sidebar-item"
|
||||
:class="{ active: isSessionsActive() }"
|
||||
@click="handleSidebarRoute('sessions')"
|
||||
>
|
||||
<span class="icon">
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4">
|
||||
<rect x="2" y="3" width="12" height="10" rx="1.5"/>
|
||||
<path d="M5 1v4M11 1v4"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="label">Sessions</span>
|
||||
<span class="badge">{{ sessionCount }}</span>
|
||||
</button>
|
||||
|
||||
<!-- Memory parent (non-clickable label) -->
|
||||
<div class="sidebar-section-title" style="padding-top: 8px;">
|
||||
<span>Memory</span>
|
||||
<span class="badge">{{ totalMemoryCount }}</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="sidebar-item sub"
|
||||
:class="{ active: isMemoryViewActive('active') }"
|
||||
@click="handleSidebarView('active')"
|
||||
>
|
||||
<span class="icon">
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4">
|
||||
<circle cx="8" cy="8" r="5.5"/>
|
||||
<path d="M8 5v3l2 1.5"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="label">Active</span>
|
||||
<span class="badge">{{ activeCount }}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="sidebar-item sub"
|
||||
:class="{ active: isMemoryViewActive('archived') }"
|
||||
@click="handleSidebarView('archived')"
|
||||
>
|
||||
<span class="icon">
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4">
|
||||
<path d="M2.5 5h11v7.5a1.5 1.5 0 0 1-1.5 1.5H4a1.5 1.5 0 0 1-1.5-1.5V5z"/>
|
||||
<path d="M1.5 3.5h13v2h-13z"/>
|
||||
<path d="M6 8h4"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="label">Archived</span>
|
||||
<span class="badge">{{ archivedCount }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Stats section -->
|
||||
<div class="sidebar-section">
|
||||
<div class="sidebar-section-title"><span>Stats</span></div>
|
||||
|
||||
<button
|
||||
class="sidebar-item"
|
||||
:class="{ active: isUsageActive() }"
|
||||
@click="handleSidebarRoute('usage')"
|
||||
>
|
||||
<span class="icon">
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4">
|
||||
<path d="M2 13h12M4 9v4M7 6v7M10 8v5M13 4v9"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="label">Usage</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Projects section -->
|
||||
<div class="sidebar-section projects">
|
||||
<div class="sidebar-section-title">
|
||||
<span>Projects</span>
|
||||
</div>
|
||||
<div class="sidebar-search">
|
||||
<svg class="sidebar-search-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<circle cx="7" cy="7" r="4.5"/>
|
||||
<path d="M10.5 10.5L14 14"/>
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Filter..."
|
||||
:value="state.projectSearch"
|
||||
@input="handleProjectSearch"
|
||||
/>
|
||||
</div>
|
||||
<div class="sidebar-list">
|
||||
<button
|
||||
v-for="p in sidebarProjects"
|
||||
:key="p.slug"
|
||||
class="sidebar-item"
|
||||
:class="{ active: isProjectActive(p.slug) }"
|
||||
@click="handleSidebarProject(p.slug)"
|
||||
>
|
||||
<span class="icon" v-html="FOLDER_SVG"></span>
|
||||
<span class="label">{{ p.label }}</span>
|
||||
<span class="badge">{{ p.count }}</span>
|
||||
</button>
|
||||
<div v-if="!sidebarProjects.length" class="sidebar-empty">
|
||||
No projects
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.sidebar {
|
||||
border-right: 1px solid var(--hairline-strong);
|
||||
background: rgba(0,0,0,0.2);
|
||||
display: flex; flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
.sidebar-brand {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 0 14px; height: 36px;
|
||||
border-bottom: 1px solid var(--hairline);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.sidebar-brand svg { width: 18px; height: 18px; filter: drop-shadow(0 0 8px rgba(167,139,250,0.4)); }
|
||||
.sidebar-brand .name { font-size: var(--text-base); font-weight: 600; color: var(--fg-2); }
|
||||
.sidebar-section { padding: 8px 6px; flex-shrink: 0; }
|
||||
.sidebar-section.projects { flex: 1; min-height: 0; display: flex; flex-direction: column; padding-bottom: 0; }
|
||||
.sidebar-section + .sidebar-section { border-top: 1px solid var(--hairline); }
|
||||
.sidebar-section-title {
|
||||
padding: 4px 10px 6px;
|
||||
font-size: 10.5px; color: var(--muted);
|
||||
font-weight: 500; letter-spacing: 0.04em;
|
||||
display: flex; justify-content: space-between;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.sidebar-search { position: relative; padding: 0 6px 6px; flex-shrink: 0; }
|
||||
.sidebar-search input {
|
||||
width: 100%; height: 24px;
|
||||
padding: 0 8px 0 24px;
|
||||
border: 1px solid var(--hairline); border-radius: 4px;
|
||||
background: var(--surface);
|
||||
font-size: var(--text-sm); color: var(--fg);
|
||||
transition: all 0.1s;
|
||||
}
|
||||
.sidebar-search input::placeholder { color: var(--muted-2); }
|
||||
.sidebar-search input:focus { outline: 0; border-color: var(--accent); background: var(--surface-strong); }
|
||||
.sidebar-search-icon {
|
||||
position: absolute; left: 14px; top: 50%; transform: translateY(-50%);
|
||||
width: 11px; height: 11px; color: var(--muted); pointer-events: none;
|
||||
}
|
||||
.sidebar-list { overflow-y: auto; padding: 2px 0 8px; flex: 1; min-height: 0; }
|
||||
.sidebar-item {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 0 10px; height: var(--row-h-compact);
|
||||
border-radius: 5px;
|
||||
color: var(--fg-2); font-size: var(--text-base);
|
||||
cursor: pointer; user-select: none;
|
||||
transition: background 0.08s; position: relative;
|
||||
width: 100%; text-align: left;
|
||||
border: none; background: none;
|
||||
}
|
||||
.sidebar-item:hover { background: var(--surface-strong); color: var(--fg); }
|
||||
.sidebar-item.active { background: var(--accent-soft); color: var(--fg); }
|
||||
.sidebar-item.active::before {
|
||||
content: ''; position: absolute; left: -6px; top: 4px; bottom: 4px;
|
||||
width: 2px; background: var(--accent); border-radius: 1px;
|
||||
box-shadow: 0 0 8px var(--accent-glow);
|
||||
}
|
||||
.sidebar-item .icon { width: 14px; height: 14px; color: var(--muted); flex-shrink: 0; transition: all 0.08s; }
|
||||
.sidebar-item.active .icon { color: var(--accent-2); filter: drop-shadow(0 0 4px var(--accent-glow)); }
|
||||
.sidebar-item.warning .icon { color: var(--danger); }
|
||||
.sidebar-item .label { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.sidebar-item .badge {
|
||||
font-family: var(--font-mono); font-size: 10.5px;
|
||||
color: var(--muted); font-variant-numeric: tabular-nums;
|
||||
line-height: 1; min-width: 22px; text-align: right;
|
||||
flex-shrink: 0; padding: 2px 0;
|
||||
}
|
||||
.sidebar-item.active .badge { color: var(--fg-2); }
|
||||
.sidebar-item.warning .badge {
|
||||
color: var(--danger); background: var(--danger-soft);
|
||||
padding: 2px 6px; border-radius: 8px;
|
||||
margin-right: -6px; min-width: 22px;
|
||||
}
|
||||
.sidebar-item.sub { padding-left: 30px; height: 26px; font-size: var(--text-sm); }
|
||||
.sidebar-item.sub .icon { width: 12px; height: 12px; }
|
||||
.sidebar-empty {
|
||||
padding: 8px 10px;
|
||||
font-size: 11px;
|
||||
color: var(--muted-2);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,379 @@
|
||||
<script setup>
|
||||
import { computed, ref, onMounted, onUnmounted } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { state, toggleSort, setQuery, toggleIncludeMessageBodies } from '../store.js';
|
||||
import { formatProjectLabel } from '../utils.js';
|
||||
|
||||
// --- Route info ---
|
||||
const route = useRoute();
|
||||
|
||||
const isListView = computed(() => {
|
||||
return route.name === 'SessionList' || route.name === 'MemoryList';
|
||||
});
|
||||
|
||||
const showSearchMsgsToggle = computed(() => {
|
||||
return route.name === 'SessionList';
|
||||
});
|
||||
|
||||
// --- Breadcrumb computation ---
|
||||
const breadcrumbs = computed(() => {
|
||||
const name = route.name;
|
||||
const crumbs = [];
|
||||
|
||||
if (name === 'SessionList') {
|
||||
crumbs.push({ label: 'Sessions', terminal: true });
|
||||
if (state.projectFilter !== 'all') {
|
||||
crumbs.push({ label: formatProjectLabel(state.projectFilter), terminal: true });
|
||||
}
|
||||
} else if (name === 'SessionDetail') {
|
||||
crumbs.push({ label: 'Sessions', to: '/sessions' });
|
||||
const s = state.sessions.find(x => x.id === route.params.id);
|
||||
crumbs.push({ label: s?.title || route.params.id, terminal: true });
|
||||
} else if (name === 'SubagentDetail') {
|
||||
crumbs.push({ label: 'Sessions', to: '/sessions' });
|
||||
const s = state.sessions.find(x => x.id === route.params.id);
|
||||
crumbs.push({ label: (s?.title || '').slice(0, 30) || route.params.id, to: `/sessions/${route.params.id}` });
|
||||
crumbs.push({ label: route.params.agentId, terminal: true });
|
||||
} else if (name === 'MemoryList') {
|
||||
crumbs.push({ label: 'Memory', terminal: true });
|
||||
if (state.projectFilter !== 'all') {
|
||||
crumbs.push({ label: formatProjectLabel(state.projectFilter), terminal: true });
|
||||
}
|
||||
} else if (name === 'MemoryDetail') {
|
||||
crumbs.push({ label: 'Memory', to: '/memory' });
|
||||
const m = state.memories.find(x => x.id === route.params.id);
|
||||
const filename = (m?.path || '').split('/').pop();
|
||||
crumbs.push({ label: filename, terminal: true, filename: true });
|
||||
} else if (name === 'Usage') {
|
||||
crumbs.push({ label: 'Usage', terminal: true });
|
||||
}
|
||||
|
||||
return crumbs;
|
||||
});
|
||||
|
||||
// --- Search ---
|
||||
const searchInput = ref(null);
|
||||
let searchTimer = null;
|
||||
|
||||
function handleSearch(e) {
|
||||
const value = e.target.value;
|
||||
clearTimeout(searchTimer);
|
||||
searchTimer = setTimeout(() => {
|
||||
setQuery(value);
|
||||
}, 200);
|
||||
}
|
||||
|
||||
// --- Keyboard shortcut: / to focus search ---
|
||||
function handleKeydown(e) {
|
||||
if (e.key === '/' && !e.ctrlKey && !e.metaKey && !e.altKey) {
|
||||
const tag = document.activeElement?.tagName;
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA') return;
|
||||
e.preventDefault();
|
||||
searchInput.value?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('keydown', handleKeydown);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('keydown', handleKeydown);
|
||||
clearTimeout(searchTimer);
|
||||
});
|
||||
|
||||
// --- Sort ---
|
||||
function handleToggleSort() {
|
||||
toggleSort();
|
||||
}
|
||||
|
||||
function handleToggleSearchMsgs() {
|
||||
toggleIncludeMessageBodies();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="toolbar">
|
||||
<div class="breadcrumb">
|
||||
<template v-for="(crumb, i) in breadcrumbs" :key="i">
|
||||
<span v-if="i > 0" class="crumb-sep">/</span>
|
||||
<router-link
|
||||
v-if="crumb.to"
|
||||
class="crumb"
|
||||
:to="crumb.to"
|
||||
>
|
||||
{{ crumb.label }}
|
||||
</router-link>
|
||||
<span
|
||||
v-else
|
||||
class="crumb terminal"
|
||||
:class="{ filename: crumb.filename }"
|
||||
>
|
||||
{{ crumb.label }}
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="toolbar-spacer"></div>
|
||||
|
||||
<!-- Search + sort controls (list views only) -->
|
||||
<template v-if="isListView">
|
||||
<div class="toolbar-search">
|
||||
<svg class="toolbar-search-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6">
|
||||
<circle cx="6.5" cy="6.5" r="4"/>
|
||||
<path d="M10 10l3.5 3.5"/>
|
||||
</svg>
|
||||
<input
|
||||
ref="searchInput"
|
||||
type="text"
|
||||
placeholder="Search..."
|
||||
@input="handleSearch"
|
||||
/>
|
||||
<span class="toolbar-search-kbd">/</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="showSearchMsgsToggle"
|
||||
class="filter-toggle"
|
||||
:class="{ active: state.includeMessageBodies }"
|
||||
@click="handleToggleSearchMsgs"
|
||||
title="Include message bodies in search"
|
||||
>
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4">
|
||||
<rect x="2" y="3" width="12" height="10" rx="1.5"/>
|
||||
<path d="M2 5.5l6 3.5 6-3.5"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="sort-group"
|
||||
:class="{ desc: state.sortDesc, asc: !state.sortDesc }"
|
||||
@click="handleToggleSort"
|
||||
>
|
||||
<span class="label">{{ state.sortDesc ? 'newest' : 'oldest' }}</span>
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4">
|
||||
<path class="arrow-up" d="M8 3v5M5.5 5.5L8 3l2.5 2.5"/>
|
||||
<path class="arrow-down" d="M8 8v5M5.5 10.5L8 13l2.5-2.5"/>
|
||||
</svg>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.toolbar {
|
||||
height: 44px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 0 14px;
|
||||
border-bottom: 1px solid var(--hairline-strong);
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
}
|
||||
|
||||
.breadcrumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.crumb {
|
||||
font-size: var(--text-md);
|
||||
color: var(--muted);
|
||||
padding: 4px 6px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.1s;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
line-height: 1;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
white-space: nowrap;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.crumb:hover {
|
||||
background: var(--surface-strong);
|
||||
color: var(--fg-2);
|
||||
}
|
||||
|
||||
.crumb.terminal {
|
||||
color: var(--fg);
|
||||
font-weight: 600;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.crumb.terminal:hover {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.crumb svg {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.crumb.filename {
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 500;
|
||||
color: var(--fg);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.crumb-sep {
|
||||
color: var(--muted-2);
|
||||
font-size: var(--text-md);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.toolbar-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.toolbar-search {
|
||||
width: 220px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.toolbar-search input {
|
||||
width: 100%;
|
||||
height: 26px;
|
||||
padding: 0 30px 0 26px;
|
||||
border: 1px solid var(--hairline);
|
||||
border-radius: 5px;
|
||||
background: var(--surface);
|
||||
font-size: var(--text-base);
|
||||
color: var(--fg);
|
||||
transition: all 0.12s;
|
||||
}
|
||||
|
||||
.toolbar-search input::placeholder {
|
||||
color: var(--muted-2);
|
||||
}
|
||||
|
||||
.toolbar-search input:focus {
|
||||
outline: 0;
|
||||
border-color: var(--accent);
|
||||
background: var(--surface-strong);
|
||||
box-shadow: 0 0 0 2px var(--accent-soft);
|
||||
}
|
||||
|
||||
.toolbar-search-icon {
|
||||
position: absolute;
|
||||
left: 8px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
color: var(--muted);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.toolbar-search-kbd {
|
||||
position: absolute;
|
||||
right: 6px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
color: var(--muted-2);
|
||||
padding: 1px 5px;
|
||||
border: 1px solid var(--hairline);
|
||||
border-radius: 3px;
|
||||
pointer-events: none;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.toolbar-search input:focus ~ .toolbar-search-kbd,
|
||||
.toolbar-search input:not(:placeholder-shown) ~ .toolbar-search-kbd {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.filter-toggle {
|
||||
height: 26px;
|
||||
width: 26px;
|
||||
border-radius: 5px;
|
||||
color: var(--muted);
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
transition: all 0.1s;
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.filter-toggle:hover {
|
||||
color: var(--fg-2);
|
||||
background: var(--surface-strong);
|
||||
}
|
||||
|
||||
.filter-toggle.active {
|
||||
color: var(--accent-2);
|
||||
background: var(--accent-soft);
|
||||
border-color: var(--accent-soft);
|
||||
}
|
||||
|
||||
.filter-toggle svg {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
}
|
||||
|
||||
.sort-group {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
height: 26px;
|
||||
padding: 0 4px 0 8px;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
color: var(--muted);
|
||||
font-size: var(--text-sm);
|
||||
transition: background 0.1s, color 0.1s;
|
||||
border: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.sort-group:hover {
|
||||
background: var(--surface-strong);
|
||||
color: var(--fg-2);
|
||||
}
|
||||
|
||||
.sort-group .label {
|
||||
font-family: var(--font-mono);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.sort-group svg {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
}
|
||||
|
||||
.sort-group .arrow-up,
|
||||
.sort-group .arrow-down {
|
||||
transition: opacity 0.12s;
|
||||
}
|
||||
|
||||
.sort-group.desc .arrow-up {
|
||||
opacity: 0.25;
|
||||
}
|
||||
|
||||
.sort-group.desc .arrow-down {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.sort-group.asc .arrow-up {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.sort-group.asc .arrow-down {
|
||||
opacity: 0.25;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user