authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-13 20:49:13-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-13 20:49:13-07:00
log1bbca0e78202c8ece73351fccd29f3c99070b82e
treeb0edaba1744b2f4cd02469373c07e912e9b61496
parent3b75e607eb909522a8f5562fb40e44efc72bb66c
signature Signed by PGP key B5690EEEBB952194

feat(settings): choose how responses stream, with a warning on legacy token mode (#11678)

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

7 files changed, 252 insertions(+), 81 deletions(-)

apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts+64-8
...@@ -465,11 +465,11 @@ describe("ProviderRuntimeIngestion", () => {...@@ -465,11 +465,11 @@ describe("ProviderRuntimeIngestion", () => {
465 });465 });
466466
467 it.each([467 it.each([
468 { delivery: "buffered", enableLegacyTokenStreaming: false },468 { delivery: "buffered", responseStreamingMode: "paragraph" as const },
469 { delivery: "streamed", enableLegacyTokenStreaming: true },469 { delivery: "streamed", responseStreamingMode: "token" as const },
470 ])("settles OpenCode aborted turns and saves $delivery assistant text", async (settings) => {470 ])("settles OpenCode aborted turns and saves $delivery assistant text", async (settings) => {
471 const harness = await createHarness({471 const harness = await createHarness({
472 serverSettings: { enableLegacyTokenStreaming: settings.enableLegacyTokenStreaming },472 serverSettings: { responseStreamingMode: settings.responseStreamingMode },
473 });473 });
474 const threadId = asThreadId("thread-1");474 const threadId = asThreadId("thread-1");
475 const turnId = asTurnId("opencode-aborted-turn");475 const turnId = asTurnId("opencode-aborted-turn");
...@@ -521,7 +521,7 @@ describe("ProviderRuntimeIngestion", () => {...@@ -521,7 +521,7 @@ describe("ProviderRuntimeIngestion", () => {
521 "finalizes old buffered text on late %s without stopping the newer turn",521 "finalizes old buffered text on late %s without stopping the newer turn",
522 async (terminalType) => {522 async (terminalType) => {
523 const harness = await createHarness({523 const harness = await createHarness({
524 serverSettings: { enableLegacyTokenStreaming: false },524 serverSettings: { responseStreamingMode: "paragraph" },
525 });525 });
526 const threadId = asThreadId("thread-1");526 const threadId = asThreadId("thread-1");
527 const oldTurnId = asTurnId("old-buffered-turn");527 const oldTurnId = asTurnId("old-buffered-turn");
...@@ -605,7 +605,7 @@ describe("ProviderRuntimeIngestion", () => {...@@ -605,7 +605,7 @@ describe("ProviderRuntimeIngestion", () => {
605 { source: "an unspecified turn", turnId: undefined },605 { source: "an unspecified turn", turnId: undefined },
606 ])("ignores late OpenCode aborts for $source across newer turns", async (lateAbort) => {606 ])("ignores late OpenCode aborts for $source across newer turns", async (lateAbort) => {
607 const harness = await createHarness({607 const harness = await createHarness({
608 serverSettings: { enableLegacyTokenStreaming: true },608 serverSettings: { responseStreamingMode: "token" },
609 });609 });
610 const threadId = asThreadId("thread-1");610 const threadId = asThreadId("thread-1");
611 const stoppedTurnId = asTurnId("opencode-stopped-turn");611 const stoppedTurnId = asTurnId("opencode-stopped-turn");
...@@ -2712,7 +2712,7 @@ describe("ProviderRuntimeIngestion", () => {...@@ -2712,7 +2712,7 @@ describe("ProviderRuntimeIngestion", () => {
2712 });2712 });
27132713
2714 it("keeps streaming while an async question is pending", async () => {2714 it("keeps streaming while an async question is pending", async () => {
2715 const harness = await createHarness({ serverSettings: { enableLegacyTokenStreaming: true } });2715 const harness = await createHarness({ serverSettings: { responseStreamingMode: "token" } });
2716 const base = {2716 const base = {
2717 provider: ProviderDriverKind.make("codex"),2717 provider: ProviderDriverKind.make("codex"),
2718 createdAt: "2026-01-01T00:00:00.000Z",2718 createdAt: "2026-01-01T00:00:00.000Z",
...@@ -2954,7 +2954,7 @@ describe("ProviderRuntimeIngestion", () => {...@@ -2954,7 +2954,7 @@ describe("ProviderRuntimeIngestion", () => {
2954 });2954 });
29552955
2956 it("starts a new streaming assistant message segment after approval", async () => {2956 it("starts a new streaming assistant message segment after approval", async () => {
2957 const harness = await createHarness({ serverSettings: { enableLegacyTokenStreaming: true } });2957 const harness = await createHarness({ serverSettings: { responseStreamingMode: "token" } });
2958 const startedAt = "2026-03-28T07:00:00.000Z";2958 const startedAt = "2026-03-28T07:00:00.000Z";
2959 const pausedAt = "2026-03-28T07:00:01.000Z";2959 const pausedAt = "2026-03-28T07:00:01.000Z";
2960 const resumedAt = "2026-03-28T07:00:02.000Z";2960 const resumedAt = "2026-03-28T07:00:02.000Z";
...@@ -3061,7 +3061,7 @@ describe("ProviderRuntimeIngestion", () => {...@@ -3061,7 +3061,7 @@ describe("ProviderRuntimeIngestion", () => {
3061 });3061 });
30623062
3063 it("streams assistant deltas when thread.turn.start requests streaming mode", async () => {3063 it("streams assistant deltas when thread.turn.start requests streaming mode", async () => {
3064 const harness = await createHarness({ serverSettings: { enableLegacyTokenStreaming: true } });3064 const harness = await createHarness({ serverSettings: { responseStreamingMode: "token" } });
3065 const now = "2026-01-01T00:00:00.000Z";3065 const now = "2026-01-01T00:00:00.000Z";
30663066
3067 await Effect.runPromise(3067 await Effect.runPromise(
...@@ -3235,6 +3235,62 @@ describe("ProviderRuntimeIngestion", () => {...@@ -3235,6 +3235,62 @@ describe("ProviderRuntimeIngestion", () => {
3235 );3235 );
3236 });3236 });
32373237
3238 it("holds every paragraph until completion in turn mode", async () => {
3239 const harness = await createHarness({ serverSettings: { responseStreamingMode: "turn" } });
3240 const now = "2026-01-01T00:00:00.000Z";
3241 const codex = ProviderDriverKind.make("codex");
3242 const threadId = asThreadId("thread-1");
3243 const turnId = asTurnId("turn-wait-mode");
3244 const itemId = asItemId("item-wait-mode");
3245
3246 await harness.emitAndDrain([
3247 {
3248 type: "turn.started",
3249 eventId: asEventId("evt-wait-started"),
3250 provider: codex,
3251 createdAt: now,
3252 threadId,
3253 turnId,
3254 },
3255 ]);
3256 harness.advanceClock(1_000);
3257 await harness.emitAndDrain([
3258 {
3259 type: "content.delta",
3260 eventId: asEventId("evt-wait-delta"),
3261 provider: codex,
3262 createdAt: now,
3263 threadId,
3264 turnId,
3265 itemId,
3266 payload: {
3267 streamKind: "assistant_text",
3268 delta: "First paragraph.\n\nSecond paragraph.\n\n",
3269 },
3270 },
3271 ]);
3272 const messageText = async () =>
3273 (await harness.readModel()).threads
3274 .find((t) => t.id === threadId)
3275 ?.messages.find((m: ProviderRuntimeTestMessage) => m.id === `assistant:${itemId}`)?.text;
3276 // Paragraph mode would have delivered both paragraphs by now.
3277 expect(await messageText()).toBeUndefined();
3278
3279 await harness.emitAndDrain([
3280 {
3281 type: "item.completed",
3282 eventId: asEventId("evt-wait-completed"),
3283 provider: codex,
3284 createdAt: now,
3285 threadId,
3286 turnId,
3287 itemId,
3288 payload: { itemType: "assistant_message", status: "completed" },
3289 },
3290 ]);
3291 expect(await messageText()).toBe("First paragraph.\n\nSecond paragraph.\n\n");
3292 });
3293
3238 it("holds paragraphs that finish inside the pacing window and lands them together", async () => {3294 it("holds paragraphs that finish inside the pacing window and lands them together", async () => {
3239 const harness = await createHarness();3295 const harness = await createHarness();
3240 const codex = ProviderDriverKind.make("codex");3296 const codex = ProviderDriverKind.make("codex");
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts+27-21
...@@ -1,6 +1,5 @@...@@ -1,6 +1,5 @@
1import {1import {
2 ApprovalRequestId,2 ApprovalRequestId,
3 type AssistantDeliveryMode,
4 CommandId,3 CommandId,
5 MessageId,4 MessageId,
6 type OrchestrationEvent,5 type OrchestrationEvent,
...@@ -14,7 +13,9 @@ import {...@@ -14,7 +13,9 @@ import {
14 TurnId,13 TurnId,
15 type OrchestrationCheckpointSummary,14 type OrchestrationCheckpointSummary,
16 type OrchestrationThreadActivity,15 type OrchestrationThreadActivity,
16 type ProjectId,
17 type ProviderRuntimeEvent,17 type ProviderRuntimeEvent,
18 type ResponseStreamingMode,
18 RuntimeRequestId,19 RuntimeRequestId,
19} from "@t3tools/contracts";20} from "@t3tools/contracts";
20import * as Cache from "effect/Cache";21import * as Cache from "effect/Cache";
...@@ -1167,7 +1168,19 @@ const make = Effect.gen(function* () {...@@ -1167,7 +1168,19 @@ const make = Effect.gen(function* () {
1167 });1168 });
1168 });1169 });
11691170
1170 const appendBufferedAssistantText = (messageId: MessageId, delta: string, atMillis: number) =>1171 const resolveResponseStreamingMode = (projectId: ProjectId) =>
1172 Effect.map(
1173 serverSettingsService.getSettings,
1174 (settings) => resolveProjectSettings(settings, projectId).settings.responseStreamingMode,
1175 );
1176
1177 // `mode` is "turn" or "paragraph"; token mode never buffers.
1178 const appendBufferedAssistantText = (
1179 messageId: MessageId,
1180 delta: string,
1181 mode: Exclude<ResponseStreamingMode, "token">,
1182 atMillis: number,
1183 ) =>
1171 Cache.getOption(bufferedAssistantTextByMessageId, messageId).pipe(1184 Cache.getOption(bufferedAssistantTextByMessageId, messageId).pipe(
1172 Effect.flatMap((existingText) =>1185 Effect.flatMap((existingText) =>
1173 Effect.gen(function* () {1186 Effect.gen(function* () {
...@@ -1176,9 +1189,13 @@ const make = Effect.gen(function* () {...@@ -1176,9 +1189,13 @@ const make = Effect.gen(function* () {
1176 onSome: (text) => `${text}${delta}`,1189 onSome: (text) => `${text}${delta}`,
1177 });1190 });
11781191
1179 // Deliver finished paragraphs and closed code blocks early so the1192 // Paragraph mode delivers finished paragraphs and closed code blocks
1180 // user sees progress without token-by-token repaints.1193 // early so the user sees progress without token-by-token repaints.
1181 const { ready, rest } = splitBufferedAssistantText(nextText);1194 // Turn mode holds everything until the turn finishes or pauses.
1195 const { ready, rest } =
1196 mode === "paragraph"
1197 ? splitBufferedAssistantText(nextText)
1198 : { ready: "", rest: nextText };
1182 const lastDeliveredAt = Option.getOrUndefined(1199 const lastDeliveredAt = Option.getOrUndefined(
1183 yield* Cache.getOption(lastAssistantDeliveryAtByMessageId, messageId),1200 yield* Cache.getOption(lastAssistantDeliveryAtByMessageId, messageId),
1184 );1201 );
...@@ -1757,19 +1774,14 @@ const make = Effect.gen(function* () {...@@ -1757,19 +1774,14 @@ const make = Effect.gen(function* () {
1757 yield* rememberAssistantMessageId(thread.id, turnId, assistantMessageId);1774 yield* rememberAssistantMessageId(thread.id, turnId, assistantMessageId);
1758 }1775 }
17591776
1760 const assistantDeliveryMode: AssistantDeliveryMode = yield* Effect.map(1777 const streamingMode = yield* resolveResponseStreamingMode(thread.projectId);
1761 serverSettingsService.getSettings,1778 if (streamingMode !== "token") {
1762 (settings) =>
1763 resolveProjectSettings(settings, thread.projectId).settings.enableLegacyTokenStreaming
1764 ? "streaming"
1765 : "buffered",
1766 );
1767 if (assistantDeliveryMode === "buffered") {
1768 // Pace on the server clock. OpenCode stamps every delta of a part1779 // Pace on the server clock. OpenCode stamps every delta of a part
1769 // with the part's start time, so the event time cannot measure gaps.1780 // with the part's start time, so the event time cannot measure gaps.
1770 const spillChunk = yield* appendBufferedAssistantText(1781 const spillChunk = yield* appendBufferedAssistantText(
1771 assistantMessageId,1782 assistantMessageId,
1772 assistantDelta,1783 assistantDelta,
1784 streamingMode,
1773 yield* Clock.currentTimeMillis,1785 yield* Clock.currentTimeMillis,
1774 );1786 );
1775 if (spillChunk.length > 0) {1787 if (spillChunk.length > 0) {
...@@ -1807,15 +1819,9 @@ const make = Effect.gen(function* () {...@@ -1807,15 +1819,9 @@ const make = Effect.gen(function* () {
1807 turnId: pauseForUserTurnId,1819 turnId: pauseForUserTurnId,
1808 streamingOnly: true,1820 streamingOnly: true,
1809 });1821 });
1810 const assistantDeliveryMode: AssistantDeliveryMode = yield* Effect.map(1822 const streamingMode = yield* resolveResponseStreamingMode(thread.projectId);
1811 serverSettingsService.getSettings,
1812 (settings) =>
1813 resolveProjectSettings(settings, thread.projectId).settings.enableLegacyTokenStreaming
1814 ? "streaming"
1815 : "buffered",
1816 );
1817 const flushedMessageIds =1823 const flushedMessageIds =
1818 assistantDeliveryMode === "buffered"1824 streamingMode !== "token"
1819 ? yield* flushBufferedAssistantMessagesForTurn({1825 ? yield* flushBufferedAssistantMessagesForTurn({
1820 event,1826 event,
1821 threadId: thread.id,1827 threadId: thread.id,
apps/web/src/components/settings/SettingsPanels.tsx+136-35
...@@ -38,6 +38,7 @@ import {...@@ -38,6 +38,7 @@ import {
38 MIN_PANEL_ANIMATION_DURATION_MS,38 MIN_PANEL_ANIMATION_DURATION_MS,
39 MIN_PROMPT_FONT_SIZE,39 MIN_PROMPT_FONT_SIZE,
40 MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS,40 MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS,
41 type ResponseStreamingMode,
41 MIN_TERMINAL_FONT_SIZE,42 MIN_TERMINAL_FONT_SIZE,
42 type QuitConfirmationMode,43 type QuitConfirmationMode,
43} from "@t3tools/contracts/settings";44} from "@t3tools/contracts/settings";
...@@ -94,6 +95,15 @@ import { isMacPlatform } from "../../lib/utils";...@@ -94,6 +95,15 @@ import { isMacPlatform } from "../../lib/utils";
94import { EMPTY_SERVER_PROVIDERS } from "../../state/server";95import { EMPTY_SERVER_PROVIDERS } from "../../state/server";
95import { useArchivedThreadSnapshots } from "../../lib/archivedThreadsState";96import { useArchivedThreadSnapshots } from "../../lib/archivedThreadsState";
96import { formatRelativeTimeLabel } from "../../timestampFormat";97import { formatRelativeTimeLabel } from "../../timestampFormat";
98import {
99 AlertDialog,
100 AlertDialogClose,
101 AlertDialogDescription,
102 AlertDialogFooter,
103 AlertDialogHeader,
104 AlertDialogPopup,
105 AlertDialogTitle,
106} from "../ui/alert-dialog";
97import { Button } from "../ui/button";107import { Button } from "../ui/button";
98import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible";108import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible";
99import {109import {
...@@ -168,6 +178,18 @@ const ENVIRONMENT_IDENTIFICATION_LABELS: Record<EnvironmentIdentificationMode, s...@@ -168,6 +178,18 @@ const ENVIRONMENT_IDENTIFICATION_LABELS: Record<EnvironmentIdentificationMode, s
168 none: "None",178 none: "None",
169};179};
170180
181const RESPONSE_STREAMING_MODE_LABELS: Record<ResponseStreamingMode, string> = {
182 turn: "Wait for the full response",
183 paragraph: "Show finished paragraphs",
184 token: "Token by token (legacy)",
185};
186
187const RESPONSE_STREAMING_MODE_DESCRIPTIONS: Record<ResponseStreamingMode, string> = {
188 turn: "Text appears once the agent finishes its turn.",
189 paragraph: "Each paragraph or code block appears as soon as it is complete.",
190 token: "Every token repaints the message as it arrives. Slower and harder to read.",
191};
192
171const TIMESTAMP_FORMAT_LABELS = {193const TIMESTAMP_FORMAT_LABELS = {
172 locale: "System default",194 locale: "System default",
173 "12-hour": "12-hour",195 "12-hour": "12-hour",
...@@ -564,9 +586,8 @@ export function useSettingsRestore(onRestored?: () => void) {...@@ -564,9 +586,8 @@ export function useSettingsRestore(onRestored?: () => void) {
564 ...(settings.contextWindowMeterEnabled !== DEFAULT_UNIFIED_SETTINGS.contextWindowMeterEnabled586 ...(settings.contextWindowMeterEnabled !== DEFAULT_UNIFIED_SETTINGS.contextWindowMeterEnabled
565 ? ["Context window indicator"]587 ? ["Context window indicator"]
566 : []),588 : []),
567 ...(settings.enableLegacyTokenStreaming !==589 ...(settings.responseStreamingMode !== DEFAULT_UNIFIED_SETTINGS.responseStreamingMode
568 DEFAULT_UNIFIED_SETTINGS.enableLegacyTokenStreaming590 ? ["Response streaming"]
569 ? ["Stream token by token"]
570 : []),591 : []),
571 ...(settings.enableProviderUpdateChecks !==592 ...(settings.enableProviderUpdateChecks !==
572 DEFAULT_UNIFIED_SETTINGS.enableProviderUpdateChecks593 DEFAULT_UNIFIED_SETTINGS.enableProviderUpdateChecks
...@@ -640,7 +661,7 @@ export function useSettingsRestore(onRestored?: () => void) {...@@ -640,7 +661,7 @@ export function useSettingsRestore(onRestored?: () => void) {
640 settings.fontSizeTerminal,661 settings.fontSizeTerminal,
641 settings.glassOpacity,662 settings.glassOpacity,
642 settings.panelAnimationDurationMs,663 settings.panelAnimationDurationMs,
643 settings.enableLegacyTokenStreaming,664 settings.responseStreamingMode,
644 settings.enableProviderUpdateChecks,665 settings.enableProviderUpdateChecks,
645 settings.continueThreadsAfterServerUpdate,666 settings.continueThreadsAfterServerUpdate,
646 settings.sidebarAutoSettleAfterDays,667 settings.sidebarAutoSettleAfterDays,
...@@ -744,7 +765,7 @@ export function useSettingsRestore(onRestored?: () => void) {...@@ -744,7 +765,7 @@ export function useSettingsRestore(onRestored?: () => void) {
744 sidebarCompactThreadRows: DEFAULT_UNIFIED_SETTINGS.sidebarCompactThreadRows,765 sidebarCompactThreadRows: DEFAULT_UNIFIED_SETTINGS.sidebarCompactThreadRows,
745 sidebarAutoSettleAfterDays: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays,766 sidebarAutoSettleAfterDays: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays,
746 sidebarAutoSettleOnMerge: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge,767 sidebarAutoSettleOnMerge: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge,
747 enableLegacyTokenStreaming: DEFAULT_UNIFIED_SETTINGS.enableLegacyTokenStreaming,768 responseStreamingMode: DEFAULT_UNIFIED_SETTINGS.responseStreamingMode,
748 enableProviderUpdateChecks: DEFAULT_UNIFIED_SETTINGS.enableProviderUpdateChecks,769 enableProviderUpdateChecks: DEFAULT_UNIFIED_SETTINGS.enableProviderUpdateChecks,
749 continueThreadsAfterServerUpdate: DEFAULT_UNIFIED_SETTINGS.continueThreadsAfterServerUpdate,770 continueThreadsAfterServerUpdate: DEFAULT_UNIFIED_SETTINGS.continueThreadsAfterServerUpdate,
750 backgroundActivity: DEFAULT_UNIFIED_SETTINGS.backgroundActivity,771 backgroundActivity: DEFAULT_UNIFIED_SETTINGS.backgroundActivity,
...@@ -797,6 +818,44 @@ export function useSettingsRestore(onRestored?: () => void) {...@@ -797,6 +818,44 @@ export function useSettingsRestore(onRestored?: () => void) {
797 };818 };
798}819}
799820
821/**
822 * Gate in front of the legacy token-by-token mode. The primary action steers
823 * the user to paragraph streaming; the legacy path is the quiet option.
824 */
825function TokenStreamingWarningDialog({
826 open,
827 onOpenChange,
828 onConfirm,
829 onUseParagraphs,
830}: {
831 open: boolean;
832 onOpenChange: (open: boolean) => void;
833 onConfirm: () => void;
834 onUseParagraphs: () => void;
835}) {
836 return (
837 <AlertDialog open={open} onOpenChange={onOpenChange}>
838 <AlertDialogPopup className="max-w-lg">
839 <AlertDialogHeader>
840 <AlertDialogTitle>Token by token is a worse experience</AlertDialogTitle>
841 <AlertDialogDescription>
842 Token streaming repaints the message on every delta. It is slower, harder to read, and
843 costs more CPU on every connected device. This mode stays only for backwards
844 compatibility. Use paragraph streaming instead.
845 </AlertDialogDescription>
846 </AlertDialogHeader>
847 <AlertDialogFooter>
848 <Button variant="ghost-muted" className="sm:mr-auto" onClick={onConfirm}>
849 Use token by token
850 </Button>
851 <AlertDialogClose render={<Button variant="outline" />}>Cancel</AlertDialogClose>
852 <Button onClick={onUseParagraphs}>Use paragraphs</Button>
853 </AlertDialogFooter>
854 </AlertDialogPopup>
855 </AlertDialog>
856 );
857}
858
800function BackgroundActivityAdvancedDialog({859function BackgroundActivityAdvancedDialog({
801 open,860 open,
802 onOpenChange,861 onOpenChange,
...@@ -2014,7 +2073,6 @@ function AutoSettleDaysInput({...@@ -2014,7 +2073,6 @@ function AutoSettleDaysInput({
2014const LEGACY_FEATURE_TARGET_IDS: ReadonlySet<string> = new Set([2073const LEGACY_FEATURE_TARGET_IDS: ReadonlySet<string> = new Set([
2015 "legacy-plan-mode",2074 "legacy-plan-mode",
2016 "legacy-context-window-indicator",2075 "legacy-context-window-indicator",
2017 "legacy-token-streaming",
2018 "legacy-sidebar",2076 "legacy-sidebar",
2019]);2077]);
20202078
...@@ -2082,35 +2140,6 @@ function LegacyFeaturesSection() {...@@ -2082,35 +2140,6 @@ function LegacyFeaturesSection() {
2082 />2140 />
2083 }2141 }
2084 />2142 />
2085 <SettingsRow
2086 serverScoped
2087 settingKeys={["enableLegacyTokenStreaming"]}
2088 {...searchableSetting("legacy-token-streaming")}
2089 description="Stream output token by token. This legacy mode is slower and harder to follow."
2090 control={
2091 <ScopedSwitch
2092 settingKeys={["enableLegacyTokenStreaming"]}
2093 checked={settings.enableLegacyTokenStreaming}
2094 onCheckedChange={(checked) => {
2095 if (!checked) {
2096 updateSettings({ enableLegacyTokenStreaming: false });
2097 return;
2098 }
2099 void (async () => {
2100 const api = readLocalApi();
2101 const confirmed = await (api ?? ensureLocalApi()).dialogs.confirm(
2102 [
2103 "Turn on token-by-token output?",
2104 "It is significantly slower than the default buffered output and hurts the reading experience. This switch exists only for backwards compatibility.",
2105 ].join("\n"),
2106 );
2107 if (confirmed) updateSettings({ enableLegacyTokenStreaming: true });
2108 })();
2109 }}
2110 aria-label="Stream token by token (legacy)"
2111 />
2112 }
2113 />
2114 <SettingsRow2143 <SettingsRow
2115 {...searchableSetting("legacy-sidebar")}2144 {...searchableSetting("legacy-sidebar")}
2116 description="Restore per-project thread trees instead of the default flat sidebar."2145 description="Restore per-project thread trees instead of the default flat sidebar."
...@@ -2144,6 +2173,8 @@ export function GeneralSettingsPanel() {...@@ -2144,6 +2173,8 @@ export function GeneralSettingsPanel() {
2144 const isEnvironmentScope = scope.environmentIds.length === 1 && environmentId !== null;2173 const isEnvironmentScope = scope.environmentIds.length === 1 && environmentId !== null;
2145 const hasServerTargets = connectedEnvironments.length > 0;2174 const hasServerTargets = connectedEnvironments.length > 0;
2146 const [backgroundActivityDialogOpen, setBackgroundActivityDialogOpen] = useState(false);2175 const [backgroundActivityDialogOpen, setBackgroundActivityDialogOpen] = useState(false);
2176 const [tokenStreamingWarningOpen, setTokenStreamingWarningOpen] = useState(false);
2177 const mixedResponseStreamingMode = useScopedSettingsMixed(["responseStreamingMode"]);
2147 const lastEnabledProjectGroupingMode = useRef<SidebarProjectGroupingMode>(2178 const lastEnabledProjectGroupingMode = useRef<SidebarProjectGroupingMode>(
2148 readLastEnabledProjectGroupingMode(),2179 readLastEnabledProjectGroupingMode(),
2149 );2180 );
...@@ -2387,6 +2418,76 @@ export function GeneralSettingsPanel() {...@@ -2387,6 +2418,76 @@ export function GeneralSettingsPanel() {
2387 </Select>2418 </Select>
2388 }2419 }
2389 />2420 />
2421 <SettingsRow
2422 serverScoped
2423 settingKeys={["responseStreamingMode"]}
2424 {...searchableSetting("response-streaming")}
2425 description={
2426 mixedResponseStreamingMode
2427 ? "The selected targets use different streaming modes."
2428 : RESPONSE_STREAMING_MODE_DESCRIPTIONS[settings.responseStreamingMode]
2429 }
2430 resetAction={
2431 settings.responseStreamingMode !== DEFAULT_UNIFIED_SETTINGS.responseStreamingMode ? (
2432 <SettingResetButton
2433 label="response streaming"
2434 onClick={() =>
2435 updateSettings({
2436 responseStreamingMode: DEFAULT_UNIFIED_SETTINGS.responseStreamingMode,
2437 })
2438 }
2439 />
2440 ) : null
2441 }
2442 control={
2443 <>
2444 <Select
2445 value={mixedResponseStreamingMode ? null : settings.responseStreamingMode}
2446 onValueChange={(value) => {
2447 if (value === "token") {
2448 // The legacy path needs an explicit confirmation.
2449 setTokenStreamingWarningOpen(true);
2450 return;
2451 }
2452 if (value === "turn" || value === "paragraph") {
2453 updateSettings({ responseStreamingMode: value });
2454 }
2455 }}
2456 >
2457 <SelectTrigger size="sm" className="w-full sm:w-56" aria-label="Response streaming">
2458 <SelectValue>
2459 {(value: ResponseStreamingMode | null) =>
2460 value === null ? "Mixed" : RESPONSE_STREAMING_MODE_LABELS[value]
2461 }
2462 </SelectValue>
2463 </SelectTrigger>
2464 <SelectPopup align="end" alignItemWithTrigger={false}>
2465 <SelectItem hideIndicator value="turn">
2466 {RESPONSE_STREAMING_MODE_LABELS.turn}
2467 </SelectItem>
2468 <SelectItem hideIndicator value="paragraph">
2469 {RESPONSE_STREAMING_MODE_LABELS.paragraph}
2470 </SelectItem>
2471 <SelectItem hideIndicator value="token">
2472 {RESPONSE_STREAMING_MODE_LABELS.token}
2473 </SelectItem>
2474 </SelectPopup>
2475 </Select>
2476 <TokenStreamingWarningDialog
2477 open={tokenStreamingWarningOpen}
2478 onOpenChange={setTokenStreamingWarningOpen}
2479 onConfirm={() => {
2480 updateSettings({ responseStreamingMode: "token" });
2481 setTokenStreamingWarningOpen(false);
2482 }}
2483 onUseParagraphs={() => {
2484 updateSettings({ responseStreamingMode: "paragraph" });
2485 setTokenStreamingWarningOpen(false);
2486 }}
2487 />
2488 </>
2489 }
2490 />
2390 <SettingsRow2491 <SettingsRow
2391 {...searchableSetting("hide-whitespace-changes")}2492 {...searchableSetting("hide-whitespace-changes")}
2392 description="Set whether the diff panel ignores whitespace-only edits by default."2493 description="Set whether the diff panel ignores whitespace-only edits by default."
apps/web/src/components/settings/settingsSearch.test.ts+1-1
...@@ -339,7 +339,7 @@ describe("settings search targets", () => {...@@ -339,7 +339,7 @@ describe("settings search targets", () => {
339 expect(isSettingsSearchScopeAvailable(updates.scope, "environment")).toBe(true);339 expect(isSettingsSearchScopeAvailable(updates.scope, "environment")).toBe(true);
340 expect(isSettingsSearchScopeAvailable(updates.scope, "all")).toBe(true);340 expect(isSettingsSearchScopeAvailable(updates.scope, "all")).toBe(true);
341 expect(isSettingsSearchScopeAvailable(updates.scope, "project")).toBe(false);341 expect(isSettingsSearchScopeAvailable(updates.scope, "project")).toBe(false);
342 const streaming = getSettingsSearchTargetScope("legacy-token-streaming")!;342 const streaming = getSettingsSearchTargetScope("response-streaming")!;
343 expect(streaming.scope).toBe("project-defaults");343 expect(streaming.scope).toBe("project-defaults");
344 expect(isSettingsSearchScopeAvailable(streaming.scope, "project")).toBe(true);344 expect(isSettingsSearchScopeAvailable(streaming.scope, "project")).toBe(true);
345 for (const id of ["legacy-plan-mode", "legacy-context-window-indicator", "legacy-sidebar"]) {345 for (const id of ["legacy-plan-mode", "legacy-context-window-indicator", "legacy-sidebar"]) {
apps/web/src/components/settings/settingsSearch.ts+7-7
...@@ -261,6 +261,13 @@ export const SETTINGS_SEARCH_ITEMS = [...@@ -261,6 +261,13 @@ export const SETTINGS_SEARCH_ITEMS = [
261 to: "/settings/general",261 to: "/settings/general",
262 searchTerms: ["timestamp clock locale system browser os 12 hour 24 hour"],262 searchTerms: ["timestamp clock locale system browser os 12 hour 24 hour"],
263 },263 },
264 {
265 id: "response-streaming",
266 title: "Response streaming",
267 to: "/settings/general",
268 scope: "project-defaults",
269 searchTerms: ["output token paragraph buffered wait turn legacy"],
270 },
264 {271 {
265 id: "hide-whitespace-changes",272 id: "hide-whitespace-changes",
266 title: "Hide whitespace changes",273 title: "Hide whitespace changes",
...@@ -398,13 +405,6 @@ export const SETTINGS_SEARCH_ITEMS = [...@@ -398,13 +405,6 @@ export const SETTINGS_SEARCH_ITEMS = [
398 to: "/settings/general",405 to: "/settings/general",
399 searchTerms: ["composer meter usage tokens circle old"],406 searchTerms: ["composer meter usage tokens circle old"],
400 },407 },
401 {
402 id: "legacy-token-streaming",
403 title: "Stream token by token (legacy)",
404 to: "/settings/general",
405 scope: "project-defaults",
406 searchTerms: ["response output old compatibility"],
407 },
408 {408 {
409 id: "legacy-sidebar",409 id: "legacy-sidebar",
410 title: "Sidebar (legacy)",410 title: "Sidebar (legacy)",
packages/contracts/src/orchestration.ts-2
...@@ -143,8 +143,6 @@ export const ProviderRequestKind = Schema.Literals([...@@ -143,8 +143,6 @@ export const ProviderRequestKind = Schema.Literals([
143 "mcp-elicitation",143 "mcp-elicitation",
144]);144]);
145export type ProviderRequestKind = typeof ProviderRequestKind.Type;145export type ProviderRequestKind = typeof ProviderRequestKind.Type;
146export const AssistantDeliveryMode = Schema.Literals(["buffered", "streaming"]);
147export type AssistantDeliveryMode = typeof AssistantDeliveryMode.Type;
148export const ProviderApprovalDecision = Schema.Literals([146export const ProviderApprovalDecision = Schema.Literals([
149 "accept",147 "accept",
150 "acceptForSession",148 "acceptForSession",
packages/contracts/src/settings.ts+17-7
...@@ -952,6 +952,15 @@ export type BackgroundActivitySettings = typeof BackgroundActivitySettings.Type;...@@ -952,6 +952,15 @@ export type BackgroundActivitySettings = typeof BackgroundActivitySettings.Type;
952 * background activity, theme. UI, search and the write planner derive952 * background activity, theme. UI, search and the write planner derive
953 * eligibility from this list, so adding a key here is the whole opt-in.953 * eligibility from this list, so adding a key here is the whole opt-in.
954 */954 */
955/**
956 * How assistant text reaches clients while a turn runs.
957 * - `turn`: hold the whole message until the turn finishes or pauses.
958 * - `paragraph`: deliver each finished paragraph or closed code block.
959 * - `token`: forward every provider delta. Legacy, kept for compatibility.
960 */
961export const ResponseStreamingMode = Schema.Literals(["turn", "paragraph", "token"]);
962export type ResponseStreamingMode = typeof ResponseStreamingMode.Type;
963
955export const PROJECT_SCOPED_SERVER_SETTING_KEYS = [964export const PROJECT_SCOPED_SERVER_SETTING_KEYS = [
956 "defaultModelSelection",965 "defaultModelSelection",
957 "defaultRuntimeMode",966 "defaultRuntimeMode",
...@@ -968,7 +977,7 @@ export const PROJECT_SCOPED_SERVER_SETTING_KEYS = [...@@ -968,7 +977,7 @@ export const PROJECT_SCOPED_SERVER_SETTING_KEYS = [
968 "sidebarAutoSettleOnMerge",977 "sidebarAutoSettleOnMerge",
969 "sidebarAutoSettleAfterDays",978 "sidebarAutoSettleAfterDays",
970 "continueThreadsAfterServerUpdate",979 "continueThreadsAfterServerUpdate",
971 "enableLegacyTokenStreaming",980 "responseStreamingMode",
972] as const;981] as const;
973export type ProjectScopedServerSettingKey = (typeof PROJECT_SCOPED_SERVER_SETTING_KEYS)[number];982export type ProjectScopedServerSettingKey = (typeof PROJECT_SCOPED_SERVER_SETTING_KEYS)[number];
974983
...@@ -993,16 +1002,17 @@ export const ProjectSettingsOverrides = Schema.Struct({...@@ -993,16 +1002,17 @@ export const ProjectSettingsOverrides = Schema.Struct({
993 sidebarAutoSettleOnMerge: Schema.optionalKey(Schema.Boolean),1002 sidebarAutoSettleOnMerge: Schema.optionalKey(Schema.Boolean),
994 sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)),1003 sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)),
995 continueThreadsAfterServerUpdate: Schema.optionalKey(Schema.Boolean),1004 continueThreadsAfterServerUpdate: Schema.optionalKey(Schema.Boolean),
996 enableLegacyTokenStreaming: Schema.optionalKey(Schema.Boolean),1005 responseStreamingMode: Schema.optionalKey(ResponseStreamingMode),
997} satisfies Record<ProjectScopedServerSettingKey, unknown>);1006} satisfies Record<ProjectScopedServerSettingKey, unknown>);
998export type ProjectSettingsOverrides = typeof ProjectSettingsOverrides.Type;1007export type ProjectSettingsOverrides = typeof ProjectSettingsOverrides.Type;
9991008
1000export const ServerSettings = Schema.Struct({1009export const ServerSettings = Schema.Struct({
1001 // Legacy token-by-token assistant output. Deliberately a fresh key (was1010 // How assistant text reaches clients during a turn. Deliberately a fresh
1011 // key (was `enableLegacyTokenStreaming`, before that
1002 // `enableAssistantStreaming`): decoding drops the old key, so everyone,1012 // `enableAssistantStreaming`): decoding drops the old key, so everyone,
1003 // including prior opt-ins, resets to the buffered default.1013 // including prior token-streaming opt-ins, resets to the paragraph default.
1004 enableLegacyTokenStreaming: Schema.Boolean.pipe(1014 responseStreamingMode: ResponseStreamingMode.pipe(
1005 Schema.withDecodingDefault(Effect.succeed(false)),1015 Schema.withDecodingDefault(Effect.succeed("paragraph" as const)),
1006 ),1016 ),
1007 enableProviderUpdateChecks: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),1017 enableProviderUpdateChecks: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
1008 // Retain the update-era key; recovery now needs an environment-owned opt-in.1018 // Retain the update-era key; recovery now needs an environment-owned opt-in.
...@@ -1337,7 +1347,7 @@ const OpenCodeSettingsPatch = Schema.Struct({...@@ -1337,7 +1347,7 @@ const OpenCodeSettingsPatch = Schema.Struct({
13371347
1338export const ServerSettingsPatch = Schema.Struct({1348export const ServerSettingsPatch = Schema.Struct({
1339 // Server settings1349 // Server settings
1340 enableLegacyTokenStreaming: Schema.optionalKey(Schema.Boolean),1350 responseStreamingMode: Schema.optionalKey(ResponseStreamingMode),
1341 enableProviderUpdateChecks: Schema.optionalKey(Schema.Boolean),1351 enableProviderUpdateChecks: Schema.optionalKey(Schema.Boolean),
1342 continueThreadsAfterServerUpdate: Schema.optionalKey(Schema.Boolean),1352 continueThreadsAfterServerUpdate: Schema.optionalKey(Schema.Boolean),
1343 enableAgentBrowserAccess: Schema.optionalKey(Schema.Boolean),1353 enableAgentBrowserAccess: Schema.optionalKey(Schema.Boolean),