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:
2026-01-30 23:04:04 -05:00
parent 9c4fc89cac
commit f69ba1f32a
4 changed files with 69 additions and 14 deletions

View File

@@ -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>

View File

@@ -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>
);
}

View File

@@ -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>
);

View File

@@ -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);
}
}
}, []);