Wire progress events through session viewer and fix request race condition
MessageBubble: - Accept progressEvents and progressEnabled props - Render ProgressBadge below tool_call content when progress data exists - Normalize left padding from mixed pl-5/px-4 to consistent px-5 SessionViewer: - Thread toolProgress map and progressEnabled flag through to messages - Look up progress events by toolUseId for each tool_call message - Fix hash-anchor scroll firing on every filter toggle by tracking whether scroll has occurred per session load (hashScrolledRef) - Increase vertical spacing on time gap dividers and header area App: - Derive progressEnabled from hook_progress category filter state - Pass toolProgress and progressEnabled to SessionViewer - Optimize sensitiveCount to compute across all session messages (not filtered subset) to avoid re-running 37 regex patterns on every filter toggle - Tighten redaction selection badge gap from 2 to 1.5 useSession: - Add AbortController to loadSession to cancel stale in-flight requests when user rapidly switches sessions - Only clear loading state if the completing request is still current - Ignore AbortError exceptions from cancelled fetches Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -54,9 +54,13 @@ export function App() {
|
||||
return filters.filterMessages(currentSession.messages);
|
||||
}, [currentSession, filters.filterMessages]);
|
||||
|
||||
const progressEnabled = filters.enabledCategories.has("hook_progress");
|
||||
|
||||
// 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(
|
||||
() => countSensitiveMessages(filteredMessages),
|
||||
[filteredMessages]
|
||||
() => countSensitiveMessages(currentSession?.messages || []),
|
||||
[currentSession?.messages]
|
||||
);
|
||||
|
||||
// Track which filtered-message indices match the search query
|
||||
@@ -224,7 +228,7 @@ export function App() {
|
||||
|
||||
{/* Main */}
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
<div className="glass flex items-center px-5 py-2.5 border-b border-border z-10">
|
||||
<div className="glass flex items-center px-5 py-4 border-b border-border z-10">
|
||||
{/* Left spacer — mirrors right side width to keep search centered */}
|
||||
<div className="flex-1 min-w-0" />
|
||||
|
||||
@@ -239,7 +243,7 @@ export function App() {
|
||||
onPrev={goToPrevMatch}
|
||||
/>
|
||||
{filters.selectedForRedaction.size > 0 && (
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-red-500/8 border border-red-500/15 animate-fade-in">
|
||||
<div className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-red-500/8 border border-red-500/15 animate-fade-in">
|
||||
<span className="text-caption text-red-400 font-medium tabular-nums whitespace-nowrap">
|
||||
{filters.selectedForRedaction.size} selected
|
||||
</span>
|
||||
@@ -285,6 +289,8 @@ export function App() {
|
||||
onToggleRedactionSelection={filters.toggleRedactionSelection}
|
||||
autoRedactEnabled={filters.autoRedactEnabled}
|
||||
focusedIndex={activeFocusIndex}
|
||||
toolProgress={currentSession?.toolProgress}
|
||||
progressEnabled={progressEnabled}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { CATEGORY_COLORS } from "../lib/constants";
|
||||
import { renderMarkdown, highlightSearchText } from "../lib/markdown";
|
||||
import { redactMessage } from "../../shared/sensitive-redactor";
|
||||
import { escapeHtml } from "../../shared/escape-html";
|
||||
import { ProgressBadge } from "./ProgressBadge";
|
||||
|
||||
interface Props {
|
||||
message: ParsedMessage;
|
||||
@@ -13,6 +14,8 @@ interface Props {
|
||||
selectedForRedaction: boolean;
|
||||
onToggleRedactionSelection: () => void;
|
||||
autoRedactEnabled: boolean;
|
||||
progressEvents?: ParsedMessage[];
|
||||
progressEnabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -29,6 +32,8 @@ export function MessageBubble({
|
||||
selectedForRedaction,
|
||||
onToggleRedactionSelection,
|
||||
autoRedactEnabled,
|
||||
progressEvents,
|
||||
progressEnabled,
|
||||
}: Props) {
|
||||
const colors = CATEGORY_COLORS[message.category];
|
||||
const label = CATEGORY_LABELS[message.category];
|
||||
@@ -110,7 +115,7 @@ 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 h-10">
|
||||
<div className="flex items-center gap-1.5 px-5 min-h-10 py-2.5">
|
||||
{isCollapsible && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setCollapsed(!collapsed); }}
|
||||
@@ -196,15 +201,18 @@ export function MessageBubble({
|
||||
{/* 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"
|
||||
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-4 pl-5 pb-3 pt-1">
|
||||
<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 && (
|
||||
<ProgressBadge events={progressEvents} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ interface Props {
|
||||
onToggleRedactionSelection: (uuid: string) => void;
|
||||
autoRedactEnabled: boolean;
|
||||
focusedIndex?: number;
|
||||
toolProgress?: Record<string, ParsedMessage[]>;
|
||||
progressEnabled?: boolean;
|
||||
}
|
||||
|
||||
function MessageSkeleton({ delay = 0 }: { delay?: number }) {
|
||||
@@ -44,6 +46,8 @@ export function SessionViewer({
|
||||
onToggleRedactionSelection,
|
||||
autoRedactEnabled,
|
||||
focusedIndex = -1,
|
||||
toolProgress,
|
||||
progressEnabled,
|
||||
}: Props) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -58,10 +62,21 @@ export function SessionViewer({
|
||||
}
|
||||
}, [focusedIndex]);
|
||||
|
||||
// Auto-scroll to hash anchor on load
|
||||
// Auto-scroll to hash anchor on initial session load only.
|
||||
// Track whether we've already scrolled for this session to avoid
|
||||
// re-triggering when filter toggles change messages.length.
|
||||
const hashScrolledRef = useRef(false);
|
||||
|
||||
// Reset the flag when the underlying session data changes (new session loaded)
|
||||
useEffect(() => {
|
||||
hashScrolledRef.current = false;
|
||||
}, [allMessages]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hashScrolledRef.current) return;
|
||||
const hash = window.location.hash;
|
||||
if (hash && hash.startsWith("#msg-") && messages.length > 0) {
|
||||
hashScrolledRef.current = true;
|
||||
requestAnimationFrame(() => {
|
||||
const el = document.getElementById(hash.slice(1));
|
||||
if (el) {
|
||||
@@ -79,6 +94,7 @@ export function SessionViewer({
|
||||
if (messages.length === 0) return [];
|
||||
|
||||
const visibleUuids = new Set(messages.map((m) => m.uuid));
|
||||
|
||||
const items: Array<
|
||||
| { type: "message"; message: ParsedMessage; messageIndex: number }
|
||||
| { type: "redacted_divider"; key: string }
|
||||
@@ -88,14 +104,17 @@ export function SessionViewer({
|
||||
let prevWasRedactedGap = false;
|
||||
let prevTimestamp: string | undefined;
|
||||
let messageIndex = 0;
|
||||
|
||||
for (const msg of allMessages) {
|
||||
if (redactedUuids.has(msg.uuid)) {
|
||||
prevWasRedactedGap = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!visibleUuids.has(msg.uuid)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (prevWasRedactedGap) {
|
||||
items.push({
|
||||
type: "redacted_divider",
|
||||
@@ -174,8 +193,8 @@ export function SessionViewer({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto px-6 py-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="max-w-6xl mx-auto px-6 py-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<span className="text-caption text-foreground-muted tabular-nums">
|
||||
{messages.length} message{messages.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
@@ -187,7 +206,7 @@ export function SessionViewer({
|
||||
}
|
||||
if (item.type === "time_gap") {
|
||||
return (
|
||||
<div key={item.key} className="flex items-center gap-3 py-2">
|
||||
<div key={item.key} className="flex items-center gap-3 py-3">
|
||||
<div className="flex-1 h-px bg-border-muted" />
|
||||
<span className="text-caption text-foreground-muted tabular-nums flex-shrink-0">
|
||||
{item.duration} later
|
||||
@@ -204,6 +223,11 @@ export function SessionViewer({
|
||||
(msg.toolInput && msg.toolInput.toLowerCase().includes(lq)));
|
||||
const isDimmed = searchQuery && !isMatch;
|
||||
const isFocused = item.messageIndex === focusedIndex;
|
||||
// Look up progress events for this tool_call
|
||||
const progressEvents =
|
||||
msg.category === "tool_call" && msg.toolUseId && toolProgress
|
||||
? toolProgress[msg.toolUseId]
|
||||
: undefined;
|
||||
return (
|
||||
<div
|
||||
key={msg.uuid}
|
||||
@@ -221,6 +245,8 @@ export function SessionViewer({
|
||||
onToggleRedactionSelection(msg.uuid)
|
||||
}
|
||||
autoRedactEnabled={autoRedactEnabled}
|
||||
progressEvents={progressEvents}
|
||||
progressEnabled={progressEnabled}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import type { SessionEntry, SessionDetailResponse } from "../lib/types";
|
||||
|
||||
interface SessionState {
|
||||
@@ -22,6 +22,9 @@ export function useSession(): SessionState {
|
||||
const [sessionLoading, setSessionLoading] = useState(false);
|
||||
const [sessionError, setSessionError] = useState<string | null>(null);
|
||||
|
||||
// Track in-flight session request to prevent stale responses
|
||||
const sessionAbortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const fetchSessions = useCallback(async (refresh = false) => {
|
||||
setSessionsLoading(true);
|
||||
setSessionsError(null);
|
||||
@@ -44,19 +47,31 @@ export function useSession(): SessionState {
|
||||
const refreshSessions = useCallback(() => fetchSessions(true), [fetchSessions]);
|
||||
|
||||
const loadSession = useCallback(async (id: string) => {
|
||||
// Abort any in-flight request to prevent stale responses
|
||||
sessionAbortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
sessionAbortRef.current = controller;
|
||||
|
||||
setSessionLoading(true);
|
||||
setSessionError(null);
|
||||
try {
|
||||
const res = await fetch(`/api/sessions/${encodeURIComponent(id)}`);
|
||||
const res = await fetch(`/api/sessions/${encodeURIComponent(id)}`, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
setCurrentSession(data);
|
||||
} catch (err) {
|
||||
// Ignore aborted requests — a newer request superseded this one
|
||||
if (err instanceof DOMException && err.name === "AbortError") return;
|
||||
setSessionError(
|
||||
err instanceof Error ? err.message : "Failed to load session"
|
||||
);
|
||||
} finally {
|
||||
setSessionLoading(false);
|
||||
// Only clear loading if this controller wasn't superseded
|
||||
if (sessionAbortRef.current === controller) {
|
||||
setSessionLoading(false);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user