feat(trace-workbench): reveal the session's jsonl in the file manager
POST /api/sessions/:id/reveal resolves the file server-side (execFile, argv array — no shell) and opens the platform file manager; the header button shows the full path on hover.
This commit is contained in:
@@ -37,3 +37,4 @@ Current UI capabilities:
|
|||||||
- Failed tool calls are marked in every view (red rail + `← error` chip in Trajectory, red bar in Waterfall, `Tool failed` in Chat).
|
- Failed tool calls are marked in every view (red rail + `← error` chip in Trajectory, red bar in Waterfall, `Tool failed` in Chat).
|
||||||
- Panes resize by dragging the dividers (clamped; widths persist), and all panes squeeze each other in one layer.
|
- Panes resize by dragging the dividers (clamped; widths persist), and all panes squeeze each other in one layer.
|
||||||
- URL carries `?session=&view=&sel=` so any selection is a shareable deep link.
|
- URL carries `?session=&view=&sel=` so any selection is a shareable deep link.
|
||||||
|
- The header's 「在 Finder 中显示」 button reveals the current session's `.jsonl` in the OS file manager (`open -R` / `explorer /select` / `xdg-open`).
|
||||||
|
|||||||
@@ -442,6 +442,8 @@ function renderChrome() {
|
|||||||
? `<button class="crumb" data-load-session="${escapeHtml(session.parent.id)}" title="Open the session that spawned this one">↳ spawned by: ${escapeHtml(truncate(session.parent.title ?? session.parent.id, 48))}</button>`
|
? `<button class="crumb" data-load-session="${escapeHtml(session.parent.id)}" title="Open the session that spawned this one">↳ spawned by: ${escapeHtml(truncate(session.parent.title ?? session.parent.id, 48))}</button>`
|
||||||
: ''
|
: ''
|
||||||
}
|
}
|
||||||
|
const reveal = $('#revealSession')
|
||||||
|
if (reveal) reveal.title = session.header.path ?? ''
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Trajectory: step-grouped rows + navigation tree ─────────────────────────
|
/* ── Trajectory: step-grouped rows + navigation tree ─────────────────────────
|
||||||
@@ -1510,6 +1512,12 @@ document.addEventListener('click', async (event) => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (event.target.closest('#revealSession')) {
|
||||||
|
const result = await postJson(`/api/sessions/${encodeURIComponent(state.session.header.id)}/reveal`, {})
|
||||||
|
toast(result.ok ? '已在文件管理器中显示' : result.error ?? '无法定位文件')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (event.target.closest('#newSessionButton')) {
|
if (event.target.closest('#newSessionButton')) {
|
||||||
const result = await postJson('/api/sessions', {})
|
const result = await postJson('/api/sessions', {})
|
||||||
toast(result.error ?? 'New session created')
|
toast(result.error ?? 'New session created')
|
||||||
|
|||||||
@@ -36,6 +36,7 @@
|
|||||||
<span class="replay-pill">replay</span>
|
<span class="replay-pill">replay</span>
|
||||||
<strong id="conversationTitle">Loading session...</strong>
|
<strong id="conversationTitle">Loading session...</strong>
|
||||||
<span id="sessionCrumb"></span>
|
<span id="sessionCrumb"></span>
|
||||||
|
<button id="revealSession" class="reveal-btn" type="button" title="">在 Finder 中显示</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="segmented large">
|
<div class="segmented large">
|
||||||
<button class="active" data-main-view="conversation">Chat</button>
|
<button class="active" data-main-view="conversation">Chat</button>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
|
const { execFile } = require('node:child_process')
|
||||||
const fs = require('node:fs')
|
const fs = require('node:fs')
|
||||||
const http = require('node:http')
|
const http = require('node:http')
|
||||||
const path = require('node:path')
|
const path = require('node:path')
|
||||||
@@ -167,6 +168,15 @@ function summarize(file) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reveal a file in the OS file manager. The path always comes from
|
||||||
|
// findSessionFile (never from the request), and execFile takes an argv array —
|
||||||
|
// no shell, no injection surface.
|
||||||
|
function revealInFileManager(file) {
|
||||||
|
if (process.platform === 'darwin') execFile('open', ['-R', file], () => {})
|
||||||
|
else if (process.platform === 'win32') execFile('explorer', [`/select,${file}`], () => {})
|
||||||
|
else execFile('xdg-open', [path.dirname(file)], () => {})
|
||||||
|
}
|
||||||
|
|
||||||
function findSessionFile(id) {
|
function findSessionFile(id) {
|
||||||
const files = walkJsonl(SESSIONS_ROOT)
|
const files = walkJsonl(SESSIONS_ROOT)
|
||||||
const byName = files.find(file => path.basename(file, '.jsonl') === id)
|
const byName = files.find(file => path.basename(file, '.jsonl') === id)
|
||||||
@@ -433,6 +443,13 @@ const server = http.createServer(async (req, res) => {
|
|||||||
if (req.method === 'GET') return send(res, 200, { feedback: readFeedback(id) })
|
if (req.method === 'GET') return send(res, 200, { feedback: readFeedback(id) })
|
||||||
if (req.method === 'POST') return send(res, 200, { feedback: appendFeedback(id, await readJsonBody(req)) })
|
if (req.method === 'POST') return send(res, 200, { feedback: appendFeedback(id, await readJsonBody(req)) })
|
||||||
}
|
}
|
||||||
|
if (req.method === 'POST' && suffix.endsWith('/reveal')) {
|
||||||
|
const id = suffix.slice(0, -'/reveal'.length)
|
||||||
|
const file = findSessionFile(id)
|
||||||
|
if (!file) return send(res, 404, { error: `session not found: ${id}` })
|
||||||
|
revealInFileManager(file)
|
||||||
|
return send(res, 200, { ok: true, path: file })
|
||||||
|
}
|
||||||
if (req.method === 'POST' && suffix.endsWith('/messages')) {
|
if (req.method === 'POST' && suffix.endsWith('/messages')) {
|
||||||
return send(res, 501, {
|
return send(res, 501, {
|
||||||
error: 'Live interaction is not connected yet. This prototype is reading persisted JSONL replay data.',
|
error: 'Live interaction is not connected yet. This prototype is reading persisted JSONL replay data.',
|
||||||
|
|||||||
@@ -790,6 +790,23 @@ h4 {
|
|||||||
color: #2456b3;
|
color: #2456b3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.reveal-btn {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 3px 10px;
|
||||||
|
background: #fff;
|
||||||
|
color: #4c586c;
|
||||||
|
font-size: 11px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reveal-btn:hover {
|
||||||
|
border-color: #b7d5fb;
|
||||||
|
background: var(--blue-soft);
|
||||||
|
color: #2456b3;
|
||||||
|
}
|
||||||
|
|
||||||
.metrics {
|
.metrics {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||||
|
|||||||
Reference in New Issue
Block a user