Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -257,7 +257,12 @@ describe("buildConversationItems", () => {
);
// Spinner row (now complete) + the failure row.
expect(statusItems.map((i) => i.update)).toEqual([
{ sessionUpdate: "status", status: "compacting", isComplete: true },
{
sessionUpdate: "status",
status: "compacting",
isComplete: true,
startedAt: 2,
},
{
sessionUpdate: "status",
status: "compacting_failed",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -599,6 +599,7 @@ function handleNotification(
sessionUpdate: "status",
status: params.status,
isComplete: params.isComplete,
startedAt: ts,
});
return;
}
Expand Down Expand Up @@ -689,9 +690,15 @@ function markCompactingStatusComplete(b: ItemBuilder) {
if (
item.type === "session_update" &&
item.update.sessionUpdate === "status" &&
item.update.status === "compacting"
item.update.status === "compacting" &&
!item.update.isComplete
) {
item.update.isComplete = true;
// Replace the row and its update with fresh objects rather than mutating
// in place. The incremental builder reuses item identity so memoized rows
// skip re-render; an in-place flip can be missed, leaving the finished row
// stuck with its spinner and a still-ticking timer. A new reference forces
// the completion to render (and the row to unmount).
b.items[i] = { ...item, update: { ...item.update, isComplete: true } };
return;
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ export type RenderItem =
sessionUpdate: "status";
status: string;
isComplete?: boolean;
/** Epoch ms a `compacting` status began; drives the elapsed timer. */
startedAt?: number;
/** Set when a status ends in failure (e.g. a failed compaction) so the row renders the error. */
error?: string;
/** Refusal statuses: display-only stop_details.explanation from the API. */
Expand Down Expand Up @@ -130,6 +132,7 @@ export const SessionUpdateView = memo(function SessionUpdateView({
<StatusNotificationView
status={item.status}
isComplete={item.isComplete}
startedAt={item.startedAt}
error={item.error}
explanation={item.explanation}
fromModel={item.fromModel}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,16 @@ import {
} from "@phosphor-icons/react";
import { ChatMarker, ChatMarkerContent } from "@posthog/quill";
import { Box, Callout, Flex, Text } from "@radix-ui/themes";
import { useEffect, useState } from "react";
import { useChatThreadChrome } from "../chat-thread/chatThreadChrome";
import { formatDuration } from "../GeneratingIndicator";

interface StatusNotificationViewProps {
status: string;
isComplete?: boolean;
/** Epoch ms when a `compacting` status began; anchors the elapsed timer so it
* survives unmount/remount in the virtualized list instead of resetting. */
startedAt?: number;
/** Failure reason, set on a `compacting_failed` status. */
error?: string;
/** Refusal statuses: display-only stop_details.explanation from the API. */
Expand All @@ -24,6 +29,7 @@ interface StatusNotificationViewProps {
export function StatusNotificationView({
status,
isComplete,
startedAt,
error,
explanation,
fromModel,
Expand Down Expand Up @@ -107,16 +113,7 @@ export function StatusNotificationView({
if (isComplete) {
return null;
}
return (
<Box className="my-1 border-blue-6 border-l-2 py-1 pl-3 dark:border-blue-8">
<Flex align="center" gap="2">
<Spinner size={14} className="animate-spin text-blue-9" />
<Text className="text-[13px] text-gray-11">
Compacting conversation history...
</Text>
</Flex>
</Box>
);
return <CompactingStatusView startedAt={startedAt} />;
}

// Generic status display for other statuses
Expand All @@ -128,3 +125,43 @@ export function StatusNotificationView({
</Box>
);
}

/**
* In-flight compaction row. Compaction is a single streaming summarization call
* with no measurable percentage, so we pair the spinner with an indeterminate
* progress bar (constant motion, so it never reads as frozen) and a live
* elapsed-time counter, which is the one honest progress signal we have.
*/
function CompactingStatusView({ startedAt }: { startedAt?: number }) {
const [elapsed, setElapsed] = useState(() =>
startedAt ? Date.now() - startedAt : 0,
);

useEffect(() => {
// Anchor to the persisted compaction start time so remounting this row
// (e.g. scrolling it out of and back into the virtualized list while
// compaction runs) keeps counting from when compaction began rather than
// resetting to zero. Fall back to mount time only if it's missing.
const start = startedAt ?? Date.now();
const tick = () => setElapsed(Date.now() - start);
tick();
const interval = setInterval(tick, 100);
return () => clearInterval(interval);
}, [startedAt]);

return (
<Box className="my-1 border-blue-6 border-l-2 px-3 py-1 dark:border-blue-8">
<Flex align="center" gap="2">
<Spinner size={14} className="animate-spin text-blue-9" />
<Text className="text-[13px] text-gray-11">
Compacting conversation history...
</Text>
<Text className="text-[13px] text-gray-10 tabular-nums">
{formatDuration(elapsed, 1)}
</Text>
</Flex>
{/* Decorative: the spinner and the text above carry the accessible status. */}
<div className="compacting-progress mt-1.5" aria-hidden="true" />
</Box>
);
}
37 changes: 37 additions & 0 deletions packages/ui/src/styles/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,43 @@
}
}

/*
* Indeterminate progress bar for the in-flight conversation-compaction row.
* Compaction is a single streaming summarization call with no measurable
* percentage, so the bar swoops continuously (reusing the section-loading
* keyframe above) to signal ongoing work while the row's elapsed timer carries
* the concrete progress signal. Blue-tinted to match the compaction status row.
*/
.compacting-progress {
position: relative;
width: 100%;
height: 0.1875rem;
overflow: hidden;
border-radius: 9999px;
background: color-mix(in oklab, var(--blue-9) 15%, transparent);
}

.compacting-progress::after {
position: absolute;
top: 0;
left: 0;
width: 50%;
height: 100%;
content: "";
border-radius: 9999px;
background: var(--blue-9);
animation: quill-section-loading-swoop 1.5s linear infinite;
}

/* No swoop when the user prefers reduced motion; leave a static filled track. */
@media (prefers-reduced-motion: reduce) {
.compacting-progress::after {
width: 100%;
animation: none;
opacity: 0.6;
}
}

/*
* Quill ships its dialog backdrop at `opacity-20` (light) / `opacity-70`
* (dark). The light value is barely visible — bump it so dialogs feel
Expand Down
Loading