| author | |
| committer | |
| log | c5a929e1ac05dec15945a36905ab8bb2f1982e59 |
| tree | b6449d3c3562ee7a86db1f3cdcde9130d70cd6f5 |
| parent | 798e945aa77939937464ff45deaa8947be8d402f |
| signature | Signed by PGP key B5690EEEBB952194 |
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 { |
| 85 | import { readPastedComposerContext } from "./composerInlineTokenPaste"; | 85 | import { readPastedComposerContext } from "./composerInlineTokenPaste"; |
| 86 | import { isPasteAsTextShortcut } from "@t3tools/client-runtime/text-paste"; | 86 | import { isPasteAsTextShortcut } from "@t3tools/client-runtime/text-paste"; |
| 87 | import { effectiveSnoozed, threadWokeAt } from "@t3tools/client-runtime/state/thread-settled"; | 87 | import { effectiveSnoozed, threadWokeAt } from "@t3tools/client-runtime/state/thread-settled"; |
| 88 | import { useThreadActions } from "../hooks/useThreadActions"; | 88 | import { useAcknowledgeThreadWoke, useThreadActions } from "../hooks/useThreadActions"; |
| 89 | import { | 89 | import { |
| 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"; |
| 253 | import { BrowserSettingsReadError } from "../browser/openFileInPreview"; | 253 | import { BrowserSettingsReadError } from "../browser/openFileInPreview"; |
| 254 | import { previewRuntimeTabId } from "../browser/previewRuntimeTabId"; | ||
| 254 | import { addBrowserSurface } from "./preview/addBrowserSurface"; | 255 | import { addBrowserSurface } from "./preview/addBrowserSurface"; |
| 255 | import { closePreviewSession } from "./preview/closePreviewSession"; | 256 | import { closePreviewSession } from "./preview/closePreviewSession"; |
| 256 | import { ThreadPreviewMiniPlayer } from "./preview/ThreadPreviewMiniPlayer"; | 257 | import { 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 && supportsSnooze | 6643 | 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 stamp | 6667 | // triggered wake from flashing a banner for one frame before the stamp |
| 6653 | // lands. An unparseable stored visit counts as never-visited: corrupt | 6668 | // 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?.completedAt | 6671 | 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"; |
| 133 | import { useThreadActions } from "../hooks/useThreadActions"; | 133 | import { useAcknowledgeThreadWoke, useThreadActions } from "../hooks/useThreadActions"; |
| 134 | import { useHandleNewThread } from "../hooks/useHandleNewThread"; | 134 | import { useHandleNewThread } from "../hooks/useHandleNewThread"; |
| 135 | import { useTerminalFocus } from "../hooks/useTerminalFocus"; | 135 | import { useTerminalFocus } from "../hooks/useTerminalFocus"; |
| 136 | import { isCommandPaletteOpen, openCommandPalette } from "../commandPaletteBus"; | 136 | import { 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"; |
| 48 | import { resolveServerBackgroundActivitySettings } from "@t3tools/shared/backgroundActivitySettings"; | 49 | import { resolveServerBackgroundActivitySettings } from "@t3tools/shared/backgroundActivitySettings"; |
| 49 | import { createModelSelection } from "@t3tools/shared/model"; | 50 | import { createModelSelection } from "@t3tools/shared/model"; |
| ... | @@ -87,6 +88,7 @@ import { useDesktopUpdateState } from "../../state/desktopUpdate"; | ... | @@ -87,6 +88,7 @@ import { useDesktopUpdateState } from "../../state/desktopUpdate"; |
| 87 | import { | 88 | import { |
| 88 | getCustomModelOptionsByInstance, | 89 | getCustomModelOptionsByInstance, |
| 89 | resolveAppModelSelectionState, | 90 | resolveAppModelSelectionState, |
| 91 | selectsPlanAgent, | ||
| 90 | } from "../../modelSelection"; | 92 | } from "../../modelSelection"; |
| 91 | import { | 93 | import { |
| 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 | }; |
| 183 | 185 | ||
| 186 | const SIDEBAR_PROJECT_SORT_ORDER_LABELS: Record<SidebarProjectSortOrder, string> = { | ||
| 187 | updated_at: "Last user message", | ||
| 188 | created_at: "Created at", | ||
| 189 | manual: "Manual", | ||
| 190 | }; | ||
| 191 | const isSidebarProjectSortOrder = Schema.is(SidebarProjectSortOrder); | ||
| 192 | |||
| 184 | const TIMESTAMP_FORMAT_LABELS = { | 193 | const 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.sidebarProjectGroupingMode | 561 | 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.sidebarWorkingShelfEnabled | 568 | 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 | /> | ||
| 2273 | 2327 | ||
| 2274 | <SettingsRow | 2328 | <SettingsRow |
| 2275 | serverScoped | 2329 | 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 | } |
| 221 | 221 | ||
| 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 | */ | ||
| 228 | export 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 | |||
| 222 | export function useThreadActions() { | 246 | export 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"; |
| 21 | 19 | ||
| 22 | function provider(input: { | 20 | function provider(input: { |
| ... | @@ -865,81 +863,44 @@ describe("instance-scoped model selection", () => { | ... | @@ -865,81 +863,44 @@ describe("instance-scoped model selection", () => { |
| 865 | }); | 863 | }); |
| 866 | }); | 864 | }); |
| 867 | 865 | ||
| 868 | describe("withoutPlanAgentSelection", () => { | 866 | describe("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"; |
| 871 | 869 | 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 | |||
| 898 | describe("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 | }; |
| 911 | 900 | ||
| 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" }]), |
| 915 | 904 | ); | |
| 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"; |
| 12 | import { | 11 | import { |
| 13 | type CustomModelDefinition, | 12 | type CustomModelDefinition, |
| ... | @@ -350,48 +349,13 @@ export function getCustomModelOptionsByInstance( | ... | @@ -350,48 +349,13 @@ export function getCustomModelOptionsByInstance( |
| 350 | } | 349 | } |
| 351 | 350 | ||
| 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-generation | 353 | * 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 | */ |
| 357 | export function withoutPlanAgentSelection( | 357 | export 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. | ||
| 376 | export 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 | } |
| 396 | 360 | ||
| 397 | export function resolveAppModelSelectionState( | 361 | export 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 | }); |
| 438 | 402 | ||
| 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 @@ | ||
| 1 | import { useEffect } from "react"; | ||
| 2 | |||
| 3 | import { | ||
| 4 | useClientSettingsHydrated, | ||
| 5 | usePrimarySettings, | ||
| 6 | useUpdatePrimarySettings, | ||
| 7 | } from "./hooks/useSettings"; | ||
| 8 | import { 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 | */ | ||
| 17 | export 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"; |
| 51 | import { applyAppearanceFontVariables } from "~/appearanceFonts"; | 51 | import { applyAppearanceFontVariables } from "~/appearanceFonts"; |
| 52 | import { applyAppearanceContrast } from "~/appearanceContrast"; | 52 | import { applyAppearanceContrast } from "~/appearanceContrast"; |
| 53 | import { useClientSettings } from "../hooks/useSettings"; | 53 | import { useClientSettings } from "../hooks/useSettings"; |
| 54 | import { PlanAgentSelectionHeal } from "../planAgentSelectionHeal"; | ||
| 55 | import { | 54 | import { |
| 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 the | 246 | {/* 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 @@ |
| 1 | import { | 1 | import { |
| 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 | ); |
| 620 | 621 | ||
| 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 @@ |
| 1 | import { ORCHESTRATION_V2_WS_METHODS, WS_METHODS } from "@t3tools/contracts"; | 1 | import { |
| 2 | EnvironmentAuthorizationError, | ||
| 3 | ORCHESTRATION_V2_WS_METHODS, | ||
| 4 | WS_METHODS, | ||
| 5 | } from "@t3tools/contracts"; | ||
| 2 | import * as Cause from "effect/Cause"; | 6 | import * as Cause from "effect/Cause"; |
| 3 | import * as Context from "effect/Context"; | 7 | import * as Context from "effect/Context"; |
| 4 | import type * as Duration from "effect/Duration"; | 8 | import * as Duration from "effect/Duration"; |
| 5 | import * as Effect from "effect/Effect"; | 9 | import * as Effect from "effect/Effect"; |
| 6 | import * as Option from "effect/Option"; | 10 | import * as Option from "effect/Option"; |
| 7 | import * as Schema from "effect/Schema"; | 11 | import * 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 | }) {} |
| 93 | 97 | ||
| 94 | export const isRpcClientError = Schema.is(RpcClientError.RpcClientError); | 98 | export const isRpcClientError = Schema.is(RpcClientError.RpcClientError); |
| 99 | const isEnvironmentAuthorizationError = Schema.is(EnvironmentAuthorizationError); | ||
| 100 | |||
| 101 | /** Ceiling for the doubling delay between same-session expected-failure retries. */ | ||
| 102 | const MAX_EXPECTED_FAILURE_RETRY_DELAY_MS = 30_000; | ||
| 95 | 103 | ||
| 96 | export type EnvironmentRpcInput<TTag extends EnvironmentRpcTag> = Parameters<RpcMethod<TTag>>[0]; | 104 | export type EnvironmentRpcInput<TTag extends EnvironmentRpcTag> = Parameters<RpcMethod<TTag>>[0]; |
| 97 | 105 | ||
| ... | @@ -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 the | 276 | // 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 | ); |