feat(app): live session update + tool renderer + input_tokens migration

SessionDetail live update:
- Extract session-view-state.mjs: capture scroll position, disclosure
  (open/skill-md-open) state, and visible-UUID anchor before refresh;
  reconcile messages by UUID (in-place update, append tail only); restore
  scroll and disclosure state after DOM patch. findLastMessageAtOrAbove uses
  binary search (O(log n)) instead of linear scan.
- scrollRevision tracks user scrolls during refresh to avoid stale anchors
  overriding manual navigation.
- Throttle onScroll to one rAF per frame.

Tool renderer:
- Extract tool-renderer.js: standalone module for rendering tool call cards
  (Read/Write/Edit diffs, Bash terminal output, search results, JS/TS
  syntax highlighting). Replaces inline rendering in SessionDetail.
- tests/app-tool-renderer.test.mjs covers escaping, highlighting, and
  terminal formatting.

Input tokens semantics migration:
- Claude provider now sums input_tokens + cache_creation_input_tokens +
  cache_read_input_tokens into a single input_tokens value (was previously
  only the raw field, undercounting when cache tokens are present).
- One-time index-wide re-parse triggered when the marker
  __claude_input_tokens_include_cache_v1__ is absent and the DB already
  has token data (self-healing on first build after upgrade).
- App indexer.ts carries the same marker check for the app's build path.

Also:
- PRODUCT.md: product register (users, purpose, brand, design principles,
  accessibility targets).
- README.md: minor wording updates.

