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
66 changes: 64 additions & 2 deletions apps/desktop/src/features/chat/transcript/ActivityGroup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import {
getToolAction,
getToolSummary,
} from "../../../lib/tool-display";
import type { LiveTokenRate } from "./hooks/useLiveTokenRate";
import { ReviewChangeCard } from "../../../components/ReviewChangeCard";
import { IconChevronRight, IconCircleAlert, IconSparkles, IconWorkflow } from "../../../components/icons";
import {
Expand Down Expand Up @@ -418,8 +419,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 / streaming 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 transcript responsive while the model waits for its first event. */
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 @@ -447,6 +479,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 @@ -456,7 +491,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 @@ -532,13 +573,34 @@ 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>
</div>
);
}

/** Rate-only strip while answer tokens are streaming (activity rows stay hidden). */
export function StreamingTokenRateIndicator({
tokenRate,
}: {
tokenRate: LiveTokenRate;
}) {
if (tokenRate.tokensPerSecond === undefined) return null;
return (
<div
className="working-indicator streaming-rate-indicator"
data-testid="streaming-rate-indicator"
aria-hidden="true"
>
<LiveTokenRateLabel rate={tokenRate} />
</div>
);
}

export function PlanningIndicator({ kind }: { kind: ProposalKind }) {
const { t } = useTranslation();
return (
Expand Down
40 changes: 37 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, useContext } from "react";
import { memo, useContext, useMemo } from "react";
import { useTranslation } from "react-i18next";
import type { PlanningState, UiMessage } from "@pi-desktop/shared";
import { proposalKindForMode } from "@pi-desktop/shared";
Expand All @@ -13,8 +13,11 @@ import { TRANSCRIPT_SKELETON_ROWS } from "../../../lib/transcript-settle";
import {
PlanningIndicator,
RunActivityIndicator,
StreamingTokenRateIndicator,
WorkingIndicator,
} from "./ActivityGroup";
import { useLiveTokenRate } from "./hooks/useLiveTokenRate";
import { assistantTurnMessages } from "../../../lib/assistant-turns";
import { TranscriptHistory, TranscriptTail } from "./AssistantTurn";
import { TranscriptReadOnlyContext, useActiveSessionTitle } from "./context";
import { SelectionQuoteButton } from "../../../components/SelectionQuoteButton";
Expand Down Expand Up @@ -166,6 +169,29 @@ export const ChatTranscript = memo(function ChatTranscript({
!assistantIsAnswering &&
!hasSpecializedActivity;

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

const liveTokenRateActive =
transcriptRunning &&
!pendingPermission &&
!askPending &&
!approvalPending;
const liveTokenRate = useLiveTokenRate({
active: liveTokenRateActive,
content: streamingAssistant?.content,
thinking: streamingAssistant?.thinking,
outputTokens:
streamingAssistant?.usage?.outputTokens ??
streamingAssistant?.responseOutputTokens,
});

return (
<TranscriptSearchContext.Provider value={searchTarget}>
<div
Expand Down Expand Up @@ -253,10 +279,18 @@ export const ChatTranscript = memo(function ChatTranscript({
/>
) : null}
{showRunActivity && specializedActivity ? (
<RunActivityIndicator activity={specializedActivity} />
<RunActivityIndicator
activity={specializedActivity}
tokenRate={liveTokenRate}
/>
) : null}
{showPlanning ? <PlanningIndicator kind={planningKind} /> : null}
{showWorking ? <WorkingIndicator /> : null}
{showWorking ? (
<WorkingIndicator tokenRate={liveTokenRate} />
) : null}
{assistantIsAnswering ? (
<StreamingTokenRateIndicator tokenRate={liveTokenRate} />
) : null}
</div>
</div>
{veilPhase !== "off" ? (
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