Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
6b7586d
feat(chat): estimate live generation speed while a turn streams
zszz3 Sep 15, 2026
4f78f92
fix(chat): hold the streaming rate through a tool call
zszz3 Sep 15, 2026
b22e592
feat(chat): clarify generation phases and smooth live throughput
zszz3 Sep 15, 2026
5867b8d
fix(chat): round displayed live throughput to whole tokens
zszz3 Sep 15, 2026
c743998
feat(chat): show measured time to first model output
zszz3 Sep 15, 2026
e7a88df
refactor(chat): remove redundant throughput timing and prose
zszz3 Sep 15, 2026
4bb1edb
fix(chat): scope first-output latency to the displayed response
zszz3 Sep 15, 2026
ba4df82
docs(chat): use a semantic ADR name for live throughput
zszz3 Sep 15, 2026
2abe9bc
test(chat): align live throughput wiring assertion with latest message
zszz3 Sep 15, 2026
9fd045a
merge(main): reconcile live throughput with current runtime and trans…
zszz3 Sep 20, 2026
0d409c9
test(chat): measure status spacing after live metadata
zszz3 Sep 20, 2026
fe7e37b
merge: refresh live throughput against current main
zszz3 Sep 21, 2026
ca22ddb
chore(integration): resolve conflicts with current main
zszz3 Sep 21, 2026
c63e50e
chore(integration): retain latency and image generation after merge
zszz3 Sep 21, 2026
7838c56
chore(integration): resolve conflicts with hosted search updates
zszz3 Sep 22, 2026
02e92fa
fix(chat): integrate current main for live generation metrics
zszz3 Sep 24, 2026
bcb70cf
chore(chat): incorporate latest settings changes
zszz3 Sep 24, 2026
2535680
chore(chat): incorporate upstream voice type fixes
zszz3 Sep 24, 2026
455ab53
fix(chat): restore integrated validation gates
zszz3 Sep 24, 2026
02a3d07
fix(chat): reconcile live metrics with current main
zszz3 Sep 24, 2026
264c341
fix(settings): preserve tab panel links after main integration
zszz3 Sep 24, 2026
d751f45
chore: integrate latest main before PR validation
zszz3 Sep 24, 2026
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
8 changes: 1 addition & 7 deletions apps/desktop/electron/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ import {
import { PersistenceOutbox } from "./persistence-outbox";
import { AgentSidecar } from "./agent-sidecar";
import { Logger, ignoreBrokenStdio } from "./logger";
import { installMainProcessErrorHandlers } from "./main-process-errors";
import { describeError, installMainProcessErrorHandlers } from "./main-process-errors";
import {
isDbSchemaTooNewError,
} from "./host-boot-diagnostics";
Expand Down Expand Up @@ -791,12 +791,6 @@ function setCurrentWorkspacePath(path: string | null): void {
}
}

/** One-line message for an error of unknown shape, for user-facing lists. */
function describeError(error: unknown): string {
if (error instanceof Error) return error.message.slice(0, 300);
return String(error).slice(0, 300);
}

/**
* True when a rejection only says the host transport is gone (D080): the call
* lost a race with shutdown, a crash, or a supervised restart. Every such
Expand Down
6 changes: 6 additions & 0 deletions apps/desktop/electron/main/main-process-errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,12 @@ export function isNonAsciiHttpHeaderError(error: unknown): boolean {
);
}

/** One-line message for an error of unknown shape, for user-facing lists. */
export function describeError(error: unknown): string {
if (error instanceof Error) return error.message.slice(0, 300);
return String(error).slice(0, 300);
}

