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:
@@ -5,3 +5,5 @@ tests/
|
|||||||
node_modules/
|
node_modules/
|
||||||
dist-renderer/
|
dist-renderer/
|
||||||
release/
|
release/
|
||||||
|
docs/
|
||||||
|
.claude/
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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 (wasAtBottom) {
|
if (!state.pendingFocusUuid) {
|
||||||
wrapRef.value.scrollTop = wrapRef.value.scrollHeight;
|
if (wasAtBottom) {
|
||||||
} else {
|
wrapRef.value.scrollTop = wrapRef.value.scrollHeight;
|
||||||
wrapRef.value.scrollTop = prevScrollTop;
|
} else {
|
||||||
|
wrapRef.value.scrollTop = prevScrollTop;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
onScroll();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Focus pending uuid if any
|
// Focus pending uuid if any
|
||||||
if (state.pendingFocusUuid) {
|
if (state.pendingFocusUuid) {
|
||||||
const targetUuid = state.pendingFocusUuid;
|
await focusPendingMessage();
|
||||||
state.pendingFocusUuid = null;
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
await nextTick();
|
||||||
const target = detailRef.value?.querySelector(`.msg[data-uuid="${targetUuid}"]`);
|
onScroll();
|
||||||
if (target) {
|
|
||||||
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
||||||
target.classList.add('is-focused');
|
|
||||||
setTimeout(() => target.classList.remove('is-focused'), 1200);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user