Compare commits

...

7 Commits

Author SHA1 Message Date
bba678568a Polish: simplify formatTimestamp and tone down export button
Replace try/catch with isNaN guard in the HTML exporter's
formatTimestamp, matching the same cleanup applied client-side.

Downgrade the export button from btn-primary to btn-secondary so it
doesn't compete visually with the main content area. The primary blue
gradient was overly prominent for a utility action.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 09:26:35 -05:00
c0e4158b77 Add time gap indicators and hash anchor navigation to SessionViewer
Time gaps:
- Insert a horizontal divider with duration label ("12m later",
  "1h 30m later") between consecutive visible messages separated
  by more than 5 minutes
- Computed during the display list build pass alongside redacted
  dividers, so no additional traversal is needed

Hash anchor navigation:
- Each message div now has id="msg-{uuid}" for deep linking
- On load, if the URL contains a #msg-* hash, scroll that message
  into view with smooth centering and a 3-second highlight ring
- Works with the copy-link feature added to MessageBubble headers

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 09:26:27 -05:00
0f3739605c Add language labels and copy-to-clipboard for code blocks
Custom marked renderer wraps fenced code blocks in a .code-block-wrapper
div containing a language label badge (top-right) and a copy button.
The language label shows the fenced language identifier in uppercase.

A delegated click handler on the document root intercepts clicks on
[data-copy-code] buttons, reads the sibling <code> element's text
content, writes it to the clipboard, and shows a "Copied!" / "Failed"
confirmation that auto-reverts after 1.5 seconds. Delegated handling
is necessary because code blocks are rendered via dangerouslySetInnerHTML
and don't participate in React's synthetic event system.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 09:26:18 -05:00
1dc178f59f Overhaul MessageBubble with collapsible sections, diff rendering, and header actions
Collapsible blocks:
- Thinking, tool_call, and tool_result messages start collapsed by default
- Chevron toggle in the header expands/collapses content
- Collapsed preview shows line count (thinking), tool name (tool_call),
  or first line truncated to 120 chars (tool_result)
- Collapsed blocks skip expensive markdown/highlight rendering entirely

Diff rendering:
- Detect unified diff content via hunk header + add/delete line heuristics
  (requires both @@ headers AND +/- lines to avoid false positives on
  YAML or markdown lists with leading dashes)
- Render diffs with color-coded line classes: green additions, red
  deletions, blue hunk headers, and muted meta/header lines
- Add full diff-view CSS with background tints and block-level spans

Header actions (appear on hover):
- Copy link button: copies a #msg-{uuid} anchor URL to clipboard with
  a checkmark confirmation animation
- Redaction toggle button: replaces the previous whole-card onClick
  handler with an explicit eye-slash icon button, colored red when
  selected — more discoverable and less accident-prone

Style adjustments:
- Raise dimmed message opacity from 0.2/0.45 to 0.35/0.65 for better
  readability during search filtering
- Fix btn-secondary hover border using explicit rgba value instead of
  Tailwind opacity modifier (which was generating invalid CSS)
- Position copy button below language label when both are present
- Simplify formatTimestamp with isNaN guard instead of try/catch
- Use fixed h-10 header height for consistent vertical alignment
- Brighten user and assistant message backgrounds (bg-surface-overlay)
  to visually distinguish them from other message types

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 09:26:09 -05:00
50b29ff0a2 Display session duration in sidebar and simplify date formatting
Show human-readable session duration (e.g. "23m", "1h 15m") in the
session list metadata row when duration > 0. Add formatSessionDuration
helper that handles sub-minute, minute-only, and hour+minute ranges.

Also replace try/catch in formatDate with an isNaN guard on the parsed
Date, which is more idiomatic and avoids swallowing unrelated errors.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 09:25:51 -05:00
4b13e7eeb9 Add session duration computation to discovery pipeline
Extend SessionEntry with an optional duration field (milliseconds)
computed from the delta between created and modified timestamps.
The computeDuration helper handles missing or invalid dates gracefully,
returning 0 for any edge case. This enables downstream UI to show
how long each session lasted without additional API calls.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 09:25:44 -05:00
afd228eab7 Add .gitignore for standard project artifacts
Ignore node_modules, dist output, TypeScript build info,
environment files, editor configs, and OS metadata files.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 09:25:38 -05:00
11 changed files with 369 additions and 50 deletions

16
.gitignore vendored Normal file
View File

@@ -0,0 +1,16 @@
node_modules/
dist/
*.tsbuildinfo
# Environment
.env
.env.*
# Editor / OS
.vscode/
.idea/
*.swp
*.swo
*~
.DS_Store
Thumbs.db

View File

