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);
|
return filters.filterMessages(currentSession.messages);
|
||||||
}, [currentSession, filters.filterMessages]);
|
}, [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(
|
const sensitiveCount = useMemo(
|
||||||
() => countSensitiveMessages(filteredMessages),
|
() => countSensitiveMessages(currentSession?.messages || []),
|
||||||
[filteredMessages]
|
[currentSession?.messages]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Track which filtered-message indices match the search query
|
// Track which filtered-message indices match the search query
|
||||||
@@ -224,7 +228,7 @@ export function App() {
|
|||||||
|
|
||||||
{/* 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-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 */}
|
{/* Left spacer — mirrors right side width to keep search centered */}
|
||||||
<div className="flex-1 min-w-0" />
|
<div className="flex-1 min-w-0" />
|
||||||
|
|
||||||
@@ -239,7 +243,7 @@ export function App() {
|
|||||||
onPrev={goToPrevMatch}
|
onPrev={goToPrevMatch}
|
||||||
/>
|
/>
|
||||||
{filters.selectedForRedaction.size > 0 && (
|
{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">
|
<span className="text-caption text-red-400 font-medium tabular-nums whitespace-nowrap">
|
||||||
{filters.selectedForRedaction.size} selected
|
{filters.selectedForRedaction.size} selected
|
||||||
</span>
|
</span>
|
||||||
@@ -285,6 +289,8 @@ export function App() {
|
|||||||
onToggleRedactionSelection={filters.toggleRedactionSelection}
|
onToggleRedactionSelection={filters.toggleRedactionSelection}
|
||||||
autoRedactEnabled={filters.autoRedactEnabled}
|
autoRedactEnabled={filters.autoRedactEnabled}
|
||||||
focusedIndex={activeFocusIndex}
|
focusedIndex={activeFocusIndex}
|
||||||
|
toolProgress={currentSession?.toolProgress}
|
||||||
|
progressEnabled={progressEnabled}
|
||||||
/>
|
/>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { CATEGORY_COLORS } from "../lib/constants";
|
|||||||
import { renderMarkdown, highlightSearchText } from "../lib/markdown";
|
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";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
message: ParsedMessage;
|
message: ParsedMessage;
|
||||||
@@ -13,6 +14,8 @@ interface Props {
|
|||||||
selectedForRedaction: boolean;
|
selectedForRedaction: boolean;
|
||||||
onToggleRedactionSelection: () => void;
|
onToggleRedactionSelection: () => void;
|
||||||
autoRedactEnabled: boolean;
|
autoRedactEnabled: boolean;
|
||||||
|
progressEvents?: ParsedMessage[];
|
||||||
|
progressEnabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -29,6 +32,8 @@ export function MessageBubble({
|
|||||||
selectedForRedaction,
|
selectedForRedaction,
|
||||||
onToggleRedactionSelection,
|
onToggleRedactionSelection,
|
||||||
autoRedactEnabled,
|
autoRedactEnabled,
|
||||||
|
progressEvents,
|
||||||
|
progressEnabled,
|
||||||
}: 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];
|
||||||
@@ -110,7 +115,7 @@ export function MessageBubble({
|
|||||||
<div className={`absolute left-0 top-0 bottom-0 w-[3px] rounded-l-xl ${colors.dot}`} />
|
<div className={`absolute left-0 top-0 bottom-0 w-[3px] rounded-l-xl ${colors.dot}`} />
|
||||||
|
|
||||||
{/* Header bar */}
|
{/* Header bar */}
|
||||||
<div className="flex items-center gap-2 px-4 pl-5 h-10">
|
<div className="flex items-center gap-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); }}
|
||||||
@@ -196,15 +201,18 @@ export function MessageBubble({
|
|||||||
{/* Content — sourced from local user-owned JSONL files, not external/untrusted input */}
|
{/* Content — sourced from local user-owned JSONL files, not external/untrusted input */}
|
||||||
{!collapsed && (
|
{!collapsed && (
|
||||||
<div
|
<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 }}
|
dangerouslySetInnerHTML={{ __html: renderedHtml }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{collapsed && message.category === "thinking" && collapsedPreview && (
|
{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>
|
<pre className="text-caption text-foreground-muted whitespace-pre-wrap line-clamp-2 font-mono">{collapsedPreview.preview}</pre>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{message.category === "tool_call" && progressEnabled && progressEvents && progressEvents.length > 0 && (
|
||||||
|
<ProgressBadge events={progressEvents} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ interface Props {
|
|||||||
onToggleRedactionSelection: (uuid: string) => void;
|
onToggleRedactionSelection: (uuid: string) => void;
|
||||||
autoRedactEnabled: boolean;
|
autoRedactEnabled: boolean;
|
||||||
focusedIndex?: number;
|
focusedIndex?: number;
|
||||||
|
toolProgress?: Record<string, ParsedMessage[]>;
|
||||||
|
progressEnabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function MessageSkeleton({ delay = 0 }: { delay?: number }) {
|
function MessageSkeleton({ delay = 0 }: { delay?: number }) {
|
||||||
@@ -44,6 +46,8 @@ export function SessionViewer({
|
|||||||
onToggleRedactionSelection,
|
onToggleRedactionSelection,
|
||||||
autoRedactEnabled,
|
autoRedactEnabled,
|
||||||
focusedIndex = -1,
|
focusedIndex = -1,
|
||||||
|
toolProgress,
|
||||||
|
progressEnabled,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
@@ -58,10 +62,21 @@ export function SessionViewer({
|
|||||||
}
|
}
|
||||||
}, [focusedIndex]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
|
hashScrolledRef.current = false;
|
||||||
|
}, [allMessages]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (hashScrolledRef.current) return;
|
||||||
const hash = window.location.hash;
|
const hash = window.location.hash;
|
||||||
if (hash && hash.startsWith("#msg-") && messages.length > 0) {
|
if (hash && hash.startsWith("#msg-") && messages.length > 0) {
|
||||||
|
hashScrolledRef.current = true;
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
const el = document.getElementById(hash.slice(1));
|
const el = document.getElementById(hash.slice(1));
|
||||||
if (el) {
|
if (el) {
|
||||||
@@ -79,6 +94,7 @@ export function SessionViewer({
|
|||||||
if (messages.length === 0) return [];
|
if (messages.length === 0) return [];
|
||||||
|
|
||||||
const visibleUuids = new Set(messages.map((m) => m.uuid));
|
const visibleUuids = new Set(messages.map((m) => m.uuid));
|
||||||
|
|
||||||
const items: Array<
|
const items: Array<
|
||||||
| { type: "message"; message: ParsedMessage; messageIndex: number }
|
| { type: "message"; message: ParsedMessage; messageIndex: number }
|
||||||
| { type: "redacted_divider"; key: string }
|
| { type: "redacted_divider"; key: string }
|
||||||
@@ -88,14 +104,17 @@ export function SessionViewer({
|
|||||||
let prevWasRedactedGap = false;
|
let prevWasRedactedGap = false;
|
||||||
let prevTimestamp: string | undefined;
|
let prevTimestamp: string | undefined;
|
||||||
let messageIndex = 0;
|
let messageIndex = 0;
|
||||||
|
|
||||||
for (const msg of allMessages) {
|
for (const msg of allMessages) {
|
||||||
if (redactedUuids.has(msg.uuid)) {
|
if (redactedUuids.has(msg.uuid)) {
|
||||||
prevWasRedactedGap = true;
|
prevWasRedactedGap = true;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!visibleUuids.has(msg.uuid)) {
|
if (!visibleUuids.has(msg.uuid)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (prevWasRedactedGap) {
|
if (prevWasRedactedGap) {
|
||||||
items.push({
|
items.push({
|
||||||
type: "redacted_divider",
|
type: "redacted_divider",
|
||||||
@@ -174,8 +193,8 @@ export function SessionViewer({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-6xl mx-auto px-6 py-5">
|
<div className="max-w-6xl mx-auto px-6 py-6">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<span className="text-caption text-foreground-muted tabular-nums">
|
<span className="text-caption text-foreground-muted tabular-nums">
|
||||||
{messages.length} message{messages.length !== 1 ? "s" : ""}
|
{messages.length} message{messages.length !== 1 ? "s" : ""}
|
||||||
</span>
|
</span>
|
||||||
@@ -187,7 +206,7 @@ export function SessionViewer({
|
|||||||
}
|
}
|
||||||
if (item.type === "time_gap") {
|
if (item.type === "time_gap") {
|
||||||
return (
|
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" />
|
<div className="flex-1 h-px bg-border-muted" />
|
||||||
<span className="text-caption text-foreground-muted tabular-nums flex-shrink-0">
|
<span className="text-caption text-foreground-muted tabular-nums flex-shrink-0">
|
||||||
{item.duration} later
|
{item.duration} later
|
||||||
@@ -204,6 +223,11 @@ export function SessionViewer({
|
|||||||
(msg.toolInput && msg.toolInput.toLowerCase().includes(lq)));
|
(msg.toolInput && msg.toolInput.toLowerCase().includes(lq)));
|
||||||
const isDimmed = searchQuery && !isMatch;
|
const isDimmed = searchQuery && !isMatch;
|
||||||
const isFocused = item.messageIndex === focusedIndex;
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
key={msg.uuid}
|
key={msg.uuid}
|
||||||
@@ -221,6 +245,8 @@ export function SessionViewer({
|
|||||||
onToggleRedactionSelection(msg.uuid)
|
onToggleRedactionSelection(msg.uuid)
|
||||||
}
|
}
|
||||||
autoRedactEnabled={autoRedactEnabled}
|
autoRedactEnabled={autoRedactEnabled}
|
||||||
|
progressEvents={progressEvents}
|
||||||
|
progressEnabled={progressEnabled}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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";
|
import type { SessionEntry, SessionDetailResponse } from "../lib/types";
|
||||||
|
|
||||||
interface SessionState {
|
interface SessionState {
|
||||||
@@ -22,6 +22,9 @@ export function useSession(): SessionState {
|
|||||||
const [sessionLoading, setSessionLoading] = useState(false);
|
const [sessionLoading, setSessionLoading] = useState(false);
|
||||||
const [sessionError, setSessionError] = useState<string | null>(null);
|
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) => {
|
const fetchSessions = useCallback(async (refresh = false) => {
|
||||||
setSessionsLoading(true);
|
setSessionsLoading(true);
|
||||||
setSessionsError(null);
|
setSessionsError(null);
|
||||||
@@ -44,20 +47,32 @@ export function useSession(): SessionState {
|
|||||||
const refreshSessions = useCallback(() => fetchSessions(true), [fetchSessions]);
|
const refreshSessions = useCallback(() => fetchSessions(true), [fetchSessions]);
|
||||||
|
|
||||||
const loadSession = useCallback(async (id: string) => {
|
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);
|
setSessionLoading(true);
|
||||||
setSessionError(null);
|
setSessionError(null);
|
||||||
try {
|
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}`);
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setCurrentSession(data);
|
setCurrentSession(data);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
// Ignore aborted requests — a newer request superseded this one
|
||||||
|
if (err instanceof DOMException && err.name === "AbortError") return;
|
||||||
setSessionError(
|
setSessionError(
|
||||||
err instanceof Error ? err.message : "Failed to load session"
|
err instanceof Error ? err.message : "Failed to load session"
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
|
// Only clear loading if this controller wasn't superseded
|
||||||
|
if (sessionAbortRef.current === controller) {
|
||||||
setSessionLoading(false);
|
setSessionLoading(false);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
Reference in New Issue
Block a user