Enhance UI with density toggle and mobile responsiveness
Add density toggle, mobile sidebar behavior, and various polish improvements to the session viewer interface. Density toggle (comfortable/compact): - Persisted to localStorage as "session-viewer-density" - Compact mode reduces message padding and spacing - Toggle button in nav bar with LayoutRows icon - Visual indicator when compact mode is active Mobile sidebar: - Sidebar slides in/out with transform transition - Hamburger menu button visible on mobile (md:hidden) - Backdrop overlay when sidebar is open - Auto-close sidebar after selecting a session Session info improvements: - Show project/session title in nav bar when viewing session - Add session ID display with copy button in SessionViewer - Copy button shows check icon for 1.5s after copying SessionList enhancements: - Relative time format: "5m ago", "2h ago", "3d ago" - Total message count per project in project list - Truncated project names showing last 2 path segments - Full path available in title attribute MessageBubble: - Add compact prop for reduced padding - Add accentBorder property to category colors - Migrate inline SVGs to shared Icons SessionViewer: - Sticky session info header with glass effect - Add compact prop that propagates to MessageBubble - Keyboard shortcut hints in empty state Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -6,10 +6,54 @@ import { SearchBar } from "./components/SearchBar";
|
|||||||
import { SearchMinimap } from "./components/SearchMinimap";
|
import { SearchMinimap } from "./components/SearchMinimap";
|
||||||
import { ExportButton } from "./components/ExportButton";
|
import { ExportButton } from "./components/ExportButton";
|
||||||
import { ErrorBoundary } from "./components/ErrorBoundary";
|
import { ErrorBoundary } from "./components/ErrorBoundary";
|
||||||
|
import { Menu, LayoutRows } from "./components/Icons";
|
||||||
import { useSession } from "./hooks/useSession";
|
import { useSession } from "./hooks/useSession";
|
||||||
import { useFilters } from "./hooks/useFilters";
|
import { useFilters } from "./hooks/useFilters";
|
||||||
import { countSensitiveMessages } from "../shared/sensitive-redactor";
|
import { countSensitiveMessages } from "../shared/sensitive-redactor";
|
||||||
|
import type { SessionEntry } from "./lib/types";
|
||||||
|
|
||||||
|
type Density = "comfortable" | "compact";
|
||||||
|
|
||||||
|
function useDensity(): [Density, (d: Density) => void] {
|
||||||
|
const [density, setDensityState] = useState<Density>(() => {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem("session-viewer-density");
|
||||||
|
if (stored === "compact" || stored === "comfortable") return stored;
|
||||||
|
} catch { /* localStorage unavailable */ }
|
||||||
|
return "comfortable";
|
||||||
|
});
|
||||||
|
|
||||||
|
const setDensity = useCallback((d: Density) => {
|
||||||
|
setDensityState(d);
|
||||||
|
try { localStorage.setItem("session-viewer-density", d); } catch { /* noop */ }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return [density, setDensity];
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavSessionInfo({ sessionId, project, sessions }: {
|
||||||
|
sessionId: string;
|
||||||
|
project: string;
|
||||||
|
sessions: SessionEntry[];
|
||||||
|
}): React.ReactElement {
|
||||||
|
const entry = sessions.find((s) => s.id === sessionId);
|
||||||
|
const title = entry?.summary || entry?.firstPrompt || "Session";
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1.5 min-w-0 overflow-hidden">
|
||||||
|
{project && (
|
||||||
|
<span className="text-caption text-foreground-muted whitespace-nowrap truncate max-w-[200px]" title={project}>
|
||||||
|
{project}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{project && (
|
||||||
|
<span className="text-foreground-muted opacity-40 flex-shrink-0">/</span>
|
||||||
|
)}
|
||||||
|
<span className="text-body font-medium text-foreground truncate">
|
||||||
|
{title}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
const {
|
const {
|
||||||
@@ -22,6 +66,16 @@ export function App() {
|
|||||||
} = useSession();
|
} = useSession();
|
||||||
|
|
||||||
const filters = useFilters();
|
const filters = useFilters();
|
||||||
|
const [density, setDensity] = useDensity();
|
||||||
|
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||||
|
|
||||||
|
// Close sidebar on mobile after selecting a session
|
||||||
|
const handleSelectSession = useCallback((id: string) => {
|
||||||
|
loadSession(id);
|
||||||
|
if (window.innerWidth < 768) {
|
||||||
|
setSidebarOpen(false);
|
||||||
|
}
|
||||||
|
}, [loadSession]);
|
||||||
|
|
||||||
// URL-driven session selection: sync session ID with URL search params
|
// URL-driven session selection: sync session ID with URL search params
|
||||||
const hasRestoredFromUrl = useRef(false);
|
const hasRestoredFromUrl = useRef(false);
|
||||||
@@ -57,7 +111,6 @@ export function App() {
|
|||||||
const progressEnabled = filters.enabledCategories.has("hook_progress");
|
const progressEnabled = filters.enabledCategories.has("hook_progress");
|
||||||
|
|
||||||
// Count across all session messages (not just filtered) — recompute only on session change.
|
// Count across all session messages (not just filtered) — recompute only on session change.
|
||||||
// This avoids re-running 37 regex patterns whenever filter toggles change.
|
|
||||||
const sensitiveCount = useMemo(
|
const sensitiveCount = useMemo(
|
||||||
() => countSensitiveMessages(currentSession?.messages || []),
|
() => countSensitiveMessages(currentSession?.messages || []),
|
||||||
[currentSession?.messages]
|
[currentSession?.messages]
|
||||||
@@ -182,10 +235,28 @@ export function App() {
|
|||||||
updateViewport();
|
updateViewport();
|
||||||
}, [filteredMessages, updateViewport]);
|
}, [filteredMessages, updateViewport]);
|
||||||
|
|
||||||
|
const isCompact = density === "compact";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen" style={{ background: "var(--color-canvas)" }}>
|
<div className="flex h-screen" style={{ background: "var(--color-canvas)" }}>
|
||||||
|
{/* Sidebar backdrop — visible on mobile when sidebar is open */}
|
||||||
|
{sidebarOpen && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-20 bg-black/50 md:hidden"
|
||||||
|
onClick={() => setSidebarOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Sidebar */}
|
{/* Sidebar */}
|
||||||
<div className="w-80 flex-shrink-0 border-r border-border bg-surface-raised flex flex-col">
|
<div
|
||||||
|
className={`
|
||||||
|
flex-shrink-0 border-r border-border bg-surface-raised flex flex-col
|
||||||
|
fixed inset-y-0 left-0 z-30 w-80
|
||||||
|
transform transition-transform duration-200 ease-out
|
||||||
|
md:relative md:translate-x-0
|
||||||
|
${sidebarOpen ? "translate-x-0" : "-translate-x-full"}
|
||||||
|
`}
|
||||||
|
>
|
||||||
<div className="px-5 py-4 border-b border-border" style={{ background: "linear-gradient(180deg, var(--color-surface-overlay) 0%, var(--color-surface-raised) 100%)" }}>
|
<div className="px-5 py-4 border-b border-border" style={{ background: "linear-gradient(180deg, var(--color-surface-overlay) 0%, var(--color-surface-raised) 100%)" }}>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-heading font-semibold text-foreground tracking-tight">
|
<h1 className="text-heading font-semibold text-foreground tracking-tight">
|
||||||
@@ -214,9 +285,10 @@ export function App() {
|
|||||||
sessions={sessions}
|
sessions={sessions}
|
||||||
loading={sessionsLoading}
|
loading={sessionsLoading}
|
||||||
selectedId={currentSession?.id}
|
selectedId={currentSession?.id}
|
||||||
onSelect={loadSession}
|
onSelect={handleSelectSession}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex-shrink-0">
|
||||||
<FilterPanel
|
<FilterPanel
|
||||||
enabledCategories={filters.enabledCategories}
|
enabledCategories={filters.enabledCategories}
|
||||||
onToggle={filters.toggleCategory}
|
onToggle={filters.toggleCategory}
|
||||||
@@ -225,12 +297,32 @@ export function App() {
|
|||||||
sensitiveCount={sensitiveCount}
|
sensitiveCount={sensitiveCount}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Main */}
|
{/* Main */}
|
||||||
<div className="flex-1 flex flex-col min-w-0">
|
<div className="flex-1 flex flex-col min-w-0">
|
||||||
<div className="glass flex items-center px-5 py-4 border-b border-border z-10">
|
<div className="glass flex items-center px-5 py-3 border-b border-border z-10 gap-3">
|
||||||
{/* Left spacer — mirrors right side width to keep search centered */}
|
{/* Mobile sidebar toggle */}
|
||||||
<div className="flex-1 min-w-0" />
|
<button
|
||||||
|
onClick={() => setSidebarOpen(!sidebarOpen)}
|
||||||
|
className="flex items-center justify-center w-8 h-8 rounded-md text-foreground-muted hover:text-foreground hover:bg-surface-overlay/60 transition-colors md:hidden flex-shrink-0"
|
||||||
|
aria-label="Toggle sidebar"
|
||||||
|
>
|
||||||
|
<Menu />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Left — session info or app title */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
{currentSession ? (
|
||||||
|
<NavSessionInfo
|
||||||
|
sessionId={currentSession.id}
|
||||||
|
project={currentSession.project}
|
||||||
|
sessions={sessions}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="text-body font-medium text-foreground-secondary hidden md:block">Session Viewer</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Center — search bar + contextual redaction controls */}
|
{/* Center — search bar + contextual redaction controls */}
|
||||||
<div className="flex items-center gap-3 flex-shrink-0">
|
<div className="flex items-center gap-3 flex-shrink-0">
|
||||||
@@ -263,8 +355,19 @@ export function App() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right — export button, right-justified */}
|
{/* Right — density toggle + export button */}
|
||||||
<div className="flex-1 min-w-0 flex justify-end">
|
<div className="flex-1 min-w-0 flex items-center justify-end gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setDensity(isCompact ? "comfortable" : "compact")}
|
||||||
|
className={`flex items-center justify-center w-8 h-8 rounded-md transition-colors flex-shrink-0 ${
|
||||||
|
isCompact
|
||||||
|
? "text-accent bg-accent-light"
|
||||||
|
: "text-foreground-muted hover:text-foreground hover:bg-surface-overlay/60"
|
||||||
|
}`}
|
||||||
|
title={isCompact ? "Switch to comfortable density" : "Switch to compact density"}
|
||||||
|
>
|
||||||
|
<LayoutRows size="w-4 h-4" />
|
||||||
|
</button>
|
||||||
{currentSession && (
|
{currentSession && (
|
||||||
<ExportButton
|
<ExportButton
|
||||||
session={currentSession}
|
session={currentSession}
|
||||||
@@ -291,6 +394,9 @@ export function App() {
|
|||||||
focusedIndex={activeFocusIndex}
|
focusedIndex={activeFocusIndex}
|
||||||
toolProgress={currentSession?.toolProgress}
|
toolProgress={currentSession?.toolProgress}
|
||||||
progressEnabled={progressEnabled}
|
progressEnabled={progressEnabled}
|
||||||
|
sessionId={currentSession?.id}
|
||||||
|
project={currentSession?.project}
|
||||||
|
compact={isCompact}
|
||||||
/>
|
/>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { renderMarkdown, highlightSearchText } from "../lib/markdown";
|
|||||||
import { redactMessage } from "../../shared/sensitive-redactor";
|
import { redactMessage } from "../../shared/sensitive-redactor";
|
||||||
import { escapeHtml } from "../../shared/escape-html";
|
import { escapeHtml } from "../../shared/escape-html";
|
||||||
import { ProgressBadge } from "./ProgressBadge";
|
import { ProgressBadge } from "./ProgressBadge";
|
||||||
|
import { ChevronRight, Copy, Check, EyeSlash } from "./Icons";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
message: ParsedMessage;
|
message: ParsedMessage;
|
||||||
@@ -16,6 +17,7 @@ interface Props {
|
|||||||
autoRedactEnabled: boolean;
|
autoRedactEnabled: boolean;
|
||||||
progressEvents?: ParsedMessage[];
|
progressEvents?: ParsedMessage[];
|
||||||
progressEnabled?: boolean;
|
progressEnabled?: boolean;
|
||||||
|
compact?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -34,6 +36,7 @@ export function MessageBubble({
|
|||||||
autoRedactEnabled,
|
autoRedactEnabled,
|
||||||
progressEvents,
|
progressEvents,
|
||||||
progressEnabled,
|
progressEnabled,
|
||||||
|
compact = false,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const colors = CATEGORY_COLORS[message.category];
|
const colors = CATEGORY_COLORS[message.category];
|
||||||
const label = CATEGORY_LABELS[message.category];
|
const label = CATEGORY_LABELS[message.category];
|
||||||
@@ -58,7 +61,6 @@ 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.
|
|
||||||
if (msg.category === "hook_progress" || msg.category === "file_snapshot") {
|
if (msg.category === "hook_progress" || msg.category === "file_snapshot") {
|
||||||
const html = `<pre class="hljs"><code>${escapeHtml(tryPrettyJson(msg.content))}</code></pre>`;
|
const html = `<pre class="hljs"><code>${escapeHtml(tryPrettyJson(msg.content))}</code></pre>`;
|
||||||
return searchQuery ? highlightSearchText(html, searchQuery) : html;
|
return searchQuery ? highlightSearchText(html, searchQuery) : html;
|
||||||
@@ -99,11 +101,28 @@ export function MessageBubble({
|
|||||||
? formatTimestamp(message.timestamp)
|
? formatTimestamp(message.timestamp)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
// Content is sourced from local user-owned JSONL files (~/.claude/projects/), not untrusted input
|
||||||
|
const contentEl = !collapsed ? (
|
||||||
|
<div
|
||||||
|
className={`prose-message text-body text-foreground max-w-none break-words overflow-hidden ${
|
||||||
|
compact ? "px-3 pb-2 pt-0.5" : "px-5 pb-4 pt-1"
|
||||||
|
}`}
|
||||||
|
dangerouslySetInnerHTML={{ __html: renderedHtml }}
|
||||||
|
/>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
const collapsedPreviewEl = collapsed && message.category === "thinking" && collapsedPreview ? (
|
||||||
|
<div className={compact ? "px-3 pb-2 pt-0.5" : "px-5 pb-3 pt-1"}>
|
||||||
|
<pre className="text-caption text-foreground-muted whitespace-pre-wrap line-clamp-2 font-mono">{collapsedPreview.preview}</pre>
|
||||||
|
</div>
|
||||||
|
) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`
|
className={`
|
||||||
group rounded-xl border bg-surface-raised
|
group rounded-xl border-l-[3px] border bg-surface-raised
|
||||||
transition-all duration-200 relative overflow-hidden
|
transition-all duration-200
|
||||||
|
${colors.accentBorder}
|
||||||
${colors.border}
|
${colors.border}
|
||||||
${dimmed ? "message-dimmed" : ""}
|
${dimmed ? "message-dimmed" : ""}
|
||||||
${selectedForRedaction ? "redaction-selected" : ""}
|
${selectedForRedaction ? "redaction-selected" : ""}
|
||||||
@@ -111,23 +130,19 @@ export function MessageBubble({
|
|||||||
shadow-card
|
shadow-card
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
{/* Category accent strip */}
|
|
||||||
<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-1.5 px-5 min-h-10 py-2.5">
|
<div className={`flex items-center gap-1.5 ${compact ? "px-3 min-h-8 py-1.5" : "px-5 min-h-10 py-2.5"}`}>
|
||||||
{isCollapsible && (
|
{isCollapsible && (
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); setCollapsed(!collapsed); }}
|
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"
|
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"}
|
aria-label={collapsed ? "Expand" : "Collapse"}
|
||||||
>
|
>
|
||||||
<svg
|
<ChevronRight
|
||||||
className={`w-3.5 h-3.5 transition-transform duration-150 ${collapsed ? "" : "rotate-90"}`}
|
size="w-3.5 h-3.5"
|
||||||
fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}
|
strokeWidth={2.5}
|
||||||
>
|
className={`transition-transform duration-150 ${collapsed ? "" : "rotate-90"}`}
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
|
/>
|
||||||
</svg>
|
|
||||||
</button>
|
</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}`} />
|
||||||
@@ -136,7 +151,7 @@ export function MessageBubble({
|
|||||||
</span>
|
</span>
|
||||||
{timestamp && (
|
{timestamp && (
|
||||||
<>
|
<>
|
||||||
<span className="text-border leading-none">·</span>
|
<span className="text-border leading-none">·</span>
|
||||||
<span className="text-caption text-foreground-muted tabular-nums leading-none">
|
<span className="text-caption text-foreground-muted tabular-nums leading-none">
|
||||||
{timestamp}
|
{timestamp}
|
||||||
</span>
|
</span>
|
||||||
@@ -144,7 +159,7 @@ export function MessageBubble({
|
|||||||
)}
|
)}
|
||||||
{isCollapsible && collapsed && collapsedPreview && (
|
{isCollapsible && collapsed && collapsedPreview && (
|
||||||
<>
|
<>
|
||||||
<span className="text-border leading-none">·</span>
|
<span className="text-border leading-none">·</span>
|
||||||
<span className="text-caption text-foreground-muted truncate max-w-[300px] leading-none">
|
<span className="text-caption text-foreground-muted truncate max-w-[300px] leading-none">
|
||||||
{message.category === "thinking" && collapsedPreview.totalLines > 2
|
{message.category === "thinking" && collapsedPreview.totalLines > 2
|
||||||
? `${collapsedPreview.totalLines} lines`
|
? `${collapsedPreview.totalLines} lines`
|
||||||
@@ -170,13 +185,9 @@ export function MessageBubble({
|
|||||||
title="Copy message content"
|
title="Copy message content"
|
||||||
>
|
>
|
||||||
{contentCopied ? (
|
{contentCopied ? (
|
||||||
<svg className="w-4 h-4 text-green-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
<Check size="w-4 h-4" className="text-green-400" />
|
||||||
<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}>
|
<Copy />
|
||||||
<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>
|
||||||
<button
|
<button
|
||||||
@@ -191,25 +202,13 @@ export function MessageBubble({
|
|||||||
}`}
|
}`}
|
||||||
title={selectedForRedaction ? "Deselect for redaction" : "Select for redaction"}
|
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}>
|
<EyeSlash />
|
||||||
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content — sourced from local user-owned JSONL files, not external/untrusted input */}
|
{contentEl}
|
||||||
{!collapsed && (
|
{collapsedPreviewEl}
|
||||||
<div
|
|
||||||
className="prose-message text-body text-foreground px-5 pb-4 pt-1 max-w-none break-words overflow-hidden"
|
|
||||||
dangerouslySetInnerHTML={{ __html: renderedHtml }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{collapsed && message.category === "thinking" && collapsedPreview && (
|
|
||||||
<div className="px-5 pb-3 pt-1">
|
|
||||||
<pre className="text-caption text-foreground-muted whitespace-pre-wrap line-clamp-2 font-mono">{collapsedPreview.preview}</pre>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{message.category === "tool_call" && progressEnabled && progressEvents && progressEvents.length > 0 && (
|
{message.category === "tool_call" && progressEnabled && progressEvents && progressEvents.length > 0 && (
|
||||||
<ProgressBadge events={progressEvents} />
|
<ProgressBadge events={progressEvents} />
|
||||||
)}
|
)}
|
||||||
@@ -230,8 +229,6 @@ function isDiffContent(content: string): boolean {
|
|||||||
diffLines++;
|
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;
|
return hunkHeaders >= 1 && diffLines >= 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -269,7 +266,6 @@ function formatTimestamp(ts: string): string {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** If the string is valid JSON, return it pretty-printed; otherwise return as-is. */
|
|
||||||
function tryPrettyJson(text: string): string {
|
function tryPrettyJson(text: string): string {
|
||||||
const trimmed = text.trimStart();
|
const trimmed = text.trimStart();
|
||||||
if (trimmed[0] !== "{" && trimmed[0] !== "[") return text;
|
if (trimmed[0] !== "{" && trimmed[0] !== "[") return text;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { useState, useEffect, useMemo } from "react";
|
import React, { useState, useEffect, useMemo } from "react";
|
||||||
import type { SessionEntry } from "../lib/types";
|
import type { SessionEntry } from "../lib/types";
|
||||||
|
import { ChevronRight, ChevronLeft, ChatBubble } from "./Icons";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
sessions: SessionEntry[];
|
sessions: SessionEntry[];
|
||||||
@@ -49,9 +50,7 @@ export function SessionList({ sessions, loading, selectedId, onSelect }: Props)
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center py-12 px-6 text-center">
|
<div className="flex flex-col items-center justify-center py-12 px-6 text-center">
|
||||||
<div className="w-10 h-10 rounded-xl bg-surface-inset flex items-center justify-center mb-3">
|
<div className="w-10 h-10 rounded-xl bg-surface-inset flex items-center justify-center mb-3">
|
||||||
<svg className="w-5 h-5 text-foreground-muted" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
<ChatBubble size="w-5 h-5" className="text-foreground-muted" />
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M20.25 8.511c.884.284 1.5 1.128 1.5 2.097v4.286c0 1.136-.847 2.1-1.98 2.193-.34.027-.68.052-1.02.072v3.091l-3-3c-1.354 0-2.694-.055-4.02-.163a2.115 2.115 0 01-.825-.242m9.345-8.334a2.126 2.126 0 00-.476-.095 48.64 48.64 0 00-8.048 0c-1.131.094-1.976 1.057-1.976 2.192v4.286c0 .837.46 1.58 1.155 1.951m9.345-8.334V6.637c0-1.621-1.152-3.026-2.76-3.235A48.455 48.455 0 0011.25 3c-2.115 0-4.198.137-6.24.402-1.608.209-2.76 1.614-2.76 3.235v6.226c0 1.621 1.152 3.026 2.76 3.235.577.075 1.157.14 1.74.194V21l4.155-4.155" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
</div>
|
||||||
<p className="text-body font-medium text-foreground-secondary">No sessions found</p>
|
<p className="text-body font-medium text-foreground-secondary">No sessions found</p>
|
||||||
<p className="text-caption text-foreground-muted mt-1">Sessions will appear here once created</p>
|
<p className="text-caption text-foreground-muted mt-1">Sessions will appear here once created</p>
|
||||||
@@ -68,13 +67,15 @@ export function SessionList({ sessions, loading, selectedId, onSelect }: Props)
|
|||||||
onClick={() => setSelectedProject(null)}
|
onClick={() => setSelectedProject(null)}
|
||||||
className="w-full text-left px-4 py-2.5 text-caption font-medium text-accent hover:text-accent-dark hover:bg-surface-overlay flex items-center gap-1.5 border-b border-border-muted transition-colors"
|
className="w-full text-left px-4 py-2.5 text-caption font-medium text-accent hover:text-accent-dark hover:bg-surface-overlay flex items-center gap-1.5 border-b border-border-muted transition-colors"
|
||||||
>
|
>
|
||||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
<ChevronLeft size="w-3.5 h-3.5" />
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
|
|
||||||
</svg>
|
|
||||||
<span>All Projects</span>
|
<span>All Projects</span>
|
||||||
</button>
|
</button>
|
||||||
<div className="px-4 py-2 text-caption font-semibold text-foreground-muted uppercase tracking-wider border-b border-border-muted" style={{ background: "var(--color-surface-inset)" }}>
|
<div
|
||||||
{formatProjectName(selectedProject)}
|
className="px-4 py-2 text-caption font-semibold text-foreground-muted uppercase tracking-wider border-b border-border-muted"
|
||||||
|
style={{ background: "var(--color-surface-inset)" }}
|
||||||
|
title={formatProjectName(selectedProject)}
|
||||||
|
>
|
||||||
|
{truncateProjectName(selectedProject)}
|
||||||
</div>
|
</div>
|
||||||
<div className="py-1 px-2">
|
<div className="py-1 px-2">
|
||||||
{projectSessions.map((session, idx) => {
|
{projectSessions.map((session, idx) => {
|
||||||
@@ -90,18 +91,18 @@ export function SessionList({ sessions, loading, selectedId, onSelect }: Props)
|
|||||||
: "hover:bg-surface-overlay"
|
: "hover:bg-surface-overlay"
|
||||||
}
|
}
|
||||||
`}
|
`}
|
||||||
style={{ animationDelay: `${idx * 30}ms` }}
|
style={{ animationDelay: `${Math.min(idx, 15) * 30}ms` }}
|
||||||
>
|
>
|
||||||
<div className={`text-body font-medium truncate ${isSelected ? "text-accent-dark" : "text-foreground"}`}>
|
<div className={`text-body font-medium truncate ${isSelected ? "text-accent-dark" : "text-foreground"}`}>
|
||||||
{session.summary || session.firstPrompt || "Untitled Session"}
|
{session.summary || session.firstPrompt || "Untitled Session"}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5 mt-1 text-caption text-foreground-muted">
|
<div className="flex items-center gap-1.5 mt-1 text-caption text-foreground-muted">
|
||||||
<span>{formatDate(session.modified || session.created)}</span>
|
<span>{formatRelativeTime(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 && (
|
{session.duration && session.duration > 0 && (
|
||||||
<>
|
<>
|
||||||
<span className="text-border">·</span>
|
<span className="text-border">·</span>
|
||||||
<span className="tabular-nums">{formatSessionDuration(session.duration)}</span>
|
<span className="tabular-nums">{formatSessionDuration(session.duration)}</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -122,24 +123,26 @@ export function SessionList({ sessions, loading, selectedId, onSelect }: Props)
|
|||||||
(a.modified || a.created) > (b.modified || b.created) ? a : b
|
(a.modified || a.created) > (b.modified || b.created) ? a : b
|
||||||
);
|
);
|
||||||
const count = projectSessions.length;
|
const count = projectSessions.length;
|
||||||
|
const totalMessages = projectSessions.reduce((sum, s) => sum + s.messageCount, 0);
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={project}
|
key={project}
|
||||||
onClick={() => setSelectedProject(project)}
|
onClick={() => setSelectedProject(project)}
|
||||||
className="w-full text-left my-0.5 px-3 py-2.5 rounded-lg hover:bg-surface-overlay transition-all duration-200 group"
|
className="w-full text-left my-0.5 px-3 py-2.5 rounded-lg hover:bg-surface-overlay transition-all duration-200 group"
|
||||||
|
title={formatProjectName(project)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="text-body font-medium text-foreground truncate">
|
<div className="text-body font-medium text-foreground truncate">
|
||||||
{formatProjectName(project)}
|
{truncateProjectName(project)}
|
||||||
</div>
|
</div>
|
||||||
<svg className="w-4 h-4 text-foreground-muted opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
<ChevronRight size="w-4 h-4" className="text-foreground-muted opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0" />
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5 mt-1 text-caption text-foreground-muted">
|
<div className="flex items-center gap-1.5 mt-1 text-caption text-foreground-muted">
|
||||||
<span className="tabular-nums">{count} {count === 1 ? "session" : "sessions"}</span>
|
<span className="tabular-nums">{count} {count === 1 ? "session" : "sessions"}</span>
|
||||||
<span className="text-border">·</span>
|
<span className="text-border">·</span>
|
||||||
<span>{formatDate(latest.modified || latest.created)}</span>
|
<span>{formatRelativeTime(latest.modified || latest.created)}</span>
|
||||||
|
<span className="text-border">·</span>
|
||||||
|
<span className="tabular-nums">{totalMessages} msgs</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
@@ -150,10 +153,6 @@ export function SessionList({ sessions, loading, selectedId, onSelect }: Props)
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Best-effort decode of Claude Code's project directory name back to a path.
|
* Best-effort decode of Claude Code's project directory name back to a path.
|
||||||
* Claude encodes project paths by replacing '/' with '-', but this is lossy:
|
|
||||||
* a path like /home/user/my-cool-app encodes as -home-user-my-cool-app and
|
|
||||||
* decodes as /home/user/my/cool/app (hyphens in the original name are lost).
|
|
||||||
* There is no way to distinguish path separators from literal hyphens.
|
|
||||||
*/
|
*/
|
||||||
function formatProjectName(project: string): string {
|
function formatProjectName(project: string): string {
|
||||||
if (project.startsWith("-")) {
|
if (project.startsWith("-")) {
|
||||||
@@ -162,6 +161,14 @@ function formatProjectName(project: string): string {
|
|||||||
return project;
|
return project;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Show last 2 path segments for compact display. */
|
||||||
|
function truncateProjectName(project: string): string {
|
||||||
|
const full = formatProjectName(project);
|
||||||
|
const segments = full.split("/").filter(Boolean);
|
||||||
|
if (segments.length <= 2) return full;
|
||||||
|
return segments.slice(-2).join("/");
|
||||||
|
}
|
||||||
|
|
||||||
function formatSessionDuration(ms: number): string {
|
function formatSessionDuration(ms: number): string {
|
||||||
const minutes = Math.floor(ms / 60000);
|
const minutes = Math.floor(ms / 60000);
|
||||||
if (minutes < 1) return "<1m";
|
if (minutes < 1) return "<1m";
|
||||||
@@ -172,14 +179,27 @@ function formatSessionDuration(ms: number): string {
|
|||||||
return `${hours}h ${rem}m`;
|
return `${hours}h ${rem}m`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(dateStr: string): string {
|
function formatRelativeTime(dateStr: string): string {
|
||||||
if (!dateStr) return "";
|
if (!dateStr) return "";
|
||||||
const d = new Date(dateStr);
|
const d = new Date(dateStr);
|
||||||
if (isNaN(d.getTime())) return dateStr;
|
if (isNaN(d.getTime())) return dateStr;
|
||||||
return d.toLocaleDateString(undefined, {
|
|
||||||
month: "short",
|
const now = Date.now();
|
||||||
day: "numeric",
|
const diffMs = now - d.getTime();
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
if (diffMs < 0) return "just now";
|
||||||
});
|
if (diffMs < 60_000) return "just now";
|
||||||
|
if (diffMs < 3_600_000) {
|
||||||
|
const mins = Math.floor(diffMs / 60_000);
|
||||||
|
return `${mins}m ago`;
|
||||||
|
}
|
||||||
|
if (diffMs < 86_400_000) {
|
||||||
|
const hours = Math.floor(diffMs / 3_600_000);
|
||||||
|
return `${hours}h ago`;
|
||||||
|
}
|
||||||
|
if (diffMs < 604_800_000) {
|
||||||
|
const days = Math.floor(diffMs / 86_400_000);
|
||||||
|
return `${days}d ago`;
|
||||||
|
}
|
||||||
|
return d.toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import React, { useRef, useEffect, useMemo } from "react";
|
import React, { useRef, useEffect, useMemo, useState } from "react";
|
||||||
import type { ParsedMessage } from "../lib/types";
|
import type { ParsedMessage } from "../lib/types";
|
||||||
import { MessageBubble } from "./MessageBubble";
|
import { MessageBubble } from "./MessageBubble";
|
||||||
import { RedactedDivider } from "./RedactedDivider";
|
import { RedactedDivider } from "./RedactedDivider";
|
||||||
|
import { Chat, Filter } from "./Icons";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
messages: ParsedMessage[];
|
messages: ParsedMessage[];
|
||||||
@@ -15,6 +16,9 @@ interface Props {
|
|||||||
focusedIndex?: number;
|
focusedIndex?: number;
|
||||||
toolProgress?: Record<string, ParsedMessage[]>;
|
toolProgress?: Record<string, ParsedMessage[]>;
|
||||||
progressEnabled?: boolean;
|
progressEnabled?: boolean;
|
||||||
|
sessionId?: string;
|
||||||
|
project?: string;
|
||||||
|
compact?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function MessageSkeleton({ delay = 0 }: { delay?: number }) {
|
function MessageSkeleton({ delay = 0 }: { delay?: number }) {
|
||||||
@@ -48,6 +52,9 @@ export function SessionViewer({
|
|||||||
focusedIndex = -1,
|
focusedIndex = -1,
|
||||||
toolProgress,
|
toolProgress,
|
||||||
progressEnabled,
|
progressEnabled,
|
||||||
|
sessionId,
|
||||||
|
project,
|
||||||
|
compact = false,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
@@ -162,12 +169,25 @@ export function SessionViewer({
|
|||||||
className="w-14 h-14 rounded-2xl flex items-center justify-center mx-auto mb-4 border border-border-muted"
|
className="w-14 h-14 rounded-2xl flex items-center justify-center mx-auto mb-4 border border-border-muted"
|
||||||
style={{ background: "linear-gradient(135deg, var(--color-surface-overlay), var(--color-surface-inset))" }}
|
style={{ background: "linear-gradient(135deg, var(--color-surface-overlay), var(--color-surface-inset))" }}
|
||||||
>
|
>
|
||||||
<svg className="w-7 h-7 text-foreground-muted" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
<Chat size="w-7 h-7" className="text-foreground-muted" />
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.129.166 2.27.293 3.423.379.35.026.67.21.865.501L12 21l2.755-4.133a1.14 1.14 0 01.865-.501 48.172 48.172 0 003.423-.379c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0012 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
</div>
|
||||||
<p className="text-subheading font-medium text-foreground">Select a session</p>
|
<p className="text-subheading font-medium text-foreground">Select a session</p>
|
||||||
<p className="text-body text-foreground-muted mt-1.5">Choose a session from the sidebar to view its messages</p>
|
<p className="text-body text-foreground-muted mt-1.5">Choose a session from the sidebar to view its messages</p>
|
||||||
|
<div className="mt-5 flex flex-col gap-2 text-caption text-foreground-muted">
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
<kbd className="inline-flex items-center justify-center w-5 h-5 text-[11px] bg-surface-overlay border border-border rounded font-mono">j</kbd>
|
||||||
|
<kbd className="inline-flex items-center justify-center w-5 h-5 text-[11px] bg-surface-overlay border border-border rounded font-mono">k</kbd>
|
||||||
|
<span>Navigate messages</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
<kbd className="inline-flex items-center justify-center w-5 h-5 text-[11px] bg-surface-overlay border border-border rounded font-mono">/</kbd>
|
||||||
|
<span>Search</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
<kbd className="inline-flex items-center justify-center px-1.5 h-5 text-[11px] bg-surface-overlay border border-border rounded font-mono">Esc</kbd>
|
||||||
|
<span>Clear search</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -181,9 +201,7 @@ export function SessionViewer({
|
|||||||
className="w-14 h-14 rounded-2xl flex items-center justify-center mx-auto mb-4 border border-border-muted"
|
className="w-14 h-14 rounded-2xl flex items-center justify-center mx-auto mb-4 border border-border-muted"
|
||||||
style={{ background: "linear-gradient(135deg, var(--color-surface-overlay), var(--color-surface-inset))" }}
|
style={{ background: "linear-gradient(135deg, var(--color-surface-overlay), var(--color-surface-inset))" }}
|
||||||
>
|
>
|
||||||
<svg className="w-7 h-7 text-foreground-muted" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
<Filter size="w-7 h-7" className="text-foreground-muted" />
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3c2.755 0 5.455.232 8.083.678.533.09.917.556.917 1.096v1.044a2.25 2.25 0 01-.659 1.591l-5.432 5.432a2.25 2.25 0 00-.659 1.591v2.927a2.25 2.25 0 01-1.244 2.013L9.75 21v-6.568a2.25 2.25 0 00-.659-1.591L3.659 7.409A2.25 2.25 0 013 5.818V4.774c0-.54.384-1.006.917-1.096A48.32 48.32 0 0112 3z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
</div>
|
||||||
<p className="text-subheading font-medium text-foreground">No matching messages</p>
|
<p className="text-subheading font-medium text-foreground">No matching messages</p>
|
||||||
<p className="text-body text-foreground-muted mt-1.5">Try adjusting your filters or search query</p>
|
<p className="text-body text-foreground-muted mt-1.5">Try adjusting your filters or search query</p>
|
||||||
@@ -194,12 +212,28 @@ export function SessionViewer({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-6xl mx-auto px-6 py-6">
|
<div className="max-w-6xl mx-auto px-6 py-6">
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="sticky top-0 z-10 -mx-6 px-6 py-3 mb-4 glass-subtle border-b border-border-muted">
|
||||||
<span className="text-caption text-foreground-muted tabular-nums">
|
<div className="flex items-center justify-between gap-4 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 min-w-0 overflow-x-auto scrollbar-none">
|
||||||
|
{project && (
|
||||||
|
<>
|
||||||
|
<span className="text-caption text-foreground-muted whitespace-nowrap">{project}</span>
|
||||||
|
<span className="text-foreground-muted opacity-40 flex-shrink-0">/</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{sessionId && (
|
||||||
|
<div className="flex items-center gap-1.5 min-w-0">
|
||||||
|
<code className="text-caption text-foreground-muted font-mono whitespace-nowrap">{sessionId}</code>
|
||||||
|
<CopyIdButton value={sessionId} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="text-caption text-foreground-muted tabular-nums whitespace-nowrap flex-shrink-0">
|
||||||
{messages.length} message{messages.length !== 1 ? "s" : ""}
|
{messages.length} message{messages.length !== 1 ? "s" : ""}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div ref={containerRef} className="space-y-3">
|
</div>
|
||||||
|
<div ref={containerRef} className={compact ? "space-y-1.5" : "space-y-3"}>
|
||||||
{displayItems.map((item, idx) => {
|
{displayItems.map((item, idx) => {
|
||||||
if (item.type === "redacted_divider") {
|
if (item.type === "redacted_divider") {
|
||||||
return <RedactedDivider key={item.key} />;
|
return <RedactedDivider key={item.key} />;
|
||||||
@@ -234,7 +268,7 @@ export function SessionViewer({
|
|||||||
id={`msg-${msg.uuid}`}
|
id={`msg-${msg.uuid}`}
|
||||||
data-msg-index={item.messageIndex}
|
data-msg-index={item.messageIndex}
|
||||||
className={`${idx < 20 ? "animate-fade-in" : ""} ${isFocused ? "search-match-focused rounded-xl" : ""}`}
|
className={`${idx < 20 ? "animate-fade-in" : ""} ${isFocused ? "search-match-focused rounded-xl" : ""}`}
|
||||||
style={idx < 20 ? { animationDelay: `${idx * 20}ms`, animationFillMode: "backwards" } : undefined}
|
style={idx < 20 ? { animationDelay: `${Math.min(idx, 15) * 20}ms`, animationFillMode: "backwards" } : undefined}
|
||||||
>
|
>
|
||||||
<MessageBubble
|
<MessageBubble
|
||||||
message={msg}
|
message={msg}
|
||||||
@@ -247,6 +281,7 @@ export function SessionViewer({
|
|||||||
autoRedactEnabled={autoRedactEnabled}
|
autoRedactEnabled={autoRedactEnabled}
|
||||||
progressEvents={progressEvents}
|
progressEvents={progressEvents}
|
||||||
progressEnabled={progressEnabled}
|
progressEnabled={progressEnabled}
|
||||||
|
compact={compact}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -256,6 +291,35 @@ export function SessionViewer({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function CopyIdButton({ value }: { value: string }) {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
function handleCopy(): void {
|
||||||
|
navigator.clipboard.writeText(value).then(() => {
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 1500);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={handleCopy}
|
||||||
|
className="flex-shrink-0 p-0.5 rounded text-foreground-muted opacity-50 hover:opacity-100 transition-opacity"
|
||||||
|
title="Copy session ID"
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<svg className="w-3.5 h-3.5 text-category-assistant" 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-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 01-.75.75H9.75a.75.75 0 01-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 011.927-.184" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function formatDuration(ms: number): string {
|
function formatDuration(ms: number): string {
|
||||||
const minutes = Math.floor(ms / 60000);
|
const minutes = Math.floor(ms / 60000);
|
||||||
if (minutes < 60) return `${minutes}m`;
|
if (minutes < 60) return `${minutes}m`;
|
||||||
|
|||||||
@@ -2,51 +2,60 @@ import type { MessageCategory } from "./types";
|
|||||||
|
|
||||||
export const CATEGORY_COLORS: Record<
|
export const CATEGORY_COLORS: Record<
|
||||||
MessageCategory,
|
MessageCategory,
|
||||||
{ dot: string; border: string; text: string }
|
{ dot: string; border: string; text: string; accentBorder: string }
|
||||||
> = {
|
> = {
|
||||||
user_message: {
|
user_message: {
|
||||||
dot: "bg-category-user",
|
dot: "bg-category-user",
|
||||||
border: "border-category-user-border",
|
border: "border-category-user-border",
|
||||||
text: "text-category-user",
|
text: "text-category-user",
|
||||||
|
accentBorder: "border-l-category-user",
|
||||||
},
|
},
|
||||||
assistant_text: {
|
assistant_text: {
|
||||||
dot: "bg-category-assistant",
|
dot: "bg-category-assistant",
|
||||||
border: "border-category-assistant-border",
|
border: "border-category-assistant-border",
|
||||||
text: "text-category-assistant",
|
text: "text-category-assistant",
|
||||||
|
accentBorder: "border-l-category-assistant",
|
||||||
},
|
},
|
||||||
thinking: {
|
thinking: {
|
||||||
dot: "bg-category-thinking",
|
dot: "bg-category-thinking",
|
||||||
border: "border-category-thinking-border",
|
border: "border-category-thinking-border",
|
||||||
text: "text-category-thinking",
|
text: "text-category-thinking",
|
||||||
|
accentBorder: "border-l-category-thinking",
|
||||||
},
|
},
|
||||||
tool_call: {
|
tool_call: {
|
||||||
dot: "bg-category-tool",
|
dot: "bg-category-tool",
|
||||||
border: "border-category-tool-border",
|
border: "border-category-tool-border",
|
||||||
text: "text-category-tool",
|
text: "text-category-tool",
|
||||||
|
accentBorder: "border-l-category-tool",
|
||||||
},
|
},
|
||||||
tool_result: {
|
tool_result: {
|
||||||
dot: "bg-category-result",
|
dot: "bg-category-result",
|
||||||
border: "border-category-result-border",
|
border: "border-category-result-border",
|
||||||
text: "text-category-result",
|
text: "text-category-result",
|
||||||
|
accentBorder: "border-l-category-result",
|
||||||
},
|
},
|
||||||
system_message: {
|
system_message: {
|
||||||
dot: "bg-category-system",
|
dot: "bg-category-system",
|
||||||
border: "border-category-system-border",
|
border: "border-category-system-border",
|
||||||
text: "text-category-system",
|
text: "text-category-system",
|
||||||
|
accentBorder: "border-l-category-system",
|
||||||
},
|
},
|
||||||
hook_progress: {
|
hook_progress: {
|
||||||
dot: "bg-category-hook",
|
dot: "bg-category-hook",
|
||||||
border: "border-category-hook-border",
|
border: "border-category-hook-border",
|
||||||
text: "text-category-hook",
|
text: "text-category-hook",
|
||||||
|
accentBorder: "border-l-category-hook",
|
||||||
},
|
},
|
||||||
file_snapshot: {
|
file_snapshot: {
|
||||||
dot: "bg-category-snapshot",
|
dot: "bg-category-snapshot",
|
||||||
border: "border-category-snapshot-border",
|
border: "border-category-snapshot-border",
|
||||||
text: "text-category-snapshot",
|
text: "text-category-snapshot",
|
||||||
|
accentBorder: "border-l-category-snapshot",
|
||||||
},
|
},
|
||||||
summary: {
|
summary: {
|
||||||
dot: "bg-category-summary",
|
dot: "bg-category-summary",
|
||||||
border: "border-category-summary-border",
|
border: "border-category-summary-border",
|
||||||
text: "text-category-summary",
|
text: "text-category-summary",
|
||||||
|
accentBorder: "border-l-category-summary",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user