feat(app): link memory to source session with focus-scroll navigation
Add a clickable session link in MemoryList detail panel that navigates to the source session and scrolls to the originating message. Refactor SessionDetail focus logic into a reusable focusPendingMessage() that reads ?focus=<uuid> from the route query, scrolls the target into view, and applies a pulse animation that fades out.
This commit is contained in:
@@ -75,6 +75,21 @@ function summaryHTML(m) {
|
||||
return highlightPlain(m.summary || '', state.query.trim());
|
||||
}
|
||||
|
||||
function sourceSessionTitle(m) {
|
||||
if (!m.session_id) return '';
|
||||
const s = state.sessions.find(x => x.id === m.session_id);
|
||||
return s?.title || m.session_id.slice(0, 8);
|
||||
}
|
||||
|
||||
function openSourceSession(m) {
|
||||
if (!m.session_id) return;
|
||||
if (m.message_start) {
|
||||
router.push({ path: `/sessions/${m.session_id}`, query: { focus: m.message_start } });
|
||||
} else {
|
||||
router.push(`/sessions/${m.session_id}`);
|
||||
}
|
||||
}
|
||||
|
||||
function timeLabel(m) {
|
||||
return fmtListTime(m.ts);
|
||||
}
|
||||
@@ -316,6 +331,18 @@ onUnmounted(() => {
|
||||
<div class="detail-path">{{ relativePath(detailMemory) }}</div>
|
||||
<div class="detail-summary">{{ detailMemory.summary }}</div>
|
||||
<div class="detail-meta">
|
||||
<button
|
||||
v-if="detailMemory.session_id"
|
||||
class="session-link"
|
||||
@click="openSourceSession(detailMemory)"
|
||||
>
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round">
|
||||
<path d="M3 4h10v8a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4z"/>
|
||||
<path d="M5.5 7h5M5.5 9.5h3" stroke-linecap="round"/>
|
||||
</svg>
|
||||
<span>{{ sourceSessionTitle(detailMemory) }}</span>
|
||||
</button>
|
||||
<span v-if="detailMemory.session_id" class="dot"></span>
|
||||
<span>{{ fmtRelative(detailMemory.ts) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -659,6 +686,17 @@ onUnmounted(() => {
|
||||
color: var(--muted); font-variant-numeric: tabular-nums;
|
||||
padding-bottom: 16px; border-bottom: 1px solid var(--hairline);
|
||||
}
|
||||
.detail-meta .dot { width: 2px; height: 2px; background: var(--muted-2); border-radius: 50%; flex-shrink: 0; }
|
||||
.session-link {
|
||||
color: var(--accent-2); border: 0; background: transparent;
|
||||
padding: 2px 5px; margin: -2px 0; border-radius: 3px;
|
||||
font: inherit; cursor: pointer; transition: all 0.1s;
|
||||
text-decoration: underline; text-decoration-color: rgba(167,139,250,0.25);
|
||||
text-underline-offset: 3px;
|
||||
display: inline-flex; align-items: center; gap: 5px;
|
||||
}
|
||||
.session-link:hover { background: rgba(167,139,250,0.12); color: var(--accent-2); text-decoration-color: var(--accent-2); }
|
||||
.session-link svg { width: 11px; height: 11px; }
|
||||
|
||||
.markdown-section { margin: 28px 0 8px; }
|
||||
.markdown-toolbar { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted, nextTick, onActivated, onDeactivated, watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { state, FOLDER_SVG } from '../store.js';
|
||||
import { loadSessionDetail, isTextTruncated, loadFullText } from '../data.js';
|
||||
import { clearSessionDirty, consumeGlobalSessionDirty } from '../session-live.mjs';
|
||||
@@ -16,6 +16,7 @@ defineOptions({ name: 'SessionDetail' });
|
||||
const props = defineProps({ id: String });
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
|
||||
// --- Reactive state ---
|
||||
const session = computed(() => state.sessions.find(s => s.id === props.id));
|
||||
@@ -80,6 +81,9 @@ const showFontHint = ref(false);
|
||||
onMounted(async () => {
|
||||
active.value = true;
|
||||
attachKeydown();
|
||||
if (route.query.focus) {
|
||||
state.pendingFocusUuid = route.query.focus;
|
||||
}
|
||||
removeSessionUpdated = window.obelisk?.onSessionUpdated?.(async ({ sessionId } = {}) => {
|
||||
if (!active.value || !props.id || sessionId !== props.id) return;
|
||||
clearSessionDirty(props.id);
|
||||
@@ -96,8 +100,13 @@ onMounted(async () => {
|
||||
onActivated(async () => {
|
||||
active.value = true;
|
||||
attachKeydown();
|
||||
if (route.query.focus) {
|
||||
state.pendingFocusUuid = route.query.focus;
|
||||
}
|
||||
if (props.id && (messages.value.length === 0 || consumeGlobalSessionDirty(props.id))) {
|
||||
await loadMessages({ force: true });
|
||||
} else if (state.pendingFocusUuid) {
|
||||
await focusPendingMessage();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -116,13 +125,16 @@ onUnmounted(() => {
|
||||
watch(() => props.id, async (newId, oldId) => {
|
||||
if (newId && newId !== oldId) {
|
||||
messages.value = [];
|
||||
progressPct.value = 0;
|
||||
currentMsgIdx.value = 0;
|
||||
await loadMessages({ force: consumeGlobalSessionDirty(newId) });
|
||||
}
|
||||
});
|
||||
|
||||
async function loadMessages({ force = false } = {}) {
|
||||
if (!props.id) return;
|
||||
const wasAtBottom = wrapRef.value && (wrapRef.value.scrollHeight - wrapRef.value.scrollTop - wrapRef.value.clientHeight) < 50;
|
||||
const hadContent = messages.value.length > 0;
|
||||
const wasAtBottom = hadContent && wrapRef.value && (wrapRef.value.scrollHeight - wrapRef.value.scrollTop - wrapRef.value.clientHeight) < 50;
|
||||
const prevScrollTop = wrapRef.value?.scrollTop || 0;
|
||||
|
||||
loading.value = true;
|
||||
@@ -140,24 +152,38 @@ async function loadMessages({ force = false } = {}) {
|
||||
|
||||
nextTick(() => {
|
||||
if (!wrapRef.value) return;
|
||||
if (wasAtBottom) {
|
||||
wrapRef.value.scrollTop = wrapRef.value.scrollHeight;
|
||||
} else {
|
||||
wrapRef.value.scrollTop = prevScrollTop;
|
||||
if (!state.pendingFocusUuid) {
|
||||
if (wasAtBottom) {
|
||||
wrapRef.value.scrollTop = wrapRef.value.scrollHeight;
|
||||
} else {
|
||||
wrapRef.value.scrollTop = prevScrollTop;
|
||||
}
|
||||
}
|
||||
onScroll();
|
||||
});
|
||||
|
||||
// Focus pending uuid if any
|
||||
if (state.pendingFocusUuid) {
|
||||
const targetUuid = state.pendingFocusUuid;
|
||||
state.pendingFocusUuid = null;
|
||||
await focusPendingMessage();
|
||||
}
|
||||
}
|
||||
|
||||
async function focusPendingMessage() {
|
||||
const targetUuid = state.pendingFocusUuid;
|
||||
if (!targetUuid) return;
|
||||
state.pendingFocusUuid = null;
|
||||
await nextTick();
|
||||
const target = detailRef.value?.querySelector(`.msg[data-uuid="${targetUuid}"], .skill-card[data-uuid="${targetUuid}"], .wf-card[data-uuid="${targetUuid}"]`);
|
||||
if (target && wrapRef.value) {
|
||||
const navHeight = 52;
|
||||
const msgBottom = target.offsetTop + target.offsetHeight;
|
||||
const scrollTarget = msgBottom - wrapRef.value.clientHeight + navHeight;
|
||||
wrapRef.value.scrollTo({ top: Math.max(0, scrollTarget), behavior: 'instant' });
|
||||
target.classList.add('is-focused');
|
||||
setTimeout(() => target.classList.remove('is-focused'), 2000);
|
||||
// Update nav position
|
||||
await nextTick();
|
||||
const target = detailRef.value?.querySelector(`.msg[data-uuid="${targetUuid}"]`);
|
||||
if (target) {
|
||||
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
target.classList.add('is-focused');
|
||||
setTimeout(() => target.classList.remove('is-focused'), 1200);
|
||||
}
|
||||
onScroll();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user