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
48 changes: 46 additions & 2 deletions apps/desktop/src/features/chat/transcript/ActivityGroup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ import {
useAutomaticDisclosure,
} from "./shared";
import { TranscriptSearchContext } from "../../../lib/transcript-search-context";
import type { LiveTokenRate } from "./hooks/useLiveTokenRate";
import { useAppStore } from "../../../stores/app-store";
import { resolveThinkingDisplayMode } from "../../../lib/turn-process";

Expand Down Expand Up @@ -421,8 +422,39 @@ export const ActivityGroup = memo(function ActivityGroup({
);
}, activityGroupPropsEqual);

function formatLiveTokenCount(value: number): string {
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(value >= 10_000_000 ? 0 : 1)}M`;
if (value >= 10_000) return `${Math.round(value / 1000)}k`;
if (value >= 1000) return `${(value / 1000).toFixed(1)}k`;
return String(value);
}

/** Compact tok/s chip for the run-activity / working status strip. */
export function LiveTokenRateLabel({ rate }: { rate: LiveTokenRate }) {
const { t } = useTranslation();
if (rate.tokensPerSecond === undefined) return null;
return (
<span
className="working-token-rate"
data-testid="live-token-rate"
title={t("chat.usageThroughputLabel")}
aria-hidden="true"
>
{t(
rate.estimated
? "chat.usageLiveThroughputEstimated"
: "chat.usageLiveThroughput",
{ count: formatLiveTokenCount(rate.tokensPerSecond) },
)}
</span>
);
}

/** Keep the running turn visible when no more specific runtime phase is known. */
export function WorkingIndicator({ startedAt }: { startedAt?: number } = {}) {
export function WorkingIndicator({
startedAt,
tokenRate,
}: { startedAt?: number; tokenRate?: LiveTokenRate } = {}) {
const { t } = useTranslation();
const [elapsed, setElapsed] = useState(0);
const startedAtRef = useRef(startedAt ?? Date.now());
Expand Down Expand Up @@ -450,6 +482,9 @@ export function WorkingIndicator({ startedAt }: { startedAt?: number } = {}) {
<span />
</span>
<span className="working-indicator-label">{t("chat.running")}</span>
{tokenRate != null && tokenRate.tokensPerSecond !== undefined ? (
<LiveTokenRateLabel rate={tokenRate} />
) : null}
{elapsed > 0 ? (
<span className="working-elapsed" aria-hidden="true">
{formatToolDuration(elapsed)}
Expand All @@ -459,7 +494,13 @@ export function WorkingIndicator({ startedAt }: { startedAt?: number } = {}) {
);
}

export function RunActivityIndicator({ activity }: { activity: AgentActivity }) {
export function RunActivityIndicator({
activity,
tokenRate,
}: {
activity: AgentActivity;
tokenRate?: LiveTokenRate;
}) {
const { t } = useTranslation();
const [now, setNow] = useState(Date.now);
const retryErrorDetailsId = useId();
Expand Down Expand Up @@ -604,6 +645,9 @@ export function RunActivityIndicator({ activity }: { activity: AgentActivity })
<span />
</span>
{labelContent}
{tokenRate != null && tokenRate.tokensPerSecond !== undefined ? (
<LiveTokenRateLabel rate={tokenRate} />
) : null}
<span className="working-elapsed" aria-hidden="true">
{elapsed}
</span>
Expand Down
31 changes: 28 additions & 3 deletions apps/desktop/src/features/chat/transcript/ChatTranscript.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { memo, type MouseEvent as ReactMouseEvent } from "react";
import { memo, useMemo, type MouseEvent as ReactMouseEvent } from "react";
import { useTranslation } from "react-i18next";
import type { PlanningState, UiMessage } from "@pi-desktop/shared";
import { proposalKindForMode } from "@pi-desktop/shared";
Expand All @@ -15,6 +15,8 @@ import {
RunActivityIndicator,
WorkingIndicator,
} from "./ActivityGroup";
import { useLiveTokenRate } from "./hooks/useLiveTokenRate";
import { assistantTurnMessages } from "../../../lib/assistant-turns";
import { TranscriptHistory, TranscriptTail } from "./AssistantTurn";
import { useTranscriptScroll } from "./hooks/useTranscriptScroll";
import type { TranscriptSearchTarget } from "../../../lib/transcript-reading";
Expand Down Expand Up @@ -171,6 +173,24 @@ function TranscriptBody({
planningState === "planning" &&
!hasSpecializedActivity;

const streamingAssistant = useMemo(() => {
if (!transcriptRunning || tailEntry?.kind !== "assistant-turn") {
return undefined;
}
return [...assistantTurnMessages(tailEntry)]
.reverse()
.find((message) => message.status === "streaming");
}, [transcriptRunning, tailEntry]);

const liveTokenRate = useLiveTokenRate({
active: showStatus,
content: streamingAssistant?.content,
thinking: streamingAssistant?.thinking,
outputTokens:
streamingAssistant?.usage?.outputTokens ??
streamingAssistant?.responseOutputTokens,
});

// The tail status lane is part of the layout for the whole running turn: the
// indicators below mount and clear with the turn's phase, and a lane that
// came and went with them would resize `.thread-content` and push the rows
Expand Down Expand Up @@ -304,10 +324,15 @@ function TranscriptBody({
{runtimeStatusLane ? (
<div className="transcript-runtime-status">
{showRunActivity && specializedActivity ? (
<RunActivityIndicator activity={specializedActivity} />
<RunActivityIndicator
activity={specializedActivity}
tokenRate={liveTokenRate}
/>
) : null}
{showPlanning ? <PlanningIndicator kind={planningKind} /> : null}
{showWorking ? <WorkingIndicator /> : null}
{showWorking ? (
<WorkingIndicator tokenRate={liveTokenRate} />
) : null}
</div>
) : null}
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { useEffect, useRef, useState } from "react";
import {
appendTokenSample,
calculateWindowedTokenRate,
resolveStreamingOutputTokens,
shouldResetTokenRateWindow,
type TokenRateSample,
} from "../../../../lib/streaming-token-rate";

export type LiveTokenRate = {
tokensPerSecond: number | undefined;
estimated: boolean;
};

const IDLE_RATE: LiveTokenRate = {
tokensPerSecond: undefined,
estimated: false,
};

/**
* Sample the active turn's output tokens on a short interval and expose a
* sliding-window tok/s reading for the transcript stream-health strip.
*
* Latest content / thinking / outputTokens live in refs so the interval is
* not torn down on every stream delta — only `active` / `tickMs` restart it.
*/
export function useLiveTokenRate(input: {
active: boolean;
content?: string;
thinking?: string;
outputTokens?: number;
tickMs?: number;
}): LiveTokenRate {
const [rate, setRate] = useState<LiveTokenRate>(IDLE_RATE);
const samplesRef = useRef<TokenRateSample[]>([]);
const estimatedRef = useRef(false);
const inputRef = useRef(input);
inputRef.current = input;

useEffect(() => {
if (!input.active) {
samplesRef.current = [];
estimatedRef.current = false;
setRate(IDLE_RATE);
return;
}

const sample = () => {
const current = inputRef.current;
const nowMs = Date.now();
const resolved = resolveStreamingOutputTokens({
outputTokens: current.outputTokens,
content: current.content,
thinking: current.thinking,
});
const last = samplesRef.current[samplesRef.current.length - 1];
if (
shouldResetTokenRateWindow({
wasEstimated: estimatedRef.current,
nowEstimated: resolved.estimated,
previousTokens: last?.tokens,
nextTokens: resolved.tokens,
})
) {
samplesRef.current = [];
}
estimatedRef.current = resolved.estimated;
samplesRef.current = appendTokenSample(
samplesRef.current,
{ atMs: nowMs, tokens: resolved.tokens },
nowMs,
);
setRate({
tokensPerSecond: calculateWindowedTokenRate(
samplesRef.current,
nowMs,
),
estimated: resolved.estimated,
});
};

sample();
const timer = window.setInterval(sample, input.tickMs ?? 250);
return () => window.clearInterval(timer);
}, [input.active, input.tickMs]);

return input.active ? rate : IDLE_RATE;
}
Loading
Loading