@@ -65,7 +65,7 @@ export function ExportButton({
btn btn-sm flex-shrink-0 gap-1.5 transition-all duration-200
${state === "success" ? "text-white shadow-glow-success" : ""}
${state === "error" ? "text-white" : ""}
${state === "idle" || state === "exporting" ? "btn-primary" : ""}
${state === "idle" || state === "exporting" ? "btn-secondary" : ""}
`}
style={
state === "success"

View File

@@ -1,4 +1,4 @@
import React, { useMemo } from "react";
import React, { useMemo, useState } from "react";
import type { ParsedMessage } from "../lib/types";
import { CATEGORY_LABELS } from "../lib/types";
import { CATEGORY_COLORS } from "../lib/constants";
@@ -33,7 +33,15 @@ export function MessageBubble({
const colors = CATEGORY_COLORS[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(() => {
// Skip expensive rendering when content is collapsed and not visible
if (collapsed) return "";
const msg = autoRedactEnabled ? redactMessage(message) : message;
if (msg.category === "tool_call") {
@@ -46,14 +54,41 @@ export function MessageBubble({
// Structured data categories: render as preformatted text, not markdown.
// 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>`;
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);
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
? formatTimestamp(message.timestamp)
@@ -61,14 +96,13 @@ export function MessageBubble({
return (
<div
onClick={onToggleRedactionSelection}
className={`
group rounded-xl border bg-surface-raised cursor-pointer
group rounded-xl border bg-surface-raised
transition-all duration-200 relative overflow-hidden
${colors.border}
${dimmed ? "message-dimmed" : ""}
${selectedForRedaction ? "redaction-selected" : ""}
hover:shadow-card-hover hover:-translate-y-px
hover:shadow-card-hover
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}`} />
{/* 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="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}
</span>
{timestamp && (
<>
<span className="text-border">·</span>
<span className="text-caption text-foreground-muted tabular-nums">
<span className="text-border leading-none">·</span>
<span className="text-caption text-foreground-muted tabular-nums leading-none">
{timestamp}
</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>
{/* Content — sourced from local user-owned JSONL files, not external/untrusted input */}
{!collapsed && (
<div
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>
);
}
function isDiffContent(content: string): boolean {
const lines = content.split("\n").slice(0, 30);
let hunkHeaders = 0;
let diffLines = 0;
for (const line of lines) {
if (line.startsWith("@@") || line.startsWith("diff --")) {
hunkHeaders++;
} else if (line.startsWith("+++") || line.startsWith("---")) {
hunkHeaders++;
} 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 {
try {
const d = new Date(ts);
if (isNaN(d.getTime())) return "";
return d.toLocaleTimeString(undefined, {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
} catch {
return "";
}
}

View File

@@ -96,6 +96,12 @@ export function SessionList({ sessions, loading, selectedId, onSelect }: Props)
<span>{formatDate(session.modified || session.created)}</span>
<span className="text-border">·</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>
</button>
);
@@ -154,17 +160,24 @@ function formatProjectName(project: string): string {
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 {
if (!dateStr) return "";
try {
const d = new Date(dateStr);
if (isNaN(d.getTime())) return dateStr;
return d.toLocaleDateString(undefined, {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
} catch {
return dateStr;
}
}

View File

@@ -58,7 +58,22 @@ export function SessionViewer({
}
}, [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.
const displayItems = useMemo(() => {
if (messages.length === 0) return [];
@@ -67,9 +82,11 @@ export function SessionViewer({
const items: Array<
| { type: "message"; message: ParsedMessage; messageIndex: number }
| { type: "redacted_divider"; key: string }
| { type: "time_gap"; key: string; duration: string }
> = [];
let prevWasRedactedGap = false;
let prevTimestamp: string | undefined;
let messageIndex = 0;
for (const msg of allMessages) {
if (redactedUuids.has(msg.uuid)) {
@@ -86,6 +103,20 @@ export function SessionViewer({
});
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 });
messageIndex++;
}
@@ -154,6 +185,17 @@ export function SessionViewer({
if (item.type === "redacted_divider") {
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 isMatch =
searchQuery &&
@@ -163,6 +205,7 @@ export function SessionViewer({
return (
<div
key={msg.uuid}
id={`msg-${msg.uuid}`}
data-msg-index={item.messageIndex}
className={`animate-fade-in ${isFocused ? "search-match-focused rounded-xl" : ""}`}
style={{ animationDelay: `${Math.min(idx * 20, 300)}ms`, animationFillMode: "backwards" }}
@@ -184,3 +227,12 @@ export function SessionViewer({
</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`;
}

View File

@@ -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 {
if (!text) return "";
try {

View File

@@ -3,6 +3,23 @@ import ReactDOM from "react-dom/client";
import { App } from "./app.js";
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(
<React.StrictMode>
<App />

View File

@@ -147,6 +147,12 @@
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 {
opacity: 1;
}
@@ -156,6 +162,54 @@
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
═══════════════════════════════════════════════ */
@@ -246,12 +300,12 @@ mark.search-highlight {
═══════════════════════════════════════════════ */
.message-dimmed {
opacity: 0.2;
opacity: 0.35;
transition: opacity 200ms ease;
}
.message-dimmed:hover {
opacity: 0.45;
opacity: 0.65;
}
/* ═══════════════════════════════════════════════
@@ -473,11 +527,15 @@ mark.search-highlight {
.btn-secondary {
@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 focus-visible:ring-accent;
}
.btn-secondary:hover {
border-color: rgba(80, 90, 110, 0.3);
}
.btn-ghost {
@apply text-foreground-secondary;
@apply hover:bg-surface-overlay hover:text-foreground;

View File

@@ -170,16 +170,13 @@ function renderMarkdown(text: string): string {
}
function formatTimestamp(ts: string): string {
try {
const d = new Date(ts);
if (isNaN(d.getTime())) return "";
return d.toLocaleTimeString(undefined, {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
} catch {
return "";
}
}
function getHighlightCss(): string {

View File

@@ -80,6 +80,7 @@ export async function discoverSessions(
modified: entry.modified || "",
messageCount: entry.messageCount || 0,
path: resolved,
duration: computeDuration(entry.created, entry.modified),
});
}
} catch {
@@ -102,3 +103,12 @@ export async function discoverSessions(
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;
}

View File

@@ -28,6 +28,7 @@ export interface SessionEntry {
modified: string;
messageCount: number;
path: string;
duration?: number; // Duration in milliseconds from first to last message
}
export interface SessionListResponse {