Co-Authored-By: Codex (GPT-5) <noreply@openai.com>
This commit is contained in:
tommy0103
2026-07-13 21:02:38 +08:00
co-authored by Codex
parent 78aacfac6c
commit a9687ba8b7
15 changed files with 1187 additions and 97 deletions
+108
View File
@@ -0,0 +1,108 @@
const DISCLOSURE_CLASSES = ['open', 'skill-md-open'];
const SCROLL_ITEM_SELECTOR = '.msg[data-uuid], .wf-card[data-uuid], .skill-card[data-uuid]';
function arrayFrom(value) {
return value ? Array.from(value) : [];
}
function scrollItems(detail) {
return arrayFrom(detail?.querySelectorAll?.(SCROLL_ITEM_SELECTOR));
}
export function captureSessionViewState({ wrap, detail, bottomThreshold = 50 } = {}) {
if (!wrap) return null;
const distanceFromBottom = wrap.scrollHeight - wrap.scrollTop - wrap.clientHeight;
const followTail = distanceFromBottom < bottomThreshold;
const wrapTop = wrap.getBoundingClientRect?.().top || 0;
const anchorElement = followTail
? null
: scrollItems(detail).find(element => element.getBoundingClientRect().bottom > wrapTop);
const disclosures = [];
for (const element of arrayFrom(detail?.querySelectorAll?.('[data-view-key]'))) {
const key = element.dataset?.viewKey;
if (!key) continue;
const classes = DISCLOSURE_CLASSES.filter(className => element.classList?.contains(className));
const rawOpen = Boolean(element.querySelector?.('.toolcall-raw')?.classList?.contains('show'));
if (classes.length || rawOpen) disclosures.push({ key, classes, rawOpen });
}
return {
followTail,
scrollTop: wrap.scrollTop,
anchor: anchorElement?.dataset?.uuid
? {
uuid: anchorElement.dataset.uuid,
offset: anchorElement.getBoundingClientRect().top - wrapTop,
}
: null,
disclosures,
};
}
export function restoreSessionViewState(snapshot, { wrap, detail, restoreScroll = true } = {}) {
if (!snapshot || !wrap) return;
const disclosuresByKey = new Map(
arrayFrom(detail?.querySelectorAll?.('[data-view-key]'))
.filter(element => element.dataset?.viewKey)
.map(element => [element.dataset.viewKey, element]),
);
for (const disclosure of snapshot.disclosures || []) {
const element = disclosuresByKey.get(disclosure.key);
if (!element) continue;
element.classList?.add(...disclosure.classes);
if (!disclosure.rawOpen) continue;
element.querySelector?.('.toolcall-raw')?.classList?.add('show');
element.querySelector?.('.toolcall-pretty')?.classList?.add('hidden');
element.querySelector?.('.raw-toggle')?.classList?.add('active');
}
if (!restoreScroll) return;
if (snapshot.followTail) {
wrap.scrollTop = wrap.scrollHeight;
return;
}
wrap.scrollTop = snapshot.scrollTop;
if (!snapshot.anchor) return;
const wrapTop = wrap.getBoundingClientRect?.().top || 0;
const anchorElement = scrollItems(detail).find(
element => element.dataset?.uuid === snapshot.anchor.uuid,
);
if (!anchorElement) return;
const currentOffset = anchorElement.getBoundingClientRect().top - wrapTop;
wrap.scrollTop += currentOffset - snapshot.anchor.offset;
}
export function reconcileSessionMessages(current = [], incoming = []) {
const currentByUuid = new Map(
current.filter(message => message?.uuid).map(message => [message.uuid, message]),
);
return incoming.map(message => {
if (!message?.uuid) return message;
const existing = currentByUuid.get(message.uuid);
if (!existing) return message;
Object.assign(existing, message);
return existing;
});
}
export function findLastMessageAtOrAbove(messages, bottomLine) {
if (!messages?.length) return -1;
let low = 0;
let high = messages.length - 1;
let result = 0;
while (low <= high) {
const middle = Math.floor((low + high) / 2);
if (messages[middle].getBoundingClientRect().bottom <= bottomLine) {
result = middle;
low = middle + 1;
} else {
high = middle - 1;
}
}
return result;
}
+402
View File
@@ -0,0 +1,402 @@
function escapeHTML(value) {
return String(value).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
const JAVASCRIPT_KEYWORDS = new Set([
'as', 'async', 'await', 'break', 'case', 'catch', 'class', 'const', 'continue',
'debugger', 'default', 'delete', 'do', 'else', 'export', 'extends', 'finally',
'for', 'from', 'function', 'get', 'if', 'implements', 'import', 'in',
'instanceof', 'interface', 'let', 'new', 'of', 'package', 'private', 'protected',
'public', 'return', 'set', 'static', 'super', 'switch', 'throw', 'try', 'typeof',
'var', 'void', 'while', 'with', 'yield',
]);
const JAVASCRIPT_LITERALS = new Set(['false', 'Infinity', 'NaN', 'null', 'true', 'undefined']);
const CODEACT_GLOBALS = new Set([
'ALL_TOOLS', 'Array', 'Boolean', 'Date', 'Error', 'JSON', 'Map', 'Math', 'Number',
'Object', 'Promise', 'RegExp', 'Set', 'String', 'clearTimeout', 'generatedImage',
'image', 'load', 'notify', 'setTimeout', 'store', 'text', 'tools', 'yield_control',
]);
function isIdentifierStart(char) {
return /[A-Za-z_$]/.test(char);
}
function isIdentifierPart(char) {
return /[\w$]/.test(char);
}
function highlightJavaScript(source) {
const code = String(source);
let html = '';
let plain = '';
let index = 0;
const flushPlain = () => {
if (!plain) return;
html += escapeHTML(plain);
plain = '';
};
const token = (kind, value) => {
flushPlain();
html += `<span class="codeact-token ${kind}">${escapeHTML(value)}</span>`;
};
while (index < code.length) {
const char = code[index];
const next = code[index + 1];
if (char === '/' && next === '/') {
const start = index;
index += 2;
while (index < code.length && code[index] !== '\n') index += 1;
token('comment', code.slice(start, index));
continue;
}
if (char === '/' && next === '*') {
const start = index;
index += 2;
while (index < code.length && !(code[index] === '*' && code[index + 1] === '/')) index += 1;
if (index < code.length) index += 2;
token('comment', code.slice(start, index));
continue;
}
if (char === '"' || char === "'" || char === '`') {
const start = index;
const quote = char;
index += 1;
while (index < code.length) {
if (code[index] === '\\') {
index = Math.min(index + 2, code.length);
continue;
}
if (code[index] === quote) {
index += 1;
break;
}
index += 1;
}
token('string', code.slice(start, index));
continue;
}
if (/\d/.test(char) || (char === '.' && /\d/.test(next))) {
const match = code.slice(index).match(/^(?:0[xX][\dA-Fa-f](?:_?[\dA-Fa-f])*n?|0[bB][01](?:_?[01])*n?|0[oO][0-7](?:_?[0-7])*n?|(?:\d(?:_?\d)*)?(?:\.\d(?:_?\d)*)?(?:[eE][+-]?\d(?:_?\d)*)?n?)/);
const value = match?.[0];
if (value) {
token('number', value);
index += value.length;
continue;
}
}
if (isIdentifierStart(char)) {
const start = index;
index += 1;
while (index < code.length && isIdentifierPart(code[index])) index += 1;
const value = code.slice(start, index);
if (JAVASCRIPT_KEYWORDS.has(value)) token('keyword', value);
else if (JAVASCRIPT_LITERALS.has(value)) token('literal', value);
else if (CODEACT_GLOBALS.has(value)) token('global', value);
else plain += value;
continue;
}
plain += char;
index += 1;
}
flushPlain();
return html;
}
const TERMINAL_ICON = '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round"><rect x="2" y="3" width="12" height="10" rx="1.2"/><path d="M5 7l2 1.5-2 1.5M8.5 10.5h2.5"/></svg>';
const TOOL_ICONS = {
Bash: TERMINAL_ICON,
exec: TERMINAL_ICON,
Read: '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round"><path d="M3.5 2h6l3 3v9a1 1 0 0 1-1 1h-8a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1z"/><path d="M9.5 2v3h3"/></svg>',
Edit: '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round"><path d="M3.5 2h6l3 3v9a1 1 0 0 1-1 1h-8a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1z"/><path d="M9.5 2v3h3"/><path d="M6 10l4-4M6.5 10.5l-1.2 1.4 1.4-1.2"/></svg>',
Write: '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round"><path d="M3.5 2h6l3 3v9a1 1 0 0 1-1 1h-8a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1z"/><path d="M9.5 2v3h3"/><path d="M6 9.5h4M6 11.5h2.5"/></svg>',
};
export function getToolIcon(name) {
return TOOL_ICONS[name] || '';
}
export function getArgPreview(toolCall) {
try {
const input = JSON.parse(toolCall.input_json || '{}');
if (typeof input === 'string') return input.slice(0, 90);
if (input.file_path) return input.file_path;
if (input.command) return input.command;
if (input.path) return input.path;
if (input.query) return input.query;
if (input.description) return input.description;
if (input.pattern) return input.pattern;
if (input.url) return input.url;
if (input.name) return input.name;
if (input.title) return input.title;
for (const key of Object.keys(input)) {
if (typeof input[key] === 'string' && input[key].length < 90) return input[key];
}
return JSON.stringify(input).slice(0, 90);
} catch {
return (toolCall.input_json || '').slice(0, 90);
}
}
function renderTerminal(command, output, isError) {
let formatted = escapeHTML(output);
formatted = formatted.replace(/(✓[^\n]*)/g, '<span style="color:#4ade80">$1</span>');
formatted = formatted.replace(/(✗[^\n]*|FAIL[^\n]*|Error:[^\n]*)/g, '<span style="color:#f87171">$1</span>');
return `<div class="terminal-view">
<div class="terminal-prompt-line"><span class="prompt-marker">$</span><span class="prompt-cmd">${escapeHTML(command)}</span></div>
${output ? `<div class="terminal-divider"></div><div class="terminal-output ${isError ? 'is-error' : ''}">${formatted}</div>` : ''}
</div>`;
}
function decodeJsonStringPrefix(source, start) {
let value = '';
let index = start;
let complete = false;
while (index < source.length) {
const char = source[index++];
if (char === '"') {
complete = true;
break;
}
if (char !== '\\') {
value += char;
continue;
}
if (index >= source.length) break;
const escaped = source[index++];
if (escaped === 'n') value += '\n';
else if (escaped === 'r') value += '\r';
else if (escaped === 't') value += '\t';
else if (escaped === 'b') value += '\b';
else if (escaped === 'f') value += '\f';
else if (escaped === 'u') {
const hex = source.slice(index, index + 4);
if (/^[0-9a-fA-F]{4}$/.test(hex)) {
value += String.fromCharCode(Number.parseInt(hex, 16));
index += 4;
}
} else {
value += escaped;
}
}
return { value, next: index, complete };
}
function extractInputTextBlocks(raw) {
try {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) {
const texts = parsed
.filter(item => item?.type === 'input_text' && typeof item.text === 'string')
.map(item => item.text);
if (texts.length) {
return {
texts,
unwrapped: true,
truncated: false,
hasOtherBlocks: texts.length !== parsed.length,
};
}
}
} catch {}
const marker = '"text":"';
const texts = [];
let cursor = 0;
while (raw.includes('"type":"input_text"', cursor)) {
const markerIndex = raw.indexOf(marker, cursor);
if (markerIndex === -1) break;
const decoded = decodeJsonStringPrefix(raw, markerIndex + marker.length);
texts.push(decoded.value);
cursor = Math.max(decoded.next, markerIndex + marker.length);
if (!decoded.complete) break;
}
if (texts.length) {
return { texts, unwrapped: true, truncated: true, hasOtherBlocks: false };
}
return { texts: [raw], unwrapped: false, truncated: false, hasOtherBlocks: false };
}
function parseScriptHeader(text, isError) {
const match = String(text).match(/^Script (completed|failed|running)(?: with cell ID ([^\n]+))?\nWall time ([^\n]+)\nOutput:\n?/);
if (!match) {
return {
status: isError ? 'failed' : 'complete',
cellId: null,
rest: String(text),
matched: false,
};
}
return {
status: match[1] === 'completed' ? 'complete' : match[1],
cellId: match[2] || null,
rest: String(text).slice(match[0].length),
matched: true,
};
}
function tryFormatJson(text) {
const trimmed = text.trim();
if (!trimmed) return null;
try {
return JSON.stringify(JSON.parse(trimmed), null, 2);
} catch {
return null;
}
}
function highlightJson(json) {
let html = '';
let plain = '';
let index = 0;
const flushPlain = () => {
if (!plain) return;
html += escapeHTML(plain);
plain = '';
};
const token = (kind, value) => {
flushPlain();
html += `<span class="codeact-json-token ${kind}">${escapeHTML(value)}</span>`;
};
while (index < json.length) {
const char = json[index];
if (char === '"') {
const start = index;
index += 1;
while (index < json.length) {
if (json[index] === '\\') {
index = Math.min(index + 2, json.length);
continue;
}
if (json[index] === '"') {
index += 1;
break;
}
index += 1;
}
let lookahead = index;
while (/\s/.test(json[lookahead])) lookahead += 1;
token(json[lookahead] === ':' ? 'key' : 'string', json.slice(start, index));
continue;
}
if (char === '-' || /\d/.test(char)) {
const match = json.slice(index).match(/^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/);
if (match) {
token('number', match[0]);
index += match[0].length;
continue;
}
}
const literal = ['true', 'false', 'null'].find(value => json.startsWith(value, index));
if (literal) {
token('literal', literal);
index += literal.length;
continue;
}
plain += char;
index += 1;
}
flushPlain();
return html;
}
function formatResultBlocks(blocks) {
return blocks.filter(block => block !== '').map(block => {
const formatted = tryFormatJson(block);
return formatted === null
? { text: block, html: escapeHTML(block), isJson: false }
: { text: formatted, html: highlightJson(formatted), isJson: true };
});
}
function decodeCodeActOutput(raw, isError) {
const extracted = extractInputTextBlocks(String(raw || ''));
const first = extracted.texts[0] || '';
const header = parseScriptHeader(first, isError);
const bodyBlocks = header.matched
? [header.rest, ...extracted.texts.slice(1)]
: extracted.texts;
return {
...header,
blocks: formatResultBlocks(bodyBlocks),
truncated: extracted.truncated || String(raw || '').length >= 10000,
hasOtherBlocks: extracted.hasOtherBlocks,
};
}
function renderCodeAct(source, output, isError) {
const code = String(source || '');
const lines = code.split('\n');
const gutter = lines.map((_, index) => index + 1).join('\n');
const result = decodeCodeActOutput(output, isError);
const statusLabel = result.status === 'failed' ? 'Failed' : 'Running';
const emptyText = result.status === 'running'
? 'Execution was still running when this event was captured.'
: result.status === 'failed'
? 'No failure details were captured.'
: 'No result returned.';
const cell = result.cellId ? `<span class="codeact-cell">Cell ${escapeHTML(result.cellId)}</span>` : '';
const status = result.status === 'complete'
? ''
: `<span class="codeact-status"><span class="codeact-status-dot" aria-hidden="true"></span>${statusLabel}</span>`;
const metadata = status || cell
? `<div class="codeact-result-meta">${status}${cell}</div>`
: '';
const notes = [
result.truncated ? '<div class="codeact-note">Indexed output truncated. Open Raw to inspect the captured envelope.</div>' : '',
result.hasOtherBlocks ? '<div class="codeact-note">Additional structured blocks are available in Raw.</div>' : '',
].join('');
const resultContent = result.blocks.length
? `<div class="codeact-result" tabindex="0" role="list" aria-label="CodeAct result">${result.blocks.map((block, index) => `
<pre class="codeact-result-block ${block.isJson ? 'is-json' : ''}" role="listitem" aria-label="Result block ${index + 1}">${block.html}</pre>`).join('')}
</div>`
: `<div class="codeact-result is-empty" tabindex="0" aria-label="CodeAct result">${escapeHTML(emptyText)}</div>`;
return `<div class="codeact-view is-${result.status}" role="group" aria-label="CodeAct execution">
<section class="codeact-section codeact-source-section">
<div class="codeact-section-head">
<span class="codeact-section-label">Source</span>
</div>
<div class="codeact-code-frame" tabindex="0" aria-label="CodeAct source">
<pre class="codeact-gutter" aria-hidden="true">${gutter}</pre>
<pre class="codeact-code"><code>${highlightJavaScript(code)}</code></pre>
</div>
</section>
<section class="codeact-section codeact-result-section">
<div class="codeact-section-head">
<span class="codeact-section-label">Result</span>
${metadata}
</div>
${resultContent}
${notes}
</section>
</div>`;
}
export function renderTerminalTool(name, input, output, isError) {
if (name === 'Bash') {
const description = input?.description
? `<div style="font-size:11.5px;color:var(--muted);margin-bottom:8px;">${escapeHTML(input.description)}</div>`
: '';
return description + renderTerminal(input?.command || '', output, isError);
}
if (name === 'exec') {
return renderCodeAct(typeof input === 'string' ? input : '', output, isError);
}
return null;
}
+62 -79
View File
@@ -4,7 +4,14 @@ 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';
import { getArgPreview, getToolIcon, renderTerminalTool } from '../tool-renderer.js';
import FlapNumber from '../components/FlapNumber.vue';
import {
captureSessionViewState,
findLastMessageAtOrAbove,
reconcileSessionMessages,
restoreSessionViewState,
} from '../session-view-state.mjs';
import {
escapeHTML,
fmtRelative,
@@ -27,6 +34,7 @@ const progressPct = ref(0);
const active = ref(false);
let removeSessionUpdated = null;
let keydownAttached = false;
let scrollRevision = 0;
// DOM refs
const wrapRef = ref(null);
@@ -119,6 +127,8 @@ onDeactivated(() => {
onUnmounted(() => {
active.value = false;
detachKeydown();
if (scrollFrame !== null) cancelAnimationFrame(scrollFrame);
scrollFrame = null;
removeSessionUpdated?.();
removeSessionUpdated = null;
});
@@ -135,33 +145,36 @@ watch(() => props.id, async (newId, oldId) => {
async function loadMessages({ force = false } = {}) {
if (!props.id) return;
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 viewState = hadContent
? captureSessionViewState({ wrap: wrapRef.value, detail: detailRef.value })
: null;
const scrollRevisionAtLoad = scrollRevision;
loading.value = true;
loading.value = !hadContent;
try {
const s = state.sessions.find(x => x.id === props.id);
if (s && (force || !s.messages || s.messages.length === 0)) {
const loaded = await loadSessionDetail(props.id);
if (loaded) Object.assign(s, loaded);
await loadSessionDetail(props.id);
}
const latest = state.sessions.find(x => x.id === props.id);
messages.value = latest?.messages || [];
const incoming = latest?.messages || [];
messages.value = hadContent
? reconcileSessionMessages(messages.value, incoming)
: incoming;
} finally {
loading.value = false;
}
nextTick(() => {
if (!wrapRef.value) return;
if (!state.pendingFocusUuid) {
if (wasAtBottom) {
wrapRef.value.scrollTop = wrapRef.value.scrollHeight;
} else {
wrapRef.value.scrollTop = prevScrollTop;
}
}
await nextTick();
syncTotalMessages();
if (!state.pendingFocusUuid) {
restoreSessionViewState(viewState, {
wrap: wrapRef.value,
detail: detailRef.value,
restoreScroll: scrollRevision === scrollRevisionAtLoad,
});
onScroll();
});
}
// Focus pending uuid if any
if (state.pendingFocusUuid) {
@@ -192,22 +205,36 @@ async function focusPendingMessage() {
const currentMsgIdx = ref(0);
const totalMsgs = ref(0);
let navLock = false;
let scrollFrame = null;
function onScroll() {
function syncTotalMessages() {
const msgs = detailRef.value?.querySelectorAll('.msg, .wf-card, .skill-card');
totalMsgs.value = msgs?.length || 0;
}
function onScroll(event) {
if (event) scrollRevision++;
if (navLock) return;
if (scrollFrame !== null) return;
scrollFrame = requestAnimationFrame(() => {
scrollFrame = null;
updateScrollProgress();
});
}
function updateScrollProgress() {
if (!wrapRef.value || !detailRef.value) return;
const msgs = detailRef.value.querySelectorAll('.msg, .wf-card, .skill-card');
if (!msgs.length) return;
totalMsgs.value = msgs.length;
if (!msgs.length) {
currentMsgIdx.value = 0;
progressPct.value = 0;
return;
}
const el = wrapRef.value;
const navHeight = 52;
const bottomLine = el.getBoundingClientRect().bottom - navHeight;
let bottomMsgIdx = 0;
for (let i = 0; i < msgs.length; i++) {
if (msgs[i].getBoundingClientRect().bottom <= bottomLine) bottomMsgIdx = i;
else break;
}
const bottomMsgIdx = findLastMessageAtOrAbove(msgs, bottomLine);
currentMsgIdx.value = bottomMsgIdx;
const pct = msgs.length <= 1 ? 100 : Math.round((bottomMsgIdx / (msgs.length - 1)) * 100);
progressPct.value = pct;
@@ -283,27 +310,6 @@ function navigateToSubagent(agentId, description) {
// --- Render helpers (produce raw HTML strings like the vanilla version) ---
function getArgPreview(tc) {
try {
const j = JSON.parse(tc.input_json || '{}');
if (j.file_path) return j.file_path;
if (j.command) return j.command;
if (j.path) return j.path;
if (j.query) return j.query;
if (j.description) return j.description;
if (j.pattern) return j.pattern;
if (j.url) return j.url;
if (j.name) return j.name;
if (j.title) return j.title;
for (const k of Object.keys(j)) {
if (typeof j[k] === 'string' && j[k].length < 90) return j[k];
}
return JSON.stringify(j).slice(0, 90);
} catch {
return (tc.input_json || '').slice(0, 90);
}
}
function formatToolInput(tc) {
try {
const j = JSON.parse(tc.input_json || '{}');
@@ -317,17 +323,6 @@ function escapeH(s) {
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
const TOOL_ICONS = {
Bash: '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round"><rect x="2" y="3" width="12" height="10" rx="1.2"/><path d="M5 7l2 1.5-2 1.5M8.5 10.5h2.5"/></svg>',
Read: '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round"><path d="M3.5 2h6l3 3v9a1 1 0 0 1-1 1h-8a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1z"/><path d="M9.5 2v3h3"/></svg>',
Edit: '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round"><path d="M3.5 2h6l3 3v9a1 1 0 0 1-1 1h-8a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1z"/><path d="M9.5 2v3h3"/><path d="M6 10l4-4M6.5 10.5l-1.2 1.4 1.4-1.2"/></svg>',
Write: '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round"><path d="M3.5 2h6l3 3v9a1 1 0 0 1-1 1h-8a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1z"/><path d="M9.5 2v3h3"/><path d="M6 9.5h4M6 11.5h2.5"/></svg>',
};
function getToolIcon(name) {
return TOOL_ICONS[name] || '';
}
function renderPrettyTool(tc) {
let args;
try { args = JSON.parse(tc.input_json || '{}'); } catch { args = {}; }
@@ -367,10 +362,8 @@ function renderPrettyTool(tc) {
return diff + chip;
}
if (tc.name === 'Bash') {
const desc = args.description ? `<div style="font-size:11.5px;color:var(--muted);margin-bottom:8px;">${escapeH(args.description)}</div>` : '';
return desc + renderTerminal(args.command || '', out, isError);
}
const terminal = renderTerminalTool(tc.name, args, out, isError);
if (terminal !== null) return terminal;
return `<div class="body-section"><div class="body-label">Input</div>${renderFieldGrid(args)}</div>` +
(out ? `<div class="body-section" style="margin-top:12px;"><div class="body-label">Output</div>${renderOutput(out, isError)}</div>` : '');
@@ -431,16 +424,6 @@ function renderDiff(oldStr, newStr) {
</div>`;
}
function renderTerminal(command, output, isError) {
let formatted = escapeH(output);
formatted = formatted.replace(/(✓[^\n]*)/g, '<span style="color:#4ade80">$1</span>');
formatted = formatted.replace(/(✗[^\n]*|FAIL[^\n]*|Error:[^\n]*)/g, '<span style="color:#f87171">$1</span>');
return `<div class="terminal-view">
<div class="terminal-prompt-line"><span class="prompt-marker">$</span><span class="prompt-cmd">${escapeH(command)}</span></div>
${output ? `<div class="terminal-divider"></div><div class="terminal-output ${isError ? 'is-error' : ''}">${formatted}</div>` : ''}
</div>`;
}
function renderFieldGrid(obj) {
const entries = Object.entries(obj);
if (!entries.length) return '';
@@ -642,7 +625,7 @@ function getToolCallParsedInput(tc) {
<!-- Meta messages: collapsed system indicator -->
<template v-if="msg.is_meta === 1">
<div class="msg meta" :data-uuid="msg.uuid">
<div class="msg-meta-collapsed">
<div class="msg-meta-collapsed" :data-view-key="`meta:${msg.uuid}`">
<button class="meta-toggle" @click="toggleMeta">
<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="meta-label">System</span>
@@ -708,7 +691,7 @@ function getToolCallParsedInput(tc) {
<div class="msg-tools">
<template v-for="tc in (msg.tool_calls || []).filter(tc2 => !(tc2.name === 'Workflow' && tc2.workflow))" :key="tc.id">
<!-- Render non-workflow tool calls -->
<div class="msg-tool" :class="{ 'is-error': tc.result && tc.result.is_error }">
<div class="msg-tool" :class="{ 'is-error': tc.result && tc.result.is_error }" :data-view-key="`tool:${tc.id}`">
<button class="toolcall-toggle" @click="toggleToolCall">
<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 v-if="getToolIcon(tc.name)" class="tool-icon" v-html="getToolIcon(tc.name)"></span>
@@ -742,7 +725,7 @@ function getToolCallParsedInput(tc) {
<!-- Skill card (standalone, like workflow) -->
<template v-else-if="msg.type === 'assistant' && (msg.tool_calls || []).length === 1 && msg.tool_calls[0].name === 'Skill' && !msg.text">
<div class="skill-card" :data-uuid="msg.uuid">
<div class="skill-card" :data-uuid="msg.uuid" :data-view-key="`skill:${msg.uuid}`">
<div class="skill-card-icon">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round" stroke-linecap="round"><rect x="2" y="3" width="12" height="10" rx="1.5"/><path d="M5 6.5h6M5 9h4"/></svg>
</div>
@@ -766,7 +749,7 @@ function getToolCallParsedInput(tc) {
<!-- Standalone thinking message -->
<template v-else-if="msg.type === 'assistant' && msg.content_type === 'thinking'">
<div class="msg assistant" :data-uuid="msg.uuid">
<div class="msg-thinking">
<div class="msg-thinking" :data-view-key="`thinking:${msg.uuid}`">
<button class="thinking-toggle" @click="toggleThinking">
<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>
@@ -790,7 +773,7 @@ function getToolCallParsedInput(tc) {
</div>
<!-- Attached thinking block (merged from preceding thinking messages) -->
<div v-if="msg._thinking" class="msg-thinking">
<div v-if="msg._thinking" class="msg-thinking" :data-view-key="`thinking:${msg.uuid}`">
<button class="thinking-toggle" @click="toggleThinking">
<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>
@@ -825,7 +808,7 @@ function getToolCallParsedInput(tc) {
<!-- Agent/Task tool call (subagent) -->
<template v-else-if="tc.name === 'Agent' || tc.name === 'Task'">
<div class="msg-tool agent-call">
<div class="msg-tool agent-call" :data-view-key="`tool:${tc.id}`">
<button class="toolcall-toggle" @click="toggleToolCall">
<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="tool-name">{{ getToolCallParsedInput(tc).subagent_type || getToolCallParsedInput(tc).agentType || 'Agent' }}</span>
@@ -852,7 +835,7 @@ function getToolCallParsedInput(tc) {
<!-- Workflow tool call (inside assistant bubble) -->
<template v-else-if="tc.name === 'Workflow'">
<div class="msg-tool agent-call">
<div class="msg-tool agent-call" :data-view-key="`tool:${tc.id}`">
<button class="toolcall-toggle" @click="toggleToolCall">
<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="tool-name">Workflow</span>
@@ -900,7 +883,7 @@ function getToolCallParsedInput(tc) {
<!-- Generic tool call -->
<template v-else>
<div class="msg-tool" :class="{ 'is-error': tc.result && tc.result.is_error }">
<div class="msg-tool" :class="{ 'is-error': tc.result && tc.result.is_error }" :data-view-key="`tool:${tc.id}`">
<button class="toolcall-toggle" @click="toggleToolCall">
<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 v-if="getToolIcon(tc.name)" class="tool-icon" v-html="getToolIcon(tc.name)"></span>
@@ -931,7 +914,7 @@ function getToolCallParsedInput(tc) {
</div>
<!-- Summary block -->
<div v-if="msg.summary" class="msg-summary">
<div v-if="msg.summary" class="msg-summary" :data-view-key="`summary:${msg.uuid}`">
<button class="summary-toggle" @click="toggleSummary">
<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="label">Session summary</span>
+135
View File
@@ -685,6 +685,141 @@
.diff-body .diff-gutter.add { background: rgba(99,102,241,0.04); color: rgba(99,102,241,0.55); }
.diff-body .diff-gutter.del { background: rgba(236,72,153,0.04); color: rgba(236,72,153,0.45); }
/* CodeAct view */
.codeact-view {
overflow: hidden;
border: 1px solid var(--hairline-strong);
border-radius: 6px;
background: oklch(0.145 0.018 278);
font-family: var(--font-mono);
font-size: 11.5px;
line-height: 1.55;
}
.codeact-section + .codeact-section { border-top: 1px solid var(--hairline-strong); }
.codeact-section-head {
min-height: 32px;
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px 6px 12px;
color: var(--muted);
background: oklch(0.18 0.018 278);
}
.codeact-section-label {
color: var(--fg-2);
font-size: 9.5px;
font-weight: 650;
letter-spacing: 0.09em;
text-transform: uppercase;
}
.codeact-cell {
padding: 1px 6px;
border: 1px solid var(--hairline-strong);
border-radius: 999px;
color: var(--muted);
font-size: 9.5px;
letter-spacing: 0.02em;
}
.codeact-result-meta {
display: flex;
align-items: center;
gap: 8px;
margin-left: auto;
}
.codeact-status {
display: inline-flex;
align-items: center;
gap: 5px;
color: var(--fg-2);
font-size: 10px;
font-weight: 550;
}
.codeact-status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: oklch(0.74 0.15 145);
}
.codeact-view.is-failed .codeact-status-dot { background: oklch(0.7 0.18 25); }
.codeact-view.is-running .codeact-status-dot { background: oklch(0.78 0.15 82); }
.codeact-code-frame {
display: grid;
grid-template-columns: max-content minmax(0, 1fr);
max-height: 220px;
overflow: auto;
background: oklch(0.115 0.014 278);
}
.codeact-code-frame:focus-visible,
.codeact-result:focus-visible {
outline: 2px solid var(--accent);
outline-offset: -2px;
}
.codeact-gutter,
.codeact-code,
.codeact-result {
margin: 0;
font: inherit;
font-variant-ligatures: none;
}
.codeact-gutter {
min-height: 100%;
padding: 8px 9px 8px 12px;
border-right: 1px solid var(--hairline);
color: var(--muted-2);
background: oklch(0.13 0.014 278);
text-align: right;
user-select: none;
}
.codeact-code {
min-width: max-content;
padding: 8px 12px;
color: var(--fg);
white-space: pre;
}
.codeact-token.keyword { color: oklch(0.79 0.12 298); }
.codeact-token.string { color: oklch(0.78 0.1 151); }
.codeact-token.number,
.codeact-token.literal { color: oklch(0.8 0.105 78); }
.codeact-token.global { color: oklch(0.79 0.095 230); }
.codeact-token.comment {
color: oklch(0.64 0.025 278);
font-style: italic;
}
.codeact-result {
width: 100%;
max-height: 320px;
overflow: auto;
color: var(--fg-2);
background: oklch(0.125 0.014 278);
}
.codeact-result-block {
margin: 0;
padding: 10px 12px;
font: inherit;
font-variant-ligatures: none;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.codeact-result-block + .codeact-result-block { border-top: 1px solid var(--hairline); }
.codeact-json-token.key { color: oklch(0.79 0.095 230); }
.codeact-json-token.string { color: oklch(0.78 0.1 151); }
.codeact-json-token.number { color: oklch(0.8 0.105 78); }
.codeact-json-token.literal { color: oklch(0.79 0.12 298); }
.codeact-result.is-empty {
padding: 10px 12px;
color: var(--muted);
font-style: italic;
}
.codeact-view.is-failed .codeact-result { color: oklch(0.82 0.09 25); }
.codeact-note {
padding: 7px 12px;
border-top: 1px solid var(--hairline);
color: oklch(0.82 0.09 82);
background: oklch(0.2 0.025 82 / 0.45);
font-family: var(--font-sans);
font-size: 10.5px;
}
/* Terminal view */
.terminal-view {
border: 1px solid var(--hairline); border-radius: 5px;