authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-10-02 22:16:54-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-02 22:16:54-07:00
logc5a929e1ac05dec15945a36905ab8bb2f1982e59
treeb6449d3c3562ee7a86db1f3cdcde9130d70cd6f5
parent798e945aa77939937464ff45deaa8947be8d402f
signature Signed by PGP key B5690EEEBB952194

fix(web): Woke dismiss syncs, preview mute is back, and subscription retries back off (#15034)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

11 files changed, 306 insertions(+), 193 deletions(-)

apps/web/src/components/ChatView.tsx+23-6
......@@ -85,7 +85,7 @@ import {
8585import { readPastedComposerContext } from "./composerInlineTokenPaste";
8686import { isPasteAsTextShortcut } from "@t3tools/client-runtime/text-paste";
8787import { effectiveSnoozed, threadWokeAt } from "@t3tools/client-runtime/state/thread-settled";
88import { useThreadActions } from "../hooks/useThreadActions";
88import { useAcknowledgeThreadWoke, useThreadActions } from "../hooks/useThreadActions";
8989import {
9090 deriveProviderSubagentStatus,
9191 deriveReportedModelSelection,
......@@ -251,6 +251,7 @@ import {
251251 useThreadPreviewState,
252252} from "../previewStateStore";
253253import { BrowserSettingsReadError } from "../browser/openFileInPreview";
254import { previewRuntimeTabId } from "../browser/previewRuntimeTabId";
254255import { addBrowserSurface } from "./preview/addBrowserSurface";
255256import { closePreviewSession } from "./preview/closePreviewSession";
256257import { ThreadPreviewMiniPlayer } from "./preview/ThreadPreviewMiniPlayer";
......@@ -2234,6 +2235,14 @@ export default function ChatView(props: ChatViewProps) {
22342235 selectActiveRightPanelSurface(state.byThreadKey, activeThreadRef),
22352236 );
22362237 const activePreviewState = useThreadPreviewState(activeThreadRef);
2238 const activePreviewServerEpoch = activePreviewState.serverEpoch;
2239 const resolvePreviewRuntimeTabId = useMemo(
2240 () =>
2241 activeThreadRef
2242 ? (tabId: string) => previewRuntimeTabId(activeThreadRef, activePreviewServerEpoch, tabId)
2243 : undefined,
2244 [activeThreadRef, activePreviewServerEpoch],
2245 );
22372246 const activePreviewMiniPlayer = usePreviewMiniPlayerStore((state) =>
22382247 selectThreadPreviewMiniPlayer(state.byThreadKey, activeThreadRef),
22392248 );
......@@ -6634,14 +6643,20 @@ export default function ChatView(props: ChatViewProps) {
66346643 activeThreadShell !== null && supportsSnooze
66356644 ? threadWokeAt(activeThreadShell, { now: new Date().toISOString() })
66366645 : null;
6646 const acknowledgeThreadWoke = useAcknowledgeThreadWoke();
66376647 const acknowledgeActiveThreadWoke = useCallback(() => {
66386648 if (activeThreadRef === null || activeThreadWokeAt === null) return;
6639 markThreadVisited(scopedThreadKey(activeThreadRef), activeThreadWokeAt);
6640 }, [activeThreadRef, activeThreadWokeAt, markThreadVisited]);
6641 // Mirror of the sidebar's Woke pill for the open thread.
6642 const activeThreadLastVisitedAt = useUiStateStore((store) =>
6649 acknowledgeThreadWoke(activeThreadRef, activeThreadWokeAt);
6650 }, [acknowledgeThreadWoke, activeThreadRef, activeThreadWokeAt]);
6651 // Mirror of the sidebar's Woke pill for the open thread. Same watermark as
6652 // the sidebar, so an acknowledgement from any device hides it.
6653 const activeThreadLocalVisitedAt = useUiStateStore((store) =>
66436654 activeThreadKey === null ? undefined : store.threadLastVisitedAtById[activeThreadKey],
66446655 );
6656 const activeThreadLastVisitedAt = resolveThreadLastVisitedAt(
6657 activeThreadShell?.lastVisitedAt,
6658 activeThreadLocalVisitedAt,
6659 );
66456660 const activeThreadWokeVisible = useMemo(() => {
66466661 if (activeThreadWokeAt === null) return false;
66476662 if (activeThreadShell?.settledOverride === "settled") return false;
......@@ -6651,7 +6666,7 @@ export default function ChatView(props: ChatViewProps) {
66516666 // above stamps it); folding that floor in here keeps a completion-
66526667 // triggered wake from flashing a banner for one frame before the stamp
66536668 // lands. An unparseable stored visit counts as never-visited: corrupt
6654 // local data must not eat the wake signal.
6669 // data must not eat the wake signal.
66556670 const storedVisitMs = activeThreadLastVisitedAt ? Date.parse(activeThreadLastVisitedAt) : NaN;
66566671 const completedAtMs = activeLatestRun?.completedAt
66576672 ? Date.parse(activeLatestRun.completedAt)
......@@ -11168,6 +11183,7 @@ export default function ChatView(props: ChatViewProps) {
1116811183 pendingSurfaceIds={pendingFileSurfaceIds}
1116911184 previewSessions={activePreviewState.sessions}
1117011185 desktopByTabId={activePreviewState.desktopByTabId}
11186 previewRuntimeTabId={resolvePreviewRuntimeTabId}
1117111187 terminalLabelsById={activeTerminalLabelsById}
1117211188 onActivate={activateRightPanelSurface}
1117311189 onCloseSurface={closeRightPanelSurface}
......@@ -11222,6 +11238,7 @@ export default function ChatView(props: ChatViewProps) {
1122211238 pendingSurfaceIds={pendingFileSurfaceIds}
1122311239 previewSessions={activePreviewState.sessions}
1122411240 desktopByTabId={activePreviewState.desktopByTabId}
11241 previewRuntimeTabId={resolvePreviewRuntimeTabId}
1122511242 terminalLabelsById={activeTerminalLabelsById}
1122611243 onActivate={activateRightPanelSurface}
1122711244 onCloseSurface={closeRightPanelSurface}
apps/web/src/components/Sidebar.tsx+2-8
......@@ -130,7 +130,7 @@ import {
130130 getThreadKeysToDeselectAfterDelete,
131131 useThreadSelectionStore,
132132} from "../threadSelectionStore";
133import { useThreadActions } from "../hooks/useThreadActions";
133import { useAcknowledgeThreadWoke, useThreadActions } from "../hooks/useThreadActions";
134134import { useHandleNewThread } from "../hooks/useHandleNewThread";
135135import { useTerminalFocus } from "../hooks/useTerminalFocus";
136136import { isCommandPaletteOpen, openCommandPalette } from "../commandPaletteBus";
......@@ -2381,13 +2381,7 @@ export default function Sidebar() {
23812381 const setSelectionAnchor = useThreadSelectionStore((s) => s.setAnchor);
23822382 const toggleThreadSelection = useThreadSelectionStore((s) => s.toggleThread);
23832383 const rangeSelectTo = useThreadSelectionStore((s) => s.rangeSelectTo);
2384 const markThreadVisited = useUiStateStore((s) => s.markThreadVisited);
2385 const acknowledgeWoke = useCallback(
2386 (threadRef: ScopedThreadRef, visitedAt: string) => {
2387 markThreadVisited(scopedThreadKey(threadRef), visitedAt);
2388 },
2389 [markThreadVisited],
2390 );
2384 const acknowledgeWoke = useAcknowledgeThreadWoke();
23912385 const routeTarget = useParams({
23922386 strict: false,
23932387 select: (params) => resolveThreadRouteTarget(params),
apps/web/src/components/settings/SettingsPanels.tsx+57-1
......@@ -44,6 +44,7 @@ import {
4444 type ResponseStreamingMode,
4545 MIN_TERMINAL_FONT_SIZE,
4646 type QuitConfirmationMode,
47 SidebarProjectSortOrder,
4748} from "@t3tools/contracts/settings";
4849import { resolveServerBackgroundActivitySettings } from "@t3tools/shared/backgroundActivitySettings";
4950import { createModelSelection } from "@t3tools/shared/model";
......@@ -87,6 +88,7 @@ import { useDesktopUpdateState } from "../../state/desktopUpdate";
8788import {
8889 getCustomModelOptionsByInstance,
8990 resolveAppModelSelectionState,
91 selectsPlanAgent,
9092} from "../../modelSelection";
9193import {
9294 applyProviderInstanceSettings,
......@@ -181,6 +183,13 @@ const RESPONSE_STREAMING_MODE_DESCRIPTIONS: Record<ResponseStreamingMode, string
181183 paragraph: "Each paragraph or code block appears as soon as it is complete.",
182184};
183185
186const SIDEBAR_PROJECT_SORT_ORDER_LABELS: Record<SidebarProjectSortOrder, string> = {
187 updated_at: "Last user message",
188 created_at: "Created at",
189 manual: "Manual",
190};
191const isSidebarProjectSortOrder = Schema.is(SidebarProjectSortOrder);
192
184193const TIMESTAMP_FORMAT_LABELS = {
185194 locale: "System default",
186195 "12-hour": "12-hour",
......@@ -552,6 +561,9 @@ export function useSettingsRestore(onRestored?: () => void) {
552561 DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode
553562 ? ["Project Grouping"]
554563 : []),
564 ...(settings.sidebarProjectSortOrder !== DEFAULT_UNIFIED_SETTINGS.sidebarProjectSortOrder
565 ? ["Project order"]
566 : []),
555567 ...(settings.sidebarWorkingShelfEnabled !==
556568 DEFAULT_UNIFIED_SETTINGS.sidebarWorkingShelfEnabled
557569 ? ["Working section"]
......@@ -690,6 +702,7 @@ export function useSettingsRestore(onRestored?: () => void) {
690702 settings.autoResumeLimitedThreads,
691703 settings.snoozeLimitedThreads,
692704 settings.sidebarProjectGroupingMode,
705 settings.sidebarProjectSortOrder,
693706 settings.sidebarWorkingShelfEnabled,
694707 settings.sidebarThreadPreviewCount,
695708 settings.showSkillsInSlashMenu,
......@@ -789,6 +802,7 @@ export function useSettingsRestore(onRestored?: () => void) {
789802 panelAnimationDurationMs: DEFAULT_UNIFIED_SETTINGS.panelAnimationDurationMs,
790803 sidebarThreadPreviewCount: DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount,
791804 sidebarProjectGroupingMode: DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode,
805 sidebarProjectSortOrder: DEFAULT_UNIFIED_SETTINGS.sidebarProjectSortOrder,
792806 sidebarWorkingShelfEnabled: DEFAULT_UNIFIED_SETTINGS.sidebarWorkingShelfEnabled,
793807 sidebarAutoSettleAfterDays: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays,
794808 sidebarAutoSettleOnMerge: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge,
......@@ -2270,6 +2284,46 @@ export function GeneralSettingsPanel() {
22702284 />
22712285 }
22722286 />
2287 <SettingsRow
2288 {...searchableSetting("project-order")}
2289 description="Order of projects in the sidebar project picker and command palette."
2290 resetAction={
2291 settings.sidebarProjectSortOrder !==
2292 DEFAULT_UNIFIED_SETTINGS.sidebarProjectSortOrder ? (
2293 <SettingResetButton
2294 label="project order"
2295 onClick={() =>
2296 updateSettings({
2297 sidebarProjectSortOrder: DEFAULT_UNIFIED_SETTINGS.sidebarProjectSortOrder,
2298 })
2299 }
2300 />
2301 ) : null
2302 }
2303 control={
2304 <Select
2305 value={settings.sidebarProjectSortOrder}
2306 onValueChange={(value) => {
2307 if (isSidebarProjectSortOrder(value)) {
2308 updateSettings({ sidebarProjectSortOrder: value });
2309 }
2310 }}
2311 >
2312 <SelectTrigger size="sm" className="w-full sm:w-44" aria-label="Project order">
2313 <SelectValue>
2314 {SIDEBAR_PROJECT_SORT_ORDER_LABELS[settings.sidebarProjectSortOrder]}
2315 </SelectValue>
2316 </SelectTrigger>
2317 <SelectPopup align="end" alignItemWithTrigger={false}>
2318 {SidebarProjectSortOrder.literals.map((sortOrder) => (
2319 <SelectItem hideIndicator key={sortOrder} value={sortOrder}>
2320 {SIDEBAR_PROJECT_SORT_ORDER_LABELS[sortOrder]}
2321 </SelectItem>
2322 ))}
2323 </SelectPopup>
2324 </Select>
2325 }
2326 />
22732327
22742328 <SettingsRow
22752329 serverScoped
......@@ -3244,7 +3298,9 @@ export function GeneralSettingsPanel() {
32443298 onPromptChange={() => {}}
32453299 modelOptions={textGenModelOptions}
32463300 allowPromptInjectedEffort={false}
3247 planModeEnabled={settings.planModeEnabled}
3301 planModeEnabled={
3302 settings.planModeEnabled || selectsPlanAgent(textGenModelOptions)
3303 }
32483304 triggerClassName={SETTINGS_PICKER_TRIGGER_CLASSNAME}
32493305 onModelOptionsChange={(nextOptions) => {
32503306 updateSettings({
apps/web/src/components/settings/settingsSearch.ts+6
......@@ -280,6 +280,12 @@ export const SETTINGS_SEARCH_ITEMS = [
280280 to: "/settings/general",
281281 searchTerms: ["combine matching repositories environments sidebar"],
282282 },
283 {
284 id: "project-order",
285 title: "Project order",
286 to: "/settings/general",
287 searchTerms: ["sort projects sidebar manual created recent"],
288 },
283289 {
284290 id: "snooze-limited-threads",
285291 title: "Snooze limited threads",
apps/web/src/hooks/useThreadActions.ts+24
......@@ -219,6 +219,30 @@ function useMarkThreadUnread() {
219219 );
220220}
221221
222/**
223 * Clears a thread's Woke marker by recording a visit at the wake time.
224 * Servers with visited tracking own the watermark (thread.visit keeps the
225 * later of the stored and supplied values, so this syncs to every device);
226 * older servers keep the browser-local watermark.
227 */
228export function useAcknowledgeThreadWoke() {
229 const visitThreadMutation = useAtomCommand(threadEnvironment.visit, { reportFailure: false });
230 const markThreadVisited = useUiStateStore((state) => state.markThreadVisited);
231 return useCallback(
232 (target: ScopedThreadRef, wokeAt: string) => {
233 if (readEnvironmentSupportsVisitedTracking(target.environmentId)) {
234 void visitThreadMutation({
235 environmentId: target.environmentId,
236 input: { threadId: target.threadId, visitedAt: wokeAt },
237 });
238 return;
239 }
240 markThreadVisited(scopedThreadKey(target), wokeAt);
241 },
242 [markThreadVisited, visitThreadMutation],
243 );
244}
245
222246export function useThreadActions() {
223247 const closeTerminal = useAtomCommand(terminalEnvironment.close);
224248 const archiveThreadMutation = useAtomCommand(threadEnvironment.archive, {
apps/web/src/modelSelection.test.ts+35-74
......@@ -15,8 +15,6 @@ import {
1515 getAppModelOptionsForInstance,
1616 resolveAppModelSelectionForInstance,
1717 resolveAppModelSelectionState,
18 resolvePlanAgentHealPatch,
19 withoutPlanAgentSelection,
2018} from "./modelSelection";
2119
2220function provider(input: {
......@@ -865,81 +863,44 @@ describe("instance-scoped model selection", () => {
865863 });
866864});
867865
868describe("withoutPlanAgentSelection", () => {
869 const instance = ProviderInstanceId.make("opencode");
866describe("resolveAppModelSelectionState with the opencode plan agent", () => {
867 const instanceId = ProviderInstanceId.make("opencode");
870868 const model = "opencode/gpt-5.4";
871
872 it("drops a stored plan agent option", () => {
873 const selection = createModelSelection(instance, model, [
874 { id: "variant", value: "high" },
869 const opencode: ServerProvider = {
870 ...provider({ provider: ProviderDriverKind.make("opencode"), instanceId: "opencode" }),
871 models: [
872 {
873 slug: model,
874 name: model,
875 isCustom: false,
876 capabilities: {
877 optionDescriptors: [
878 {
879 id: "agent",
880 label: "Agent",
881 type: "select",
882 options: [
883 { id: "build", label: "Build", isDefault: true },
884 { id: "plan", label: "Plan" },
885 ],
886 currentValue: "build",
887 },
888 ],
889 },
890 },
891 ],
892 };
893 const settings: UnifiedSettings = {
894 ...DEFAULT_UNIFIED_SETTINGS,
895 planModeEnabled: false,
896 textGenerationModelSelection: createModelSelection(instanceId, model, [
875897 { id: "agent", value: "plan" },
876 ]);
877 expect(withoutPlanAgentSelection(selection)).toEqual(
878 createModelSelection(instance, model, [{ id: "variant", value: "high" }]),
879 );
880 });
881
882 it("keeps non-plan agent options", () => {
883 const selection = createModelSelection(instance, model, [{ id: "agent", value: "build" }]);
884 expect(withoutPlanAgentSelection(selection)).toBe(selection);
885 });
886
887 it("omits options entirely when plan was the only stored option", () => {
888 const selection = createModelSelection(instance, model, [{ id: "agent", value: "plan" }]);
889 expect(withoutPlanAgentSelection(selection)).toEqual({ instanceId: instance, model });
890 });
891
892 it("returns null and undefined selections unchanged", () => {
893 expect(withoutPlanAgentSelection(null)).toBeNull();
894 expect(withoutPlanAgentSelection(undefined)).toBeUndefined();
895 });
896});
897
898describe("resolvePlanAgentHealPatch", () => {
899 const instance = ProviderInstanceId.make("opencode");
900 const model = "opencode/gpt-5.4";
901 const healed = createModelSelection(instance, model, [{ id: "variant", value: "high" }]);
902 const storedPlan = createModelSelection(instance, model, [
903 { id: "variant", value: "high" },
904 { id: "agent", value: "plan" },
905 ]);
906 const nullPatch = {
907 planModeEnabled: true,
908 textGenerationModelSelection: storedPlan,
909 sourceControlWriterModelSelection: null,
898 ]),
910899 };
911900
912 it("returns null when plan mode is on", () => {
913 expect(resolvePlanAgentHealPatch(nullPatch)).toBeNull();
914 });
915
916 it("returns null when nothing needs healing", () => {
917 expect(
918 resolvePlanAgentHealPatch({
919 planModeEnabled: false,
920 textGenerationModelSelection: healed,
921 sourceControlWriterModelSelection: null,
922 }),
923 ).toBeNull();
924 });
925
926 it("patches the stored text generation selection to drop the plan agent", () => {
927 expect(
928 resolvePlanAgentHealPatch({
929 planModeEnabled: false,
930 textGenerationModelSelection: storedPlan,
931 sourceControlWriterModelSelection: null,
932 }),
933 ).toEqual({ textGenerationModelSelection: healed });
934 });
935
936 it("patches a stored source control writer selection that uses the plan agent", () => {
937 expect(
938 resolvePlanAgentHealPatch({
939 planModeEnabled: false,
940 textGenerationModelSelection: healed,
941 sourceControlWriterModelSelection: storedPlan,
942 }),
943 ).toEqual({ sourceControlWriterModelSelection: healed });
901 it("keeps a stored plan agent this device did not pick", () => {
902 expect(resolveAppModelSelectionState(settings, [opencode])).toEqual(
903 createModelSelection(instanceId, model, [{ id: "agent", value: "plan" }]),
904 );
944905 });
945906});
apps/web/src/modelSelection.ts+7-43
......@@ -7,7 +7,6 @@ import {
77 ProviderDriverKind,
88 ProviderInstanceId,
99 type ServerProvider,
10 type ServerSettingsPatch,
1110} from "@t3tools/contracts";
1211import {
1312 type CustomModelDefinition,
......@@ -350,48 +349,13 @@ export function getCustomModelOptionsByInstance(
350349}
351350
352351/**
353 * Drop the opencode "plan" agent option from a stored model selection.
354 * Used when legacy plan mode is turned off so server-side text-generation
355 * tasks (title, branch, PR) cannot keep dispatching the plan agent.
352 * Whether stored model options pick the opencode "plan" agent. Shared settings
353 * pickers keep and show such a value even while this device's legacy plan
354 * mode is off: another device may have chosen it, and this device's filter
355 * only applies to picks made here.
356356 */
357export function withoutPlanAgentSelection(
358 selection: ModelSelection | null | undefined,
359): ModelSelection | null | undefined {
360 if (!selection?.options) {
361 return selection;
362 }
363 const options = selection.options.filter(
364 (option) => !(option.id === "agent" && option.value === "plan"),
365 );
366 if (options.length === selection.options.length) {
367 return selection;
368 }
369 return createModelSelection(selection.instanceId, selection.model, options);
370}
371
372// The dropdown hides the opencode "plan" agent while legacy plan mode is off,
373// but the persisted text-generation selections are only healed when the toggle
374// flips. Users who already have plan mode off and a stored "plan" selection
375// never trip the toggle handler, so resolve the heal once per settings load.
376export function resolvePlanAgentHealPatch(input: {
377 readonly planModeEnabled: boolean;
378 readonly textGenerationModelSelection: ModelSelection | null | undefined;
379 readonly sourceControlWriterModelSelection: ModelSelection | null | undefined;
380}): ServerSettingsPatch | null {
381 if (input.planModeEnabled) {
382 return null;
383 }
384 const healedText = withoutPlanAgentSelection(input.textGenerationModelSelection);
385 const healedSourceControl = withoutPlanAgentSelection(input.sourceControlWriterModelSelection);
386 const patch: ServerSettingsPatch = {
387 ...(healedText && healedText !== input.textGenerationModelSelection
388 ? { textGenerationModelSelection: healedText }
389 : {}),
390 ...(healedSourceControl && healedSourceControl !== input.sourceControlWriterModelSelection
391 ? { sourceControlWriterModelSelection: healedSourceControl }
392 : {}),
393 };
394 return Object.keys(patch).length > 0 ? patch : null;
357export function selectsPlanAgent(options: ModelSelection["options"]): boolean {
358 return options?.some((option) => option.id === "agent" && option.value === "plan") ?? false;
395359}
396360
397361export function resolveAppModelSelectionState(
......@@ -433,7 +397,7 @@ export function resolveAppModelSelectionState(
433397 model,
434398 models: entry.models,
435399 modelOptions: selectedEntry ? selection.options : undefined,
436 planModeEnabled: settings.planModeEnabled,
400 planModeEnabled: settings.planModeEnabled || selectsPlanAgent(selection.options),
437401 });
438402
439403 return createModelSelection(entry.instanceId, model, modelOptionsForDispatch);
apps/web/src/planAgentSelectionHeal.tsx deleted-52
......@@ -1,52 +0,0 @@
1import { useEffect } from "react";
2
3import {
4 useClientSettingsHydrated,
5 usePrimarySettings,
6 useUpdatePrimarySettings,
7} from "./hooks/useSettings";
8import { resolvePlanAgentHealPatch } from "./modelSelection";
9
10/**
11 * Heals persisted text-generation model selections that still reference the
12 * opencode "plan" agent. The dropdown hides the option while legacy plan mode
13 * is off, but the toggle handler only runs when the setting flips; users who
14 * already have plan mode off with a stored "plan" selection need this pass
15 * whenever the settings load.
16 */
17export function PlanAgentSelectionHeal() {
18 const planModeEnabled = usePrimarySettings((settings) => settings.planModeEnabled);
19 const textGenerationModelSelection = usePrimarySettings(
20 (settings) => settings.textGenerationModelSelection,
21 );
22 const sourceControlWriterModelSelection = usePrimarySettings(
23 (settings) => settings.sourceControlWriterModelSelection,
24 );
25 const settingsHydrated = useClientSettingsHydrated();
26 const updateSettings = useUpdatePrimarySettings();
27
28 useEffect(() => {
29 // planModeEnabled reads as false until client settings hydrate, so never
30 // heal before then: we would strip a stored plan selection from a user
31 // whose plan mode is actually on.
32 if (!settingsHydrated) {
33 return;
34 }
35 const patch = resolvePlanAgentHealPatch({
36 planModeEnabled,
37 textGenerationModelSelection,
38 sourceControlWriterModelSelection,
39 });
40 if (patch) {
41 updateSettings(patch);
42 }
43 }, [
44 planModeEnabled,
45 settingsHydrated,
46 textGenerationModelSelection,
47 sourceControlWriterModelSelection,
48 updateSettings,
49 ]);
50
51 return null;
52}
apps/web/src/routes/__root.tsx-2
......@@ -51,7 +51,6 @@ import { isElectron } from "../env";
5151import { applyAppearanceFontVariables } from "~/appearanceFonts";
5252import { applyAppearanceContrast } from "~/appearanceContrast";
5353import { useClientSettings } from "../hooks/useSettings";
54import { PlanAgentSelectionHeal } from "../planAgentSelectionHeal";
5554import {
5655 deriveLogicalProjectKeyFromSettings,
5756 derivePhysicalProjectKeyFromPath,
......@@ -242,7 +241,6 @@ function RootRouteView() {
242241 {primaryEnvironmentAuthenticated ? (
243242 <EventRouter skipInitialBootstrapNavigation={returningFromWelcomeRef.current} />
244243 ) : null}
245 {primaryEnvironmentAuthenticated ? <PlanAgentSelectionHeal /> : null}
246244 {primaryEnvironmentAuthenticated ? <ProviderUpdateLaunchNotification /> : null}
247245 {appShell}
248246 {/* Above the router: a theme draft is judged by walking the app, so the
packages/client-runtime/src/rpc/client.test.ts+109
......@@ -1,5 +1,6 @@
11import {
22 DEFAULT_SERVER_SETTINGS,
3 EnvironmentAuthorizationError,
34 EnvironmentId,
45 PreviewTabId,
56 ThreadId,
......@@ -618,6 +619,114 @@ describe("environment RPC", () => {
618619 }),
619620 );
620621
622 it.effect("doubles the retry delay for repeated failures and resets it after a value", () =>
623 Effect.gen(function* () {
624 const domainError = new Error("thread not hydrated yet");
625 const subscriptions = yield* Queue.unbounded<number>();
626 const failures = yield* Queue.unbounded<void>();
627 let attempts = 0;
628 const client = {
629 [WS_METHODS.subscribeTerminalEvents]: () =>
630 Stream.unwrap(
631 Effect.sync(() => {
632 attempts += 1;
633 return attempts;
634 }).pipe(
635 Effect.tap((attempt) => Queue.offer(subscriptions, attempt)),
636 Effect.map((attempt) => {
637 if (attempt <= 2) return Stream.fail(domainError);
638 if (attempt === 3)
639 return Stream.concat(Stream.make("event"), Stream.fail(domainError));
640 return Stream.never;
641 }),
642 ),
643 ),
644 } as unknown as WsRpcProtocolClient;
645 const { activeSession, supervisor } = yield* makeHarness();
646
647 yield* SubscriptionRef.set(activeSession, Option.some(session(client)));
648 const subscriptionFiber = yield* subscribe(
649 WS_METHODS.subscribeTerminalEvents,
650 {},
651 {
652 onExpectedFailure: () => Queue.offer(failures, undefined),
653 retryExpectedFailureAfter: "100 millis",
654 },
655 ).pipe(
656 Stream.runDrain,
657 Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
658 Effect.forkChild,
659 );
660
661 expect(yield* Queue.take(subscriptions)).toBe(1);
662 yield* Queue.take(failures);
663 yield* TestClock.adjust("100 millis");
664 expect(yield* Queue.take(subscriptions)).toBe(2);
665 yield* Queue.take(failures);
666 // The second retry waits 200ms, so 100ms is not enough.
667 yield* TestClock.adjust("100 millis");
668 expect(Option.isNone(yield* Queue.poll(subscriptions))).toBe(true);
669 yield* TestClock.adjust("100 millis");
670 expect(yield* Queue.take(subscriptions)).toBe(3);
671 // Attempt 3 delivered a value before failing, so the delay is back to 100ms.
672 yield* Queue.take(failures);
673 yield* TestClock.adjust("100 millis");
674 expect(yield* Queue.take(subscriptions)).toBe(4);
675 yield* Fiber.interrupt(subscriptionFiber);
676 }),
677 );
678
679 it.effect("waits for the next session after an authorization failure", () =>
680 Effect.gen(function* () {
681 const subscriptions = yield* Queue.unbounded<void>();
682 const failed = yield* Deferred.make<void>();
683 let attempts = 0;
684 const client = {
685 [WS_METHODS.subscribeTerminalEvents]: () =>
686 Stream.unwrap(
687 Queue.offer(subscriptions, undefined).pipe(
688 Effect.map(() => {
689 attempts += 1;
690 return attempts === 1
691 ? Stream.fail(
692 new EnvironmentAuthorizationError({
693 message: "Missing scope",
694 requiredScope: "orchestration:read",
695 }),
696 )
697 : Stream.never;
698 }),
699 ),
700 ),
701 } as unknown as WsRpcProtocolClient;
702 const { activeSession, supervisor } = yield* makeHarness();
703
704 yield* SubscriptionRef.set(activeSession, Option.some(session(client)));
705 const subscriptionFiber = yield* subscribe(
706 WS_METHODS.subscribeTerminalEvents,
707 {},
708 {
709 onExpectedFailure: () => Deferred.succeed(failed, undefined).pipe(Effect.asVoid),
710 retryExpectedFailureAfter: "100 millis",
711 },
712 ).pipe(
713 Stream.runDrain,
714 Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
715 Effect.forkChild,
716 );
717
718 yield* Queue.take(subscriptions);
719 yield* Deferred.await(failed);
720 yield* TestClock.adjust("1 minute");
721 expect(Option.isNone(yield* Queue.poll(subscriptions))).toBe(true);
722
723 yield* SubscriptionRef.set(activeSession, Option.some(session(client)));
724 yield* Queue.take(subscriptions);
725 yield* Fiber.interrupt(subscriptionFiber);
726 expect(attempts).toBe(2);
727 }),
728 );
729
621730 it.effect.each(["input", "stream"] as const)(
622731 "does not classify %s subscription defects as expected failures",
623732 (where) =>
packages/client-runtime/src/rpc/client.ts+43-7
......@@ -1,7 +1,11 @@
1import { ORCHESTRATION_V2_WS_METHODS, WS_METHODS } from "@t3tools/contracts";
1import {
2 EnvironmentAuthorizationError,
3 ORCHESTRATION_V2_WS_METHODS,
4 WS_METHODS,
5} from "@t3tools/contracts";
26import * as Cause from "effect/Cause";
37import * as Context from "effect/Context";
4import type * as Duration from "effect/Duration";
8import * as Duration from "effect/Duration";
59import * as Effect from "effect/Effect";
610import * as Option from "effect/Option";
711import * as Schema from "effect/Schema";
......@@ -92,6 +96,10 @@ export class EnvironmentRpcSubscriptionObserver extends Context.Reference<{
9296}) {}
9397
9498export const isRpcClientError = Schema.is(RpcClientError.RpcClientError);
99const isEnvironmentAuthorizationError = Schema.is(EnvironmentAuthorizationError);
100
101/** Ceiling for the doubling delay between same-session expected-failure retries. */
102const MAX_EXPECTED_FAILURE_RETRY_DELAY_MS = 30_000;
95103
96104export type EnvironmentRpcInput<TTag extends EnvironmentRpcTag> = Parameters<RpcMethod<TTag>>[0];
97105
......@@ -192,6 +200,12 @@ interface SubscriptionOptions<TTag extends EnvironmentSubscriptionRpcTag> {
192200 readonly onExpectedFailure?: (
193201 cause: Cause.Cause<EnvironmentRpcStreamFailure<TTag>>,
194202 ) => Effect.Effect<void, never, never>;
203 /**
204 * First delay before resubscribing on the same session after an expected
205 * failure. Each consecutive failure doubles it up to 30 seconds, and the
206 * first value from a healthy stream resets it. Authorization failures are
207 * not retried; they wait for the next session or `resubscribe` signal.
208 */
195209 readonly retryExpectedFailureAfter?: Duration.Input;
196210 readonly resubscribe?: Stream.Stream<unknown, never, never>;
197211}
......@@ -238,6 +252,9 @@ function subscribeDynamicMapped<TTag extends EnvironmentSubscriptionRpcTag, A>(
238252 EnvironmentRpcStreamValue<TTag>,
239253 EnvironmentRpcStreamFailure<TTag>
240254 >;
255 // Consecutive expected-failure retries on this session. Reset by
256 // the first value a resubscribed stream delivers.
257 let expectedFailureRetries = 0;
241258 const subscribeToSession = (): Stream.Stream<A, EnvironmentRpcStreamFailure<TTag>> =>
242259 Stream.suspend(() =>
243260 Stream.unwrap(
......@@ -248,7 +265,13 @@ function subscribeDynamicMapped<TTag extends EnvironmentSubscriptionRpcTag, A>(
248265 method: tag,
249266 input,
250267 });
251 const stream = mapStream(session, method(input));
268 const stream = mapStream(session, method(input)).pipe(
269 Stream.onFirst(() =>
270 Effect.sync(() => {
271 expectedFailureRetries = 0;
272 }),
273 ),
274 );
252275 // An evicted preview host completes its registration stream.
253276 // Re-register only after completion; failures still follow the
254277 // session recovery policy and browser actions are never replayed.
......@@ -296,14 +319,27 @@ function subscribeDynamicMapped<TTag extends EnvironmentSubscriptionRpcTag, A>(
296319 const handled = Stream.fromEffect(options.onExpectedFailure(cause)).pipe(
297320 Stream.drain,
298321 );
299 if (options.retryExpectedFailureAfter === undefined) {
322 const isAuthorizationFailure = cause.reasons.some(
323 (reason) =>
324 reason._tag === "Fail" && isEnvironmentAuthorizationError(reason.error),
325 );
326 if (
327 options.retryExpectedFailureAfter === undefined ||
328 isAuthorizationFailure
329 ) {
300330 return handled;
301331 }
332 const retryDelay = Duration.millis(
333 Math.min(
334 Duration.toMillis(options.retryExpectedFailureAfter) *
335 2 ** expectedFailureRetries,
336 MAX_EXPECTED_FAILURE_RETRY_DELAY_MS,
337 ),
338 );
339 expectedFailureRetries += 1;
302340 return handled.pipe(
303341 Stream.concat(
304 Stream.fromEffect(Effect.sleep(options.retryExpectedFailureAfter)).pipe(
305 Stream.drain,
306 ),
342 Stream.fromEffect(Effect.sleep(retryDelay)).pipe(Stream.drain),
307343 ),
308344 Stream.concat(subscribeToSession()),
309345 );