Skip to content
Open
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
1 change: 1 addition & 0 deletions src/browser/assets/animations/thinking-dots.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
7 changes: 5 additions & 2 deletions src/browser/components/Messages/ChatBarrier/BaseBarrier.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,19 @@ import React from "react";
import { cn } from "@/common/lib/utils";

interface BaseBarrierProps {
text: string;
text: React.ReactNode;
color: string;
animate?: boolean;
className?: string;
leadingElement?: React.ReactNode;
}

export const BaseBarrier: React.FC<BaseBarrierProps> = ({
text,
color,
animate = false,
className,
leadingElement,
}) => {
return (
<div
Expand All @@ -29,9 +31,10 @@ export const BaseBarrier: React.FC<BaseBarrierProps> = ({
}}
/>
<div
className="font-mono text-[10px] tracking-wide whitespace-nowrap uppercase"
className="flex items-center gap-1 font-mono text-[10px] tracking-wide whitespace-nowrap uppercase"
style={{ color }}
>
{leadingElement}
{text}
</div>
<div
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ export const StreamingBarrier: React.FC<StreamingBarrierProps> = ({ workspaceId,
tps={tps}
cancelText={cancelText}
className={className}
isStreamingPhase={phase === "streaming"}
hintElement={
showCompactionHint ? (
<button
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import React from "react";

import { Shimmer } from "@/browser/components/ai-elements/shimmer";
import { StreamingActivityIcon } from "@/browser/components/icons/StreamingActivityIcon";
import { BaseBarrier } from "./BaseBarrier";

export interface StreamingBarrierViewProps {
Expand All @@ -8,6 +10,7 @@ export interface StreamingBarrierViewProps {
tps?: number;
cancelText: string;
className?: string;
isStreamingPhase?: boolean;
/** Optional hint element shown after status (e.g., settings link) */
hintElement?: React.ReactNode;
}
Expand All @@ -18,11 +21,37 @@ export interface StreamingBarrierViewProps {
* Keep this file free of WorkspaceStore imports so it can be reused by alternate
* frontends (e.g. the VS Code webview) without pulling in the desktop state layer.
*/
const STREAMING_SHIMMER_DURATION_SECONDS = 2;

export const StreamingBarrierView: React.FC<StreamingBarrierViewProps> = (props) => {
const statusText = props.isStreamingPhase ? (
<Shimmer
duration={STREAMING_SHIMMER_DURATION_SECONDS}
colorClass="var(--color-assistant-border)"
>
{props.statusText}
</Shimmer>
) : (
props.statusText
);

const leadingElement = props.isStreamingPhase ? (
<StreamingActivityIcon
className="size-3"
shimmerColor="var(--color-assistant-border)"
shimmerDurationSeconds={STREAMING_SHIMMER_DURATION_SECONDS}
/>
) : undefined;

return (
<div className={`flex items-center justify-between gap-4 ${props.className ?? ""}`}>
<div className="flex flex-1 items-center gap-2">
<BaseBarrier text={props.statusText} color="var(--color-assistant-border)" animate />
<BaseBarrier
text={statusText}
leadingElement={leadingElement}
color="var(--color-assistant-border)"
animate
/>
{props.hintElement}
{props.tokenCount !== undefined && (
<span className="text-assistant-border font-mono text-[11px] whitespace-nowrap select-none">
Expand Down
37 changes: 33 additions & 4 deletions src/browser/components/WorkspaceStatusIndicator.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
import { useWorkspaceSidebarState } from "@/browser/stores/WorkspaceStore";
import { ModelDisplay } from "@/browser/components/Messages/ModelDisplay";
import { Shimmer } from "@/browser/components/ai-elements/shimmer";
import { EmojiIcon } from "@/browser/components/icons/EmojiIcon";
import { StreamingActivityIcon } from "@/browser/components/icons/StreamingActivityIcon";
import { CircleHelp, ExternalLinkIcon, Loader2 } from "lucide-react";
import { memo } from "react";
import { Tooltip, TooltipTrigger, TooltipContent } from "./ui/tooltip";
import { Button } from "./ui/button";

const STREAMING_STATUS_SHIMMER_DURATION_SECONDS = 2;
const STREAMING_STATUS_SHIMMER_COLOR = "var(--color-muted)";

export const WorkspaceStatusIndicator = memo<{
workspaceId: string;
fallbackModel: string;
Expand Down Expand Up @@ -63,23 +68,47 @@ export const WorkspaceStatusIndicator = memo<{

const modelToShow = canInterrupt ? (currentModel ?? fallbackModel) : fallbackModel;
const suffix = phase === "starting" ? "- starting..." : "- streaming...";
const isStreamingPhase = phase === "streaming";

return (
<div className="text-muted flex min-w-0 items-center gap-1.5 text-xs">
{phase === "starting" && (
<Loader2 aria-hidden="true" className="h-3 w-3 shrink-0 animate-spin opacity-70" />
)}
{isStreamingPhase && (
<StreamingActivityIcon
className="text-muted h-3 w-3 shrink-0"
shimmerColor={STREAMING_STATUS_SHIMMER_COLOR}
shimmerDurationSeconds={STREAMING_STATUS_SHIMMER_DURATION_SECONDS}
/>
)}
{modelToShow ? (
<>
<span className="min-w-0 truncate">
<ModelDisplay modelString={modelToShow} showTooltip={false} />
</span>
<span className="shrink-0 opacity-70">{suffix}</span>
{isStreamingPhase ? (
<Shimmer
className="shrink-0 opacity-70"
duration={STREAMING_STATUS_SHIMMER_DURATION_SECONDS}
colorClass={STREAMING_STATUS_SHIMMER_COLOR}
>
{suffix}
</Shimmer>
) : (
<span className="shrink-0 opacity-70">{suffix}</span>
)}
</>
) : isStreamingPhase ? (
<Shimmer
className="min-w-0 truncate"
duration={STREAMING_STATUS_SHIMMER_DURATION_SECONDS}
colorClass={STREAMING_STATUS_SHIMMER_COLOR}
>
Assistant - streaming...
</Shimmer>
) : (
<span className="min-w-0 truncate">
{phase === "starting" ? "Assistant - starting..." : "Assistant - streaming..."}
</span>
<span className="min-w-0 truncate">Assistant - starting...</span>
)}
</div>
);
Expand Down
43 changes: 43 additions & 0 deletions src/browser/components/icons/StreamingActivityIcon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import thinkingDotsSvg from "@/browser/assets/animations/thinking-dots.svg?raw";
import { cn } from "@/common/lib/utils";
import { useId } from "react";

interface StreamingActivityIconProps {
className?: string;
shimmerColor: string;
shimmerDurationSeconds: number;
}

type StreamingActivityIconStyle = React.CSSProperties &
Record<"--shimmer-duration" | "--shimmer-color", string>;

function scopeStreamingSvgIds(svgMarkup: string, idPrefix: string): string {
// The uploaded SVG contains many <animate href="#..."> links. Multiple instances of
// identical IDs collide in the DOM, which can make later icons appear static.
return svgMarkup.replaceAll('"_R_G', `"${idPrefix}_R_G`).replaceAll("#_R_G", `#${idPrefix}_R_G`);
}

/**
* Animated streaming indicator used beside "...streaming" labels.
* The shimmer overlay shares the same duration and color as the adjacent text.
*/
export function StreamingActivityIcon(props: StreamingActivityIconProps) {
const style: StreamingActivityIconStyle = {
"--shimmer-duration": `${props.shimmerDurationSeconds}s`,
"--shimmer-color": props.shimmerColor,
};
const iconId = useId().replaceAll(":", "");
const scopedSvg = scopeStreamingSvgIds(thinkingDotsSvg, `mux_streaming_${iconId}`);

return (
<span
className={cn(
"streaming-activity-icon shimmer-surface inline-flex items-center justify-center",
props.className
)}
style={style}
aria-hidden
dangerouslySetInnerHTML={{ __html: scopedSvg }}
/>
);
}
33 changes: 33 additions & 0 deletions src/browser/styles/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -1945,6 +1945,39 @@ pre code {
-webkit-box-decoration-break: clone;
}

/* Shared shimmer overlay for non-text surfaces (e.g., streaming status icons). */
.shimmer-surface {
position: relative;
display: inline-flex;
overflow: hidden;
isolation: isolate;
}

.streaming-activity-icon > svg {
width: 100%;
height: 100%;
display: block;
}

.shimmer-surface::after {
--shimmer-overlay: color-mix(in srgb, var(--shimmer-color, var(--color-muted-foreground)) 35%, black);
content: "";
position: absolute;
inset: 0;
pointer-events: none;
background: linear-gradient(
90deg,
transparent 0%,
transparent 35%,
var(--shimmer-overlay) 50%,
transparent 65%,
transparent 100%
);
background-size: 300% 100%;
animation: shimmer-text-sweep var(--shimmer-duration, 1.4s) steps(42, end) infinite;
mix-blend-mode: multiply;
}

@keyframes shimmer-text-sweep {
from {
background-position: 100% 0;
Expand Down
Loading