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 {...@@ -85,7 +85,7 @@ import {
85import { readPastedComposerContext } from "./composerInlineTokenPaste";85import { readPastedComposerContext } from "./composerInlineTokenPaste";
86import { isPasteAsTextShortcut } from "@t3tools/client-runtime/text-paste";86import { isPasteAsTextShortcut } from "@t3tools/client-runtime/text-paste";
87import { effectiveSnoozed, threadWokeAt } from "@t3tools/client-runtime/state/thread-settled";87import { effectiveSnoozed, threadWokeAt } from "@t3tools/client-runtime/state/thread-settled";
88import { useThreadActions } from "../hooks/useThreadActions";88import { useAcknowledgeThreadWoke, useThreadActions } from "../hooks/useThreadActions";
89import {89import {
90 deriveProviderSubagentStatus,90 deriveProviderSubagentStatus,
91 deriveReportedModelSelection,91 deriveReportedModelSelection,
...@@ -251,6 +251,7 @@ import {...@@ -251,6 +251,7 @@ import {
251 useThreadPreviewState,251 useThreadPreviewState,
252} from "../previewStateStore";252} from "../previewStateStore";
253import { BrowserSettingsReadError } from "../browser/openFileInPreview";253import { BrowserSettingsReadError } from "../browser/openFileInPreview";
254import { previewRuntimeTabId } from "../browser/previewRuntimeTabId";
254import { addBrowserSurface } from "./preview/addBrowserSurface";255import { addBrowserSurface } from "./preview/addBrowserSurface";
255import { closePreviewSession } from "./preview/closePreviewSession";256import { closePreviewSession } from "./preview/closePreviewSession";
256import { ThreadPreviewMiniPlayer } from "./preview/ThreadPreviewMiniPlayer";257import { ThreadPreviewMiniPlayer } from "./preview/ThreadPreviewMiniPlayer";
...@@ -2234,6 +2235,14 @@ export default function ChatView(props: ChatViewProps) {...@@ -2234,6 +2235,14 @@ export default function ChatView(props: ChatViewProps) {
2234 selectActiveRightPanelSurface(state.byThreadKey, activeThreadRef),2235 selectActiveRightPanelSurface(state.byThreadKey, activeThreadRef),
2235 );2236 );
2236 const activePreviewState = useThreadPreviewState(activeThreadRef);2237 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 );
2237 const activePreviewMiniPlayer = usePreviewMiniPlayerStore((state) =>2246 const activePreviewMiniPlayer = usePreviewMiniPlayerStore((state) =>
2238 selectThreadPreviewMiniPlayer(state.byThreadKey, activeThreadRef),2247 selectThreadPreviewMiniPlayer(state.byThreadKey, activeThreadRef),
2239 );2248 );
...@@ -6634,14 +6643,20 @@ export default function ChatView(props: ChatViewProps) {...@@ -6634,14 +6643,20 @@ export default function ChatView(props: ChatViewProps) {
6634 activeThreadShell !== null && supportsSnooze6643 activeThreadShell !== null && supportsSnooze
6635 ? threadWokeAt(activeThreadShell, { now: new Date().toISOString() })6644 ? threadWokeAt(activeThreadShell, { now: new Date().toISOString() })
6636 : null;6645 : null;
6646 const acknowledgeThreadWoke = useAcknowledgeThreadWoke();
6637 const acknowledgeActiveThreadWoke = useCallback(() => {6647 const acknowledgeActiveThreadWoke = useCallback(() => {
6638 if (activeThreadRef === null || activeThreadWokeAt === null) return;6648 if (activeThreadRef === null || activeThreadWokeAt === null) return;
6639 markThreadVisited(scopedThreadKey(activeThreadRef), activeThreadWokeAt);6649 acknowledgeThreadWoke(activeThreadRef, activeThreadWokeAt);
6640 }, [activeThreadRef, activeThreadWokeAt, markThreadVisited]);6650 }, [acknowledgeThreadWoke, activeThreadRef, activeThreadWokeAt]);
6641 // Mirror of the sidebar's Woke pill for the open thread.6651 // Mirror of the sidebar's Woke pill for the open thread. Same watermark as
6642 const activeThreadLastVisitedAt = useUiStateStore((store) =>6652 // the sidebar, so an acknowledgement from any device hides it.
6653 const activeThreadLocalVisitedAt = useUiStateStore((store) =>
6643 activeThreadKey === null ? undefined : store.threadLastVisitedAtById[activeThreadKey],6654 activeThreadKey === null ? undefined : store.threadLastVisitedAtById[activeThreadKey],
6644 );6655 );
6656 const activeThreadLastVisitedAt = resolveThreadLastVisitedAt(
6657 activeThreadShell?.lastVisitedAt,
6658 activeThreadLocalVisitedAt,
6659 );
6645 const activeThreadWokeVisible = useMemo(() => {6660 const activeThreadWokeVisible = useMemo(() => {
6646 if (activeThreadWokeAt === null) return false;6661 if (activeThreadWokeAt === null) return false;
6647 if (activeThreadShell?.settledOverride === "settled") return false;6662 if (activeThreadShell?.settledOverride === "settled") return false;
...@@ -6651,7 +6666,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -6651,7 +6666,7 @@ export default function ChatView(props: ChatViewProps) {
6651 // above stamps it); folding that floor in here keeps a completion-6666 // above stamps it); folding that floor in here keeps a completion-
6652 // triggered wake from flashing a banner for one frame before the stamp6667 // triggered wake from flashing a banner for one frame before the stamp
6653 // lands. An unparseable stored visit counts as never-visited: corrupt6668 // 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.
6655 const storedVisitMs = activeThreadLastVisitedAt ? Date.parse(activeThreadLastVisitedAt) : NaN;6670 const storedVisitMs = activeThreadLastVisitedAt ? Date.parse(activeThreadLastVisitedAt) : NaN;
6656 const completedAtMs = activeLatestRun?.completedAt6671 const completedAtMs = activeLatestRun?.completedAt
6657 ? Date.parse(activeLatestRun.completedAt)6672 ? Date.parse(activeLatestRun.completedAt)
...@@ -11168,6 +11183,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -11168,6 +11183,7 @@ export default function ChatView(props: ChatViewProps) {
11168 pendingSurfaceIds={pendingFileSurfaceIds}11183 pendingSurfaceIds={pendingFileSurfaceIds}
11169 previewSessions={activePreviewState.sessions}11184 previewSessions={activePreviewState.sessions}
11170 desktopByTabId={activePreviewState.desktopByTabId}11185 desktopByTabId={activePreviewState.desktopByTabId}
11186 previewRuntimeTabId={resolvePreviewRuntimeTabId}
11171 terminalLabelsById={activeTerminalLabelsById}11187 terminalLabelsById={activeTerminalLabelsById}
11172 onActivate={activateRightPanelSurface}11188 onActivate={activateRightPanelSurface}
11173 onCloseSurface={closeRightPanelSurface}11189 onCloseSurface={closeRightPanelSurface}
...@@ -11222,6 +11238,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -11222,6 +11238,7 @@ export default function ChatView(props: ChatViewProps) {
11222 pendingSurfaceIds={pendingFileSurfaceIds}11238 pendingSurfaceIds={pendingFileSurfaceIds}
11223 previewSessions={activePreviewState.sessions}11239 previewSessions={activePreviewState.sessions}
11224 desktopByTabId={activePreviewState.desktopByTabId}11240 desktopByTabId={activePreviewState.desktopByTabId}
11241 previewRuntimeTabId={resolvePreviewRuntimeTabId}
11225 terminalLabelsById={activeTerminalLabelsById}11242 terminalLabelsById={activeTerminalLabelsById}
11226 onActivate={activateRightPanelSurface}11243 onActivate={activateRightPanelSurface}
11227 onCloseSurface={closeRightPanelSurface}11244 onCloseSurface={closeRightPanelSurface}
apps/web/src/components/Sidebar.tsx+2-8
...@@ -130,7 +130,7 @@ import {...@@ -130,7 +130,7 @@ import {
130 getThreadKeysToDeselectAfterDelete,130 getThreadKeysToDeselectAfterDelete,
131 useThreadSelectionStore,131 useThreadSelectionStore,
132} from "../threadSelectionStore";132} from "../threadSelectionStore";
133import { useThreadActions } from "../hooks/useThreadActions";133import { useAcknowledgeThreadWoke, useThreadActions } from "../hooks/useThreadActions";
134import { useHandleNewThread } from "../hooks/useHandleNewThread";134import { useHandleNewThread } from "../hooks/useHandleNewThread";
135import { useTerminalFocus } from "../hooks/useTerminalFocus";135import { useTerminalFocus } from "../hooks/useTerminalFocus";
136import { isCommandPaletteOpen, openCommandPalette } from "../commandPaletteBus";136import { isCommandPaletteOpen, openCommandPalette } from "../commandPaletteBus";
...@@ -2381,13 +2381,7 @@ export default function Sidebar() {...@@ -2381,13 +2381,7 @@ export default function Sidebar() {
2381 const setSelectionAnchor = useThreadSelectionStore((s) => s.setAnchor);2381 const setSelectionAnchor = useThreadSelectionStore((s) => s.setAnchor);
2382 const toggleThreadSelection = useThreadSelectionStore((s) => s.toggleThread);2382 const toggleThreadSelection = useThreadSelectionStore((s) => s.toggleThread);
2383 const rangeSelectTo = useThreadSelectionStore((s) => s.rangeSelectTo);2383 const rangeSelectTo = useThreadSelectionStore((s) => s.rangeSelectTo);
2384 const markThreadVisited = useUiStateStore((s) => s.markThreadVisited);2384 const acknowledgeWoke = useAcknowledgeThreadWoke();
2385 const acknowledgeWoke = useCallback(
2386 (threadRef: ScopedThreadRef, visitedAt: string) => {
2387 markThreadVisited(scopedThreadKey(threadRef), visitedAt);
2388 },
2389 [markThreadVisited],
2390 );
2391 const routeTarget = useParams({2385 const routeTarget = useParams({
2392 strict: false,2386 strict: false,
2393 select: (params) => resolveThreadRouteTarget(params),2387 select: (params) => resolveThreadRouteTarget(params),
apps/web/src/components/settings/SettingsPanels.tsx+57-1
...@@ -44,6 +44,7 @@ import {...@@ -44,6 +44,7 @@ import {
44 type ResponseStreamingMode,44 type ResponseStreamingMode,
45 MIN_TERMINAL_FONT_SIZE,45 MIN_TERMINAL_FONT_SIZE,
46 type QuitConfirmationMode,46 type QuitConfirmationMode,
47 SidebarProjectSortOrder,
47} from "@t3tools/contracts/settings";48} from "@t3tools/contracts/settings";
48import { resolveServerBackgroundActivitySettings } from "@t3tools/shared/backgroundActivitySettings";49import { resolveServerBackgroundActivitySettings } from "@t3tools/shared/backgroundActivitySettings";
49import { createModelSelection } from "@t3tools/shared/model";50import { createModelSelection } from "@t3tools/shared/model";
...@@ -87,6 +88,7 @@ import { useDesktopUpdateState } from "../../state/desktopUpdate";...@@ -87,6 +88,7 @@ import { useDesktopUpdateState } from "../../state/desktopUpdate";
87import {88import {
88 getCustomModelOptionsByInstance,89 getCustomModelOptionsByInstance,
89 resolveAppModelSelectionState,90 resolveAppModelSelectionState,
91 selectsPlanAgent,
90} from "../../modelSelection";92} from "../../modelSelection";
91import {93import {
92 applyProviderInstanceSettings,94 applyProviderInstanceSettings,
...@@ -181,6 +183,13 @@ const RESPONSE_STREAMING_MODE_DESCRIPTIONS: Record<ResponseStreamingMode, string...@@ -181,6 +183,13 @@ const RESPONSE_STREAMING_MODE_DESCRIPTIONS: Record<ResponseStreamingMode, string
181 paragraph: "Each paragraph or code block appears as soon as it is complete.",183 paragraph: "Each paragraph or code block appears as soon as it is complete.",
182};184};
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
184const TIMESTAMP_FORMAT_LABELS = {193const TIMESTAMP_FORMAT_LABELS = {
185 locale: "System default",194 locale: "System default",
186 "12-hour": "12-hour",195 "12-hour": "12-hour",
...@@ -552,6 +561,9 @@ export function useSettingsRestore(onRestored?: () => void) {...@@ -552,6 +561,9 @@ export function useSettingsRestore(onRestored?: () => void) {
552 DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode561 DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode
553 ? ["Project Grouping"]562 ? ["Project Grouping"]
554 : []),563 : []),
564 ...(settings.sidebarProjectSortOrder !== DEFAULT_UNIFIED_SETTINGS.sidebarProjectSortOrder
565 ? ["Project order"]
566 : []),
555 ...(settings.sidebarWorkingShelfEnabled !==567 ...(settings.sidebarWorkingShelfEnabled !==
556 DEFAULT_UNIFIED_SETTINGS.sidebarWorkingShelfEnabled568 DEFAULT_UNIFIED_SETTINGS.sidebarWorkingShelfEnabled
557 ? ["Working section"]569 ? ["Working section"]
...@@ -690,6 +702,7 @@ export function useSettingsRestore(onRestored?: () => void) {...@@ -690,6 +702,7 @@ export function useSettingsRestore(onRestored?: () => void) {
690 settings.autoResumeLimitedThreads,702 settings.autoResumeLimitedThreads,
691 settings.snoozeLimitedThreads,703 settings.snoozeLimitedThreads,
692 settings.sidebarProjectGroupingMode,704 settings.sidebarProjectGroupingMode,
705 settings.sidebarProjectSortOrder,
693 settings.sidebarWorkingShelfEnabled,706 settings.sidebarWorkingShelfEnabled,
694 settings.sidebarThreadPreviewCount,707 settings.sidebarThreadPreviewCount,
695 settings.showSkillsInSlashMenu,708 settings.showSkillsInSlashMenu,
...@@ -789,6 +802,7 @@ export function useSettingsRestore(onRestored?: () => void) {...@@ -789,6 +802,7 @@ export function useSettingsRestore(onRestored?: () => void) {
789 panelAnimationDurationMs: DEFAULT_UNIFIED_SETTINGS.panelAnimationDurationMs,802 panelAnimationDurationMs: DEFAULT_UNIFIED_SETTINGS.panelAnimationDurationMs,
790 sidebarThreadPreviewCount: DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount,803 sidebarThreadPreviewCount: DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount,
791 sidebarProjectGroupingMode: DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode,804 sidebarProjectGroupingMode: DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode,
805 sidebarProjectSortOrder: DEFAULT_UNIFIED_SETTINGS.sidebarProjectSortOrder,
792 sidebarWorkingShelfEnabled: DEFAULT_UNIFIED_SETTINGS.sidebarWorkingShelfEnabled,806 sidebarWorkingShelfEnabled: DEFAULT_UNIFIED_SETTINGS.sidebarWorkingShelfEnabled,
793 sidebarAutoSettleAfterDays: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays,807 sidebarAutoSettleAfterDays: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays,
794 sidebarAutoSettleOnMerge: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge,808 sidebarAutoSettleOnMerge: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge,
...@@ -2270,6 +2284,46 @@ export function GeneralSettingsPanel() {...@@ -2270,6 +2284,46 @@ export function GeneralSettingsPanel() {
2270 />2284 />
2271 }2285 }
2272 />2286 />
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
2274 <SettingsRow2328 <SettingsRow
2275 serverScoped2329 serverScoped
...@@ -3244,7 +3298,9 @@ export function GeneralSettingsPanel() {...@@ -3244,7 +3298,9 @@ export function GeneralSettingsPanel() {
3244 onPromptChange={() => {}}3298 onPromptChange={() => {}}
3245 modelOptions={textGenModelOptions}3299 modelOptions={textGenModelOptions}
3246 allowPromptInjectedEffort={false}3300 allowPromptInjectedEffort={false}
3247 planModeEnabled={settings.planModeEnabled}3301 planModeEnabled={
3302 settings.planModeEnabled || selectsPlanAgent(textGenModelOptions)
3303 }
3248 triggerClassName={SETTINGS_PICKER_TRIGGER_CLASSNAME}3304 triggerClassName={SETTINGS_PICKER_TRIGGER_CLASSNAME}
3249 onModelOptionsChange={(nextOptions) => {3305 onModelOptionsChange={(nextOptions) => {
3250 updateSettings({3306 updateSettings({
apps/web/src/components/settings/settingsSearch.ts+6
...@@ -280,6 +280,12 @@ export const SETTINGS_SEARCH_ITEMS = [...@@ -280,6 +280,12 @@ export const SETTINGS_SEARCH_ITEMS = [
280 to: "/settings/general",280 to: "/settings/general",
281 searchTerms: ["combine matching repositories environments sidebar"],281 searchTerms: ["combine matching repositories environments sidebar"],
282 },282 },
283 {
284 id: "project-order",
285 title: "Project order",
286 to: "/settings/general",
287 searchTerms: ["sort projects sidebar manual created recent"],
288 },
283 {289 {
284 id: "snooze-limited-threads",290 id: "snooze-limited-threads",
285 title: "Snooze limited threads",291 title: "Snooze limited threads",
apps/web/src/hooks/useThreadActions.ts+24
...@@ -219,6 +219,30 @@ function useMarkThreadUnread() {...@@ -219,6 +219,30 @@ function useMarkThreadUnread() {
219 );219 );
220}220}
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
222export function useThreadActions() {246export function useThreadActions() {
223 const closeTerminal = useAtomCommand(terminalEnvironment.close);247 const closeTerminal = useAtomCommand(terminalEnvironment.close);
224 const archiveThreadMutation = useAtomCommand(threadEnvironment.archive, {248 const archiveThreadMutation = useAtomCommand(threadEnvironment.archive, {
apps/web/src/modelSelection.test.ts+35-74
...@@ -15,8 +15,6 @@ import {...@@ -15,8 +15,6 @@ import {
15 getAppModelOptionsForInstance,15 getAppModelOptionsForInstance,
16 resolveAppModelSelectionForInstance,16 resolveAppModelSelectionForInstance,
17 resolveAppModelSelectionState,17 resolveAppModelSelectionState,
18 resolvePlanAgentHealPatch,
19 withoutPlanAgentSelection,
20} from "./modelSelection";18} from "./modelSelection";
2119
22function provider(input: {20function provider(input: {
...@@ -865,81 +863,44 @@ describe("instance-scoped model selection", () => {...@@ -865,81 +863,44 @@ describe("instance-scoped model selection", () => {
865 });863 });
866});864});
867865
868describe("withoutPlanAgentSelection", () => {866describe("resolveAppModelSelectionState with the opencode plan agent", () => {
869 const instance = ProviderInstanceId.make("opencode");867 const instanceId = ProviderInstanceId.make("opencode");
870 const model = "opencode/gpt-5.4";868 const model = "opencode/gpt-5.4";
871869 const opencode: ServerProvider = {
872 it("drops a stored plan agent option", () => {870 ...provider({ provider: ProviderDriverKind.make("opencode"), instanceId: "opencode" }),
873 const selection = createModelSelection(instance, model, [871 models: [
874 { id: "variant", value: "high" },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, [
875 { id: "agent", value: "plan" },897 { id: "agent", value: "plan" },
876 ]);898 ]),
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,
910 };899 };
911900
912 it("returns null when plan mode is on", () => {901 it("keeps a stored plan agent this device did not pick", () => {
913 expect(resolvePlanAgentHealPatch(nullPatch)).toBeNull();902 expect(resolveAppModelSelectionState(settings, [opencode])).toEqual(
914 });903 createModelSelection(instanceId, model, [{ id: "agent", value: "plan" }]),
915904 );
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 });
944 });905 });
945});906});
apps/web/src/modelSelection.ts+7-43
...@@ -7,7 +7,6 @@ import {...@@ -7,7 +7,6 @@ import {
7 ProviderDriverKind,7 ProviderDriverKind,
8 ProviderInstanceId,8 ProviderInstanceId,
9 type ServerProvider,9 type ServerProvider,
10 type ServerSettingsPatch,
11} from "@t3tools/contracts";10} from "@t3tools/contracts";
12import {11import {
13 type CustomModelDefinition,12 type CustomModelDefinition,
...@@ -350,48 +349,13 @@ export function getCustomModelOptionsByInstance(...@@ -350,48 +349,13 @@ export function getCustomModelOptionsByInstance(
350}349}
351350
352/**351/**
353 * Drop the opencode "plan" agent option from a stored model selection.352 * Whether stored model options pick the opencode "plan" agent. Shared settings
354 * Used when legacy plan mode is turned off so server-side text-generation353 * pickers keep and show such a value even while this device's legacy plan
355 * tasks (title, branch, PR) cannot keep dispatching the plan agent.354 * mode is off: another device may have chosen it, and this device's filter
355 * only applies to picks made here.
356 */356 */
357export function withoutPlanAgentSelection(357export function selectsPlanAgent(options: ModelSelection["options"]): boolean {
358 selection: ModelSelection | null | undefined,358 return options?.some((option) => option.id === "agent" && option.value === "plan") ?? false;
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;
395}359}
396360
397export function resolveAppModelSelectionState(361export function resolveAppModelSelectionState(
...@@ -433,7 +397,7 @@ export function resolveAppModelSelectionState(...@@ -433,7 +397,7 @@ export function resolveAppModelSelectionState(
433 model,397 model,
434 models: entry.models,398 models: entry.models,
435 modelOptions: selectedEntry ? selection.options : undefined,399 modelOptions: selectedEntry ? selection.options : undefined,
436 planModeEnabled: settings.planModeEnabled,400 planModeEnabled: settings.planModeEnabled || selectsPlanAgent(selection.options),
437 });401 });
438402
439 return createModelSelection(entry.instanceId, model, modelOptionsForDispatch);403 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";...@@ -51,7 +51,6 @@ import { isElectron } from "../env";
51import { applyAppearanceFontVariables } from "~/appearanceFonts";51import { applyAppearanceFontVariables } from "~/appearanceFonts";
52import { applyAppearanceContrast } from "~/appearanceContrast";52import { applyAppearanceContrast } from "~/appearanceContrast";
53import { useClientSettings } from "../hooks/useSettings";53import { useClientSettings } from "../hooks/useSettings";
54import { PlanAgentSelectionHeal } from "../planAgentSelectionHeal";
55import {54import {
56 deriveLogicalProjectKeyFromSettings,55 deriveLogicalProjectKeyFromSettings,
57 derivePhysicalProjectKeyFromPath,56 derivePhysicalProjectKeyFromPath,
...@@ -242,7 +241,6 @@ function RootRouteView() {...@@ -242,7 +241,6 @@ function RootRouteView() {
242 {primaryEnvironmentAuthenticated ? (241 {primaryEnvironmentAuthenticated ? (
243 <EventRouter skipInitialBootstrapNavigation={returningFromWelcomeRef.current} />242 <EventRouter skipInitialBootstrapNavigation={returningFromWelcomeRef.current} />
244 ) : null}243 ) : null}
245 {primaryEnvironmentAuthenticated ? <PlanAgentSelectionHeal /> : null}
246 {primaryEnvironmentAuthenticated ? <ProviderUpdateLaunchNotification /> : null}244 {primaryEnvironmentAuthenticated ? <ProviderUpdateLaunchNotification /> : null}
247 {appShell}245 {appShell}
248 {/* Above the router: a theme draft is judged by walking the app, so the246 {/* 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 @@...@@ -1,5 +1,6 @@
1import {1import {
2 DEFAULT_SERVER_SETTINGS,2 DEFAULT_SERVER_SETTINGS,
3 EnvironmentAuthorizationError,
3 EnvironmentId,4 EnvironmentId,
4 PreviewTabId,5 PreviewTabId,
5 ThreadId,6 ThreadId,
...@@ -618,6 +619,114 @@ describe("environment RPC", () => {...@@ -618,6 +619,114 @@ describe("environment RPC", () => {
618 }),619 }),
619 );620 );
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
621 it.effect.each(["input", "stream"] as const)(730 it.effect.each(["input", "stream"] as const)(
622 "does not classify %s subscription defects as expected failures",731 "does not classify %s subscription defects as expected failures",
623 (where) =>732 (where) =>
packages/client-runtime/src/rpc/client.ts+43-7
...@@ -1,7 +1,11 @@...@@ -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";
2import * as Cause from "effect/Cause";6import * as Cause from "effect/Cause";
3import * as Context from "effect/Context";7import * as Context from "effect/Context";
4import type * as Duration from "effect/Duration";8import * as Duration from "effect/Duration";
5import * as Effect from "effect/Effect";9import * as Effect from "effect/Effect";
6import * as Option from "effect/Option";10import * as Option from "effect/Option";
7import * as Schema from "effect/Schema";11import * as Schema from "effect/Schema";
...@@ -92,6 +96,10 @@ export class EnvironmentRpcSubscriptionObserver extends Context.Reference<{...@@ -92,6 +96,10 @@ export class EnvironmentRpcSubscriptionObserver extends Context.Reference<{
92}) {}96}) {}
9397
94export const isRpcClientError = Schema.is(RpcClientError.RpcClientError);98export 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
96export type EnvironmentRpcInput<TTag extends EnvironmentRpcTag> = Parameters<RpcMethod<TTag>>[0];104export type EnvironmentRpcInput<TTag extends EnvironmentRpcTag> = Parameters<RpcMethod<TTag>>[0];
97105
...@@ -192,6 +200,12 @@ interface SubscriptionOptions<TTag extends EnvironmentSubscriptionRpcTag> {...@@ -192,6 +200,12 @@ interface SubscriptionOptions<TTag extends EnvironmentSubscriptionRpcTag> {
192 readonly onExpectedFailure?: (200 readonly onExpectedFailure?: (
193 cause: Cause.Cause<EnvironmentRpcStreamFailure<TTag>>,201 cause: Cause.Cause<EnvironmentRpcStreamFailure<TTag>>,
194 ) => Effect.Effect<void, never, never>;202 ) => 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 */
195 readonly retryExpectedFailureAfter?: Duration.Input;209 readonly retryExpectedFailureAfter?: Duration.Input;
196 readonly resubscribe?: Stream.Stream<unknown, never, never>;210 readonly resubscribe?: Stream.Stream<unknown, never, never>;
197}211}
...@@ -238,6 +252,9 @@ function subscribeDynamicMapped<TTag extends EnvironmentSubscriptionRpcTag, A>(...@@ -238,6 +252,9 @@ function subscribeDynamicMapped<TTag extends EnvironmentSubscriptionRpcTag, A>(
238 EnvironmentRpcStreamValue<TTag>,252 EnvironmentRpcStreamValue<TTag>,
239 EnvironmentRpcStreamFailure<TTag>253 EnvironmentRpcStreamFailure<TTag>
240 >;254 >;
255 // Consecutive expected-failure retries on this session. Reset by
256 // the first value a resubscribed stream delivers.
257 let expectedFailureRetries = 0;
241 const subscribeToSession = (): Stream.Stream<A, EnvironmentRpcStreamFailure<TTag>> =>258 const subscribeToSession = (): Stream.Stream<A, EnvironmentRpcStreamFailure<TTag>> =>
242 Stream.suspend(() =>259 Stream.suspend(() =>
243 Stream.unwrap(260 Stream.unwrap(
...@@ -248,7 +265,13 @@ function subscribeDynamicMapped<TTag extends EnvironmentSubscriptionRpcTag, A>(...@@ -248,7 +265,13 @@ function subscribeDynamicMapped<TTag extends EnvironmentSubscriptionRpcTag, A>(
248 method: tag,265 method: tag,
249 input,266 input,
250 });267 });
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 );
252 // An evicted preview host completes its registration stream.275 // An evicted preview host completes its registration stream.
253 // Re-register only after completion; failures still follow the276 // Re-register only after completion; failures still follow the
254 // session recovery policy and browser actions are never replayed.277 // session recovery policy and browser actions are never replayed.
...@@ -296,14 +319,27 @@ function subscribeDynamicMapped<TTag extends EnvironmentSubscriptionRpcTag, A>(...@@ -296,14 +319,27 @@ function subscribeDynamicMapped<TTag extends EnvironmentSubscriptionRpcTag, A>(
296 const handled = Stream.fromEffect(options.onExpectedFailure(cause)).pipe(319 const handled = Stream.fromEffect(options.onExpectedFailure(cause)).pipe(
297 Stream.drain,320 Stream.drain,
298 );321 );
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 ) {
300 return handled;330 return handled;
301 }331 }
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;
302 return handled.pipe(340 return handled.pipe(
303 Stream.concat(341 Stream.concat(
304 Stream.fromEffect(Effect.sleep(options.retryExpectedFailureAfter)).pipe(342 Stream.fromEffect(Effect.sleep(retryDelay)).pipe(Stream.drain),
305 Stream.drain,
306 ),
307 ),343 ),
308 Stream.concat(subscribeToSession()),344 Stream.concat(subscribeToSession()),
309 );345 );