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:
tommy0103
2026-06-17 01:15:16 +08:00
parent 42c7c0da66
commit e3ca1735b9
4 changed files with 86 additions and 14 deletions
+2
View File
@@ -5,3 +5,5 @@ tests/
node_modules/ node_modules/
dist-renderer/ dist-renderer/
release/ release/
docs/
.claude/
+38
View File
@@ -75,6 +75,21 @@ function summaryHTML(m) {
return highlightPlain(m.summary || '', state.query.trim()); 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) { function timeLabel(m) {
return fmtListTime(m.ts); return fmtListTime(m.ts);
} }
@@ -316,6 +331,18 @@ onUnmounted(() => {
<div class="detail-path">{{ relativePath(detailMemory) }}</div> <div class="detail-path">{{ relativePath(detailMemory) }}</div>
<div class="detail-summary">{{ detailMemory.summary }}</div> <div class="detail-summary">{{ detailMemory.summary }}</div>
<div class="detail-meta"> <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> <span>{{ fmtRelative(detailMemory.ts) }}</span>
</div> </div>
</div> </div>
@@ -659,6 +686,17 @@ onUnmounted(() => {
color: var(--muted); font-variant-numeric: tabular-nums; color: var(--muted); font-variant-numeric: tabular-nums;
padding-bottom: 16px; border-bottom: 1px solid var(--hairline); 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-section { margin: 28px 0 8px; }
.markdown-toolbar { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; } .markdown-toolbar { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
+33 -7
View File
@@ -1,6 +1,6 @@
<script setup> <script setup>
import { ref, computed, onMounted, onUnmounted, nextTick, onActivated, onDeactivated, watch } from 'vue'; 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 { state, FOLDER_SVG } from '../store.js';
import { loadSessionDetail, isTextTruncated, loadFullText } from '../data.js'; import { loadSessionDetail, isTextTruncated, loadFullText } from '../data.js';
import { clearSessionDirty, consumeGlobalSessionDirty } from '../session-live.mjs'; import { clearSessionDirty, consumeGlobalSessionDirty } from '../session-live.mjs';
@@ -16,6 +16,7 @@ defineOptions({ name: 'SessionDetail' });
const props = defineProps({ id: String }); const props = defineProps({ id: String });
const router = useRouter(); const router = useRouter();
const route = useRoute();
// --- Reactive state --- // --- Reactive state ---
const session = computed(() => state.sessions.find(s => s.id === props.id)); const session = computed(() => state.sessions.find(s => s.id === props.id));
@@ -80,6 +81,9 @@ const showFontHint = ref(false);
onMounted(async () => { onMounted(async () => {
active.value = true; active.value = true;
attachKeydown(); attachKeydown();
if (route.query.focus) {
state.pendingFocusUuid = route.query.focus;
}
removeSessionUpdated = window.obelisk?.onSessionUpdated?.(async ({ sessionId } = {}) => { removeSessionUpdated = window.obelisk?.onSessionUpdated?.(async ({ sessionId } = {}) => {
if (!active.value || !props.id || sessionId !== props.id) return; if (!active.value || !props.id || sessionId !== props.id) return;
clearSessionDirty(props.id); clearSessionDirty(props.id);
@@ -96,8 +100,13 @@ onMounted(async () => {
onActivated(async () => { onActivated(async () => {
active.value = true; active.value = true;
attachKeydown(); attachKeydown();
if (route.query.focus) {
state.pendingFocusUuid = route.query.focus;
}
if (props.id && (messages.value.length === 0 || consumeGlobalSessionDirty(props.id))) { if (props.id && (messages.value.length === 0 || consumeGlobalSessionDirty(props.id))) {
await loadMessages({ force: true }); await loadMessages({ force: true });
} else if (state.pendingFocusUuid) {
await focusPendingMessage();
} }
}); });
@@ -116,13 +125,16 @@ onUnmounted(() => {
watch(() => props.id, async (newId, oldId) => { watch(() => props.id, async (newId, oldId) => {
if (newId && newId !== oldId) { if (newId && newId !== oldId) {
messages.value = []; messages.value = [];
progressPct.value = 0;
currentMsgIdx.value = 0;
await loadMessages({ force: consumeGlobalSessionDirty(newId) }); await loadMessages({ force: consumeGlobalSessionDirty(newId) });
} }
}); });
async function loadMessages({ force = false } = {}) { async function loadMessages({ force = false } = {}) {
if (!props.id) return; 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; const prevScrollTop = wrapRef.value?.scrollTop || 0;
loading.value = true; loading.value = true;
@@ -140,24 +152,38 @@ async function loadMessages({ force = false } = {}) {
nextTick(() => { nextTick(() => {
if (!wrapRef.value) return; if (!wrapRef.value) return;
if (!state.pendingFocusUuid) {
if (wasAtBottom) { if (wasAtBottom) {
wrapRef.value.scrollTop = wrapRef.value.scrollHeight; wrapRef.value.scrollTop = wrapRef.value.scrollHeight;
} else { } else {
wrapRef.value.scrollTop = prevScrollTop; wrapRef.value.scrollTop = prevScrollTop;
} }
}
onScroll();
}); });
// Focus pending uuid if any // Focus pending uuid if any
if (state.pendingFocusUuid) { if (state.pendingFocusUuid) {
await focusPendingMessage();
}
}
async function focusPendingMessage() {
const targetUuid = state.pendingFocusUuid; const targetUuid = state.pendingFocusUuid;
if (!targetUuid) return;
state.pendingFocusUuid = null; state.pendingFocusUuid = null;
await nextTick(); await nextTick();
const target = detailRef.value?.querySelector(`.msg[data-uuid="${targetUuid}"]`); const target = detailRef.value?.querySelector(`.msg[data-uuid="${targetUuid}"], .skill-card[data-uuid="${targetUuid}"], .wf-card[data-uuid="${targetUuid}"]`);
if (target) { if (target && wrapRef.value) {
target.scrollIntoView({ behavior: 'smooth', block: 'start' }); 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'); target.classList.add('is-focused');
setTimeout(() => target.classList.remove('is-focused'), 1200); setTimeout(() => target.classList.remove('is-focused'), 2000);
} // Update nav position
await nextTick();
onScroll();
} }
} }
+6
View File
@@ -448,6 +448,12 @@
.msg.is-focused { .msg.is-focused {
border-color: var(--accent); border-color: var(--accent);
box-shadow: 0 0 0 1px var(--accent), 0 0 22px var(--accent-glow); box-shadow: 0 0 0 1px var(--accent), 0 0 22px var(--accent-glow);
animation: focus-pulse 2s ease-out forwards;
}
@keyframes focus-pulse {
0% { box-shadow: 0 0 0 2px var(--accent), 0 0 30px var(--accent-glow); }
70% { box-shadow: 0 0 0 1px var(--accent), 0 0 15px var(--accent-glow); }
100% { box-shadow: none; border-color: var(--asst-bubble-border); }
} }
.msg-head { .msg-head {
display: flex; align-items: center; gap: 8px; display: flex; align-items: center; gap: 8px;