export function describeMainProcessError(error: unknown): string {
if (error instanceof Error) {
return error.stack ?? `${error.name}: ${error.message}`;
Expand Down
4 changes: 2 additions & 2 deletions apps/desktop/src/components/settings/RemoteHostsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -245,8 +245,8 @@ export function RemoteHostsPage() {
value={addMode}
onChange={(mode) => setAddMode(mode)}
options={[
{ value: "ssh", label: t("settings.remoteHosts.addSsh") },
{ value: "pair", label: t("settings.remoteHosts.addPair") },
{ value: "ssh", id: "remote-host-add-ssh", controls: "remote-host-add-panel-ssh", label: t("settings.remoteHosts.addSsh") },
{ value: "pair", id: "remote-host-add-pair", controls: "remote-host-add-panel-pair", label: t("settings.remoteHosts.addPair") },
]}
label={t("settings.remoteHosts.addTitle")}
role="tablist"
Expand Down
14 changes: 12 additions & 2 deletions apps/desktop/src/components/ui.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -695,7 +695,12 @@ export function SegmentedControl<T extends string>({
}: {
value: T;
onChange: (value: T) => void;
options: readonly { readonly value: T; readonly label: ReactNode }[];
options: readonly {
readonly value: T;
readonly label: ReactNode;
readonly id?: string;
readonly controls?: string;
}[];
label: string;
role?: "group" | "radiogroup" | "tablist";
className?: string;
Expand All @@ -714,7 +719,12 @@ export function SegmentedControl<T extends string>({
key={option.value}
type="button"
{...(itemRole === "tab"
? { role: "tab", id: `${label}-tab-${option.value}`, "aria-selected": value === option.value }
? {
role: "tab",
id: option.id ?? `${label}-tab-${option.value}`,
"aria-selected": value === option.value,
"aria-controls": option.controls,
}
: itemRole === "radio"
? { role: "radio", "aria-checked": value === option.value }
: { "aria-pressed": value === option.value })}
Expand Down
18 changes: 17 additions & 1 deletion apps/desktop/src/features/chat/transcript/AssistantTurn.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,15 @@ import {
shouldGroupTurnProcess,
} from "../../../lib/turn-process";
import { useAppStore } from "../../../stores/app-store";
import { latestGenerationMessage } from "../../../lib/live-throughput";
import { Markdown } from "../../../components/Markdown";
import { IconBranch, IconReview } from "../../../components/icons";
import { TooltipButton } from "../../../components/ui";
import {
AssistantErrorMessage,
CopyButton,
MessageMeta,
LiveMessageMeta,
MessageTimestamp,
} from "./shared";
import { activityItemsEqual, ActivityGroup } from "./ActivityGroup";
Expand Down Expand Up @@ -295,6 +297,9 @@ export const AssistantTurn = memo(function AssistantTurn({
const responseDurationMs = assistantTurnResponseDuration(entry);
const responseOutputTokens = assistantTurnResponseOutputTokens(entry);
const modelId = metaMessage?.modelId ?? latestUsageMessage?.modelId;
// The tail message is the one still growing; the live rate is estimated from
// it because the provider only reports usage at message_end.
const latestMessage = latestGenerationMessage(entry);
const hasError = messages.some((message) => Boolean(message.error));
const complete =
!isActive && !hasError && Boolean(content) && Boolean(actionMessage);
Expand Down Expand Up @@ -413,12 +418,23 @@ export const AssistantTurn = memo(function AssistantTurn({
{turnAllActivityItems.filter((item) => item.kind === "tool" && item.message.toolName === "GenerateImages").map((item) => (
<GeneratedImages key={item.message.id} message={item.message} />
))}
{!isActive && metaMessage ? (
{!isActive && (metaMessage || latestMessage?.timeToFirstTokenMs !== undefined) ? (
<MessageMeta
modelId={modelId}
usage={usage}
responseDurationMs={responseDurationMs}
responseOutputTokens={responseOutputTokens}
timeToFirstTokenMs={latestMessage?.timeToFirstTokenMs}
/>
) : null}
{isActive ? (
<LiveMessageMeta
key={entry.id}
modelId={modelId}
message={latestMessage}
toolRunning={turnAllActivityItems.some(
(item) => item.kind === "tool" && item.message.toolStatus === "running",
)}
/>
) : null}
{complete && actionMessage ? (
Expand Down
14 changes: 14 additions & 0 deletions apps/desktop/src/features/chat/transcript/FirstOutputLatency.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { useTranslation } from "react-i18next";

/** A measured request latency; absent on old records and before the first output. */
export function FirstOutputLatency({ milliseconds }: { milliseconds?: number }) {
const { t } = useTranslation();
if (milliseconds === undefined || !Number.isFinite(milliseconds) || milliseconds < 0) {
return null;
}
return (
<span className="message-meta-chip first-output-latency" title={t("chat.firstOutputHint")}>
{t("chat.firstOutputLatency", { seconds: (milliseconds / 1000).toFixed(1) })}
</span>
);
}
64 changes: 63 additions & 1 deletion apps/desktop/src/features/chat/transcript/shared.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { FirstOutputLatency } from "./FirstOutputLatency";
import {
memo,
useCallback,
Expand All @@ -19,6 +20,9 @@ import {
type ThinkingLevel,
} from "@pi-desktop/shared";
import { useOpenChatFileRef, useOpenPreviewTarget } from "../../../hooks/use-preview-target";
import { generationPhase, type ThroughputMessage } from "../../../lib/live-throughput";
import { useLiveThroughput } from "./use-live-throughput";

import { useDisclosureAnchorNotifier } from "../../../lib/disclosure-anchor-context";
import { isThinkingActive, resolveThinkingDisplayMode } from "../../../lib/turn-process";
import { TranscriptSearchContext } from "../../../lib/transcript-search-context";
Expand Down Expand Up @@ -155,19 +159,21 @@ export function MessageMeta({
usage,
responseDurationMs,
responseOutputTokens,
timeToFirstTokenMs,
}: {
modelId?: string;
usage?: MessageUsage;
responseDurationMs?: number;
responseOutputTokens?: number;
timeToFirstTokenMs?: number;
}) {
const { t } = useTranslation();
const throughput = calculateTokenRate(
responseOutputTokens ?? usage?.outputTokens ?? 0,
responseDurationMs,
);
const showThroughput = !usage && throughput !== undefined;
if (!modelId && !showThroughput) {
if (!modelId && !showThroughput && timeToFirstTokenMs === undefined) {
return null;
}
return (
Expand All @@ -177,6 +183,7 @@ export function MessageMeta({
{modelId}
</span>
) : null}
<FirstOutputLatency milliseconds={timeToFirstTokenMs} />
{showThroughput ? (
<span className="message-meta-chip throughput">
{t("chat.usageThroughputEstimated", {
Expand All @@ -188,6 +195,61 @@ export function MessageMeta({
);
}

/**
* Meta row for the turn that is still streaming.
*
* Mounted only for the active tail turn, which keeps the sampler and its
* interval off every history row and keeps per-token work out of the store.
* The figure is always an estimate — the provider reports usage once, at
* `message_end` — so it carries the "≈" copy (ADR 0073 §4), and `MessageMeta`
* takes over with the exact value once the turn completes.
*/
export function LiveMessageMeta({
modelId,
message,
toolRunning = false,
}: {
modelId?: string;
message?: ThroughputMessage;
toolRunning?: boolean;
}) {
const { t } = useTranslation();
const phase = generationPhase(message, toolRunning);
const generating = phase === "thinking" || phase === "generating";
const { rate, stale } = useLiveThroughput(message, generating);
const phaseLabel = t({
waiting: "chat.liveWaiting",
thinking: "chat.thinking",
generating: "chat.liveGenerating",
tool: "chat.liveToolRunning",
}[phase]);
const rateLabel = rate === undefined ? undefined : t("chat.usageThroughputEstimated", {
count: Math.round(rate),
});
return (
<div className="message-meta">
{modelId ? (
<span className="message-meta-chip model" title={modelId}>
{modelId}
</span>
) : null}
<FirstOutputLatency milliseconds={generating ? message?.timeToFirstTokenMs : undefined} />
<span className="message-meta-chip generation-phase" data-generation-phase={phase}>
{phaseLabel}
</span>
{rate === undefined ? null : (
<span
className="message-meta-chip throughput"
data-stale={stale ? "true" : undefined}
title={t("chat.usageThroughputLabel")}
>
{stale ? t("chat.liveLastRate", { rate: rateLabel }) : rateLabel}
</span>
)}
</div>
);
}

export function AssistantErrorMessage({ message }: { message: UiMessage }) {
const { t } = useTranslation();
const [open, setOpen] = useState(true);
Expand Down
46 changes: 46 additions & 0 deletions apps/desktop/src/features/chat/transcript/use-live-throughput.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { useEffect, useRef, useState } from "react";
import {
LIVE_THROUGHPUT_SAMPLE_MS,
advanceThroughput,
type LiveTokenRate,
type ThroughputMessage,
type ThroughputTracker,
} from "../../../lib/live-throughput";

/** Only the active turn mounts this sampler. Committed props feed a fixed cadence. */
export function useLiveThroughput(
message: ThroughputMessage | undefined,
generating: boolean,
): LiveTokenRate {
const inputRef = useRef({ message, generating });
const trackerRef = useRef<ThroughputTracker>({ samples: [] });
const [view, setView] = useState<LiveTokenRate & { messageId?: string }>({ stale: false });

useEffect(() => {
inputRef.current = { message, generating };
}, [message, generating]);

useEffect(() => {
const sample = () => {
const input = inputRef.current;
const next = advanceThroughput(
trackerRef.current, input.message, input.generating, performance.now(),
);
trackerRef.current = next.tracker;
setView((previous) =>
previous.rate === next.view.rate && previous.stale === next.view.stale &&
previous.messageId === input.message?.id
? previous
: { ...next.view, messageId: input.message?.id },
);
};
sample();
const timer = window.setInterval(sample, LIVE_THROUGHPUT_SAMPLE_MS);
return () => window.clearInterval(timer);
}, []);

return {
rate: view.rate,
stale: view.stale || !generating || view.messageId !== message?.id,
};
}
2 changes: 2 additions & 0 deletions apps/desktop/src/features/settings/import-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ export function ImportSection() {
onChange={(value) => setKind(value)}
options={IMPORT_KINDS.map((entry) => ({
value: entry.id,
id: `import-tab-${entry.id}`,
controls: `import-panel-${entry.id}`,
label: t(entry.labelKey),
}))}
label={t("settings.import")}
Expand Down
Loading
Loading