chore(renderer): merge main into image rendering fix
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const EDITOR_SCHEMES: Record<string, string> = {
|
||||
vscode: 'vscode',
|
||||
'vscode-insiders': 'vscode-insiders',
|
||||
cursor: 'cursor',
|
||||
windsurf: 'windsurf',
|
||||
zed: 'zed',
|
||||
};
|
||||
|
||||
const DEFAULT_EDITOR_SCHEME = 'vscode';
|
||||
|
||||
interface FileReferenceQuery {
|
||||
rawPath?: string | null;
|
||||
cwd?: string | null;
|
||||
roots?: (string | null | undefined)[];
|
||||
}
|
||||
|
||||
interface EditorUrlOptions {
|
||||
scheme?: string | null;
|
||||
filePath: string;
|
||||
line?: number | null;
|
||||
column?: number | null;
|
||||
}
|
||||
|
||||
function normalizeRoots(roots: (string | null | undefined)[] = []): string[] {
|
||||
const seen = new Set<string>();
|
||||
for (const root of roots) {
|
||||
if (typeof root !== 'string' || !root.trim() || !path.isAbsolute(root)) continue;
|
||||
seen.add(path.resolve(root));
|
||||
}
|
||||
return [...seen];
|
||||
}
|
||||
|
||||
function isWithinRoots(candidate: string, roots: string[]): boolean {
|
||||
return roots.some((root) => candidate === root || candidate.startsWith(root + path.sep));
|
||||
}
|
||||
|
||||
// A reference may be absolute, relative to the message cwd, or project-relative with a
|
||||
// leading slash (`/src/foo.ts`). path.join treats the leading slash as a no-op, so the same
|
||||
// join covers the last two cases.
|
||||
function fileReferenceCandidates({ rawPath, cwd, roots = [] }: FileReferenceQuery): string[] {
|
||||
if (typeof rawPath !== 'string' || !rawPath.trim()) return [];
|
||||
const cleaned = rawPath.trim();
|
||||
const bases = normalizeRoots([cwd, ...roots]);
|
||||
const candidates: string[] = [];
|
||||
if (path.isAbsolute(cleaned)) candidates.push(path.resolve(cleaned));
|
||||
for (const base of bases) candidates.push(path.resolve(base, `.${path.sep}${cleaned}`));
|
||||
return [...new Set(candidates)];
|
||||
}
|
||||
|
||||
// Resolves to an existing file that lives inside one of the session's own roots. Transcript
|
||||
// text is untrusted input, so containment is checked after realpath — a symlink pointing out
|
||||
// of the project must not widen what the app will open.
|
||||
function resolveFileReference(query: FileReferenceQuery): string | null {
|
||||
const roots = normalizeRoots([query.cwd, ...(query.roots || [])]);
|
||||
if (!roots.length) return null;
|
||||
for (const candidate of fileReferenceCandidates(query)) {
|
||||
if (!isWithinRoots(candidate, roots)) continue;
|
||||
try {
|
||||
const real = fs.realpathSync(candidate);
|
||||
if (!isWithinRoots(real, roots)) continue;
|
||||
if (fs.statSync(real).isFile()) return real;
|
||||
} catch {}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildEditorUrl({ scheme, filePath, line, column }: EditorUrlOptions): string {
|
||||
const resolved = EDITOR_SCHEMES[String(scheme || '')] || DEFAULT_EDITOR_SCHEME;
|
||||
let target = `${resolved}://file${encodeURI(filePath)}`;
|
||||
const lineNumber = Number(line);
|
||||
if (Number.isInteger(lineNumber) && lineNumber > 0) {
|
||||
target += `:${lineNumber}`;
|
||||
const columnNumber = Number(column);
|
||||
if (Number.isInteger(columnNumber) && columnNumber > 0) target += `:${columnNumber}`;
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
export {
|
||||
DEFAULT_EDITOR_SCHEME,
|
||||
EDITOR_SCHEMES,
|
||||
buildEditorUrl,
|
||||
fileReferenceCandidates,
|
||||
isWithinRoots,
|
||||
normalizeRoots,
|
||||
resolveFileReference,
|
||||
};
|
||||
@@ -9,6 +9,7 @@ import { writeHeartbeat } from './indexer.ts';
|
||||
import { createIndexerService } from './indexer-service.ts';
|
||||
import { createWorkerBuildIndex } from './indexer-worker-client.ts';
|
||||
import { buildRecapExportQuery } from './recap-capture-query.ts';
|
||||
import { buildEditorUrl, DEFAULT_EDITOR_SCHEME, EDITOR_SCHEMES, resolveFileReference } from './file-reference.ts';
|
||||
import { acquireWriterLease, writerLockPathFor } from '../../../packages/core/src/writer-lease.ts';
|
||||
import { migrateCoreSchemaColumns } from '../../../packages/core/src/schema-migrations.ts';
|
||||
import { createBuiltinProviderRegistry } from '../../../packages/core/src/providers/builtins.ts';
|
||||
@@ -298,6 +299,30 @@ async function stopBackgroundResources({ stopWorker = false } = {}) {
|
||||
closeDb();
|
||||
}
|
||||
|
||||
function safeProtocol(url: string): string {
|
||||
try { return new URL(url).protocol; } catch { return ''; }
|
||||
}
|
||||
|
||||
function isSameOrigin(url: string, currentUrl: string): boolean {
|
||||
try {
|
||||
return new URL(url).origin === new URL(currentUrl).origin;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Reloads and in-app routing keep the same origin and pathname; anything else is a real
|
||||
// navigation away from the renderer document.
|
||||
function isSameDocumentNavigation(url: string, currentUrl: string): boolean {
|
||||
try {
|
||||
const target = new URL(url);
|
||||
const current = new URL(currentUrl);
|
||||
return target.origin === current.origin && target.pathname === current.pathname;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function createWindow() {
|
||||
const isDev = process.argv.includes('--dev') || !!process.env.ELECTRON_RENDERER_URL;
|
||||
const shouldOpenDevTools = process.argv.includes('--devtools');
|
||||
@@ -325,6 +350,25 @@ function createWindow() {
|
||||
}
|
||||
});
|
||||
|
||||
// Keep the SPA in place: a Markdown link in a transcript must never replace the running
|
||||
// renderer. External http(s) targets are handed to the system browser instead.
|
||||
// Only genuinely external targets go to the browser. A same-origin URL is a local document
|
||||
// reference — in dev that would hand the system browser a Vite-served source file.
|
||||
const releaseNavigation = (url: string) => {
|
||||
if (!/^https?:$/i.test(safeProtocol(url))) return;
|
||||
if (isSameOrigin(url, win.webContents.getURL())) return;
|
||||
shell.openExternal(url).catch(() => {});
|
||||
};
|
||||
win.webContents.setWindowOpenHandler(({ url }) => {
|
||||
releaseNavigation(url);
|
||||
return { action: 'deny' };
|
||||
});
|
||||
win.webContents.on('will-navigate', (event, url) => {
|
||||
if (isSameDocumentNavigation(url, win.webContents.getURL())) return;
|
||||
event.preventDefault();
|
||||
releaseNavigation(url);
|
||||
});
|
||||
|
||||
if (isDev) {
|
||||
win.loadURL(process.env.ELECTRON_RENDERER_URL || process.env.OBELISK_DEV_SERVER_URL || 'http://localhost:5173');
|
||||
if (shouldOpenDevTools) {
|
||||
@@ -589,6 +633,39 @@ ipcMain.handle('db:readMemoryFile', (_, filePath) => {
|
||||
} catch { return null; }
|
||||
});
|
||||
|
||||
// Every root this session actually worked in. A reference is only opened if it lands inside
|
||||
// one of them, so untrusted transcript text cannot reach a file outside the session's own
|
||||
// projects. Scoping is deliberately per session, not corpus-wide.
|
||||
function querySessionFileRoots(sessionId: unknown): string[] {
|
||||
if (!db || typeof sessionId !== 'string' || !sessionId) return [];
|
||||
const roots: string[] = [];
|
||||
try {
|
||||
const rows = db.prepare(
|
||||
`SELECT DISTINCT cwd FROM messages WHERE session_id = ? AND cwd IS NOT NULL AND cwd != ''`
|
||||
).all(sessionId);
|
||||
for (const row of rows) roots.push(row.cwd);
|
||||
const session = db.prepare(`SELECT project_path FROM sessions WHERE id = ?`).get(sessionId);
|
||||
if (session?.project_path) roots.push(session.project_path);
|
||||
} catch {}
|
||||
return roots;
|
||||
}
|
||||
|
||||
ipcMain.handle('file-ref:open', async (_, ref) => {
|
||||
const { sessionId, path: rawPath, cwd, line, column } = ref || {};
|
||||
const roots = querySessionFileRoots(sessionId);
|
||||
// The renderer-supplied cwd only counts if this session actually recorded it.
|
||||
const scopedCwd = typeof cwd === 'string' && roots.includes(cwd) ? cwd : null;
|
||||
const filePath = resolveFileReference({ rawPath, cwd: scopedCwd, roots });
|
||||
if (!filePath) return { opened: false };
|
||||
const { editorScheme } = loadPersistedSettings();
|
||||
try {
|
||||
await shell.openExternal(buildEditorUrl({ scheme: editorScheme, filePath, line, column }));
|
||||
return { opened: true, path: filePath };
|
||||
} catch {
|
||||
return { opened: false, path: filePath };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('db:archiveMemory', (_, id, reason) => {
|
||||
return runAppDbWrite(() => {
|
||||
db.prepare(`UPDATE memories SET deleted_at = ?, deleted_reason = ? WHERE id = ?`)
|
||||
@@ -814,6 +891,7 @@ ipcMain.handle('settings:get', () => {
|
||||
dbPath: dbFile,
|
||||
recapDir,
|
||||
autoRefresh: persisted.autoRefresh !== false,
|
||||
editorScheme: persisted.editorScheme || DEFAULT_EDITOR_SCHEME,
|
||||
sources,
|
||||
memoryCount,
|
||||
sessionCount,
|
||||
|
||||
@@ -22,6 +22,8 @@ contextBridge.exposeInMainWorld('obelisk', {
|
||||
getMessageFullText: (uuid: string) => ipcRenderer.invoke('db:getMessageFullText', uuid),
|
||||
getMemories: () => ipcRenderer.invoke('db:getMemories'),
|
||||
readMemoryFile: (path: string) => ipcRenderer.invoke('db:readMemoryFile', path),
|
||||
openFileReference: (ref: { sessionId?: string | null; path: string; cwd?: string | null; line?: number | null; column?: number | null }) =>
|
||||
ipcRenderer.invoke('file-ref:open', ref),
|
||||
archiveMemory: (id: string, reason?: string) => ipcRenderer.invoke('db:archiveMemory', id, reason),
|
||||
restoreMemory: (id: string) => ipcRenderer.invoke('db:restoreMemory', id),
|
||||
getProjects: () => ipcRenderer.invoke('db:getProjects'),
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
// Two reference shapes appear in transcripts and need different handling:
|
||||
// A. Markdown links with an absolute path — `[roadmap.md](/Users/me/proj/roadmap.md:162)`
|
||||
// B. Inline code holding a project-relative path — `` `src/hooks/hook.ts:40` ``
|
||||
// Both end up as an anchor carrying the split-out components; the main process resolves them.
|
||||
|
||||
const REFERENCE_SUFFIX = /:(\d+)(?::(\d+)|-(\d+))?$/;
|
||||
const INLINE_CODE_REFERENCE = /^[\w@.\-/]+\.[A-Za-z0-9]+:\d+(?::\d+|-\d+)?$/;
|
||||
const LOCAL_HREF = /^(?:file:|\/|[A-Za-z]:[\\/])/;
|
||||
|
||||
export function isLocalHref(href) {
|
||||
return typeof href === 'string' && LOCAL_HREF.test(href.trim());
|
||||
}
|
||||
|
||||
// Deliberately strict: an extension *and* a line number are both required. Plain inline code
|
||||
// such as `package.json` or `useState` must never become a link.
|
||||
export function isInlineCodeReference(text) {
|
||||
return typeof text === 'string' && INLINE_CODE_REFERENCE.test(text.trim());
|
||||
}
|
||||
|
||||
export function parseFileReference(raw) {
|
||||
if (typeof raw !== 'string' || !raw.trim()) return null;
|
||||
const text = raw.trim();
|
||||
const match = REFERENCE_SUFFIX.exec(text);
|
||||
if (!match) return { path: text, line: null, column: null, endLine: null };
|
||||
return {
|
||||
path: text.slice(0, match.index),
|
||||
line: Number(match[1]),
|
||||
column: match[2] ? Number(match[2]) : null,
|
||||
endLine: match[3] ? Number(match[3]) : null,
|
||||
};
|
||||
}
|
||||
|
||||
// Only `file:` URLs are percent-encoded. Decoding a plain path would corrupt any filename
|
||||
// that legitimately contains `%`.
|
||||
export function hrefToPath(href) {
|
||||
if (!href.startsWith('file:')) return href;
|
||||
try {
|
||||
return decodeURIComponent(new URL(href).pathname);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function applyReference(el, raw, { cwd, sessionId }) {
|
||||
const ref = parseFileReference(raw);
|
||||
if (!ref?.path) return false;
|
||||
el.classList.add('file-ref');
|
||||
el.dataset.filePath = ref.path;
|
||||
if (cwd) el.dataset.fileCwd = cwd;
|
||||
if (sessionId) el.dataset.fileSession = sessionId;
|
||||
if (ref.line) el.dataset.fileLine = String(ref.line);
|
||||
if (ref.column) el.dataset.fileColumn = String(ref.column);
|
||||
if (ref.endLine) el.dataset.fileEndLine = String(ref.endLine);
|
||||
return true;
|
||||
}
|
||||
|
||||
function markLinkReferences(rootEl, context) {
|
||||
for (const anchor of rootEl.querySelectorAll('a[href]')) {
|
||||
const href = anchor.getAttribute('href') || '';
|
||||
if (!isLocalHref(href)) continue;
|
||||
const filePath = hrefToPath(href.trim());
|
||||
if (filePath) applyReference(anchor, filePath, context);
|
||||
}
|
||||
}
|
||||
|
||||
// Fenced blocks render as <pre><code>; only inline code is linkified, so a path quoted inside
|
||||
// a code sample never turns into navigation.
|
||||
function markInlineCodeReferences(rootEl, context) {
|
||||
if (!context.cwd) return;
|
||||
for (const code of rootEl.querySelectorAll('code')) {
|
||||
if (code.closest('pre')) continue;
|
||||
const text = code.textContent.trim();
|
||||
if (!isInlineCodeReference(text)) continue;
|
||||
const anchor = document.createElement('a');
|
||||
anchor.textContent = text;
|
||||
if (!applyReference(anchor, text, context)) continue;
|
||||
code.replaceChildren(anchor);
|
||||
}
|
||||
}
|
||||
|
||||
// Cheap pre-filter on the rendered HTML. Most messages contain neither a link nor inline code,
|
||||
// and this runs for every rendered row — a string scan is far cheaper than walking the DOM.
|
||||
export function mayContainFileReference(html) {
|
||||
return typeof html === 'string' && (html.includes('<a ') || html.includes('<code'));
|
||||
}
|
||||
|
||||
export function markFileReferences(rootEl, { cwd = null, sessionId = null } = {}) {
|
||||
const context = { cwd, sessionId };
|
||||
markLinkReferences(rootEl, context);
|
||||
markInlineCodeReferences(rootEl, context);
|
||||
return rootEl;
|
||||
}
|
||||
|
||||
function onClick(event) {
|
||||
const anchor = event.target instanceof Element ? event.target.closest('a.file-ref') : null;
|
||||
if (!anchor) return;
|
||||
event.preventDefault();
|
||||
const { filePath, fileCwd, fileSession, fileLine, fileColumn } = anchor.dataset;
|
||||
if (!filePath) return;
|
||||
void window.obelisk?.openFileReference?.({
|
||||
sessionId: fileSession || null,
|
||||
path: filePath,
|
||||
cwd: fileCwd || null,
|
||||
line: fileLine ? Number(fileLine) : null,
|
||||
column: fileColumn ? Number(fileColumn) : null,
|
||||
});
|
||||
}
|
||||
|
||||
export function installFileReferenceHandler(target = document) {
|
||||
target.addEventListener('click', onClick);
|
||||
return () => target.removeEventListener('click', onClick);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { commitInitialData, fetchInitialData } from './data.js';
|
||||
import { noteSessionUpdated, sessionLiveState } from './session-live.mjs';
|
||||
import { createGlobalDataRefreshCoordinator } from './session-global-refresh.mjs';
|
||||
import { registerSessionImageElement } from './session-image-element.js';
|
||||
import { installFileReferenceHandler } from './file-references.mjs';
|
||||
|
||||
// Import shared renderer CSS globally
|
||||
import '../styles/base.css';
|
||||
@@ -62,4 +63,6 @@ window.obelisk?.onSessionUpdated?.(({ sessionId } = {}) => {
|
||||
noteSessionUpdated(sessionLiveState, sessionId, currentSessionId);
|
||||
});
|
||||
|
||||
installFileReferenceHandler();
|
||||
|
||||
app.mount('#app');
|
||||
|
||||
@@ -252,6 +252,7 @@ export function buildSessionTimelinePresentation(item, { query = '', expandedTex
|
||||
const toolArgPreviews = new Map();
|
||||
const toolIcons = new Map();
|
||||
const workflowAgentGroups = new Map();
|
||||
const refs = { cwd: message.cwd || null, sessionId: message.session_id || null };
|
||||
|
||||
for (const toolCall of toolCalls) {
|
||||
const input = parseToolInput(toolCall);
|
||||
@@ -263,7 +264,7 @@ export function buildSessionTimelinePresentation(item, { query = '', expandedTex
|
||||
toolPrettyHtml.set(toolCall.id, renderPrettyTool(toolCall));
|
||||
}
|
||||
if ((toolCall.name === 'Agent' || toolCall.name === 'Task') && toolCall.result?.content) {
|
||||
toolResultHtml.set(toolCall.id, renderMarkdown(toolCall.result.content, { variant: 'compact' }));
|
||||
toolResultHtml.set(toolCall.id, renderMarkdown(toolCall.result.content, { variant: 'compact', ...refs }));
|
||||
}
|
||||
if (toolCall.name === 'Workflow') workflowAgentGroups.set(toolCall.id, groupWorkflowAgents(toolCall.workflow));
|
||||
}
|
||||
@@ -271,14 +272,14 @@ export function buildSessionTimelinePresentation(item, { query = '', expandedTex
|
||||
const effectiveText = expandedText ?? message.text;
|
||||
return {
|
||||
messageHtml: message.text
|
||||
? renderMarkdown(effectiveText, { variant: item?.kind === 'meta' ? 'compact' : 'msg', query })
|
||||
? renderMarkdown(effectiveText, { variant: item?.kind === 'meta' ? 'compact' : 'msg', query, ...refs })
|
||||
: '',
|
||||
thinkingHtml: message._thinking
|
||||
? renderMarkdown(message._thinking, { variant: 'msg', query })
|
||||
: (item?.kind === 'thinking' ? renderMarkdown(message.text, { variant: 'msg', query }) : ''),
|
||||
skillHtml: message._skillMd ? renderMarkdown(message._skillMd, { variant: 'compact' }) : '',
|
||||
? renderMarkdown(message._thinking, { variant: 'msg', query, ...refs })
|
||||
: (item?.kind === 'thinking' ? renderMarkdown(message.text, { variant: 'msg', query, ...refs }) : ''),
|
||||
skillHtml: message._skillMd ? renderMarkdown(message._skillMd, { variant: 'compact', ...refs }) : '',
|
||||
summaryHtml: message.summary?.content
|
||||
? renderMarkdown(message.summary.content, { variant: 'compact' })
|
||||
? renderMarkdown(message.summary.content, { variant: 'compact', ...refs })
|
||||
: '',
|
||||
toolInputs,
|
||||
toolInputText,
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
import { state } from './store.js';
|
||||
import { configureMarkdownImages } from './markdown-image-renderer.js';
|
||||
import { markFileReferences, mayContainFileReference } from './file-references.mjs';
|
||||
|
||||
// --- Time / formatting ---
|
||||
|
||||
@@ -104,6 +105,11 @@ export function renderMarkdown(text, opts = {}) {
|
||||
const container = document.createElement('div');
|
||||
container.className = cls;
|
||||
container.innerHTML = html;
|
||||
// Without a cwd or session to resolve against, a reference could never be opened — leaving it
|
||||
// unmarked keeps it from looking actionable.
|
||||
if ((opts.cwd || opts.sessionId) && mayContainFileReference(html)) {
|
||||
markFileReferences(container, { cwd: opts.cwd, sessionId: opts.sessionId });
|
||||
}
|
||||
if (opts.query) highlightTextNodes(container, opts.query.trim());
|
||||
return container.outerHTML;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ const sources = ref([]);
|
||||
const dbPath = ref('');
|
||||
const recapPath = ref('');
|
||||
const autoRefresh = ref(true);
|
||||
const editorScheme = ref('vscode');
|
||||
const editorSchemes = ['vscode', 'vscode-insiders', 'cursor', 'windsurf', 'zed'];
|
||||
const memoryCount = ref(0);
|
||||
const rebuilding = ref(false);
|
||||
const version = ref('0.1.0');
|
||||
@@ -22,9 +24,15 @@ async function loadSettings() {
|
||||
dbPath.value = s.dbPath || '';
|
||||
recapPath.value = s.recapDir || '~/.obelisk/recap';
|
||||
autoRefresh.value = s.autoRefresh !== false;
|
||||
editorScheme.value = s.editorScheme || 'vscode';
|
||||
memoryCount.value = s.memoryCount || 0;
|
||||
}
|
||||
|
||||
async function saveEditorScheme(value) {
|
||||
editorScheme.value = value;
|
||||
await saveSetting('editorScheme', value);
|
||||
}
|
||||
|
||||
async function browseSourcePath(source) {
|
||||
if (!window.obelisk?.browseFolder) return;
|
||||
const result = await window.obelisk.browseFolder();
|
||||
@@ -168,6 +176,25 @@ function fmtRelative(iso) {
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<!-- Editor -->
|
||||
<section class="settings-section">
|
||||
<div class="settings-section-head">
|
||||
<h2>Editor</h2>
|
||||
<p>Which editor opens file references found in session transcripts.</p>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div>
|
||||
<div class="form-label">Editor URL scheme</div>
|
||||
<div class="form-label-hint">Clicking <code>src/app.ts:42</code> jumps to that line.</div>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<select class="form-input" :value="editorScheme" @change="saveEditorScheme($event.target.value)">
|
||||
<option v-for="opt in editorSchemes" :key="opt" :value="opt">{{ opt }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Recap -->
|
||||
<section class="settings-section">
|
||||
<div class="settings-section-head">
|
||||
|
||||
@@ -37,6 +37,10 @@ async function handleLoadFull(uuid, el) {
|
||||
el.remove();
|
||||
}
|
||||
}
|
||||
|
||||
function renderMsgMarkdown(msg, text, variant) {
|
||||
return renderMarkdown(text, { variant, cwd: msg?.cwd || null, sessionId: msg?.session_id || null });
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -69,7 +73,7 @@ async function handleLoadFull(uuid, el) {
|
||||
<svg class="chevron" viewBox="0 0 8 8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M2.5 1.5l3 2.5-3 2.5"/></svg>
|
||||
<span class="thinking-label">Thinking</span>
|
||||
</button>
|
||||
<div class="thinking-body" v-html="renderMarkdown(msg.text, { variant: 'msg' })"></div>
|
||||
<div class="thinking-body" v-html="renderMsgMarkdown(msg, msg.text, 'msg')"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -81,7 +85,7 @@ async function handleLoadFull(uuid, el) {
|
||||
<span class="meta-label">System</span>
|
||||
<span class="meta-preview">{{ (msg.text || '').replace(/<[^>]+>/g, '').slice(0, 80) }}</span>
|
||||
</button>
|
||||
<div class="meta-body" v-html="renderMarkdown(msg.text, { variant: 'compact' })"></div>
|
||||
<div class="meta-body" v-html="renderMsgMarkdown(msg, msg.text, 'compact')"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -96,9 +100,9 @@ async function handleLoadFull(uuid, el) {
|
||||
<svg class="chevron" viewBox="0 0 8 8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M2.5 1.5l3 2.5-3 2.5"/></svg>
|
||||
<span class="thinking-label">Thinking</span>
|
||||
</button>
|
||||
<div class="thinking-body" v-html="renderMarkdown(msg._thinking, { variant: 'msg' })"></div>
|
||||
<div class="thinking-body" v-html="renderMsgMarkdown(msg, msg._thinking, 'msg')"></div>
|
||||
</div>
|
||||
<div v-if="msg.text" v-html="renderMarkdown(msg.text, { variant: 'msg' })"></div>
|
||||
<div v-if="msg.text" v-html="renderMsgMarkdown(msg, msg.text, 'msg')"></div>
|
||||
<div v-else-if="!msg.tool_calls?.length" class="msg-text empty-text">(no text content)</div>
|
||||
<button
|
||||
v-if="isTextTruncated(msg.text)"
|
||||
|
||||
@@ -1356,3 +1356,12 @@
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.msg-nav-pos .msg-nav-current { color: var(--fg-2); font-weight: 500; }
|
||||
|
||||
.file-ref {
|
||||
color: var(--accent-2); cursor: pointer;
|
||||
text-decoration: underline; text-decoration-color: var(--accent-soft);
|
||||
text-underline-offset: 3px; transition: all 0.1s;
|
||||
}
|
||||
.file-ref:hover { color: var(--accent-2); text-decoration-color: var(--accent-2); }
|
||||
code > .file-ref { color: inherit; text-decoration-color: var(--muted-2); }
|
||||
code > .file-ref:hover { color: var(--accent-2); text-decoration-color: var(--accent-2); }
|
||||
|
||||
Reference in New Issue
Block a user