feat: index Codex sessions alongside Claude Code with source tagging

Add `source` column to sessions and messages ('claude' | 'codex').
  Discover and parse Codex JSONL files from ~/.codex/sessions/, mapping
  Codex thread/item structures to the same schema (messages, tool_calls,
  tool_results, subagents). Move DB to ~/.obelisk/ with legacy migration.
  Add rebuild-to-temp-then-swap for safe full rebuilds. On the app side:
  source filter toggle, collapsible untitled session fold, configurable
  codexDir in Settings, and a dev script. Update SKILL.md and query
  helpers to expose source fields and accept source filter opt.
This commit is contained in:
tommy0103
2026-06-17 23:40:36 +08:00
parent e3ca1735b9
commit 12407227cc
22 changed files with 2259 additions and 244 deletions
+109 -2
View File
@@ -47,6 +47,11 @@ const sidebarProjectsForCurrentScope = (search = state.projectSearch) => buildSi
const sidebarProjects = computed(() => sidebarProjectsForCurrentScope());
const NOISE_PROJECT_RE = /^(od-conn-test|[0-9a-f]{6,})/i;
const normalProjects = computed(() => sidebarProjects.value.filter(p => p.count > 1 || !NOISE_PROJECT_RE.test(p.label)));
const noiseProjects = computed(() => sidebarProjects.value.filter(p => p.count <= 1 && NOISE_PROJECT_RE.test(p.label)));
const showNoiseProjects = ref(false);
const totalProjectCount = computed(() => {
return sidebarProjectsForCurrentScope('').length;
});
@@ -158,11 +163,36 @@ const keepAliveIncludes = ['SessionDetail'];
const isExportRoute = computed(() => route.name === 'RecapExport');
// --- Source health dots ---
const sourceDots = ref([]);
const sourceDetails = ref([]);
const showSourcePopover = ref(false);
async function loadSourceDots() {
if (!window.obelisk?.getSettings) return;
const s = await window.obelisk.getSettings();
sourceDots.value = (s.sources || []).map(src => ({ id: src.id, status: src.status }));
sourceDetails.value = s.sources || [];
}
loadSourceDots();
// --- Recap ---
const recapGenerateOpen = ref(false);
function setRecapKind(k) {
router.replace({ path: '/recap', query: { kind: k } });
}
// --- Source filter ---
const showSourceFilter = ref(false);
const sourceFilterActive = computed(() => state.sourceFilter !== 'all' && state.sourceFilter !== undefined);
const sourceFilterLabel = computed(() => {
if (!state.sourceFilter || state.sourceFilter === 'all') return 'All sources';
return state.sourceFilter === 'claude' ? 'Claude Code' : 'Codex';
});
function toggleSourceFilter() { showSourceFilter.value = !showSourceFilter.value; }
function setSourceFilter(id) {
state.sourceFilter = id;
showSourceFilter.value = false;
}
provide('recapGenerateOpen', recapGenerateOpen);
</script>
@@ -203,6 +233,24 @@ provide('recapGenerateOpen', recapGenerateOpen);
<rect x="15.5" y="33" width="9" height="1.6" rx="0.3" fill="#0f172a"/>
</svg>
<span class="name">Obelisk</span>
<button class="source-health" title="Connected sources" @click="showSourcePopover = !showSourcePopover">
<span v-for="src in sourceDots" :key="src.id" class="h-dot" :class="src.id + '-' + src.status"></span>
</button>
<div class="sources-popover" :class="{ show: showSourcePopover }">
<div class="sp-head">Connected sources</div>
<div class="sp-list">
<button v-for="src in sourceDetails" :key="src.id" class="sp-row" @click="router.push('/settings')">
<span class="sp-dot" :class="src.id"></span>
<div class="sp-body">
<div class="sp-name">{{ src.name }} <span class="sp-count" v-if="src.sessionCount">{{ src.sessionCount }} sessions</span></div>
<div class="sp-meta" :class="src.status">{{ src.statusText }}</div>
</div>
</button>
</div>
<div class="sp-foot">
<button @click="router.push('/settings'); showSourcePopover = false">Manage in Settings </button>
</div>
</div>
</div>
<div class="sidebar-section">
@@ -283,7 +331,15 @@ provide('recapGenerateOpen', recapGenerateOpen);
</div>
<div class="sidebar-section projects" v-if="currentRouteType === 'sessions' || currentRouteType === 'memory'">
<div class="sidebar-section-title"><span>Projects</span></div>
<div class="sidebar-section-title">
<span>Projects</span>
<button v-if="noiseProjects.length" class="filter-toggle" :class="{ active: showNoiseProjects }" @click.stop="showNoiseProjects = !showNoiseProjects">
<svg viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round">
<path d="M2 6h8M2 3h8M2 9h5"/>
</svg>
{{ showNoiseProjects ? 'hide noise' : 'show all' }}
</button>
</div>
<div class="sidebar-search" v-if="totalProjectCount >= 6">
<svg class="sidebar-search-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6">
<circle cx="7" cy="7" r="5"/>
@@ -299,7 +355,7 @@ provide('recapGenerateOpen', recapGenerateOpen);
</div>
<div class="sidebar-list" id="sidebar-projects">
<button
v-for="p in sidebarProjects"
v-for="p in normalProjects"
:key="p.slug"
class="sidebar-item"
:class="{ active: state.projectFilter === p.slug }"
@@ -311,6 +367,28 @@ provide('recapGenerateOpen', recapGenerateOpen);
<span class="label">{{ p.label }}</span>
<span class="badge">{{ p.count }}</span>
</button>
<!-- Noise projects fold -->
<button v-if="noiseProjects.length" class="project-fold" :class="{ expanded: showNoiseProjects }" @click="showNoiseProjects = !showNoiseProjects">
<svg class="chev" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M4 2.5l3 3.5-3 3.5"/></svg>
<span class="label">{{ noiseProjects.length }} test projects hidden</span>
<span class="count">{{ noiseProjects.length }}</span>
</button>
<template v-if="showNoiseProjects">
<button
v-for="p in noiseProjects"
:key="p.slug"
class="sidebar-item noise"
:class="{ active: state.projectFilter === p.slug }"
@click="handleSidebarProject(p.slug)"
>
<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round">
<path d="M2 5.5V12a1.5 1.5 0 0 0 1.5 1.5h9A1.5 1.5 0 0 0 14 12V6.5A1.5 1.5 0 0 0 12.5 5H8.3L7 3.5H3.5A1.5 1.5 0 0 0 2 5z"/>
</svg>
<span class="label">{{ p.label }}</span>
<span class="badge">{{ p.count }}</span>
</button>
</template>
</div>
</div>
@@ -403,6 +481,35 @@ provide('recapGenerateOpen', recapGenerateOpen);
</button>
</template>
<!-- Source filter (session list only, multi-source) -->
<div v-if="showToolbar && route.name === 'SessionList' && sourceDots.length > 1" class="source-filter-wrap">
<button class="filter-btn" :class="{ active: sourceFilterActive }" @click="toggleSourceFilter">
<svg viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round">
<path d="M2 3h8M3.5 6h5M5 9h2"/>
</svg>
<span class="filter-label">{{ sourceFilterLabel }}</span>
</button>
<div class="filter-dropdown" :class="{ show: showSourceFilter }">
<div
v-for="src in sourceDots" :key="src.id"
class="fd-row" :class="{ checked: state.sourceFilter === 'all' || state.sourceFilter === src.id }"
@click.stop="setSourceFilter(src.id)"
>
<div class="fd-check">
<svg viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2.5 6l2.5 2.5 4.5-5"/></svg>
</div>
<span class="fd-name">{{ src.id === 'claude' ? 'Claude Code' : 'Codex' }}</span>
</div>
<div class="fd-divider"></div>
<div class="fd-row" :class="{ checked: state.sourceFilter === 'all' }" @click.stop="setSourceFilter('all')">
<div class="fd-check">
<svg viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2.5 6l2.5 2.5 4.5-5"/></svg>
</div>
<span class="fd-name" style="color: var(--accent-2);">All sources</span>
</div>
</div>
</div>
<div class="toolbar-search" id="search-wrap" v-if="showToolbar">
<svg class="toolbar-search-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6">
<circle cx="7" cy="7" r="5"/>
+18 -1
View File
@@ -11,7 +11,7 @@ import { state, clearUndo } from './store.js';
export async function loadInitialData() {
const [rawMemories, rawSessions, stats, projects] = await Promise.all([
window.obelisk.getMemories(),
window.obelisk.getSessions(),
window.obelisk.getSessions({ source: 'all', limit: 1000 }),
window.obelisk.getStats(),
window.obelisk.getProjects()
]);
@@ -213,6 +213,23 @@ export async function loadSessionDetail(sessionId) {
} else {
const out = { ...msg };
if (msg._thinking) out._thinking = msg._thinking;
// For text assistant messages, absorb following tool_use messages (Codex pattern)
if (msg.type === 'assistant' && msg.content_type !== 'tool_use' && msg.content_type !== 'thinking') {
if (!out.tool_calls) out.tool_calls = [];
let j = i + 1;
while (j < rawAssembled.length) {
const next = rawAssembled[j];
if (next.content_type === 'tool_result') { j++; continue; }
if (next.type === 'assistant' && next.content_type === 'tool_use') {
if (next.tool_calls) out.tool_calls.push(...next.tool_calls);
j++;
continue;
}
break;
}
if (!out.tool_calls.length) delete out.tool_calls;
i = j - 1;
}
assembledMessages.push(out);
}
}
+1
View File
@@ -17,6 +17,7 @@ export const state = reactive({
pendingFocusUuid: null,
query: '',
projectFilter: 'all',
sourceFilter: 'all',
projectSearch: '',
sortDesc: true,
includeMessageBodies: false,
+4
View File
@@ -615,6 +615,10 @@ function getToolCallParsedInput(tc) {
<span class="project-name">{{ formatProjectLabel(session.project) }}</span>
<span class="sep">&middot;</span>
<span class="project-path">{{ session.project_path || '' }}</span>
<span class="via">
<span class="via-dot" :class="session.source || 'claude'"></span>
via {{ (session.source || 'claude') === 'codex' ? 'Codex' : 'Claude Code' }}
</span>
</div>
<div class="session-title">{{ session.title || '(untitled)' }}</div>
<div class="session-meta-inline">
+113 -1
View File
@@ -23,6 +23,7 @@ const visibleSessions = computed(() => {
const q = state.query.trim().toLowerCase();
return state.sessions
.filter(s => state.projectFilter === 'all' || s.project === state.projectFilter)
.filter(s => state.sourceFilter === 'all' || (s.source || 'claude') === state.sourceFilter)
.map(s => {
if (!q) return { ...s, messageHit: null };
const topMatch = (s.title || '').toLowerCase().includes(q) ||
@@ -40,6 +41,14 @@ const visibleSessions = computed(() => {
});
const showProjectPrefix = computed(() => state.projectFilter === 'all');
const showNoise = ref(false);
function isNoise(s) {
return !s.title;
}
const normalSessions = computed(() => visibleSessions.value.filter(s => !isNoise(s)));
const noiseSessions = computed(() => visibleSessions.value.filter(s => isNoise(s)));
function titleHTML(session) {
return highlightPlain(session.title || '(untitled)', state.query.trim());
@@ -135,7 +144,7 @@ function obeliskStyle(session) {
<div v-else class="session-list">
<div
v-for="s in visibleSessions"
v-for="s in normalSessions"
:key="s.id"
class="srow"
:class="{ cursor: state.cursorId === s.id }"
@@ -155,6 +164,48 @@ function obeliskStyle(session) {
</div>
<div class="srow-right">{{ timeLabel(s) }}</div>
</div>
<!-- Noise fold banner -->
<div v-if="noiseSessions.length && !state.query" class="fold-banner" :class="{ expanded: showNoise }" @click="showNoise = !showNoise">
<svg class="chev" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round">
<path d="M4 2.5l3 3.5-3 3.5"/>
</svg>
<div class="body">
<strong>{{ noiseSessions.length }}</strong> quiet sessions hidden untitled, likely tests or incomplete runs.
</div>
<span v-if="!showNoise" class="reveal-link">Show all</span>
</div>
<!-- Noise sessions (collapsed by default) -->
<div v-if="showNoise && noiseSessions.length" class="noise-group">
<div class="noise-group-head">
{{ noiseSessions.length }} sessions · untitled
</div>
<div
v-for="s in noiseSessions"
:key="s.id"
class="srow noise"
@click="openSession(s)"
>
<div class="srow-body">
<div class="srow-title">(untitled)</div>
<div class="srow-meta">
<template v-if="showProjectPrefix">
<span class="project-tag" v-html="projectLabel(s)"></span>
<span class="dot"></span>
</template>
<span>{{ s.message_count || 0 }} msg</span>
</div>
</div>
<div class="srow-right">{{ timeLabel(s) }}</div>
</div>
<button class="noise-fold-bottom" @click.stop="showNoise = false">
<svg class="chev" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round">
<path d="M4 2.5l3 3.5-3 3.5"/>
</svg>
Collapse
</button>
</div>
</div>
</div>
</template>
@@ -333,4 +384,65 @@ function obeliskStyle(session) {
font-family: var(--font-mono); color: var(--fg-2);
background: rgba(0,0,0,0.3); padding: 1px 6px; border-radius: 3px;
}
/* Noise fold */
.fold-banner {
display: flex; align-items: center; gap: 12px;
padding: 10px 22px;
background: rgba(255,255,255,0.015);
border-top: 1px solid var(--hairline);
border-bottom: 1px solid var(--hairline);
font-size: 12.5px; color: var(--muted);
cursor: pointer; transition: all 0.1s;
}
.fold-banner:hover { background: rgba(255,255,255,0.03); color: var(--fg-2); }
.fold-banner.expanded { color: var(--fg-3); background: rgba(255,255,255,0.02); }
.fold-banner .chev {
width: 10px; height: 10px; color: var(--muted-2);
transition: transform 0.15s; flex-shrink: 0;
}
.fold-banner.expanded .chev { transform: rotate(90deg); color: var(--accent-2); }
.fold-banner .body { flex: 1; }
.fold-banner .body strong {
color: var(--fg-2); font-weight: 500;
font-variant-numeric: tabular-nums;
font-family: var(--font-mono); font-size: 11.5px;
}
.fold-banner .reveal-link {
font-size: 11.5px; color: var(--accent-2);
text-decoration: none; border-bottom: 1px solid rgba(167,139,250,0.4);
padding-bottom: 1px; transition: all 0.12s; flex-shrink: 0;
}
.fold-banner:hover .reveal-link { color: var(--accent); border-bottom-color: var(--accent); }
.noise-group {
border-bottom: 1px solid var(--hairline-strong);
background: rgba(0,0,0,0.15);
}
.noise-group-head {
padding: 6px 22px;
font-family: var(--font-mono); font-size: 10px; color: var(--muted-2);
letter-spacing: 0.06em; text-transform: uppercase;
background: rgba(0,0,0,0.1); border-bottom: 1px solid var(--hairline);
}
.srow.noise { padding: 8px 22px 8px 18px; }
.srow.noise .srow-title {
color: var(--muted); font-style: italic;
font-size: 13px; font-weight: 400;
}
.srow.noise .srow-meta { color: var(--muted-2); }
.noise-fold-bottom {
padding: 8px 22px; background: rgba(0,0,0,0.2);
font-family: var(--font-mono); font-size: 11px; color: var(--muted);
cursor: pointer; transition: all 0.1s;
display: flex; align-items: center; gap: 8px;
border-top: 1px solid var(--hairline);
border: none; width: 100%; text-align: left;
}
.noise-fold-bottom:hover { background: rgba(0,0,0,0.3); color: var(--fg-2); }
.noise-fold-bottom .chev {
width: 9px; height: 9px; color: var(--muted-2);
transform: rotate(-90deg);
}
</style>
+109 -88
View File
@@ -1,17 +1,13 @@
<script setup>
import { ref, onMounted, watch } from 'vue';
import { ref, onMounted, nextTick } from 'vue';
defineOptions({ name: 'Settings' });
const claudePath = ref('');
const sources = ref([]);
const dbPath = ref('');
const recapPath = ref('');
const autoRefresh = ref(true);
const status = ref('ok');
const statusText = ref('Connected');
const sessionCount = ref(0);
const memoryCount = ref(0);
const lastIndexed = ref('');
const rebuilding = ref(false);
const version = ref('0.1.0');
@@ -22,23 +18,19 @@ onMounted(async () => {
async function loadSettings() {
if (!window.obelisk?.getSettings) return;
const s = await window.obelisk.getSettings();
claudePath.value = s.claudeDir || '~/.claude';
sources.value = s.sources || [];
dbPath.value = s.dbPath || '';
recapPath.value = s.recapDir || '~/.obelisk/recap';
autoRefresh.value = s.autoRefresh !== false;
sessionCount.value = s.sessionCount || 0;
memoryCount.value = s.memoryCount || 0;
lastIndexed.value = s.lastIndexed || '';
status.value = s.status || 'ok';
statusText.value = s.statusText || 'Connected';
}
async function browsePath() {
async function browseSourcePath(source) {
if (!window.obelisk?.browseFolder) return;
const result = await window.obelisk.browseFolder();
if (result) {
claudePath.value = result;
await saveSetting('claudeDir', result);
const key = source.id === 'claude' ? 'claudeDir' : 'codexDir';
await saveSetting(key, result);
await loadSettings();
}
}
@@ -52,11 +44,6 @@ async function browseRecapPath() {
}
}
async function resetPath() {
await saveSetting('claudeDir', null);
await loadSettings();
}
async function toggleAutoRefresh() {
autoRefresh.value = !autoRefresh.value;
await saveSetting('autoRefresh', autoRefresh.value);
@@ -68,11 +55,6 @@ async function saveSetting(key, value) {
}
}
async function commitClaudePath() {
await saveSetting('claudeDir', claudePath.value);
await loadSettings();
}
async function commitRecapPath() {
await saveSetting('recapDir', recapPath.value);
}
@@ -80,7 +62,8 @@ async function commitRecapPath() {
async function rebuildIndex() {
if (rebuilding.value || !window.obelisk?.rebuildIndex) return;
rebuilding.value = true;
statusText.value = 'Rebuilding…';
await nextTick();
await new Promise(resolve => requestAnimationFrame(resolve));
try {
await window.obelisk.rebuildIndex();
await loadSettings();
@@ -111,86 +94,79 @@ function fmtRelative(iso) {
<div class="settings-wrap">
<div class="settings-content">
<!-- Data Source -->
<!-- Data Sources -->
<section class="settings-section">
<div class="settings-section-head">
<h2>Data Source</h2>
<p>Where Obelisk reads your Claude Code session history.</p>
<h2>Data Sources</h2>
<p>Where Obelisk reads your agent session history.</p>
</div>
<div class="form-row">
<div>
<div class="form-label">Claude Code path</div>
<div class="form-label-hint">Default <code>~/.claude</code> on macOS &amp; Linux.</div>
<div
v-for="src in sources" :key="src.id"
class="source-card"
:class="{ error: src.status === 'error', warn: src.status === 'warn' }"
>
<div class="source-card-head">
<div class="source-card-mark" :class="src.id">
<span class="mark-dot"></span>
</div>
<div class="source-card-info">
<div class="source-card-name">
{{ src.name }}
<span class="vendor">by {{ src.vendor }}</span>
</div>
<div class="source-card-status">
<span class="stat-dot" :class="src.status"></span>
<span class="stat-text" :class="src.status">{{ src.statusText }}</span>
<template v-if="src.lastIndexed">
<span class="sep">·</span>
<span>last read <strong>{{ fmtRelative(src.lastIndexed) }}</strong></span>
</template>
<template v-if="src.sessionCount">
<span class="sep">·</span>
<span><strong>{{ src.sessionCount }}</strong> sessions</span>
</template>
</div>
</div>
</div>
<div class="form-control">
<div class="source-card-body">
<div class="path-input">
<input
class="path-field"
:class="{ error: status === 'error' }"
type="text"
v-model="claudePath"
spellcheck="false"
@keydown.enter="commitClaudePath"
@blur="commitClaudePath"
/>
<button class="btn" @click="browsePath">
<input class="path-field" :class="{ error: src.status === 'error' }" type="text" :value="src.path" spellcheck="false" readonly/>
<button class="btn" @click="browseSourcePath(src)">
<svg viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round">
<path d="M2.5 3.5h3.5l1.2 1.2h4.3a1 1 0 0 1 1 1V11a1 1 0 0 1-1 1H2.5a1 1 0 0 1-1-1V4.5a1 1 0 0 1 1-1z"/>
</svg>
Browse
</button>
<button class="btn subtle" @click="resetPath" title="Reset to default">
<svg viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M12.5 6.5A5 5 0 1 0 12 9.5"/>
<path d="M12.5 2v4.5h-4.5"/>
</svg>
</button>
</div>
<div class="status-row" :class="status">
<span class="status-dot" :class="status"></span>
<span class="status-text">{{ statusText }}</span>
<div class="status-meta" v-if="sessionCount || lastIndexed">
<template v-if="lastIndexed">
<span>last read <strong>{{ fmtRelative(lastIndexed) }}</strong></span>
<span class="sep">·</span>
</template>
<span><strong>{{ sessionCount }}</strong> sessions</span>
<span class="sep">·</span>
<span><strong>{{ memoryCount }}</strong> memories</span>
</div>
</div>
</div>
</div>
</section>
<div class="form-row">
<div>
<div class="form-label">Index location</div>
<div class="form-label-hint">SQLite database where Obelisk caches the session index.</div>
</div>
<div class="form-control">
<div class="path-input">
<input class="path-field" type="text" :value="dbPath" spellcheck="false" readonly/>
<button class="btn" @click="revealDb">Reveal</button>
</div>
</div>
<!-- Index -->
<section class="settings-section">
<div class="settings-section-head">
<h2>Index location</h2>
<p>SQLite database where Obelisk caches the unified session index.</p>
</div>
<div class="path-input" style="max-width: 480px;">
<input class="path-field" type="text" :value="dbPath" spellcheck="false" readonly/>
<button class="btn" @click="revealDb">Reveal</button>
</div>
</section>
<div class="form-row">
<div>
<div class="form-label">Auto-refresh</div>
<div class="form-label-hint">Obelisk re-reads when new session files appear.</div>
</div>
<div class="form-control">
<label class="toggle-label" @click.prevent="toggleAutoRefresh">
<span class="toggle-track" :class="{ on: autoRefresh }">
<span class="toggle-thumb"></span>
</span>
<span class="toggle-text">Watch <code>.claude</code> for changes</span>
</label>
</div>
<!-- Auto-refresh -->
<section class="settings-section">
<div class="settings-section-head">
<h2>Auto-refresh</h2>
<p>Obelisk re-reads when new session files appear.</p>
</div>
<label class="toggle-label" @click.prevent="toggleAutoRefresh">
<span class="toggle-track" :class="{ on: autoRefresh }">
<span class="toggle-thumb"></span>
</span>
<span class="toggle-text">Watch data sources for changes</span>
</label>
</section>
<!-- Recap -->
@@ -241,7 +217,7 @@ function fmtRelative(iso) {
</button>
</div>
<div class="reset-hint">
Rebuilding only re-reads your Claude Code data. It does not delete memories or recaps.
Rebuilding re-reads your coding agent session data. It does not delete memories or recaps.
</div>
</div>
</div>
@@ -269,6 +245,51 @@ function fmtRelative(iso) {
font-size: 13px; color: var(--muted);
}
/* Source cards */
.source-card {
padding: 18px; border: 1px solid var(--hairline); border-radius: 8px;
background: rgba(0,0,0,0.18); margin-bottom: 12px;
transition: border-color 0.15s;
}
.source-card:hover { border-color: var(--hairline-strong); }
.source-card.error { border-color: rgba(248,113,113,0.25); }
.source-card.warn { border-color: rgba(251,191,36,0.20); }
.source-card-head { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; }
.source-card-mark {
width: 28px; height: 28px; border-radius: 6px;
background: rgba(0,0,0,0.4); border: 1px solid var(--hairline-strong);
display: grid; place-items: center; flex-shrink: 0;
}
.source-card-mark .mark-dot { width: 8px; height: 8px; border-radius: 50%; }
.source-card-mark.claude .mark-dot { background: #d97757; box-shadow: 0 0 6px rgba(217,119,87,0.5); }
.source-card-mark.codex .mark-dot { background: #10a37f; box-shadow: 0 0 6px rgba(16,163,127,0.5); }
.source-card-info { flex: 1; min-width: 0; }
.source-card-name {
font-size: 14px; color: var(--fg); font-weight: 600; letter-spacing: -0.005em;
display: flex; align-items: baseline; gap: 8px;
}
.source-card-name .vendor { font-size: 11.5px; color: var(--muted); font-weight: 400; }
.source-card-status {
font-family: var(--font-mono); font-size: 10.5px; color: var(--muted);
margin-top: 3px; display: flex; align-items: center; gap: 8px;
}
.source-card-status .stat-dot { width: 6px; height: 6px; border-radius: 50%; position: relative; }
.source-card-status .stat-dot.ok { background: #34d399; box-shadow: 0 0 5px rgba(52,211,153,0.5); }
.source-card-status .stat-dot.warn { background: #fbbf24; box-shadow: 0 0 5px rgba(251,191,36,0.5); }
.source-card-status .stat-dot.error { background: #f87171; box-shadow: 0 0 5px rgba(248,113,113,0.5); }
.source-card-status .stat-dot.ok::before {
content: ''; position: absolute; inset: -2.5px; border-radius: 50%;
border: 1px solid #34d399; opacity: 0.5; animation: src-pulse 1.6s ease-out infinite;
}
@keyframes src-pulse { 0% { transform: scale(0.8); opacity: 0.5; } 100% { transform: scale(1.8); opacity: 0; } }
.source-card-status .stat-text { color: var(--fg-2); }
.source-card-status .stat-text.ok { color: #34d399; }
.source-card-status .stat-text.warn { color: #fbbf24; }
.source-card-status .stat-text.error { color: #f87171; }
.source-card-status .sep { color: var(--muted-3); }
.source-card-status strong { color: var(--fg-2); font-weight: 500; }
.source-card-body { display: flex; flex-direction: column; gap: 10px; }
.form-row {
display: grid; grid-template-columns: 180px 1fr;
gap: 24px; padding: 14px 0; align-items: start;
+11
View File
@@ -425,6 +425,17 @@
.session-eyebrow .project-name { color: var(--fg-2); font-weight: 500; }
.session-eyebrow .project-path { font-family: var(--font-mono); color: var(--muted); }
.session-eyebrow .sep { color: var(--muted-2); margin: 0 2px; }
.session-eyebrow .via {
display: inline-flex; align-items: center; gap: 5px;
font-family: var(--font-mono); font-size: 10.5px; color: var(--muted);
letter-spacing: 0.02em;
padding: 1px 7px; background: rgba(255,255,255,0.04);
border: 1px solid var(--hairline); border-radius: 3px;
margin-left: 6px;
}
.session-eyebrow .via .via-dot { width: 4px; height: 4px; border-radius: 50%; }
.session-eyebrow .via .via-dot.claude { background: #d97757; box-shadow: 0 0 4px rgba(217,119,87,0.5); }
.session-eyebrow .via .via-dot.codex { background: #10a37f; box-shadow: 0 0 4px rgba(16,163,127,0.5); }
.session-title {
font-size: 22px; font-weight: 600; color: var(--fg);
line-height: 1.3; margin-bottom: 14px; letter-spacing: -0.01em;
+106 -3
View File
@@ -2,11 +2,12 @@
border-right: 1px solid var(--hairline-strong);
background: rgba(0,0,0,0.2);
display: flex; flex-direction: column;
min-height: 0;
min-height: 0; min-width: 0; overflow: hidden;
}
.sidebar-brand {
display: flex; align-items: center; gap: 8px;
padding: 0 14px; height: 36px;
position: relative;
border-bottom: 1px solid var(--hairline);
flex-shrink: 0;
}
@@ -17,13 +18,115 @@
.sidebar-section + .sidebar-section { border-top: 1px solid var(--hairline); }
.sidebar-spacer { flex: 1; min-height: 0; }
.sidebar-bottom { margin-top: auto; }
/* Source health dots — each dot = one source, colored by brand + status */
.source-health {
display: inline-flex; align-items: center; gap: 3px;
padding: 4px 6px; border-radius: 4px; margin-left: auto;
cursor: pointer; transition: background 0.1s;
}
.source-health:hover { background: var(--surface-strong); }
.source-health .h-dot {
width: 5px; height: 5px; border-radius: 50%; flex-shrink: 0;
}
.source-health .h-dot.claude-ok { background: #d97757; box-shadow: 0 0 4px rgba(217,119,87,0.6); }
.source-health .h-dot.claude-warn { background: rgba(217,119,87,0.4); }
.source-health .h-dot.claude-error { background: rgba(217,119,87,0.25); }
.source-health .h-dot.codex-ok { background: #10a37f; box-shadow: 0 0 4px rgba(16,163,127,0.6); }
.source-health .h-dot.codex-warn { background: rgba(16,163,127,0.4); }
.source-health .h-dot.codex-error { background: rgba(16,163,127,0.25); }
.source-health .h-dot.off { background: var(--muted-3); }
/* Sources popover */
.sources-popover {
position: absolute; top: 100%; left: 0; margin-top: 6px;
width: 260px; background: rgba(20, 22, 38, 0.98);
backdrop-filter: blur(24px); -webkit-backdrop-filter: blur(24px);
border: 1px solid var(--hairline-strong); border-radius: 8px;
box-shadow: 0 12px 40px rgba(0,0,0,0.6), inset 0 1px 0 rgba(255,255,255,0.05);
opacity: 0; transform: translateY(-4px);
pointer-events: none; transition: all 0.15s; z-index: 200; overflow: hidden;
}
.sources-popover.show { opacity: 1; transform: translateY(0); pointer-events: auto; }
.sp-head {
padding: 10px 14px 8px; border-bottom: 1px solid var(--hairline);
font-size: 11.5px; color: var(--muted);
}
.sp-list { padding: 6px 0; }
.sp-row {
display: flex; align-items: center; gap: 10px;
padding: 8px 14px; cursor: pointer; transition: background 0.08s;
width: 100%; text-align: left; border: none; background: none; color: inherit;
}
.sp-row:hover { background: rgba(255,255,255,0.03); }
.sp-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; }
.sp-dot.claude { background: #d97757; box-shadow: 0 0 6px rgba(217,119,87,0.5); }
.sp-dot.codex { background: #10a37f; box-shadow: 0 0 6px rgba(16,163,127,0.5); }
.sp-dot.off { background: var(--muted-3); }
.sp-body { flex: 1; min-width: 0; }
.sp-name { font-size: 12.5px; color: var(--fg); font-weight: 500; display: flex; align-items: baseline; gap: 6px; }
.sp-name .sp-count { font-family: var(--font-mono); font-size: 10.5px; color: var(--muted); font-weight: 400; }
.sp-meta { font-family: var(--font-mono); font-size: 10.5px; color: var(--muted); margin-top: 2px; }
.sp-meta.warn { color: #fbbf24; }
.sp-meta.error { color: #f87171; }
.sp-foot {
padding: 8px 14px; border-top: 1px solid var(--hairline); background: rgba(0,0,0,0.2);
}
.sp-foot button {
font-size: 11.5px; color: var(--accent-2); border: none; background: none;
cursor: pointer; border-bottom: 1px solid rgba(167,139,250,0.4); padding-bottom: 1px;
transition: all 0.12s;
}
.sp-foot button:hover { color: var(--accent); border-bottom-color: var(--accent); }
/* Project noise fold */
.project-fold {
display: flex; align-items: center; gap: 8px;
padding: 0 10px; height: 26px; border-radius: 5px;
color: var(--muted); font-size: 12px;
cursor: pointer; user-select: none; transition: all 0.08s;
width: 100%; text-align: left; border: none; background: none;
}
.project-fold:hover { background: var(--surface-strong); color: var(--fg-2); }
.project-fold.expanded { color: var(--fg-3); }
.project-fold .chev {
width: 9px; height: 9px; color: var(--muted-2);
transition: transform 0.15s; flex-shrink: 0;
}
.project-fold.expanded .chev { transform: rotate(90deg); color: var(--muted); }
.project-fold .label { flex: 1; }
.project-fold .count {
font-family: var(--font-mono); font-size: 10px; color: var(--muted-2);
font-variant-numeric: tabular-nums; letter-spacing: 0.02em;
}
.sidebar-item.noise { opacity: 0.6; }
.sidebar-item.noise .icon { color: var(--muted-2); }
.sidebar-item.noise .label {
font-family: var(--font-mono); font-size: 11.5px;
color: var(--muted); letter-spacing: 0.005em;
}
.sidebar-item.noise:hover { opacity: 1; }
.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;
display: flex; align-items: center; justify-content: space-between;
}
.sidebar-section-title .filter-toggle {
display: inline-flex; align-items: center; gap: 4px;
font-family: var(--font-mono); font-size: 10px; color: var(--muted);
letter-spacing: 0.02em; cursor: pointer;
padding: 2px 6px; border-radius: 3px;
text-transform: lowercase; transition: all 0.1s;
white-space: nowrap; flex-shrink: 0;
background: none; border: 1px solid transparent;
width: auto; max-width: none;
}
.sidebar-section-title .filter-toggle svg { width: 10px; height: 10px; flex-shrink: 0; }
.sidebar-section-title .filter-toggle:hover { color: var(--fg-2); background: var(--surface); border-color: var(--hairline-strong); }
.sidebar-section-title .filter-toggle.active { color: var(--accent-2); background: var(--accent-soft); border-color: rgba(167,139,250,0.35); }
.sidebar-search { position: relative; padding: 0 6px 6px; flex-shrink: 0; }
.sidebar-search input {
width: 100%; height: 24px;
+47
View File
@@ -7,6 +7,7 @@
background: rgba(0,0,0,0.15);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
position: relative; z-index: 50;
}
.breadcrumb { display: flex; align-items: center; gap: 6px; min-width: 0; }
.crumb {
@@ -96,6 +97,52 @@
.tab-group button:hover { background: var(--surface); color: var(--fg-2); }
.tab-group button.active { background: var(--accent-soft); color: var(--accent-2); }
/* Source filter */
.source-filter-wrap { position: relative; }
.filter-btn {
display: inline-flex; align-items: center; gap: 6px;
height: 26px; padding: 0 10px;
border: 1px solid var(--hairline-strong); border-radius: 5px;
background: var(--surface); color: var(--fg-2);
font-size: 11.5px; font-weight: 500; cursor: pointer;
transition: all 0.12s;
}
.filter-btn:hover { background: var(--surface-strong); color: var(--fg); border-color: var(--hairline-vivid); }
.filter-btn.active { border-color: rgba(167,139,250,0.35); background: var(--accent-soft); color: var(--accent-2); }
.filter-btn svg { width: 11px; height: 11px; }
.filter-btn .filter-label { font-family: var(--font-mono); font-size: 10.5px; color: var(--muted); letter-spacing: 0.04em; }
.filter-btn.active .filter-label { color: var(--accent); }
.filter-dropdown {
position: absolute; top: calc(100% + 6px); right: 0;
width: 220px; background: rgba(20, 22, 38, 0.98);
backdrop-filter: blur(24px); -webkit-backdrop-filter: blur(24px);
border: 1px solid var(--hairline-strong); border-radius: 8px;
box-shadow: 0 12px 40px rgba(0,0,0,0.6), inset 0 1px 0 rgba(255,255,255,0.05);
opacity: 0; transform: translateY(-4px);
pointer-events: none; transition: all 0.15s;
z-index: 100; padding: 6px;
}
.filter-dropdown.show { opacity: 1; transform: translateY(0); pointer-events: auto; }
.fd-row {
display: flex; align-items: center; gap: 10px;
padding: 8px 10px; border-radius: 5px; cursor: pointer;
transition: background 0.08s;
}
.fd-row:hover { background: rgba(255,255,255,0.03); }
.fd-row .fd-check {
width: 14px; height: 14px;
border: 1.5px solid var(--muted-2); border-radius: 3px;
flex-shrink: 0; display: grid; place-items: center;
transition: all 0.1s;
}
.fd-row.checked .fd-check { background: var(--accent); border-color: var(--accent); box-shadow: 0 0 6px var(--accent-glow); }
.fd-row .fd-check svg { width: 10px; height: 10px; color: var(--bg); opacity: 0; }
.fd-row.checked .fd-check svg { opacity: 1; }
.fd-row .fd-name { font-size: 12.5px; color: var(--fg-2); flex: 1; }
.fd-row.checked .fd-name { color: var(--fg); }
.fd-divider { height: 1px; background: var(--hairline); margin: 4px 6px; }
.toolbar-action-primary {
display: inline-flex; align-items: center; gap: 5px;
height: 26px; padding: 0 12px;