Compare commits
7 Commits
69857fa825
...
bba678568a
| Author | SHA1 | Date | |
|---|---|---|---|
| bba678568a | |||
| c0e4158b77 | |||
| 0f3739605c | |||
| 1dc178f59f | |||
| 50b29ff0a2 | |||
| 4b13e7eeb9 | |||
| afd228eab7 |
16
.gitignore
vendored
Normal file
16
.gitignore
vendored
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
|
||||||
|
# Editor / OS
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
@@ -65,7 +65,7 @@ export function ExportButton({
|
|||||||
btn btn-sm flex-shrink-0 gap-1.5 transition-all duration-200
|
btn btn-sm flex-shrink-0 gap-1.5 transition-all duration-200
|
||||||
${state === "success" ? "text-white shadow-glow-success" : ""}
|
${state === "success" ? "text-white shadow-glow-success" : ""}
|
||||||
${state === "error" ? "text-white" : ""}
|
${state === "error" ? "text-white" : ""}
|
||||||
${state === "idle" || state === "exporting" ? "btn-primary" : ""}
|
${state === "idle" || state === "exporting" ? "btn-secondary" : ""}
|
||||||
`}
|
`}
|
||||||
style={
|
style={
|
||||||
state === "success"
|
state === "success"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useMemo } from "react";
|
import React, { useMemo, useState } from "react";
|
||||||
import type { ParsedMessage } from "../lib/types";
|
import type { ParsedMessage } from "../lib/types";
|
||||||
import { CATEGORY_LABELS } from "../lib/types";
|
import { CATEGORY_LABELS } from "../lib/types";
|
||||||
import { CATEGORY_COLORS } from "../lib/constants";
|
import { CATEGORY_COLORS } from "../lib/constants";
|
||||||
@@ -33,7 +33,15 @@ export function MessageBubble({
|
|||||||
const colors = CATEGORY_COLORS[message.category];
|
const colors = CATEGORY_COLORS[message.category];
|
||||||
const label = CATEGORY_LABELS[message.category];
|
const label = CATEGORY_LABELS[message.category];
|
||||||
|
|
||||||
|
// Collapsible state for thinking blocks and tool calls/results
|
||||||
|
const isCollapsible = message.category === "thinking" || message.category === "tool_call" || message.category === "tool_result";
|
||||||
|
const [collapsed, setCollapsed] = useState(isCollapsible);
|
||||||
|
const [linkCopied, setLinkCopied] = useState(false);
|
||||||
|
|
||||||
const renderedHtml = useMemo(() => {
|
const renderedHtml = useMemo(() => {
|
||||||
|
// Skip expensive rendering when content is collapsed and not visible
|
||||||
|
if (collapsed) return "";
|
||||||
|
|
||||||
const msg = autoRedactEnabled ? redactMessage(message) : message;
|
const msg = autoRedactEnabled ? redactMessage(message) : message;
|
||||||
|
|
||||||
if (msg.category === "tool_call") {
|
if (msg.category === "tool_call") {
|
||||||
@@ -46,14 +54,41 @@ export function MessageBubble({
|
|||||||
|
|
||||||
// Structured data categories: render as preformatted text, not markdown.
|
// Structured data categories: render as preformatted text, not markdown.
|
||||||
// Avoids expensive marked.parse() on large JSON/log blobs.
|
// Avoids expensive marked.parse() on large JSON/log blobs.
|
||||||
if (msg.category === "hook_progress" || msg.category === "tool_result" || msg.category === "file_snapshot") {
|
if (msg.category === "hook_progress" || msg.category === "file_snapshot") {
|
||||||
const html = `<pre class="hljs"><code>${escapeHtml(msg.content)}</code></pre>`;
|
const html = `<pre class="hljs"><code>${escapeHtml(msg.content)}</code></pre>`;
|
||||||
return searchQuery ? highlightSearchText(html, searchQuery) : html;
|
return searchQuery ? highlightSearchText(html, searchQuery) : html;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (msg.category === "tool_result") {
|
||||||
|
const html = isDiffContent(msg.content)
|
||||||
|
? renderDiffHtml(msg.content)
|
||||||
|
: `<pre class="hljs"><code>${escapeHtml(msg.content)}</code></pre>`;
|
||||||
|
return searchQuery ? highlightSearchText(html, searchQuery) : html;
|
||||||
|
}
|
||||||
|
|
||||||
const html = renderMarkdown(msg.content);
|
const html = renderMarkdown(msg.content);
|
||||||
return searchQuery ? highlightSearchText(html, searchQuery) : html;
|
return searchQuery ? highlightSearchText(html, searchQuery) : html;
|
||||||
}, [message, searchQuery, autoRedactEnabled, colors.text]);
|
}, [message, searchQuery, autoRedactEnabled, colors.text, collapsed]);
|
||||||
|
|
||||||
|
// Generate preview for collapsed thinking blocks
|
||||||
|
const collapsedPreview = useMemo(() => {
|
||||||
|
if (!isCollapsible || !collapsed) return null;
|
||||||
|
if (message.category === "thinking") {
|
||||||
|
const lines = message.content.split("\n").filter(l => l.trim());
|
||||||
|
const preview = lines.slice(0, 2).join("\n");
|
||||||
|
const totalLines = lines.length;
|
||||||
|
return { preview, totalLines };
|
||||||
|
}
|
||||||
|
if (message.category === "tool_call") {
|
||||||
|
return { preview: message.toolName || "Unknown Tool", totalLines: 0 };
|
||||||
|
}
|
||||||
|
if (message.category === "tool_result") {
|
||||||
|
const lines = message.content.split("\n");
|
||||||
|
const preview = lines[0]?.substring(0, 120) || "Result";
|
||||||
|
return { preview, totalLines: lines.length };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}, [isCollapsible, collapsed, message]);
|
||||||
|
|
||||||
const timestamp = message.timestamp
|
const timestamp = message.timestamp
|
||||||
? formatTimestamp(message.timestamp)
|
? formatTimestamp(message.timestamp)
|
||||||
@@ -61,14 +96,13 @@ export function MessageBubble({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
onClick={onToggleRedactionSelection}
|
|
||||||
className={`
|
className={`
|
||||||
group rounded-xl border bg-surface-raised cursor-pointer
|
group rounded-xl border bg-surface-raised
|
||||||
transition-all duration-200 relative overflow-hidden
|
transition-all duration-200 relative overflow-hidden
|
||||||
${colors.border}
|
${colors.border}
|
||||||
${dimmed ? "message-dimmed" : ""}
|
${dimmed ? "message-dimmed" : ""}
|
||||||
${selectedForRedaction ? "redaction-selected" : ""}
|
${selectedForRedaction ? "redaction-selected" : ""}
|
||||||
hover:shadow-card-hover hover:-translate-y-px
|
hover:shadow-card-hover
|
||||||
shadow-card
|
shadow-card
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
@@ -76,39 +110,151 @@ export function MessageBubble({
|
|||||||
<div className={`absolute left-0 top-0 bottom-0 w-[3px] rounded-l-xl ${colors.dot}`} />
|
<div className={`absolute left-0 top-0 bottom-0 w-[3px] rounded-l-xl ${colors.dot}`} />
|
||||||
|
|
||||||
{/* Header bar */}
|
{/* Header bar */}
|
||||||
<div className="flex items-center gap-2 px-4 pl-5 pt-3 pb-1">
|
<div className="flex items-center gap-2 px-4 pl-5 h-10">
|
||||||
|
{isCollapsible && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); setCollapsed(!collapsed); }}
|
||||||
|
className="flex items-center justify-center w-5 h-5 text-foreground-muted hover:text-foreground transition-colors flex-shrink-0"
|
||||||
|
aria-label={collapsed ? "Expand" : "Collapse"}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className={`w-3.5 h-3.5 transition-transform duration-150 ${collapsed ? "" : "rotate-90"}`}
|
||||||
|
fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<span className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${colors.dot}`} />
|
<span className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${colors.dot}`} />
|
||||||
<span className="text-caption font-semibold uppercase tracking-wider text-foreground-muted">
|
<span className="text-caption font-semibold uppercase tracking-wider text-foreground-muted leading-none">
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
{timestamp && (
|
{timestamp && (
|
||||||
<>
|
<>
|
||||||
<span className="text-border">·</span>
|
<span className="text-border leading-none">·</span>
|
||||||
<span className="text-caption text-foreground-muted tabular-nums">
|
<span className="text-caption text-foreground-muted tabular-nums leading-none">
|
||||||
{timestamp}
|
{timestamp}
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{isCollapsible && collapsed && collapsedPreview && (
|
||||||
|
<>
|
||||||
|
<span className="text-border leading-none">·</span>
|
||||||
|
<span className="text-caption text-foreground-muted truncate max-w-[300px] leading-none">
|
||||||
|
{message.category === "thinking" && collapsedPreview.totalLines > 2
|
||||||
|
? `${collapsedPreview.totalLines} lines`
|
||||||
|
: collapsedPreview.preview}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Right-aligned header actions */}
|
||||||
|
<div className="ml-auto flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0">
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const url = `${window.location.origin}${window.location.pathname}#msg-${message.uuid}`;
|
||||||
|
navigator.clipboard.writeText(url).then(() => {
|
||||||
|
setLinkCopied(true);
|
||||||
|
setTimeout(() => setLinkCopied(false), 1500);
|
||||||
|
}).catch(() => {});
|
||||||
|
}}
|
||||||
|
className="flex items-center justify-center w-7 h-7 rounded-md text-foreground-muted hover:text-foreground hover:bg-surface-overlay/60 transition-colors"
|
||||||
|
title="Copy link to message"
|
||||||
|
>
|
||||||
|
{linkCopied ? (
|
||||||
|
<svg className="w-4 h-4 text-green-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 17.25v3.375c0 .621-.504 1.125-1.125 1.125h-9.75a1.125 1.125 0 01-1.125-1.125V7.875c0-.621.504-1.125 1.125-1.125H6.75a9.06 9.06 0 011.5.124m7.5 10.376h3.375c.621 0 1.125-.504 1.125-1.125V11.25c0-4.46-3.243-8.161-7.5-8.876a9.06 9.06 0 00-1.5-.124H9.375c-.621 0-1.125.504-1.125 1.125v3.5m7.5 10.375H9.375a1.125 1.125 0 01-1.125-1.125v-9.25m0 0a2.625 2.625 0 115.25 0H12m-3.75 0h3.75" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onToggleRedactionSelection();
|
||||||
|
}}
|
||||||
|
className={`flex items-center justify-center w-7 h-7 rounded-md transition-colors ${
|
||||||
|
selectedForRedaction
|
||||||
|
? "text-red-400 bg-red-500/15 hover:bg-red-500/25"
|
||||||
|
: "text-foreground-muted hover:text-red-400 hover:bg-red-500/10"
|
||||||
|
}`}
|
||||||
|
title={selectedForRedaction ? "Deselect for redaction" : "Select for redaction"}
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M3.98 8.223A10.477 10.477 0 001.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.45 10.45 0 0112 4.5c4.756 0 8.773 3.162 10.065 7.498a10.523 10.523 0 01-4.293 5.774M6.228 6.228L3 3m3.228 3.228l3.65 3.65m7.894 7.894L21 21m-3.228-3.228l-3.65-3.65m0 0a3 3 0 10-4.243-4.243m4.242 4.242L9.88 9.88" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content — sourced from local user-owned JSONL files, not external/untrusted input */}
|
{/* Content — sourced from local user-owned JSONL files, not external/untrusted input */}
|
||||||
<div
|
{!collapsed && (
|
||||||
className="prose-message text-body text-foreground px-4 pl-5 pb-4 pt-1 max-w-none break-words overflow-hidden"
|
<div
|
||||||
dangerouslySetInnerHTML={{ __html: renderedHtml }}
|
className="prose-message text-body text-foreground px-4 pl-5 pb-4 pt-1 max-w-none break-words overflow-hidden"
|
||||||
/>
|
dangerouslySetInnerHTML={{ __html: renderedHtml }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{collapsed && message.category === "thinking" && collapsedPreview && (
|
||||||
|
<div className="px-4 pl-5 pb-3 pt-1">
|
||||||
|
<pre className="text-caption text-foreground-muted whitespace-pre-wrap line-clamp-2 font-mono">{collapsedPreview.preview}</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTimestamp(ts: string): string {
|
function isDiffContent(content: string): boolean {
|
||||||
try {
|
const lines = content.split("\n").slice(0, 30);
|
||||||
const d = new Date(ts);
|
let hunkHeaders = 0;
|
||||||
return d.toLocaleTimeString(undefined, {
|
let diffLines = 0;
|
||||||
hour: "2-digit",
|
for (const line of lines) {
|
||||||
minute: "2-digit",
|
if (line.startsWith("@@") || line.startsWith("diff --")) {
|
||||||
second: "2-digit",
|
hunkHeaders++;
|
||||||
});
|
} else if (line.startsWith("+++") || line.startsWith("---")) {
|
||||||
} catch {
|
hunkHeaders++;
|
||||||
return "";
|
} else if (line.startsWith("+") || line.startsWith("-")) {
|
||||||
|
diffLines++;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
// Require at least one hunk header AND some +/- lines to avoid false positives
|
||||||
|
// on YAML lists, markdown lists, or other content with leading dashes
|
||||||
|
return hunkHeaders >= 1 && diffLines >= 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDiffHtml(content: string): string {
|
||||||
|
const lines = content.split("\n");
|
||||||
|
const htmlLines = lines.map((line) => {
|
||||||
|
const escaped = escapeHtml(line);
|
||||||
|
if (line.startsWith("@@")) {
|
||||||
|
return `<span class="diff-hunk">${escaped}</span>`;
|
||||||
|
}
|
||||||
|
if (line.startsWith("+++") || line.startsWith("---")) {
|
||||||
|
return `<span class="diff-meta">${escaped}</span>`;
|
||||||
|
}
|
||||||
|
if (line.startsWith("diff --")) {
|
||||||
|
return `<span class="diff-header">${escaped}</span>`;
|
||||||
|
}
|
||||||
|
if (line.startsWith("+")) {
|
||||||
|
return `<span class="diff-add">${escaped}</span>`;
|
||||||
|
}
|
||||||
|
if (line.startsWith("-")) {
|
||||||
|
return `<span class="diff-del">${escaped}</span>`;
|
||||||
|
}
|
||||||
|
return escaped;
|
||||||
|
});
|
||||||
|
return `<pre class="hljs diff-view"><code>${htmlLines.join("\n")}</code></pre>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTimestamp(ts: string): string {
|
||||||
|
const d = new Date(ts);
|
||||||
|
if (isNaN(d.getTime())) return "";
|
||||||
|
return d.toLocaleTimeString(undefined, {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
second: "2-digit",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,6 +96,12 @@ export function SessionList({ sessions, loading, selectedId, onSelect }: Props)
|
|||||||
<span>{formatDate(session.modified || session.created)}</span>
|
<span>{formatDate(session.modified || session.created)}</span>
|
||||||
<span className="text-border">·</span>
|
<span className="text-border">·</span>
|
||||||
<span className="tabular-nums">{session.messageCount} msgs</span>
|
<span className="tabular-nums">{session.messageCount} msgs</span>
|
||||||
|
{session.duration && session.duration > 0 && (
|
||||||
|
<>
|
||||||
|
<span className="text-border">·</span>
|
||||||
|
<span className="tabular-nums">{formatSessionDuration(session.duration)}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
@@ -154,17 +160,24 @@ function formatProjectName(project: string): string {
|
|||||||
return project;
|
return project;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatSessionDuration(ms: number): string {
|
||||||
|
const minutes = Math.floor(ms / 60000);
|
||||||
|
if (minutes < 1) return "<1m";
|
||||||
|
if (minutes < 60) return `${minutes}m`;
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
const rem = minutes % 60;
|
||||||
|
if (rem === 0) return `${hours}h`;
|
||||||
|
return `${hours}h ${rem}m`;
|
||||||
|
}
|
||||||
|
|
||||||
function formatDate(dateStr: string): string {
|
function formatDate(dateStr: string): string {
|
||||||
if (!dateStr) return "";
|
if (!dateStr) return "";
|
||||||
try {
|
const d = new Date(dateStr);
|
||||||
const d = new Date(dateStr);
|
if (isNaN(d.getTime())) return dateStr;
|
||||||
return d.toLocaleDateString(undefined, {
|
return d.toLocaleDateString(undefined, {
|
||||||
month: "short",
|
month: "short",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
hour: "2-digit",
|
hour: "2-digit",
|
||||||
minute: "2-digit",
|
minute: "2-digit",
|
||||||
});
|
});
|
||||||
} catch {
|
|
||||||
return dateStr;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,7 +58,22 @@ export function SessionViewer({
|
|||||||
}
|
}
|
||||||
}, [focusedIndex]);
|
}, [focusedIndex]);
|
||||||
|
|
||||||
// Build display list with redacted dividers.
|
// Auto-scroll to hash anchor on load
|
||||||
|
useEffect(() => {
|
||||||
|
const hash = window.location.hash;
|
||||||
|
if (hash && hash.startsWith("#msg-") && messages.length > 0) {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const el = document.getElementById(hash.slice(1));
|
||||||
|
if (el) {
|
||||||
|
el.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||||
|
el.classList.add("search-match-focused", "rounded-xl");
|
||||||
|
setTimeout(() => el.classList.remove("search-match-focused"), 3000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [messages.length]);
|
||||||
|
|
||||||
|
// Build display list with redacted dividers and time gaps.
|
||||||
// Must be called before any early returns to satisfy Rules of Hooks.
|
// Must be called before any early returns to satisfy Rules of Hooks.
|
||||||
const displayItems = useMemo(() => {
|
const displayItems = useMemo(() => {
|
||||||
if (messages.length === 0) return [];
|
if (messages.length === 0) return [];
|
||||||
@@ -67,9 +82,11 @@ export function SessionViewer({
|
|||||||
const items: Array<
|
const items: Array<
|
||||||
| { type: "message"; message: ParsedMessage; messageIndex: number }
|
| { type: "message"; message: ParsedMessage; messageIndex: number }
|
||||||
| { type: "redacted_divider"; key: string }
|
| { type: "redacted_divider"; key: string }
|
||||||
|
| { type: "time_gap"; key: string; duration: string }
|
||||||
> = [];
|
> = [];
|
||||||
|
|
||||||
let prevWasRedactedGap = false;
|
let prevWasRedactedGap = false;
|
||||||
|
let prevTimestamp: string | undefined;
|
||||||
let messageIndex = 0;
|
let messageIndex = 0;
|
||||||
for (const msg of allMessages) {
|
for (const msg of allMessages) {
|
||||||
if (redactedUuids.has(msg.uuid)) {
|
if (redactedUuids.has(msg.uuid)) {
|
||||||
@@ -86,6 +103,20 @@ export function SessionViewer({
|
|||||||
});
|
});
|
||||||
prevWasRedactedGap = false;
|
prevWasRedactedGap = false;
|
||||||
}
|
}
|
||||||
|
// Insert time gap indicator if > 5 minutes between visible messages
|
||||||
|
if (prevTimestamp && msg.timestamp) {
|
||||||
|
const currTime = new Date(msg.timestamp).getTime();
|
||||||
|
const prevTime = new Date(prevTimestamp).getTime();
|
||||||
|
const gap = currTime - prevTime;
|
||||||
|
if (!isNaN(gap) && gap > 5 * 60 * 1000) {
|
||||||
|
items.push({
|
||||||
|
type: "time_gap",
|
||||||
|
key: `gap-${msg.uuid}`,
|
||||||
|
duration: formatDuration(gap),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (msg.timestamp) prevTimestamp = msg.timestamp;
|
||||||
items.push({ type: "message", message: msg, messageIndex });
|
items.push({ type: "message", message: msg, messageIndex });
|
||||||
messageIndex++;
|
messageIndex++;
|
||||||
}
|
}
|
||||||
@@ -154,6 +185,17 @@ export function SessionViewer({
|
|||||||
if (item.type === "redacted_divider") {
|
if (item.type === "redacted_divider") {
|
||||||
return <RedactedDivider key={item.key} />;
|
return <RedactedDivider key={item.key} />;
|
||||||
}
|
}
|
||||||
|
if (item.type === "time_gap") {
|
||||||
|
return (
|
||||||
|
<div key={item.key} className="flex items-center gap-3 py-2">
|
||||||
|
<div className="flex-1 h-px bg-border-muted" />
|
||||||
|
<span className="text-caption text-foreground-muted tabular-nums flex-shrink-0">
|
||||||
|
{item.duration} later
|
||||||
|
</span>
|
||||||
|
<div className="flex-1 h-px bg-border-muted" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
const msg = item.message;
|
const msg = item.message;
|
||||||
const isMatch =
|
const isMatch =
|
||||||
searchQuery &&
|
searchQuery &&
|
||||||
@@ -163,6 +205,7 @@ export function SessionViewer({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={msg.uuid}
|
key={msg.uuid}
|
||||||
|
id={`msg-${msg.uuid}`}
|
||||||
data-msg-index={item.messageIndex}
|
data-msg-index={item.messageIndex}
|
||||||
className={`animate-fade-in ${isFocused ? "search-match-focused rounded-xl" : ""}`}
|
className={`animate-fade-in ${isFocused ? "search-match-focused rounded-xl" : ""}`}
|
||||||
style={{ animationDelay: `${Math.min(idx * 20, 300)}ms`, animationFillMode: "backwards" }}
|
style={{ animationDelay: `${Math.min(idx * 20, 300)}ms`, animationFillMode: "backwards" }}
|
||||||
@@ -184,3 +227,12 @@ export function SessionViewer({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatDuration(ms: number): string {
|
||||||
|
const minutes = Math.floor(ms / 60000);
|
||||||
|
if (minutes < 60) return `${minutes}m`;
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
const remainMinutes = minutes % 60;
|
||||||
|
if (remainMinutes === 0) return `${hours}h`;
|
||||||
|
return `${hours}h ${remainMinutes}m`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,6 +18,15 @@ marked.use(
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Custom renderer to wrap code blocks with copy button
|
||||||
|
const renderer = new marked.Renderer();
|
||||||
|
renderer.code = function ({ text, lang }: { text: string; lang?: string | undefined; escaped?: boolean }) {
|
||||||
|
const langLabel = lang ? `<span class="code-lang-label">${escapeHtml(lang)}</span>` : "";
|
||||||
|
return `<div class="code-block-wrapper">${langLabel}<button class="code-copy-btn" data-copy-code type="button">Copy</button><pre class="hljs"><code>${text}</code></pre></div>`;
|
||||||
|
};
|
||||||
|
|
||||||
|
marked.use({ renderer });
|
||||||
|
|
||||||
export function renderMarkdown(text: string): string {
|
export function renderMarkdown(text: string): string {
|
||||||
if (!text) return "";
|
if (!text) return "";
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -3,6 +3,23 @@ import ReactDOM from "react-dom/client";
|
|||||||
import { App } from "./app.js";
|
import { App } from "./app.js";
|
||||||
import "./styles/main.css";
|
import "./styles/main.css";
|
||||||
|
|
||||||
|
// Delegated click handler for code block copy buttons
|
||||||
|
document.addEventListener("click", (e) => {
|
||||||
|
const btn = (e.target as HTMLElement).closest("[data-copy-code]") as HTMLButtonElement | null;
|
||||||
|
if (!btn) return;
|
||||||
|
e.stopPropagation();
|
||||||
|
const wrapper = btn.closest(".code-block-wrapper");
|
||||||
|
const code = wrapper?.querySelector("code");
|
||||||
|
if (!code) return;
|
||||||
|
navigator.clipboard.writeText(code.textContent || "").then(() => {
|
||||||
|
btn.textContent = "Copied!";
|
||||||
|
setTimeout(() => { btn.textContent = "Copy"; }, 1500);
|
||||||
|
}).catch(() => {
|
||||||
|
btn.textContent = "Failed";
|
||||||
|
setTimeout(() => { btn.textContent = "Copy"; }, 1500);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<App />
|
<App />
|
||||||
|
|||||||
@@ -147,6 +147,12 @@
|
|||||||
transition: opacity 150ms, background-color 150ms, color 150ms;
|
transition: opacity 150ms, background-color 150ms, color 150ms;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* When a language label is present, shift copy button below it */
|
||||||
|
.code-block-wrapper .code-lang-label + .code-copy-btn,
|
||||||
|
.code-block-wrapper:has(.code-lang-label) .code-copy-btn {
|
||||||
|
top: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
.code-block-wrapper:hover .code-copy-btn {
|
.code-block-wrapper:hover .code-copy-btn {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
@@ -156,6 +162,54 @@
|
|||||||
background: var(--color-surface-overlay);
|
background: var(--color-surface-overlay);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ═══════════════════════════════════════════════
|
||||||
|
Diff view — colored line-level highlighting
|
||||||
|
═══════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
.diff-view {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-add {
|
||||||
|
background: rgba(63, 185, 80, 0.12);
|
||||||
|
color: #3fb950;
|
||||||
|
display: block;
|
||||||
|
margin: 0 -1rem;
|
||||||
|
padding: 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-del {
|
||||||
|
background: rgba(248, 81, 73, 0.12);
|
||||||
|
color: #f85149;
|
||||||
|
display: block;
|
||||||
|
margin: 0 -1rem;
|
||||||
|
padding: 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-hunk {
|
||||||
|
color: #79c0ff;
|
||||||
|
display: block;
|
||||||
|
margin: 0 -1rem;
|
||||||
|
padding: 0.25rem 1rem;
|
||||||
|
background: rgba(121, 192, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-meta {
|
||||||
|
color: var(--color-foreground-muted);
|
||||||
|
font-weight: 600;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-header {
|
||||||
|
color: var(--color-foreground-secondary);
|
||||||
|
font-weight: 600;
|
||||||
|
display: block;
|
||||||
|
margin: 0 -1rem;
|
||||||
|
padding: 0.25rem 1rem;
|
||||||
|
background: rgba(136, 144, 245, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
/* ═══════════════════════════════════════════════
|
/* ═══════════════════════════════════════════════
|
||||||
Search highlight — warm amber glow
|
Search highlight — warm amber glow
|
||||||
═══════════════════════════════════════════════ */
|
═══════════════════════════════════════════════ */
|
||||||
@@ -246,12 +300,12 @@ mark.search-highlight {
|
|||||||
═══════════════════════════════════════════════ */
|
═══════════════════════════════════════════════ */
|
||||||
|
|
||||||
.message-dimmed {
|
.message-dimmed {
|
||||||
opacity: 0.2;
|
opacity: 0.35;
|
||||||
transition: opacity 200ms ease;
|
transition: opacity 200ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-dimmed:hover {
|
.message-dimmed:hover {
|
||||||
opacity: 0.45;
|
opacity: 0.65;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ═══════════════════════════════════════════════
|
/* ═══════════════════════════════════════════════
|
||||||
@@ -473,11 +527,15 @@ mark.search-highlight {
|
|||||||
|
|
||||||
.btn-secondary {
|
.btn-secondary {
|
||||||
@apply bg-surface-raised text-foreground-secondary border border-border;
|
@apply bg-surface-raised text-foreground-secondary border border-border;
|
||||||
@apply hover:bg-surface-overlay hover:text-foreground hover:border-foreground-muted/30;
|
@apply hover:bg-surface-overlay hover:text-foreground;
|
||||||
@apply disabled:opacity-50 disabled:cursor-not-allowed;
|
@apply disabled:opacity-50 disabled:cursor-not-allowed;
|
||||||
@apply focus-visible:ring-accent;
|
@apply focus-visible:ring-accent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
border-color: rgba(80, 90, 110, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
.btn-ghost {
|
.btn-ghost {
|
||||||
@apply text-foreground-secondary;
|
@apply text-foreground-secondary;
|
||||||
@apply hover:bg-surface-overlay hover:text-foreground;
|
@apply hover:bg-surface-overlay hover:text-foreground;
|
||||||
|
|||||||
@@ -170,16 +170,13 @@ function renderMarkdown(text: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatTimestamp(ts: string): string {
|
function formatTimestamp(ts: string): string {
|
||||||
try {
|
const d = new Date(ts);
|
||||||
const d = new Date(ts);
|
if (isNaN(d.getTime())) return "";
|
||||||
return d.toLocaleTimeString(undefined, {
|
return d.toLocaleTimeString(undefined, {
|
||||||
hour: "2-digit",
|
hour: "2-digit",
|
||||||
minute: "2-digit",
|
minute: "2-digit",
|
||||||
second: "2-digit",
|
second: "2-digit",
|
||||||
});
|
});
|
||||||
} catch {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getHighlightCss(): string {
|
function getHighlightCss(): string {
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ export async function discoverSessions(
|
|||||||
modified: entry.modified || "",
|
modified: entry.modified || "",
|
||||||
messageCount: entry.messageCount || 0,
|
messageCount: entry.messageCount || 0,
|
||||||
path: resolved,
|
path: resolved,
|
||||||
|
duration: computeDuration(entry.created, entry.modified),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -102,3 +103,12 @@ export async function discoverSessions(
|
|||||||
|
|
||||||
return sessions;
|
return sessions;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function computeDuration(created?: string, modified?: string): number {
|
||||||
|
if (!created || !modified) return 0;
|
||||||
|
const createdMs = new Date(created).getTime();
|
||||||
|
const modifiedMs = new Date(modified).getTime();
|
||||||
|
if (isNaN(createdMs) || isNaN(modifiedMs)) return 0;
|
||||||
|
const diff = modifiedMs - createdMs;
|
||||||
|
return diff > 0 ? diff : 0;
|
||||||
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export interface SessionEntry {
|
|||||||
modified: string;
|
modified: string;
|
||||||
messageCount: number;
|
messageCount: number;
|
||||||
path: string;
|
path: string;
|
||||||
|
duration?: number; // Duration in milliseconds from first to last message
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SessionListResponse {
|
export interface SessionListResponse {
|
||||||
|
|||||||
Reference in New Issue
Block a user