From 375da0fb9a20dfdbd2eb2db99050341fc30f3a23 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 25 Oct 2026 04:09:58 -0700 Subject: [PATCH 001/219] test(server): restart replay tests no longer hang on a leftover checkpoint capture (#24851) Co-authored-by: Claude Opus 5.5 --- .../testkit/OrchestratorReplayRecovery.integration.test.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/server/src/orchestration-v2/testkit/OrchestratorReplayRecovery.integration.test.ts b/apps/server/src/orchestration-v2/testkit/OrchestratorReplayRecovery.integration.test.ts index 2d441a74f319d..d17b407506018 100644 --- a/apps/server/src/orchestration-v2/testkit/OrchestratorReplayRecovery.integration.test.ts +++ b/apps/server/src/orchestration-v2/testkit/OrchestratorReplayRecovery.integration.test.ts @@ -243,7 +243,8 @@ const runCursorRecovery = Effect.fn("runCursorRecovery")(function* (input: { runtimePolicyOverride: { cwd: tempDir }, }, harness, - options, + // A real restart requeues effects, like checkpoint capture, the first runtime left running. + { ...options, recoverOnStartup: true }, ), ); @@ -336,7 +337,7 @@ describe("orchestrator replay recovery", () => { runtimePolicyOverride: { cwd: workspace }, }, harness, - options, + { ...options, recoverOnStartup: true }, ); assertBaseProjection({ @@ -436,7 +437,7 @@ describe("orchestrator replay recovery", () => { runtimePolicyOverride: { cwd: workspace }, }, harness, - options, + { ...options, recoverOnStartup: true }, ), ); yield* assertComplete; From 3faeb9e6f98f0f319be8de509de85af5597bd8b7 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 25 Oct 2026 04:55:49 -0700 Subject: [PATCH 002/219] feat(web): Settings and Usage open as large modals over your thread (#24847) Co-authored-by: Claude Opus 5.5 --- apps/web/src/components/AppRouteModal.tsx | 81 +++ apps/web/src/components/AppSidebarLayout.tsx | 32 +- apps/web/src/components/ChatView.tsx | 8 +- apps/web/src/components/ThreadRouteView.tsx | 33 +- .../chat/ComposerPendingUserInputPanel.tsx | 8 +- apps/web/src/components/chat/OpenInPicker.tsx | 7 +- .../src/components/chat/backgroundThread.ts | 4 + .../src/components/chat/useComposerStash.ts | 5 + .../settings/SettingsSidebarNav.tsx | 82 ++- .../src/components/sidebar/SidebarChrome.tsx | 2 +- .../src/components/sidebar/mainAppLocation.ts | 31 +- apps/web/src/components/usage/UsagePage.tsx | 17 +- .../src/components/usage/usageShortcuts.ts | 4 +- apps/web/src/routeTree.gen.ts | 599 +++++++++--------- ...ance.tsx => _chat.settings.appearance.tsx} | 2 +- ...chived.tsx => _chat.settings.archived.tsx} | 2 +- ...tings.beta.tsx => _chat.settings.beta.tsx} | 2 +- ...ons.tsx => _chat.settings.connections.tsx} | 2 +- ...ics.tsx => _chat.settings.diagnostics.tsx} | 2 +- ...general.tsx => _chat.settings.general.tsx} | 2 +- ...ns.tsx => _chat.settings.integrations.tsx} | 2 +- ...ngs.tsx => _chat.settings.keybindings.tsx} | 2 +- ...> _chat.settings.open-source-licenses.tsx} | 2 +- ...ojects.tsx => _chat.settings.projects.tsx} | 2 +- ...iders.tsx => _chat.settings.providers.tsx} | 2 +- ...tsx => _chat.settings.scheduled-tasks.tsx} | 2 +- ...-shot.tsx => _chat.settings.snap-shot.tsx} | 2 +- ....tsx => _chat.settings.source-control.tsx} | 2 +- ...storage.tsx => _chat.settings.storage.tsx} | 4 +- .../{settings.tsx => _chat.settings.tsx} | 53 +- apps/web/src/routes/_chat.tsx | 38 +- apps/web/src/routes/_chat.usage.tsx | 7 + apps/web/src/routes/usage.tsx | 15 - 33 files changed, 593 insertions(+), 465 deletions(-) create mode 100644 apps/web/src/components/AppRouteModal.tsx create mode 100644 apps/web/src/components/chat/backgroundThread.ts rename apps/web/src/routes/{settings.appearance.tsx => _chat.settings.appearance.tsx} (79%) rename apps/web/src/routes/{settings.archived.tsx => _chat.settings.archived.tsx} (73%) rename apps/web/src/routes/{settings.beta.tsx => _chat.settings.beta.tsx} (58%) rename apps/web/src/routes/{settings.connections.tsx => _chat.settings.connections.tsx} (72%) rename apps/web/src/routes/{settings.diagnostics.tsx => _chat.settings.diagnostics.tsx} (73%) rename apps/web/src/routes/{settings.general.tsx => _chat.settings.general.tsx} (79%) rename apps/web/src/routes/{settings.integrations.tsx => _chat.settings.integrations.tsx} (73%) rename apps/web/src/routes/{settings.keybindings.tsx => _chat.settings.keybindings.tsx} (73%) rename apps/web/src/routes/{settings.open-source-licenses.tsx => _chat.settings.open-source-licenses.tsx} (70%) rename apps/web/src/routes/{settings.projects.tsx => _chat.settings.projects.tsx} (71%) rename apps/web/src/routes/{settings.providers.tsx => _chat.settings.providers.tsx} (95%) rename apps/web/src/routes/{settings.scheduled-tasks.tsx => _chat.settings.scheduled-tasks.tsx} (86%) rename apps/web/src/routes/{settings.snap-shot.tsx => _chat.settings.snap-shot.tsx} (78%) rename apps/web/src/routes/{settings.source-control.tsx => _chat.settings.source-control.tsx} (72%) rename apps/web/src/routes/{settings.storage.tsx => _chat.settings.storage.tsx} (56%) rename apps/web/src/routes/{settings.tsx => _chat.settings.tsx} (79%) create mode 100644 apps/web/src/routes/_chat.usage.tsx delete mode 100644 apps/web/src/routes/usage.tsx diff --git a/apps/web/src/components/AppRouteModal.tsx b/apps/web/src/components/AppRouteModal.tsx new file mode 100644 index 0000000000000..89e676da2e3c3 --- /dev/null +++ b/apps/web/src/components/AppRouteModal.tsx @@ -0,0 +1,81 @@ +import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"; +import { XIcon } from "lucide-react"; +import { useRef, useState, type ReactNode } from "react"; + +import { useCloseAppModal } from "./sidebar/mainAppLocation"; +import { Button } from "./ui/button"; +import { DialogBackdrop } from "./ui/dialog"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; + +/** + * Large modal for the Settings and Usage routes. Esc, the close button, or a press on the + * backdrop returns to the page that opened it. Keyboard guards that skip dialogs exempt + * `[data-app-modal]`, because these pages own their shortcuts. + */ +export function AppRouteModal({ label, children }: { label: string; children: ReactNode }) { + const closeAppModal = useCloseAppModal(); + const [open, setOpen] = useState(true); + // Text selection that ends on the backdrop is not a dismissal. + const backdropPressRef = useRef(false); + // Focus the surface, not its first control: no stray focus ring, no phone keyboard. + // On close, leave focus alone; returning it to the opener paints a focus ring. + const popupRef = useRef(null); + + return ( + { + if (!nextOpen) setOpen(false); + }} + onOpenChangeComplete={(nextOpen) => { + if (!nextOpen) void closeAppModal(); + }} + > + + + { + backdropPressRef.current = event.target === event.currentTarget; + }} + onClick={(event) => { + if (backdropPressRef.current && event.target === event.currentTarget) setOpen(false); + backdropPressRef.current = false; + }} + > + + {children} + + + + + ); +} + +export function AppRouteModalClose() { + return ( + + } + /> + } + > + + + Close (Esc) + + ); +} diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index e84e40c8f5448..43006c81153ec 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -41,12 +41,10 @@ import { BackgroundThreadAnnouncer } from "./BackgroundThreadAnnouncer"; import LegacyThreadSidebar from "./LegacySidebar"; import { useThreadVisitedMigration } from "../hooks/useThreadVisitedMigration"; import ThreadSidebar from "./Sidebar"; -import { SettingsSidebarNav } from "./settings/SettingsSidebarNav"; -import { SidebarBrandWidthProbe, SidebarChromeHeader } from "./sidebar/SidebarChrome"; -import { MainAppLocationTracker } from "./sidebar/mainAppLocation"; +import { SidebarBrandWidthProbe } from "./sidebar/SidebarChrome"; +import { isAppModalPage, MainAppLocationTracker } from "./sidebar/mainAppLocation"; import type { SidebarRouteReveal } from "./sidebar/routeReveal"; import { useSidebarStageBackdropVariant } from "./SidebarStageBackdrop"; -import { useProjects } from "../state/entities"; import { clampThreadSidebarWidth, resolveInitialThreadSidebarWidth, @@ -261,8 +259,8 @@ export function NavigationHistoryShortcuts() { const MODIFIER_KEYS = new Set(["Alt", "Control", "Meta", "Shift"]); -// Undoes the thread actions in the sidebar's notice. Mounted wherever the -// thread sidebar can show; Settings replaces it. Listens in the capture phase +// Undoes the thread actions in the sidebar's notice. Settings and Usage own +// mod+z while open. Listens in the capture phase // so mod+z reaches it before the composer's own undo handler consumes the key. function ThreadUndoShortcut() { const keybindings = useAtomValue(primaryServerKeybindingsAtom); @@ -320,14 +318,6 @@ function ThreadUndoShortcut() { return null; } -// Settings swaps the thread sidebar out of the tree. Keep the lightweight -// project projection subscribed so returning to a draft never renders the -// zero-project state while the environment snapshot reconnects. -function ProjectProjectionRetention() { - useProjects(); - return null; -} - export function AppSidebarLayout({ children }: { children: ReactNode }) { const navigate = useNavigate(); const legacySidebarEnabled = useLegacySidebarEnabled(); @@ -341,7 +331,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { const pathname = useLocation({ select: (location) => location.pathname }); const panelAnimationsSuppressed = usePanelNavigationSuppression(pathname); const routePanelAnimationsActive = panelAnimationsActive && !panelAnimationsSuppressed; - const isOnSettings = pathname === "/settings" || pathname.startsWith("/settings/"); + const appModalOpen = isAppModalPage(pathname); const isMacosDesktop = isElectron && isMacPlatform(navigator.platform); const [sidebarWidth, setSidebarWidth] = useState(readInitialThreadSidebarWidth); // A clamped drag may not change width, so viewport changes need a separate subscription. @@ -401,13 +391,12 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { style={sidebarProviderStyle} > - - {isOnSettings ? ( - <> - - - - ) : legacySidebarEnabled ? ( + {legacySidebarEnabled ? ( ) : ( @@ -435,7 +419,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { - {isOnSettings ? null : } + {appModalOpen ? null : } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 53a6a7deb2b3f..1319b1030b083 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -105,7 +105,8 @@ import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { resolveThreadReferenceCopyTarget } from "@t3tools/client-runtime/thread-reference"; import { resolveTerminalSessionLabel } from "@t3tools/shared/terminalLabels"; import { useAtomValue } from "@effect/atom-react"; -import { type SetStateAction, useEffect, useLayoutEffect, useRef, useState } from "react"; +import { type SetStateAction, use, useEffect, useLayoutEffect, useRef, useState } from "react"; +import { BackgroundThreadContext } from "./chat/backgroundThread"; import { useLocation, useNavigate } from "@tanstack/react-router"; import { assistantCitationFromLocation } from "../lib/assistantCitationNavigation"; import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection"; @@ -1590,6 +1591,7 @@ export default function ChatView(props: ChatViewProps) { resourceKey: `git-status:${activeThreadKey ?? ""}:${gitStatusCwd ?? ""}`, }); const keybindings = useAtomValue(primaryServerKeybindingsAtom); + const backgroundThread = use(BackgroundThreadContext); const scriptKeybindings = useAtomValue(serverEnvironment.configValueAtom(environmentId))?.keybindings ?? DEFAULT_RESOLVED_KEYBINDINGS; @@ -1602,7 +1604,7 @@ export default function ChatView(props: ChatViewProps) { remoteOpenMode: remoteOpenState.mode, }); useOpenFavoriteEditorShortcut({ - enabled: showOpenInPicker, + enabled: showOpenInPicker && !backgroundThread, environmentId: activeThread?.environmentId ?? environmentId, keybindings, availableEditors, @@ -2683,7 +2685,7 @@ export default function ChatView(props: ChatViewProps) { const closeThreadFind = () => threadFindControlsRef.current?.close(); useChatShortcuts({ - enabled: activeThreadId !== null, + enabled: activeThreadId !== null && !backgroundThread, keybindings, scriptKeybindings, getShortcutContext, diff --git a/apps/web/src/components/ThreadRouteView.tsx b/apps/web/src/components/ThreadRouteView.tsx index 9ffaa3738db45..d798eb884312e 100644 --- a/apps/web/src/components/ThreadRouteView.tsx +++ b/apps/web/src/components/ThreadRouteView.tsx @@ -4,6 +4,7 @@ import { useNavigate } from "@tanstack/react-router"; import { useEffect, useState } from "react"; import ChatView from "./ChatView"; +import { BackgroundThreadContext } from "./chat/backgroundThread"; import { resolveDraftPromotionNavigationTarget, threadHasStarted } from "./ChatView.logic"; import { waitForDraftHeroTransition } from "./chat/draftHeroTransition"; import { SidebarInset } from "./ui/sidebar"; @@ -24,8 +25,17 @@ import { type ThreadRouteTarget, } from "../threadRoutes"; -/** Render in the shared layout to retain ChatView across route swaps; plain server threads remain unkeyed. */ -export function ThreadRouteView({ target }: { target: ThreadRouteTarget }) { +/** + * Render in the shared layout to retain ChatView across route swaps; plain server threads remain unkeyed. + * `background` keeps the thread mounted, inert and in place under Settings or Usage. + */ +export function ThreadRouteView({ + target, + background = false, +}: { + target: ThreadRouteTarget; + background?: boolean; +}) { const navigate = useNavigate(); const draftId = target.kind === "draft" ? target.draftId : null; const draftSession = useComposerDraftStore((store) => @@ -111,7 +121,7 @@ export function ThreadRouteView({ target }: { target: ThreadRouteTarget }) { }, [draftSession?.promotedTo, inferredThreadRef]); useEffect(() => { - if (!canonicalThreadRef) { + if (!canonicalThreadRef || background) { return; } let cancelled = false; @@ -128,14 +138,14 @@ export function ThreadRouteView({ target }: { target: ThreadRouteTarget }) { return () => { cancelled = true; }; - }, [canonicalThreadRef, navigate]); + }, [background, canonicalThreadRef, navigate]); useEffect(() => { - if (target.kind !== "draft" || draftSession || canonicalThreadRef) { + if (background || target.kind !== "draft" || draftSession || canonicalThreadRef) { return; } void navigate({ to: "/", replace: true }); - }, [canonicalThreadRef, draftSession, navigate, target.kind]); + }, [background, canonicalThreadRef, draftSession, navigate, target.kind]); useEffect(() => { if (target.kind !== "server" || !bootstrapComplete) { @@ -144,11 +154,11 @@ export function ThreadRouteView({ target }: { target: ThreadRouteTarget }) { if (renderState === "missing") { const { clearPendingFileDropsForThread } = useSidebarPendingFileDropStore.getState(); clearPendingFileDropsForThread(target.threadRef); - if (environmentHasAnyThreads) { + if (environmentHasAnyThreads && !background) { void navigate({ to: "/", replace: true }); } } - }, [bootstrapComplete, environmentHasAnyThreads, navigate, renderState, target]); + }, [background, bootstrapComplete, environmentHasAnyThreads, navigate, renderState, target]); useEffect(() => { if (target.kind !== "server" || !serverThreadStarted || !draftThread) { @@ -183,8 +193,11 @@ export function ThreadRouteView({ target }: { target: ThreadRouteTarget }) { } return ( - - {view} + + {view} ); } diff --git a/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx b/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx index 99f80d8e50813..a17e67a621302 100644 --- a/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx +++ b/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx @@ -1,5 +1,6 @@ import { type RuntimeRequestId, type ScopedThreadRef } from "@t3tools/contracts"; -import { memo, useEffect, useEffectEvent, useRef, useState } from "react"; +import { memo, use, useEffect, useEffectEvent, useRef, useState } from "react"; +import { BackgroundThreadContext } from "./backgroundThread"; import { useStableCallback } from "~/hooks/useStableCallback"; import { type PendingUserInput } from "../../session-logic"; import { @@ -142,9 +143,10 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( }; const selectOptionFromShortcut = useEffectEvent(handleOptionSelection); + const backgroundThread = use(BackgroundThreadContext); useEffect(() => { - if (!activeQuestion || responseDisabled || isCollapsed) return; + if (!activeQuestion || responseDisabled || isCollapsed || backgroundThread) return; const handler = (event: globalThis.KeyboardEvent) => { if (event.metaKey || event.ctrlKey || event.altKey) return; const target = event.target; @@ -168,7 +170,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( }; document.addEventListener("keydown", handler); return () => document.removeEventListener("keydown", handler); - }, [activeQuestion, isCollapsed, responseDisabled]); + }, [activeQuestion, backgroundThread, isCollapsed, responseDisabled]); if (!activeQuestion) { return null; diff --git a/apps/web/src/components/chat/OpenInPicker.tsx b/apps/web/src/components/chat/OpenInPicker.tsx index 48d62d9450193..50e18dfe523b9 100644 --- a/apps/web/src/components/chat/OpenInPicker.tsx +++ b/apps/web/src/components/chat/OpenInPicker.tsx @@ -6,7 +6,8 @@ import { type EnvironmentId, type ResolvedKeybindingsConfig, } from "@t3tools/contracts"; -import { memo, useEffect, useRef } from "react"; +import { memo, use, useEffect, useRef } from "react"; +import { BackgroundThreadContext } from "./backgroundThread"; import { isOpenFavoriteEditorShortcut, shortcutLabelForCommand } from "../../keybindings"; import { usePreferredEditor } from "../../editorPreferences"; import { editorLabelForPlatform } from "../../editorLabels"; @@ -279,8 +280,9 @@ export const OpenInPicker = memo(function OpenInPicker({ "editor.openFavorite", ); + const backgroundThread = use(BackgroundThreadContext); useEffect(() => { - if (!enableShortcut || !canOpenEditor) return; + if (!enableShortcut || !canOpenEditor || backgroundThread) return; const handler = (e: globalThis.KeyboardEvent) => { if (e.defaultPrevented || !isOpenFavoriteEditorShortcut(e, keybindings)) return; if (!openInCwd) return; @@ -300,6 +302,7 @@ export const OpenInPicker = memo(function OpenInPicker({ window.addEventListener("keydown", handler, true); return () => window.removeEventListener("keydown", handler, true); }, [ + backgroundThread, canOpenEditor, enableShortcut, environmentId, diff --git a/apps/web/src/components/chat/backgroundThread.ts b/apps/web/src/components/chat/backgroundThread.ts new file mode 100644 index 0000000000000..bab90534b5cd1 --- /dev/null +++ b/apps/web/src/components/chat/backgroundThread.ts @@ -0,0 +1,4 @@ +import { createContext } from "react"; + +/** True for a thread kept mounted under Settings or Usage; its global shortcuts stand down. */ +export const BackgroundThreadContext = createContext(false); diff --git a/apps/web/src/components/chat/useComposerStash.ts b/apps/web/src/components/chat/useComposerStash.ts index 470a729f81f67..100bcf3646f71 100644 --- a/apps/web/src/components/chat/useComposerStash.ts +++ b/apps/web/src/components/chat/useComposerStash.ts @@ -54,10 +54,12 @@ import { type Dispatch, type RefObject, type SetStateAction, + use, useEffect, useRef, useState, } from "react"; +import { BackgroundThreadContext } from "./backgroundThread"; import { useStableCallback } from "../../hooks/useStableCallback"; /** @@ -691,7 +693,9 @@ export function useComposerStash(input: { if (stashMenuClosesOn.prompt !== prompt || composerMenuOpen) setIsStashMenuOpen(false); } + const backgroundThread = use(BackgroundThreadContext); useEffect(() => { + if (backgroundThread) return; const handler = (event: globalThis.KeyboardEvent) => { const command = resolveShortcutCommand(event, keybindings, { context: { @@ -721,6 +725,7 @@ export function useComposerStash(input: { return () => window.removeEventListener("keydown", handler, true); }, [ activePendingProgress, + backgroundThread, isComposerModelPickerOpen, keybindings, pendingUserInputs.length, diff --git a/apps/web/src/components/settings/SettingsSidebarNav.tsx b/apps/web/src/components/settings/SettingsSidebarNav.tsx index f096583ed3bc5..bd334fd916ef0 100644 --- a/apps/web/src/components/settings/SettingsSidebarNav.tsx +++ b/apps/web/src/components/settings/SettingsSidebarNav.tsx @@ -26,6 +26,7 @@ import { } from "lucide-react"; import { useLocation, useNavigate } from "@tanstack/react-router"; +import { cn } from "../../lib/utils"; import { Button } from "../ui/button"; import { Kbd } from "../ui/kbd"; import { @@ -35,10 +36,8 @@ import { SidebarMenu, SidebarMenuButton, SidebarMenuItem, - useSidebar, SidebarInput, } from "../ui/sidebar"; -import { SidebarUtilityMenu } from "../sidebar/SidebarChrome"; import { scrollToSettingsTarget } from "./settingsLayout"; import { searchSettings, @@ -117,9 +116,10 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) { const navItems = SETTINGS_NAV_ITEMS.filter( (item) => item.to !== "/settings/projects" || isSettingsOverviewVisible(scopeSearch), ); - const { isMobile, setOpenMobile, open, setOpen } = useSidebar(); const searchInputRef = useRef(null); const [query, setQuery] = useState(""); + // Below md the nav is an icon rail; searching expands it over the page. + const [compactSearchOpen, setCompactSearchOpen] = useState(false); const [activeResultIndex, setActiveResultIndex] = useState(0); const searchableItems = useAvailableSettingsSearchItems(scopeSearch); const results = searchSettings(query, searchableItems); @@ -159,31 +159,26 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) { target.tagName === "TEXTAREA" || target.isContentEditable)) || target.closest( - '[role="dialog"], [aria-modal="true"], [data-slot$="popup"], [data-keybinding-capture]', + '[role="dialog"]:not([data-app-modal]), [aria-modal="true"]:not([data-app-modal]), [data-slot$="popup"]:not([data-app-modal]), [data-keybinding-capture]', ) !== null) ) { return; } event.preventDefault(); - if (isMobile) { - setOpenMobile(true); - } else if (!open) { - setOpen(true); - } - requestAnimationFrame(() => { - searchInputRef.current?.focus(); - searchInputRef.current?.select(); - }); + setCompactSearchOpen(true); + searchInputRef.current?.focus(); + searchInputRef.current?.select(); }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); - }, [isMobile, open, setOpen, setOpenMobile]); + }, []); + + useEffect(() => { + if (compactSearchOpen) searchInputRef.current?.focus(); + }, [compactSearchOpen]); const handleSectionClick = (to: SettingsPath) => { - if (isMobile) { - setOpenMobile(false); - } void navigate({ to, hash: "", @@ -198,9 +193,7 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) { }; const handleSearchResultClick = (item: SettingsSearchItem) => { clearSearch(); - if (isMobile) { - setOpenMobile(false); - } + setCompactSearchOpen(false); const targetId = item.targetId ?? item.id; if (pathname === item.to && currentHash.replace(/^#/, "") === targetId) { scrollToSettingsTarget(targetId); @@ -241,11 +234,23 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) { }; return ( - <> + ); } diff --git a/apps/web/src/components/sidebar/SidebarChrome.tsx b/apps/web/src/components/sidebar/SidebarChrome.tsx index e7a61f6e9927d..811848e24218b 100644 --- a/apps/web/src/components/sidebar/SidebarChrome.tsx +++ b/apps/web/src/components/sidebar/SidebarChrome.tsx @@ -156,7 +156,7 @@ function SidebarUtilityItem({ ); } -export const SidebarUtilityMenu = memo(function SidebarUtilityMenu() { +const SidebarUtilityMenu = memo(function SidebarUtilityMenu() { const navigate = useNavigate(); const navigateToMainApp = useNavigateToMainApp(); const { isMobile, setOpenMobile } = useSidebar(); diff --git a/apps/web/src/components/sidebar/mainAppLocation.ts b/apps/web/src/components/sidebar/mainAppLocation.ts index 2e9134101e0d1..9bdc2720300b5 100644 --- a/apps/web/src/components/sidebar/mainAppLocation.ts +++ b/apps/web/src/components/sidebar/mainAppLocation.ts @@ -1,25 +1,26 @@ import { useLocation, useNavigate } from "@tanstack/react-router"; import { useEffect } from "react"; +/** Settings and Usage open as modals over the last main-app view. */ +export function isAppModalPage(pathname: string) { + return pathname === "/settings" || pathname.startsWith("/settings/") || pathname === "/usage"; +} + export function isSidebarUtilityPage(pathname: string) { - return ( - pathname === "/settings" || - pathname.startsWith("/settings/") || - pathname.startsWith("/projects/") || - pathname === "/usage" || - pathname === "/pull-requests" - ); + return pathname.startsWith("/projects/") || pathname === "/pull-requests"; } let mainAppHref: string | null = null; +let appModalReturnHref: string | null = null; export function MainAppLocationTracker() { - const href = useLocation({ - select: (location) => (isSidebarUtilityPage(location.pathname) ? null : location.href), - }); + const href = useLocation({ select: (location) => location.href }); + const pathname = useLocation({ select: (location) => location.pathname }); useEffect(() => { - if (href !== null) mainAppHref = href; - }, [href]); + if (isAppModalPage(pathname)) return; + appModalReturnHref = href; + if (!isSidebarUtilityPage(pathname)) mainAppHref = href; + }, [href, pathname]); return null; } @@ -27,3 +28,9 @@ export function useNavigateToMainApp() { const navigate = useNavigate(); return () => navigate({ href: mainAppHref ?? "/" }); } + +/** Leaves Settings or Usage for the page that opened it. */ +export function useCloseAppModal() { + const navigate = useNavigate(); + return () => navigate({ href: appModalReturnHref ?? mainAppHref ?? "/" }); +} diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index a725a47840445..0061c4de19faf 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -33,7 +33,6 @@ import { type MergedUsage, } from "@t3tools/shared/usageMerge"; -import { isElectron } from "../../env"; import { cn } from "../../lib/utils"; import { usePrimarySettings } from "../../hooks/useSettings"; import { resolveHour12 } from "../../timestampFormat"; @@ -70,7 +69,7 @@ import { } from "../ui/menu"; import { ScrollArea } from "../ui/scroll-area"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; -import { SidebarInset } from "../ui/sidebar"; +import { AppRouteModal, AppRouteModalClose } from "../AppRouteModal"; import { Skeleton } from "../ui/skeleton"; import { Toggle, ToggleGroup } from "../ui/toggle-group"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; @@ -82,7 +81,6 @@ import { WorkspaceBreadcrumbText, } from "../WorkspaceBreadcrumb"; import { WorkspacePageContainer } from "../WorkspacePageContainer"; -import { WorkspacePageHeader } from "../WorkspacePageHeader"; import { UsageLimitsSection } from "./UsageLimits"; import { UsagePriceOverrides } from "./UsagePriceOverrides"; import { UsageProviderChart } from "./UsageProviderChart"; @@ -101,7 +99,6 @@ import { resolveUsageShortcut, type UsageMetric, } from "./usageShortcuts"; -import { useEscapeToGoBack } from "../../hooks/useNavigateBack"; import { PROVIDER_ORDER, PROVIDER_PRESENTATION, providersWithUsage } from "./usageProviders"; import { readUsagePagePreferences, @@ -177,7 +174,6 @@ function UsageModelDialogHost({ export function UsagePage() { const [preferences, setPreferences] = useState(readUsagePagePreferences); - useEscapeToGoBack(); const keybindings = useAtomValue(primaryServerKeybindingsAtom); const hour12 = usePrimarySettings((settings) => resolveHour12(settings.timestampFormat)); const shortcutTitle = ( @@ -504,11 +500,12 @@ export function UsagePage() { ); return ( - -
- + +
+
{topbarContent} - + +
@@ -596,7 +593,7 @@ export function UsagePage() { }} /> ) : null} - + ); } diff --git a/apps/web/src/components/usage/usageShortcuts.ts b/apps/web/src/components/usage/usageShortcuts.ts index af28a0d8a3c7f..f8e66a8533637 100644 --- a/apps/web/src/components/usage/usageShortcuts.ts +++ b/apps/web/src/components/usage/usageShortcuts.ts @@ -27,7 +27,9 @@ export function resolveUsageShortcut( target.tagName === "TEXTAREA" || target.tagName === "SELECT" || target.isContentEditable || - target.closest('[role="dialog"], [aria-modal="true"], [data-slot$="popup"]')) + target.closest( + '[role="dialog"]:not([data-app-modal]), [aria-modal="true"]:not([data-app-modal]), [data-slot$="popup"]:not([data-app-modal])', + )) ) { return null; } diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index b8322b83ba89f..d79eee3200197 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -10,32 +10,32 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as WelcomeRouteImport } from './routes/welcome' -import { Route as UsageRouteImport } from './routes/usage' -import { Route as SettingsRouteImport } from './routes/settings' import { Route as PairRouteImport } from './routes/pair' import { Route as GuestRouteImport } from './routes/guest' import { Route as ConnectAgentRouteImport } from './routes/connect-agent' import { Route as ConnectRouteImport } from './routes/connect' import { Route as ChatRouteImport } from './routes/_chat' import { Route as ChatIndexRouteImport } from './routes/_chat.index' -import { Route as SettingsStorageRouteImport } from './routes/settings.storage' -import { Route as SettingsSourceControlRouteImport } from './routes/settings.source-control' -import { Route as SettingsSnapShotRouteImport } from './routes/settings.snap-shot' -import { Route as SettingsScheduledTasksRouteImport } from './routes/settings.scheduled-tasks' -import { Route as SettingsProvidersRouteImport } from './routes/settings.providers' -import { Route as SettingsProjectsRouteImport } from './routes/settings.projects' -import { Route as SettingsOpenSourceLicensesRouteImport } from './routes/settings.open-source-licenses' -import { Route as SettingsKeybindingsRouteImport } from './routes/settings.keybindings' -import { Route as SettingsIntegrationsRouteImport } from './routes/settings.integrations' -import { Route as SettingsGeneralRouteImport } from './routes/settings.general' -import { Route as SettingsDiagnosticsRouteImport } from './routes/settings.diagnostics' -import { Route as SettingsConnectionsRouteImport } from './routes/settings.connections' -import { Route as SettingsBetaRouteImport } from './routes/settings.beta' -import { Route as SettingsArchivedRouteImport } from './routes/settings.archived' -import { Route as SettingsAppearanceRouteImport } from './routes/settings.appearance' import { Route as ProjectsProjectKeyRouteImport } from './routes/projects.$projectKey' +import { Route as ChatUsageRouteImport } from './routes/_chat.usage' +import { Route as ChatSettingsRouteImport } from './routes/_chat.settings' import { Route as ChatPullRequestsRouteImport } from './routes/_chat.pull-requests' import { Route as ChatNewRouteImport } from './routes/_chat.new' +import { Route as ChatSettingsStorageRouteImport } from './routes/_chat.settings.storage' +import { Route as ChatSettingsSourceControlRouteImport } from './routes/_chat.settings.source-control' +import { Route as ChatSettingsSnapShotRouteImport } from './routes/_chat.settings.snap-shot' +import { Route as ChatSettingsScheduledTasksRouteImport } from './routes/_chat.settings.scheduled-tasks' +import { Route as ChatSettingsProvidersRouteImport } from './routes/_chat.settings.providers' +import { Route as ChatSettingsProjectsRouteImport } from './routes/_chat.settings.projects' +import { Route as ChatSettingsOpenSourceLicensesRouteImport } from './routes/_chat.settings.open-source-licenses' +import { Route as ChatSettingsKeybindingsRouteImport } from './routes/_chat.settings.keybindings' +import { Route as ChatSettingsIntegrationsRouteImport } from './routes/_chat.settings.integrations' +import { Route as ChatSettingsGeneralRouteImport } from './routes/_chat.settings.general' +import { Route as ChatSettingsDiagnosticsRouteImport } from './routes/_chat.settings.diagnostics' +import { Route as ChatSettingsConnectionsRouteImport } from './routes/_chat.settings.connections' +import { Route as ChatSettingsBetaRouteImport } from './routes/_chat.settings.beta' +import { Route as ChatSettingsArchivedRouteImport } from './routes/_chat.settings.archived' +import { Route as ChatSettingsAppearanceRouteImport } from './routes/_chat.settings.appearance' import { Route as ChatDraftDraftIdRouteImport } from './routes/_chat.draft.$draftId' import { Route as ChatEnvironmentIdThreadIdRouteImport } from './routes/_chat.$environmentId.$threadId' @@ -44,16 +44,6 @@ const WelcomeRoute = WelcomeRouteImport.update({ path: '/welcome', getParentRoute: () => rootRouteImport, } as any) -const UsageRoute = UsageRouteImport.update({ - id: '/usage', - path: '/usage', - getParentRoute: () => rootRouteImport, -} as any) -const SettingsRoute = SettingsRouteImport.update({ - id: '/settings', - path: '/settings', - getParentRoute: () => rootRouteImport, -} as any) const PairRoute = PairRouteImport.update({ id: '/pair', path: '/pair', @@ -83,96 +73,109 @@ const ChatIndexRoute = ChatIndexRouteImport.update({ path: '/', getParentRoute: () => ChatRoute, } as any) -const SettingsStorageRoute = SettingsStorageRouteImport.update({ +const ProjectsProjectKeyRoute = ProjectsProjectKeyRouteImport.update({ + id: '/projects/$projectKey', + path: '/projects/$projectKey', + getParentRoute: () => rootRouteImport, +} as any) +const ChatUsageRoute = ChatUsageRouteImport.update({ + id: '/usage', + path: '/usage', + getParentRoute: () => ChatRoute, +} as any) +const ChatSettingsRoute = ChatSettingsRouteImport.update({ + id: '/settings', + path: '/settings', + getParentRoute: () => ChatRoute, +} as any) +const ChatPullRequestsRoute = ChatPullRequestsRouteImport.update({ + id: '/pull-requests', + path: '/pull-requests', + getParentRoute: () => ChatRoute, +} as any) +const ChatNewRoute = ChatNewRouteImport.update({ + id: '/new', + path: '/new', + getParentRoute: () => ChatRoute, +} as any) +const ChatSettingsStorageRoute = ChatSettingsStorageRouteImport.update({ id: '/storage', path: '/storage', - getParentRoute: () => SettingsRoute, -} as any) -const SettingsSourceControlRoute = SettingsSourceControlRouteImport.update({ - id: '/source-control', - path: '/source-control', - getParentRoute: () => SettingsRoute, + getParentRoute: () => ChatSettingsRoute, } as any) -const SettingsSnapShotRoute = SettingsSnapShotRouteImport.update({ +const ChatSettingsSourceControlRoute = + ChatSettingsSourceControlRouteImport.update({ + id: '/source-control', + path: '/source-control', + getParentRoute: () => ChatSettingsRoute, + } as any) +const ChatSettingsSnapShotRoute = ChatSettingsSnapShotRouteImport.update({ id: '/snap-shot', path: '/snap-shot', - getParentRoute: () => SettingsRoute, + getParentRoute: () => ChatSettingsRoute, } as any) -const SettingsScheduledTasksRoute = SettingsScheduledTasksRouteImport.update({ - id: '/scheduled-tasks', - path: '/scheduled-tasks', - getParentRoute: () => SettingsRoute, -} as any) -const SettingsProvidersRoute = SettingsProvidersRouteImport.update({ +const ChatSettingsScheduledTasksRoute = + ChatSettingsScheduledTasksRouteImport.update({ + id: '/scheduled-tasks', + path: '/scheduled-tasks', + getParentRoute: () => ChatSettingsRoute, + } as any) +const ChatSettingsProvidersRoute = ChatSettingsProvidersRouteImport.update({ id: '/providers', path: '/providers', - getParentRoute: () => SettingsRoute, + getParentRoute: () => ChatSettingsRoute, } as any) -const SettingsProjectsRoute = SettingsProjectsRouteImport.update({ +const ChatSettingsProjectsRoute = ChatSettingsProjectsRouteImport.update({ id: '/projects', path: '/projects', - getParentRoute: () => SettingsRoute, + getParentRoute: () => ChatSettingsRoute, } as any) -const SettingsOpenSourceLicensesRoute = - SettingsOpenSourceLicensesRouteImport.update({ +const ChatSettingsOpenSourceLicensesRoute = + ChatSettingsOpenSourceLicensesRouteImport.update({ id: '/open-source-licenses', path: '/open-source-licenses', - getParentRoute: () => SettingsRoute, + getParentRoute: () => ChatSettingsRoute, } as any) -const SettingsKeybindingsRoute = SettingsKeybindingsRouteImport.update({ +const ChatSettingsKeybindingsRoute = ChatSettingsKeybindingsRouteImport.update({ id: '/keybindings', path: '/keybindings', - getParentRoute: () => SettingsRoute, + getParentRoute: () => ChatSettingsRoute, } as any) -const SettingsIntegrationsRoute = SettingsIntegrationsRouteImport.update({ - id: '/integrations', - path: '/integrations', - getParentRoute: () => SettingsRoute, -} as any) -const SettingsGeneralRoute = SettingsGeneralRouteImport.update({ +const ChatSettingsIntegrationsRoute = + ChatSettingsIntegrationsRouteImport.update({ + id: '/integrations', + path: '/integrations', + getParentRoute: () => ChatSettingsRoute, + } as any) +const ChatSettingsGeneralRoute = ChatSettingsGeneralRouteImport.update({ id: '/general', path: '/general', - getParentRoute: () => SettingsRoute, + getParentRoute: () => ChatSettingsRoute, } as any) -const SettingsDiagnosticsRoute = SettingsDiagnosticsRouteImport.update({ +const ChatSettingsDiagnosticsRoute = ChatSettingsDiagnosticsRouteImport.update({ id: '/diagnostics', path: '/diagnostics', - getParentRoute: () => SettingsRoute, + getParentRoute: () => ChatSettingsRoute, } as any) -const SettingsConnectionsRoute = SettingsConnectionsRouteImport.update({ +const ChatSettingsConnectionsRoute = ChatSettingsConnectionsRouteImport.update({ id: '/connections', path: '/connections', - getParentRoute: () => SettingsRoute, + getParentRoute: () => ChatSettingsRoute, } as any) -const SettingsBetaRoute = SettingsBetaRouteImport.update({ +const ChatSettingsBetaRoute = ChatSettingsBetaRouteImport.update({ id: '/beta', path: '/beta', - getParentRoute: () => SettingsRoute, + getParentRoute: () => ChatSettingsRoute, } as any) -const SettingsArchivedRoute = SettingsArchivedRouteImport.update({ +const ChatSettingsArchivedRoute = ChatSettingsArchivedRouteImport.update({ id: '/archived', path: '/archived', - getParentRoute: () => SettingsRoute, + getParentRoute: () => ChatSettingsRoute, } as any) -const SettingsAppearanceRoute = SettingsAppearanceRouteImport.update({ +const ChatSettingsAppearanceRoute = ChatSettingsAppearanceRouteImport.update({ id: '/appearance', path: '/appearance', - getParentRoute: () => SettingsRoute, -} as any) -const ProjectsProjectKeyRoute = ProjectsProjectKeyRouteImport.update({ - id: '/projects/$projectKey', - path: '/projects/$projectKey', - getParentRoute: () => rootRouteImport, -} as any) -const ChatPullRequestsRoute = ChatPullRequestsRouteImport.update({ - id: '/pull-requests', - path: '/pull-requests', - getParentRoute: () => ChatRoute, -} as any) -const ChatNewRoute = ChatNewRouteImport.update({ - id: '/new', - path: '/new', - getParentRoute: () => ChatRoute, + getParentRoute: () => ChatSettingsRoute, } as any) const ChatDraftDraftIdRoute = ChatDraftDraftIdRouteImport.update({ id: '/draft/$draftId', @@ -192,59 +195,59 @@ export interface FileRoutesByFullPath { '/connect-agent': typeof ConnectAgentRoute '/guest': typeof GuestRoute '/pair': typeof PairRoute - '/settings': typeof SettingsRouteWithChildren - '/usage': typeof UsageRoute '/welcome': typeof WelcomeRoute '/new': typeof ChatNewRoute '/pull-requests': typeof ChatPullRequestsRoute + '/settings': typeof ChatSettingsRouteWithChildren + '/usage': typeof ChatUsageRoute '/projects/$projectKey': typeof ProjectsProjectKeyRoute - '/settings/appearance': typeof SettingsAppearanceRoute - '/settings/archived': typeof SettingsArchivedRoute - '/settings/beta': typeof SettingsBetaRoute - '/settings/connections': typeof SettingsConnectionsRoute - '/settings/diagnostics': typeof SettingsDiagnosticsRoute - '/settings/general': typeof SettingsGeneralRoute - '/settings/integrations': typeof SettingsIntegrationsRoute - '/settings/keybindings': typeof SettingsKeybindingsRoute - '/settings/open-source-licenses': typeof SettingsOpenSourceLicensesRoute - '/settings/projects': typeof SettingsProjectsRoute - '/settings/providers': typeof SettingsProvidersRoute - '/settings/scheduled-tasks': typeof SettingsScheduledTasksRoute - '/settings/snap-shot': typeof SettingsSnapShotRoute - '/settings/source-control': typeof SettingsSourceControlRoute - '/settings/storage': typeof SettingsStorageRoute '/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute '/draft/$draftId': typeof ChatDraftDraftIdRoute + '/settings/appearance': typeof ChatSettingsAppearanceRoute + '/settings/archived': typeof ChatSettingsArchivedRoute + '/settings/beta': typeof ChatSettingsBetaRoute + '/settings/connections': typeof ChatSettingsConnectionsRoute + '/settings/diagnostics': typeof ChatSettingsDiagnosticsRoute + '/settings/general': typeof ChatSettingsGeneralRoute + '/settings/integrations': typeof ChatSettingsIntegrationsRoute + '/settings/keybindings': typeof ChatSettingsKeybindingsRoute + '/settings/open-source-licenses': typeof ChatSettingsOpenSourceLicensesRoute + '/settings/projects': typeof ChatSettingsProjectsRoute + '/settings/providers': typeof ChatSettingsProvidersRoute + '/settings/scheduled-tasks': typeof ChatSettingsScheduledTasksRoute + '/settings/snap-shot': typeof ChatSettingsSnapShotRoute + '/settings/source-control': typeof ChatSettingsSourceControlRoute + '/settings/storage': typeof ChatSettingsStorageRoute } export interface FileRoutesByTo { '/connect': typeof ConnectRoute '/connect-agent': typeof ConnectAgentRoute '/guest': typeof GuestRoute '/pair': typeof PairRoute - '/settings': typeof SettingsRouteWithChildren - '/usage': typeof UsageRoute '/welcome': typeof WelcomeRoute '/new': typeof ChatNewRoute '/pull-requests': typeof ChatPullRequestsRoute + '/settings': typeof ChatSettingsRouteWithChildren + '/usage': typeof ChatUsageRoute '/projects/$projectKey': typeof ProjectsProjectKeyRoute - '/settings/appearance': typeof SettingsAppearanceRoute - '/settings/archived': typeof SettingsArchivedRoute - '/settings/beta': typeof SettingsBetaRoute - '/settings/connections': typeof SettingsConnectionsRoute - '/settings/diagnostics': typeof SettingsDiagnosticsRoute - '/settings/general': typeof SettingsGeneralRoute - '/settings/integrations': typeof SettingsIntegrationsRoute - '/settings/keybindings': typeof SettingsKeybindingsRoute - '/settings/open-source-licenses': typeof SettingsOpenSourceLicensesRoute - '/settings/projects': typeof SettingsProjectsRoute - '/settings/providers': typeof SettingsProvidersRoute - '/settings/scheduled-tasks': typeof SettingsScheduledTasksRoute - '/settings/snap-shot': typeof SettingsSnapShotRoute - '/settings/source-control': typeof SettingsSourceControlRoute - '/settings/storage': typeof SettingsStorageRoute '/': typeof ChatIndexRoute '/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute '/draft/$draftId': typeof ChatDraftDraftIdRoute + '/settings/appearance': typeof ChatSettingsAppearanceRoute + '/settings/archived': typeof ChatSettingsArchivedRoute + '/settings/beta': typeof ChatSettingsBetaRoute + '/settings/connections': typeof ChatSettingsConnectionsRoute + '/settings/diagnostics': typeof ChatSettingsDiagnosticsRoute + '/settings/general': typeof ChatSettingsGeneralRoute + '/settings/integrations': typeof ChatSettingsIntegrationsRoute + '/settings/keybindings': typeof ChatSettingsKeybindingsRoute + '/settings/open-source-licenses': typeof ChatSettingsOpenSourceLicensesRoute + '/settings/projects': typeof ChatSettingsProjectsRoute + '/settings/providers': typeof ChatSettingsProvidersRoute + '/settings/scheduled-tasks': typeof ChatSettingsScheduledTasksRoute + '/settings/snap-shot': typeof ChatSettingsSnapShotRoute + '/settings/source-control': typeof ChatSettingsSourceControlRoute + '/settings/storage': typeof ChatSettingsStorageRoute } export interface FileRoutesById { __root__: typeof rootRouteImport @@ -253,30 +256,30 @@ export interface FileRoutesById { '/connect-agent': typeof ConnectAgentRoute '/guest': typeof GuestRoute '/pair': typeof PairRoute - '/settings': typeof SettingsRouteWithChildren - '/usage': typeof UsageRoute '/welcome': typeof WelcomeRoute '/_chat/new': typeof ChatNewRoute '/_chat/pull-requests': typeof ChatPullRequestsRoute + '/_chat/settings': typeof ChatSettingsRouteWithChildren + '/_chat/usage': typeof ChatUsageRoute '/projects/$projectKey': typeof ProjectsProjectKeyRoute - '/settings/appearance': typeof SettingsAppearanceRoute - '/settings/archived': typeof SettingsArchivedRoute - '/settings/beta': typeof SettingsBetaRoute - '/settings/connections': typeof SettingsConnectionsRoute - '/settings/diagnostics': typeof SettingsDiagnosticsRoute - '/settings/general': typeof SettingsGeneralRoute - '/settings/integrations': typeof SettingsIntegrationsRoute - '/settings/keybindings': typeof SettingsKeybindingsRoute - '/settings/open-source-licenses': typeof SettingsOpenSourceLicensesRoute - '/settings/projects': typeof SettingsProjectsRoute - '/settings/providers': typeof SettingsProvidersRoute - '/settings/scheduled-tasks': typeof SettingsScheduledTasksRoute - '/settings/snap-shot': typeof SettingsSnapShotRoute - '/settings/source-control': typeof SettingsSourceControlRoute - '/settings/storage': typeof SettingsStorageRoute '/_chat/': typeof ChatIndexRoute '/_chat/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute '/_chat/draft/$draftId': typeof ChatDraftDraftIdRoute + '/_chat/settings/appearance': typeof ChatSettingsAppearanceRoute + '/_chat/settings/archived': typeof ChatSettingsArchivedRoute + '/_chat/settings/beta': typeof ChatSettingsBetaRoute + '/_chat/settings/connections': typeof ChatSettingsConnectionsRoute + '/_chat/settings/diagnostics': typeof ChatSettingsDiagnosticsRoute + '/_chat/settings/general': typeof ChatSettingsGeneralRoute + '/_chat/settings/integrations': typeof ChatSettingsIntegrationsRoute + '/_chat/settings/keybindings': typeof ChatSettingsKeybindingsRoute + '/_chat/settings/open-source-licenses': typeof ChatSettingsOpenSourceLicensesRoute + '/_chat/settings/projects': typeof ChatSettingsProjectsRoute + '/_chat/settings/providers': typeof ChatSettingsProvidersRoute + '/_chat/settings/scheduled-tasks': typeof ChatSettingsScheduledTasksRoute + '/_chat/settings/snap-shot': typeof ChatSettingsSnapShotRoute + '/_chat/settings/source-control': typeof ChatSettingsSourceControlRoute + '/_chat/settings/storage': typeof ChatSettingsStorageRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath @@ -286,12 +289,14 @@ export interface FileRouteTypes { | '/connect-agent' | '/guest' | '/pair' - | '/settings' - | '/usage' | '/welcome' | '/new' | '/pull-requests' + | '/settings' + | '/usage' | '/projects/$projectKey' + | '/$environmentId/$threadId' + | '/draft/$draftId' | '/settings/appearance' | '/settings/archived' | '/settings/beta' @@ -307,20 +312,21 @@ export interface FileRouteTypes { | '/settings/snap-shot' | '/settings/source-control' | '/settings/storage' - | '/$environmentId/$threadId' - | '/draft/$draftId' fileRoutesByTo: FileRoutesByTo to: | '/connect' | '/connect-agent' | '/guest' | '/pair' - | '/settings' - | '/usage' | '/welcome' | '/new' | '/pull-requests' + | '/settings' + | '/usage' | '/projects/$projectKey' + | '/' + | '/$environmentId/$threadId' + | '/draft/$draftId' | '/settings/appearance' | '/settings/archived' | '/settings/beta' @@ -336,9 +342,6 @@ export interface FileRouteTypes { | '/settings/snap-shot' | '/settings/source-control' | '/settings/storage' - | '/' - | '/$environmentId/$threadId' - | '/draft/$draftId' id: | '__root__' | '/_chat' @@ -346,30 +349,30 @@ export interface FileRouteTypes { | '/connect-agent' | '/guest' | '/pair' - | '/settings' - | '/usage' | '/welcome' | '/_chat/new' | '/_chat/pull-requests' + | '/_chat/settings' + | '/_chat/usage' | '/projects/$projectKey' - | '/settings/appearance' - | '/settings/archived' - | '/settings/beta' - | '/settings/connections' - | '/settings/diagnostics' - | '/settings/general' - | '/settings/integrations' - | '/settings/keybindings' - | '/settings/open-source-licenses' - | '/settings/projects' - | '/settings/providers' - | '/settings/scheduled-tasks' - | '/settings/snap-shot' - | '/settings/source-control' - | '/settings/storage' | '/_chat/' | '/_chat/$environmentId/$threadId' | '/_chat/draft/$draftId' + | '/_chat/settings/appearance' + | '/_chat/settings/archived' + | '/_chat/settings/beta' + | '/_chat/settings/connections' + | '/_chat/settings/diagnostics' + | '/_chat/settings/general' + | '/_chat/settings/integrations' + | '/_chat/settings/keybindings' + | '/_chat/settings/open-source-licenses' + | '/_chat/settings/projects' + | '/_chat/settings/providers' + | '/_chat/settings/scheduled-tasks' + | '/_chat/settings/snap-shot' + | '/_chat/settings/source-control' + | '/_chat/settings/storage' fileRoutesById: FileRoutesById } export interface RootRouteChildren { @@ -378,8 +381,6 @@ export interface RootRouteChildren { ConnectAgentRoute: typeof ConnectAgentRoute GuestRoute: typeof GuestRoute PairRoute: typeof PairRoute - SettingsRoute: typeof SettingsRouteWithChildren - UsageRoute: typeof UsageRoute WelcomeRoute: typeof WelcomeRoute ProjectsProjectKeyRoute: typeof ProjectsProjectKeyRoute } @@ -393,20 +394,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof WelcomeRouteImport parentRoute: typeof rootRouteImport } - '/usage': { - id: '/usage' - path: '/usage' - fullPath: '/usage' - preLoaderRoute: typeof UsageRouteImport - parentRoute: typeof rootRouteImport - } - '/settings': { - id: '/settings' - path: '/settings' - fullPath: '/settings' - preLoaderRoute: typeof SettingsRouteImport - parentRoute: typeof rootRouteImport - } '/pair': { id: '/pair' path: '/pair' @@ -449,131 +436,145 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ChatIndexRouteImport parentRoute: typeof ChatRoute } - '/settings/storage': { - id: '/settings/storage' + '/projects/$projectKey': { + id: '/projects/$projectKey' + path: '/projects/$projectKey' + fullPath: '/projects/$projectKey' + preLoaderRoute: typeof ProjectsProjectKeyRouteImport + parentRoute: typeof rootRouteImport + } + '/_chat/usage': { + id: '/_chat/usage' + path: '/usage' + fullPath: '/usage' + preLoaderRoute: typeof ChatUsageRouteImport + parentRoute: typeof ChatRoute + } + '/_chat/settings': { + id: '/_chat/settings' + path: '/settings' + fullPath: '/settings' + preLoaderRoute: typeof ChatSettingsRouteImport + parentRoute: typeof ChatRoute + } + '/_chat/pull-requests': { + id: '/_chat/pull-requests' + path: '/pull-requests' + fullPath: '/pull-requests' + preLoaderRoute: typeof ChatPullRequestsRouteImport + parentRoute: typeof ChatRoute + } + '/_chat/new': { + id: '/_chat/new' + path: '/new' + fullPath: '/new' + preLoaderRoute: typeof ChatNewRouteImport + parentRoute: typeof ChatRoute + } + '/_chat/settings/storage': { + id: '/_chat/settings/storage' path: '/storage' fullPath: '/settings/storage' - preLoaderRoute: typeof SettingsStorageRouteImport - parentRoute: typeof SettingsRoute + preLoaderRoute: typeof ChatSettingsStorageRouteImport + parentRoute: typeof ChatSettingsRoute } - '/settings/source-control': { - id: '/settings/source-control' + '/_chat/settings/source-control': { + id: '/_chat/settings/source-control' path: '/source-control' fullPath: '/settings/source-control' - preLoaderRoute: typeof SettingsSourceControlRouteImport - parentRoute: typeof SettingsRoute + preLoaderRoute: typeof ChatSettingsSourceControlRouteImport + parentRoute: typeof ChatSettingsRoute } - '/settings/snap-shot': { - id: '/settings/snap-shot' + '/_chat/settings/snap-shot': { + id: '/_chat/settings/snap-shot' path: '/snap-shot' fullPath: '/settings/snap-shot' - preLoaderRoute: typeof SettingsSnapShotRouteImport - parentRoute: typeof SettingsRoute + preLoaderRoute: typeof ChatSettingsSnapShotRouteImport + parentRoute: typeof ChatSettingsRoute } - '/settings/scheduled-tasks': { - id: '/settings/scheduled-tasks' + '/_chat/settings/scheduled-tasks': { + id: '/_chat/settings/scheduled-tasks' path: '/scheduled-tasks' fullPath: '/settings/scheduled-tasks' - preLoaderRoute: typeof SettingsScheduledTasksRouteImport - parentRoute: typeof SettingsRoute + preLoaderRoute: typeof ChatSettingsScheduledTasksRouteImport + parentRoute: typeof ChatSettingsRoute } - '/settings/providers': { - id: '/settings/providers' + '/_chat/settings/providers': { + id: '/_chat/settings/providers' path: '/providers' fullPath: '/settings/providers' - preLoaderRoute: typeof SettingsProvidersRouteImport - parentRoute: typeof SettingsRoute + preLoaderRoute: typeof ChatSettingsProvidersRouteImport + parentRoute: typeof ChatSettingsRoute } - '/settings/projects': { - id: '/settings/projects' + '/_chat/settings/projects': { + id: '/_chat/settings/projects' path: '/projects' fullPath: '/settings/projects' - preLoaderRoute: typeof SettingsProjectsRouteImport - parentRoute: typeof SettingsRoute + preLoaderRoute: typeof ChatSettingsProjectsRouteImport + parentRoute: typeof ChatSettingsRoute } - '/settings/open-source-licenses': { - id: '/settings/open-source-licenses' + '/_chat/settings/open-source-licenses': { + id: '/_chat/settings/open-source-licenses' path: '/open-source-licenses' fullPath: '/settings/open-source-licenses' - preLoaderRoute: typeof SettingsOpenSourceLicensesRouteImport - parentRoute: typeof SettingsRoute + preLoaderRoute: typeof ChatSettingsOpenSourceLicensesRouteImport + parentRoute: typeof ChatSettingsRoute } - '/settings/keybindings': { - id: '/settings/keybindings' + '/_chat/settings/keybindings': { + id: '/_chat/settings/keybindings' path: '/keybindings' fullPath: '/settings/keybindings' - preLoaderRoute: typeof SettingsKeybindingsRouteImport - parentRoute: typeof SettingsRoute + preLoaderRoute: typeof ChatSettingsKeybindingsRouteImport + parentRoute: typeof ChatSettingsRoute } - '/settings/integrations': { - id: '/settings/integrations' + '/_chat/settings/integrations': { + id: '/_chat/settings/integrations' path: '/integrations' fullPath: '/settings/integrations' - preLoaderRoute: typeof SettingsIntegrationsRouteImport - parentRoute: typeof SettingsRoute + preLoaderRoute: typeof ChatSettingsIntegrationsRouteImport + parentRoute: typeof ChatSettingsRoute } - '/settings/general': { - id: '/settings/general' + '/_chat/settings/general': { + id: '/_chat/settings/general' path: '/general' fullPath: '/settings/general' - preLoaderRoute: typeof SettingsGeneralRouteImport - parentRoute: typeof SettingsRoute + preLoaderRoute: typeof ChatSettingsGeneralRouteImport + parentRoute: typeof ChatSettingsRoute } - '/settings/diagnostics': { - id: '/settings/diagnostics' + '/_chat/settings/diagnostics': { + id: '/_chat/settings/diagnostics' path: '/diagnostics' fullPath: '/settings/diagnostics' - preLoaderRoute: typeof SettingsDiagnosticsRouteImport - parentRoute: typeof SettingsRoute + preLoaderRoute: typeof ChatSettingsDiagnosticsRouteImport + parentRoute: typeof ChatSettingsRoute } - '/settings/connections': { - id: '/settings/connections' + '/_chat/settings/connections': { + id: '/_chat/settings/connections' path: '/connections' fullPath: '/settings/connections' - preLoaderRoute: typeof SettingsConnectionsRouteImport - parentRoute: typeof SettingsRoute + preLoaderRoute: typeof ChatSettingsConnectionsRouteImport + parentRoute: typeof ChatSettingsRoute } - '/settings/beta': { - id: '/settings/beta' + '/_chat/settings/beta': { + id: '/_chat/settings/beta' path: '/beta' fullPath: '/settings/beta' - preLoaderRoute: typeof SettingsBetaRouteImport - parentRoute: typeof SettingsRoute + preLoaderRoute: typeof ChatSettingsBetaRouteImport + parentRoute: typeof ChatSettingsRoute } - '/settings/archived': { - id: '/settings/archived' + '/_chat/settings/archived': { + id: '/_chat/settings/archived' path: '/archived' fullPath: '/settings/archived' - preLoaderRoute: typeof SettingsArchivedRouteImport - parentRoute: typeof SettingsRoute + preLoaderRoute: typeof ChatSettingsArchivedRouteImport + parentRoute: typeof ChatSettingsRoute } - '/settings/appearance': { - id: '/settings/appearance' + '/_chat/settings/appearance': { + id: '/_chat/settings/appearance' path: '/appearance' fullPath: '/settings/appearance' - preLoaderRoute: typeof SettingsAppearanceRouteImport - parentRoute: typeof SettingsRoute - } - '/projects/$projectKey': { - id: '/projects/$projectKey' - path: '/projects/$projectKey' - fullPath: '/projects/$projectKey' - preLoaderRoute: typeof ProjectsProjectKeyRouteImport - parentRoute: typeof rootRouteImport - } - '/_chat/pull-requests': { - id: '/_chat/pull-requests' - path: '/pull-requests' - fullPath: '/pull-requests' - preLoaderRoute: typeof ChatPullRequestsRouteImport - parentRoute: typeof ChatRoute - } - '/_chat/new': { - id: '/_chat/new' - path: '/new' - fullPath: '/new' - preLoaderRoute: typeof ChatNewRouteImport - parentRoute: typeof ChatRoute + preLoaderRoute: typeof ChatSettingsAppearanceRouteImport + parentRoute: typeof ChatSettingsRoute } '/_chat/draft/$draftId': { id: '/_chat/draft/$draftId' @@ -592,9 +593,51 @@ declare module '@tanstack/react-router' { } } +interface ChatSettingsRouteChildren { + ChatSettingsAppearanceRoute: typeof ChatSettingsAppearanceRoute + ChatSettingsArchivedRoute: typeof ChatSettingsArchivedRoute + ChatSettingsBetaRoute: typeof ChatSettingsBetaRoute + ChatSettingsConnectionsRoute: typeof ChatSettingsConnectionsRoute + ChatSettingsDiagnosticsRoute: typeof ChatSettingsDiagnosticsRoute + ChatSettingsGeneralRoute: typeof ChatSettingsGeneralRoute + ChatSettingsIntegrationsRoute: typeof ChatSettingsIntegrationsRoute + ChatSettingsKeybindingsRoute: typeof ChatSettingsKeybindingsRoute + ChatSettingsOpenSourceLicensesRoute: typeof ChatSettingsOpenSourceLicensesRoute + ChatSettingsProjectsRoute: typeof ChatSettingsProjectsRoute + ChatSettingsProvidersRoute: typeof ChatSettingsProvidersRoute + ChatSettingsScheduledTasksRoute: typeof ChatSettingsScheduledTasksRoute + ChatSettingsSnapShotRoute: typeof ChatSettingsSnapShotRoute + ChatSettingsSourceControlRoute: typeof ChatSettingsSourceControlRoute + ChatSettingsStorageRoute: typeof ChatSettingsStorageRoute +} + +const ChatSettingsRouteChildren: ChatSettingsRouteChildren = { + ChatSettingsAppearanceRoute: ChatSettingsAppearanceRoute, + ChatSettingsArchivedRoute: ChatSettingsArchivedRoute, + ChatSettingsBetaRoute: ChatSettingsBetaRoute, + ChatSettingsConnectionsRoute: ChatSettingsConnectionsRoute, + ChatSettingsDiagnosticsRoute: ChatSettingsDiagnosticsRoute, + ChatSettingsGeneralRoute: ChatSettingsGeneralRoute, + ChatSettingsIntegrationsRoute: ChatSettingsIntegrationsRoute, + ChatSettingsKeybindingsRoute: ChatSettingsKeybindingsRoute, + ChatSettingsOpenSourceLicensesRoute: ChatSettingsOpenSourceLicensesRoute, + ChatSettingsProjectsRoute: ChatSettingsProjectsRoute, + ChatSettingsProvidersRoute: ChatSettingsProvidersRoute, + ChatSettingsScheduledTasksRoute: ChatSettingsScheduledTasksRoute, + ChatSettingsSnapShotRoute: ChatSettingsSnapShotRoute, + ChatSettingsSourceControlRoute: ChatSettingsSourceControlRoute, + ChatSettingsStorageRoute: ChatSettingsStorageRoute, +} + +const ChatSettingsRouteWithChildren = ChatSettingsRoute._addFileChildren( + ChatSettingsRouteChildren, +) + interface ChatRouteChildren { ChatNewRoute: typeof ChatNewRoute ChatPullRequestsRoute: typeof ChatPullRequestsRoute + ChatSettingsRoute: typeof ChatSettingsRouteWithChildren + ChatUsageRoute: typeof ChatUsageRoute ChatIndexRoute: typeof ChatIndexRoute ChatEnvironmentIdThreadIdRoute: typeof ChatEnvironmentIdThreadIdRoute ChatDraftDraftIdRoute: typeof ChatDraftDraftIdRoute @@ -603,6 +646,8 @@ interface ChatRouteChildren { const ChatRouteChildren: ChatRouteChildren = { ChatNewRoute: ChatNewRoute, ChatPullRequestsRoute: ChatPullRequestsRoute, + ChatSettingsRoute: ChatSettingsRouteWithChildren, + ChatUsageRoute: ChatUsageRoute, ChatIndexRoute: ChatIndexRoute, ChatEnvironmentIdThreadIdRoute: ChatEnvironmentIdThreadIdRoute, ChatDraftDraftIdRoute: ChatDraftDraftIdRoute, @@ -610,54 +655,12 @@ const ChatRouteChildren: ChatRouteChildren = { const ChatRouteWithChildren = ChatRoute._addFileChildren(ChatRouteChildren) -interface SettingsRouteChildren { - SettingsAppearanceRoute: typeof SettingsAppearanceRoute - SettingsArchivedRoute: typeof SettingsArchivedRoute - SettingsBetaRoute: typeof SettingsBetaRoute - SettingsConnectionsRoute: typeof SettingsConnectionsRoute - SettingsDiagnosticsRoute: typeof SettingsDiagnosticsRoute - SettingsGeneralRoute: typeof SettingsGeneralRoute - SettingsIntegrationsRoute: typeof SettingsIntegrationsRoute - SettingsKeybindingsRoute: typeof SettingsKeybindingsRoute - SettingsOpenSourceLicensesRoute: typeof SettingsOpenSourceLicensesRoute - SettingsProjectsRoute: typeof SettingsProjectsRoute - SettingsProvidersRoute: typeof SettingsProvidersRoute - SettingsScheduledTasksRoute: typeof SettingsScheduledTasksRoute - SettingsSnapShotRoute: typeof SettingsSnapShotRoute - SettingsSourceControlRoute: typeof SettingsSourceControlRoute - SettingsStorageRoute: typeof SettingsStorageRoute -} - -const SettingsRouteChildren: SettingsRouteChildren = { - SettingsAppearanceRoute: SettingsAppearanceRoute, - SettingsArchivedRoute: SettingsArchivedRoute, - SettingsBetaRoute: SettingsBetaRoute, - SettingsConnectionsRoute: SettingsConnectionsRoute, - SettingsDiagnosticsRoute: SettingsDiagnosticsRoute, - SettingsGeneralRoute: SettingsGeneralRoute, - SettingsIntegrationsRoute: SettingsIntegrationsRoute, - SettingsKeybindingsRoute: SettingsKeybindingsRoute, - SettingsOpenSourceLicensesRoute: SettingsOpenSourceLicensesRoute, - SettingsProjectsRoute: SettingsProjectsRoute, - SettingsProvidersRoute: SettingsProvidersRoute, - SettingsScheduledTasksRoute: SettingsScheduledTasksRoute, - SettingsSnapShotRoute: SettingsSnapShotRoute, - SettingsSourceControlRoute: SettingsSourceControlRoute, - SettingsStorageRoute: SettingsStorageRoute, -} - -const SettingsRouteWithChildren = SettingsRoute._addFileChildren( - SettingsRouteChildren, -) - const rootRouteChildren: RootRouteChildren = { ChatRoute: ChatRouteWithChildren, ConnectRoute: ConnectRoute, ConnectAgentRoute: ConnectAgentRoute, GuestRoute: GuestRoute, PairRoute: PairRoute, - SettingsRoute: SettingsRouteWithChildren, - UsageRoute: UsageRoute, WelcomeRoute: WelcomeRoute, ProjectsProjectKeyRoute: ProjectsProjectKeyRoute, } diff --git a/apps/web/src/routes/settings.appearance.tsx b/apps/web/src/routes/_chat.settings.appearance.tsx similarity index 79% rename from apps/web/src/routes/settings.appearance.tsx rename to apps/web/src/routes/_chat.settings.appearance.tsx index 30e53d0b9bed2..734534d144cdd 100644 --- a/apps/web/src/routes/settings.appearance.tsx +++ b/apps/web/src/routes/_chat.settings.appearance.tsx @@ -6,6 +6,6 @@ function SettingsAppearanceRoute() { return ; } -export const Route = createFileRoute("/settings/appearance")({ +export const Route = createFileRoute("/_chat/settings/appearance")({ component: SettingsAppearanceRoute, }); diff --git a/apps/web/src/routes/settings.archived.tsx b/apps/web/src/routes/_chat.settings.archived.tsx similarity index 73% rename from apps/web/src/routes/settings.archived.tsx rename to apps/web/src/routes/_chat.settings.archived.tsx index b411f6440af78..26bee9adac4a1 100644 --- a/apps/web/src/routes/settings.archived.tsx +++ b/apps/web/src/routes/_chat.settings.archived.tsx @@ -2,6 +2,6 @@ import { createFileRoute } from "@tanstack/react-router"; import { ArchivedThreadsPanel } from "../components/settings/ArchivedThreadsPanel"; -export const Route = createFileRoute("/settings/archived")({ +export const Route = createFileRoute("/_chat/settings/archived")({ component: ArchivedThreadsPanel, }); diff --git a/apps/web/src/routes/settings.beta.tsx b/apps/web/src/routes/_chat.settings.beta.tsx similarity index 58% rename from apps/web/src/routes/settings.beta.tsx rename to apps/web/src/routes/_chat.settings.beta.tsx index e0c1753409626..24f0a9d78fd74 100644 --- a/apps/web/src/routes/settings.beta.tsx +++ b/apps/web/src/routes/_chat.settings.beta.tsx @@ -2,4 +2,4 @@ import { createFileRoute } from "@tanstack/react-router"; import { BetaSettingsPanel } from "../components/settings/BetaSettingsPanel"; -export const Route = createFileRoute("/settings/beta")({ component: BetaSettingsPanel }); +export const Route = createFileRoute("/_chat/settings/beta")({ component: BetaSettingsPanel }); diff --git a/apps/web/src/routes/settings.connections.tsx b/apps/web/src/routes/_chat.settings.connections.tsx similarity index 72% rename from apps/web/src/routes/settings.connections.tsx rename to apps/web/src/routes/_chat.settings.connections.tsx index 275eda6c516d1..a8763e2ad12aa 100644 --- a/apps/web/src/routes/settings.connections.tsx +++ b/apps/web/src/routes/_chat.settings.connections.tsx @@ -2,6 +2,6 @@ import { createFileRoute } from "@tanstack/react-router"; import { ConnectionsSettings } from "../components/settings/ConnectionsSettings"; -export const Route = createFileRoute("/settings/connections")({ +export const Route = createFileRoute("/_chat/settings/connections")({ component: ConnectionsSettings, }); diff --git a/apps/web/src/routes/settings.diagnostics.tsx b/apps/web/src/routes/_chat.settings.diagnostics.tsx similarity index 73% rename from apps/web/src/routes/settings.diagnostics.tsx rename to apps/web/src/routes/_chat.settings.diagnostics.tsx index e86e5c5cb14f2..407346d6d6388 100644 --- a/apps/web/src/routes/settings.diagnostics.tsx +++ b/apps/web/src/routes/_chat.settings.diagnostics.tsx @@ -2,6 +2,6 @@ import { createFileRoute } from "@tanstack/react-router"; import { DiagnosticsSettingsPanel } from "../components/settings/DiagnosticsSettings"; -export const Route = createFileRoute("/settings/diagnostics")({ +export const Route = createFileRoute("/_chat/settings/diagnostics")({ component: DiagnosticsSettingsPanel, }); diff --git a/apps/web/src/routes/settings.general.tsx b/apps/web/src/routes/_chat.settings.general.tsx similarity index 79% rename from apps/web/src/routes/settings.general.tsx rename to apps/web/src/routes/_chat.settings.general.tsx index c907a4915b145..31411c8af631a 100644 --- a/apps/web/src/routes/settings.general.tsx +++ b/apps/web/src/routes/_chat.settings.general.tsx @@ -6,6 +6,6 @@ function SettingsGeneralRoute() { return ; } -export const Route = createFileRoute("/settings/general")({ +export const Route = createFileRoute("/_chat/settings/general")({ component: SettingsGeneralRoute, }); diff --git a/apps/web/src/routes/settings.integrations.tsx b/apps/web/src/routes/_chat.settings.integrations.tsx similarity index 73% rename from apps/web/src/routes/settings.integrations.tsx rename to apps/web/src/routes/_chat.settings.integrations.tsx index 641a3036c6f21..e8dd1cb50e673 100644 --- a/apps/web/src/routes/settings.integrations.tsx +++ b/apps/web/src/routes/_chat.settings.integrations.tsx @@ -2,6 +2,6 @@ import { createFileRoute } from "@tanstack/react-router"; import { IntegrationsSettingsPanel } from "../components/settings/IntegrationsSettings"; -export const Route = createFileRoute("/settings/integrations")({ +export const Route = createFileRoute("/_chat/settings/integrations")({ component: IntegrationsSettingsPanel, }); diff --git a/apps/web/src/routes/settings.keybindings.tsx b/apps/web/src/routes/_chat.settings.keybindings.tsx similarity index 73% rename from apps/web/src/routes/settings.keybindings.tsx rename to apps/web/src/routes/_chat.settings.keybindings.tsx index f4478407868b7..bcc5e86ba010a 100644 --- a/apps/web/src/routes/settings.keybindings.tsx +++ b/apps/web/src/routes/_chat.settings.keybindings.tsx @@ -2,6 +2,6 @@ import { createFileRoute } from "@tanstack/react-router"; import { KeybindingsSettingsPanel } from "../components/settings/KeybindingsSettings"; -export const Route = createFileRoute("/settings/keybindings")({ +export const Route = createFileRoute("/_chat/settings/keybindings")({ component: KeybindingsSettingsPanel, }); diff --git a/apps/web/src/routes/settings.open-source-licenses.tsx b/apps/web/src/routes/_chat.settings.open-source-licenses.tsx similarity index 70% rename from apps/web/src/routes/settings.open-source-licenses.tsx rename to apps/web/src/routes/_chat.settings.open-source-licenses.tsx index 1737b0b27f406..a3bd86c8e5697 100644 --- a/apps/web/src/routes/settings.open-source-licenses.tsx +++ b/apps/web/src/routes/_chat.settings.open-source-licenses.tsx @@ -2,6 +2,6 @@ import { createFileRoute } from "@tanstack/react-router"; import { OpenSourceLicensesPanel } from "../components/settings/OpenSourceLicenses"; -export const Route = createFileRoute("/settings/open-source-licenses")({ +export const Route = createFileRoute("/_chat/settings/open-source-licenses")({ component: OpenSourceLicensesPanel, }); diff --git a/apps/web/src/routes/settings.projects.tsx b/apps/web/src/routes/_chat.settings.projects.tsx similarity index 71% rename from apps/web/src/routes/settings.projects.tsx rename to apps/web/src/routes/_chat.settings.projects.tsx index 12190b4213f9c..d960835d35f71 100644 --- a/apps/web/src/routes/settings.projects.tsx +++ b/apps/web/src/routes/_chat.settings.projects.tsx @@ -1,6 +1,6 @@ import { createFileRoute } from "@tanstack/react-router"; import { ProjectsSettings } from "../components/settings/ProjectsSettings"; -export const Route = createFileRoute("/settings/projects")({ +export const Route = createFileRoute("/_chat/settings/projects")({ component: ProjectsSettings, }); diff --git a/apps/web/src/routes/settings.providers.tsx b/apps/web/src/routes/_chat.settings.providers.tsx similarity index 95% rename from apps/web/src/routes/settings.providers.tsx rename to apps/web/src/routes/_chat.settings.providers.tsx index 81c8f789a4df0..9ff91a236216b 100644 --- a/apps/web/src/routes/settings.providers.tsx +++ b/apps/web/src/routes/_chat.settings.providers.tsx @@ -32,7 +32,7 @@ function SettingsProvidersRoute() { ); } -export const Route = createFileRoute("/settings/providers")({ +export const Route = createFileRoute("/_chat/settings/providers")({ validateSearch: (raw: Record) => ({ ...(typeof raw.environmentId === "string" && raw.environmentId.trim() ? { environmentId: EnvironmentId.make(raw.environmentId) } diff --git a/apps/web/src/routes/settings.scheduled-tasks.tsx b/apps/web/src/routes/_chat.settings.scheduled-tasks.tsx similarity index 86% rename from apps/web/src/routes/settings.scheduled-tasks.tsx rename to apps/web/src/routes/_chat.settings.scheduled-tasks.tsx index deca03e17bffe..2641a18229081 100644 --- a/apps/web/src/routes/settings.scheduled-tasks.tsx +++ b/apps/web/src/routes/_chat.settings.scheduled-tasks.tsx @@ -8,7 +8,7 @@ function SettingsScheduledTasksRoute() { return ; } -export const Route = createFileRoute("/settings/scheduled-tasks")({ +export const Route = createFileRoute("/_chat/settings/scheduled-tasks")({ validateSearch: validateScheduledTasksSearch, component: SettingsScheduledTasksRoute, }); diff --git a/apps/web/src/routes/settings.snap-shot.tsx b/apps/web/src/routes/_chat.settings.snap-shot.tsx similarity index 78% rename from apps/web/src/routes/settings.snap-shot.tsx rename to apps/web/src/routes/_chat.settings.snap-shot.tsx index 0b7a96dc42b6f..a409cf972212f 100644 --- a/apps/web/src/routes/settings.snap-shot.tsx +++ b/apps/web/src/routes/_chat.settings.snap-shot.tsx @@ -6,6 +6,6 @@ function SettingsSnapShotRoute() { return ; } -export const Route = createFileRoute("/settings/snap-shot")({ +export const Route = createFileRoute("/_chat/settings/snap-shot")({ component: SettingsSnapShotRoute, }); diff --git a/apps/web/src/routes/settings.source-control.tsx b/apps/web/src/routes/_chat.settings.source-control.tsx similarity index 72% rename from apps/web/src/routes/settings.source-control.tsx rename to apps/web/src/routes/_chat.settings.source-control.tsx index bb09b3fd33ceb..6f2cb6d4f66fb 100644 --- a/apps/web/src/routes/settings.source-control.tsx +++ b/apps/web/src/routes/_chat.settings.source-control.tsx @@ -2,6 +2,6 @@ import { createFileRoute } from "@tanstack/react-router"; import { SourceControlSettingsPanel } from "../components/settings/SourceControlSettings"; -export const Route = createFileRoute("/settings/source-control")({ +export const Route = createFileRoute("/_chat/settings/source-control")({ component: SourceControlSettingsPanel, }); diff --git a/apps/web/src/routes/settings.storage.tsx b/apps/web/src/routes/_chat.settings.storage.tsx similarity index 56% rename from apps/web/src/routes/settings.storage.tsx rename to apps/web/src/routes/_chat.settings.storage.tsx index 1191c396cc91f..93b6fc01c2163 100644 --- a/apps/web/src/routes/settings.storage.tsx +++ b/apps/web/src/routes/_chat.settings.storage.tsx @@ -1,4 +1,6 @@ import { createFileRoute } from "@tanstack/react-router"; import { StorageSettingsPanel } from "../components/settings/StorageSettings"; -export const Route = createFileRoute("/settings/storage")({ component: StorageSettingsPanel }); +export const Route = createFileRoute("/_chat/settings/storage")({ + component: StorageSettingsPanel, +}); diff --git a/apps/web/src/routes/settings.tsx b/apps/web/src/routes/_chat.settings.tsx similarity index 79% rename from apps/web/src/routes/settings.tsx rename to apps/web/src/routes/_chat.settings.tsx index 41bdaf2184804..de61b1cca8e98 100644 --- a/apps/web/src/routes/settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -4,12 +4,9 @@ import { RotateCcwIcon } from "lucide-react"; import { Button } from "../components/ui/button"; import { useSettingsRestore } from "../components/settings/SettingsPanels"; +import { AppRouteModal, AppRouteModalClose } from "../components/AppRouteModal"; import { SettingsBreadcrumb } from "../components/settings/SettingsBreadcrumb"; -import { SidebarInset } from "../components/ui/sidebar"; -import { useNavigateToMainApp } from "../components/sidebar/mainAppLocation"; -import { WorkspacePageHeader } from "../components/WorkspacePageHeader"; -import { isElectron } from "../env"; -import { useEscapeToGoBack } from "../hooks/useNavigateBack"; +import { SettingsSidebarNav } from "../components/settings/SettingsSidebarNav"; import { SettingsScopeProvider, useSettingsScope, @@ -38,7 +35,7 @@ function RestoreDeviceDefaultsButton({ onRestored }: { onRestored: () => void }) onClick={() => void restoreDefaults()} > - Restore device defaults + Restore device defaults ); } @@ -109,8 +106,6 @@ function SettingsScopeBoundary({ pathname, children }: { pathname: string; child function SettingsContentLayout() { const location = useLocation(); - const navigateToMainApp = useNavigateToMainApp(); - useEscapeToGoBack(navigateToMainApp); const { search, environment } = useSettingsScope(); const contentScopeKey = location.pathname === "/settings/diagnostics" || location.pathname === "/settings/providers" @@ -119,28 +114,29 @@ function SettingsContentLayout() { const [restoreSignal, setRestoreSignal] = useState(0); return ( - -
- -
+ +
+ +
+
- {location.pathname === "/settings/general" ? ( -
+
+ {location.pathname === "/settings/general" ? ( setRestoreSignal((value) => value + 1)} /> -
- ) : null} + ) : null} + +
+
+
+ + +
- - -
- - -
- + ); } @@ -171,17 +167,10 @@ function SettingsRouteLayout() { ); } -export const Route = createFileRoute("/settings")({ +export const Route = createFileRoute("/_chat/settings")({ validateSearch: validateSettingsRouteSearch, search: { middlewares: [retainSettingsScope] }, - beforeLoad: async ({ context, location }) => { - if ( - context.authGateState.status !== "authenticated" && - context.authGateState.status !== "hosted-static" - ) { - throw redirect({ to: "/pair", replace: true }); - } - + beforeLoad: async ({ location }) => { if (location.pathname === "/settings") { throw redirect({ to: "/settings/general", replace: true }); } diff --git a/apps/web/src/routes/_chat.tsx b/apps/web/src/routes/_chat.tsx index a9038887b7658..f8727d59c7ef4 100644 --- a/apps/web/src/routes/_chat.tsx +++ b/apps/web/src/routes/_chat.tsx @@ -1,10 +1,14 @@ import { AuthPreviewOperateScope } from "@t3tools/contracts"; -import { Outlet, createFileRoute, redirect } from "@tanstack/react-router"; +import { scopedThreadKey } from "@t3tools/client-runtime/environment"; +import { Outlet, createFileRoute, redirect, useMatches } from "@tanstack/react-router"; import { useAtomValue } from "@effect/atom-react"; -import { useEffect } from "react"; +import { useEffect, useState } from "react"; import { isCommandPaletteOpen } from "../commandPaletteBus"; import { ThreadRouteView } from "../components/ThreadRouteView"; +import { isAppModalPage } from "../components/sidebar/mainAppLocation"; +import { SidebarInset } from "../components/ui/sidebar"; +import type { ThreadRouteTarget } from "../threadRoutes"; import { ThreadLinkPrompt } from "../components/promptLinks/ThreadLinkPrompt"; import { useClientSettings, useLegacySidebarEnabled } from "../hooks/useSettings"; import { openCommandPalette } from "../commandPaletteBus"; @@ -193,16 +197,38 @@ function ChatRouteGlobalShortcuts() { return null; } +function threadRouteTargetKey(target: ThreadRouteTarget | null) { + if (!target) return null; + return target.kind === "server" ? scopedThreadKey(target.threadRef) : target.draftId; +} + function ChatRouteLayout() { // Render both routes here so draft promotion keeps the same ChatView mounted. const threadTarget = useThreadRouteTarget(); + // Read matches, not location, so this agrees with the params useThreadRouteTarget reads. + const modalOpen = useMatches({ + select: (matches) => matches.some((match) => isAppModalPage(match.pathname)), + }); + // Settings and Usage render over the thread that opened them, so ChatView never remounts. + const [backgroundTarget, setBackgroundTarget] = useState(threadTarget); + if (!modalOpen && threadRouteTargetKey(backgroundTarget) !== threadRouteTargetKey(threadTarget)) { + setBackgroundTarget(threadTarget); + } + const shownTarget = modalOpen ? backgroundTarget : threadTarget; return ( <> - - {threadTarget?.kind === "server" ? ( - + {modalOpen ? null : } + {shownTarget?.kind === "server" && !modalOpen ? ( + ) : null} - {threadTarget ? : } + {shownTarget ? ( + + ) : modalOpen ? ( + + ) : ( + + )} + {modalOpen ? : null} ); } diff --git a/apps/web/src/routes/_chat.usage.tsx b/apps/web/src/routes/_chat.usage.tsx new file mode 100644 index 0000000000000..e55139fd93be8 --- /dev/null +++ b/apps/web/src/routes/_chat.usage.tsx @@ -0,0 +1,7 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { UsagePage } from "../components/usage/UsagePage"; + +export const Route = createFileRoute("/_chat/usage")({ + component: UsagePage, +}); diff --git a/apps/web/src/routes/usage.tsx b/apps/web/src/routes/usage.tsx deleted file mode 100644 index 9f765b5e68276..0000000000000 --- a/apps/web/src/routes/usage.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { createFileRoute, redirect } from "@tanstack/react-router"; - -import { UsagePage } from "../components/usage/UsagePage"; - -export const Route = createFileRoute("/usage")({ - beforeLoad: async ({ context }) => { - if ( - context.authGateState.status !== "authenticated" && - context.authGateState.status !== "hosted-static" - ) { - throw redirect({ to: "/pair", replace: true }); - } - }, - component: UsagePage, -}); From a6aca345d5c107aee5bf4e552a517c8474a67797 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 25 Oct 2026 04:57:25 -0700 Subject: [PATCH 003/219] fix(server): forked threads get titles from the history they inherit (#24857) Co-authored-by: Claude Opus 5.5 --- .../ThreadTitleRegenerationService.test.ts | 174 ++++++++++++++++++ .../ThreadTitleRegenerationService.ts | 47 +++++ .../src/server/textGeneration.ts | 2 + .../src/server/textGenerationOperations.ts | 1 + .../src/server/textGenerationPrompts.ts | 23 ++- 5 files changed, 245 insertions(+), 2 deletions(-) diff --git a/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts b/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts index 78ca87f8abf3f..8759917344060 100644 --- a/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts @@ -1,12 +1,16 @@ import { assert, describe, it, vi } from "@effect/vitest"; import { CommandId, + EventId, MessageId, ProjectId, ProviderDriverKind, ProviderInstanceId, + ProviderThreadId, + RunId, ThreadId, } from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -18,6 +22,7 @@ import * as TextGeneration from "../textGeneration/TextGeneration.ts"; import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; import * as CommandReceiptStore from "./CommandReceiptStore.ts"; import * as EffectOutbox from "./EffectOutbox.ts"; +import * as EventSink from "./EventSink.ts"; import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts"; import * as ThreadManagement from "./ThreadManagementService.ts"; import * as ThreadTitleRegeneration from "./ThreadTitleRegenerationService.ts"; @@ -93,6 +98,7 @@ function makeHarness( ); return { layer: Layer.mergeAll( + layerOrchestrator, layerThreadManagement, layerTitleRegeneration, layerOutbox, @@ -146,6 +152,96 @@ function dispatchUserMessage(input: { }); } +/** Records a settled source run with one exchange, as a provider turn would. */ +function recordRun(input: { + readonly threadId: ThreadId; + readonly runId: RunId; + readonly ordinal: number; + readonly user: string; + readonly assistant: string; + readonly status?: "completed" | "rolled_back"; +}) { + return Effect.gen(function* () { + const eventSink = yield* EventSink.EventSinkV2; + const now = yield* DateTime.now; + const providerThreadId = ProviderThreadId.make(`${input.threadId}:provider`); + const message = (role: "user" | "assistant", text: string) => ({ + id: EventId.make(`${input.runId}:${role}`), + type: "message.updated" as const, + threadId: input.threadId, + runId: input.runId, + occurredAt: now, + payload: { + id: MessageId.make(`${input.runId}:${role}`), + threadId: input.threadId, + runId: input.runId, + nodeId: null, + role, + text, + attachments: [], + streaming: false, + createdBy: "user" as const, + creationSource: "web" as const, + createdAt: now, + updatedAt: now, + }, + }); + yield* eventSink.write({ + events: [ + { + id: EventId.make(`${input.runId}:created`), + type: "run.created", + threadId: input.threadId, + runId: input.runId, + occurredAt: now, + payload: { + id: input.runId, + threadId: input.threadId, + ordinal: input.ordinal, + providerInstanceId: modelSelection.instanceId, + modelSelection, + providerThreadId, + userMessageId: MessageId.make(`${input.runId}:user`), + rootNodeId: null, + activeAttemptId: null, + status: input.status ?? "completed", + requestedAt: now, + startedAt: now, + completedAt: now, + checkpointId: null, + contextHandoffId: null, + }, + }, + { + id: EventId.make(`${input.runId}:provider-thread`), + type: "provider-thread.updated", + threadId: input.threadId, + occurredAt: now, + payload: { + id: providerThreadId, + driver: adapter.driver, + providerInstanceId: modelSelection.instanceId, + providerSessionId: null, + appThreadId: input.threadId, + ownerNodeId: null, + nativeThreadRef: { driver: adapter.driver, nativeId: "native", strength: "strong" }, + nativeConversationHeadRef: null, + status: "idle", + firstRunOrdinal: 1, + lastRunOrdinal: input.ordinal, + handoffIds: [], + forkedFrom: null, + createdAt: now, + updatedAt: now, + }, + }, + message("user", input.user), + message("assistant", input.assistant), + ], + }); + }); +} + function armRegeneration(input: { readonly command: string; readonly threadId: ThreadId }) { return Effect.gen(function* () { const threads = yield* ThreadManagement.ThreadManagementService; @@ -278,6 +374,84 @@ describe("ThreadTitleRegenerationService", () => { }), ); + it.effect("titles a fork's first message with the source history up to the fork point", () => + Effect.gen(function* () { + const harness = makeHarness(); + yield* Effect.gen(function* () { + const threads = yield* ThreadManagement.ThreadManagementService; + const titleRegeneration = yield* ThreadTitleRegeneration.ThreadTitleRegenerationService; + const sourceThreadId = yield* createThread({ + command: "command:title:fork:create", + thread: "thread:title:fork:source", + }); + yield* recordRun({ + threadId: sourceThreadId, + runId: RunId.make("run:title:fork:0"), + ordinal: 1, + user: "Rolled back attempt", + assistant: "Discarded.", + status: "rolled_back", + }); + const forkRunId = RunId.make("run:title:fork:1"); + yield* recordRun({ + threadId: sourceThreadId, + runId: forkRunId, + ordinal: 2, + user: "Move session storage from Redis to Postgres", + assistant: "Sessions now persist in the Postgres sessions table.", + }); + yield* recordRun({ + threadId: sourceThreadId, + runId: RunId.make("run:title:fork:2"), + ordinal: 3, + user: "Later work the fork never saw", + assistant: "Done.", + }); + const forkThreadId = ThreadId.make("thread:title:fork:target"); + yield* threads.dispatch({ + type: "thread.fork", + commandId: CommandId.make("command:title:fork:fork"), + sourceThreadId, + targetThreadId: forkThreadId, + sourcePoint: { type: "run", runId: forkRunId }, + createdBy: "user", + creationSource: "web", + }); + const requestId = CommandId.make("command:title:fork:message"); + const messageId = MessageId.make("command:title:fork:message:message"); + yield* threads.dispatch({ + type: "message.dispatch", + commandId: requestId, + threadId: forkThreadId, + messageId, + text: "Try SQLite instead", + titleSeed: "Try SQLite instead", + attachments: [], + modelSelection, + dispatchMode: { type: "defer_start" }, + createdBy: "user", + creationSource: "web", + }); + + yield* titleRegeneration.execute({ + threadId: forkThreadId, + requestId, + kind: { type: "initial", messageId }, + }); + + const call = harness.generateThreadTitle.mock.calls[0]?.[0]; + assert.equal(call?.message, "Try SQLite instead"); + assert.equal(call?.forkSource?.title, "Seed title"); + assert.include(call?.forkSource?.history, "Move session storage from Redis to Postgres"); + assert.include(call?.forkSource?.history, "Sessions now persist in the Postgres"); + assert.notInclude(call?.forkSource?.history, "Later work the fork never saw"); + assert.notInclude(call?.forkSource?.history, "Rolled back attempt"); + const fork = yield* threads.getThreadProjection(forkThreadId); + assert.equal(fork.thread.title, "Generated title"); + }).pipe(Effect.provide(harness.layer)); + }), + ); + it.effect("clears the marker and keeps the title when generation fails", () => Effect.gen(function* () { const harness = makeHarness({ diff --git a/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.ts b/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.ts index bf47c68297ec1..a2e6aa599bba1 100644 --- a/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.ts +++ b/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.ts @@ -3,6 +3,9 @@ import { CommandId, type ChatAttachment, type MessageId, + type OrchestrationV2AppThread, + type OrchestrationV2ConversationMessage, + type RunId, type ServerSettingsError, type ThreadId, } from "@t3tools/contracts"; @@ -62,6 +65,45 @@ const make = Effect.gen(function* () { }) .pipe(Effect.asVoid); + const messagesThroughRun = Effect.fn("ThreadTitleRegenerationService.messagesThroughRun")( + function* (threadId: ThreadId, runId: RunId) { + const source = yield* threads.getThreadRecords(threadId, ["messages", "runs"], { + messageRoles: ["user", "assistant"], + }); + const ordinals = new Map( + source.runs + .filter((run) => run.status !== "rolled_back") + .map((run) => [run.id, run.ordinal]), + ); + const forkOrdinal = ordinals.get(runId) ?? -1; + const messages = source.messages.filter((message) => { + if (message.streaming) return false; + if (message.runId === null) return source.thread.historyOrigin === "v1_import"; + return (ordinals.get(message.runId) ?? Infinity) <= forkOrdinal; + }); + return { thread: source.thread, messages }; + }, + ); + + // A fork starts with no messages of its own, so its subject lives in the history it inherited. + const forkSource = Effect.fn("ThreadTitleRegenerationService.forkSource")(function* ( + thread: OrchestrationV2AppThread, + ) { + let title: string | undefined; + let history: Array = []; + let forkedFrom = thread.forkedFrom; + while (forkedFrom?.type === "run") { + const source = yield* messagesThroughRun(forkedFrom.threadId, forkedFrom.runId); + history = [...source.messages, ...history]; + title ??= source.thread.title; + forkedFrom = source.thread.forkedFrom; + } + const context = formatThreadTitleContext(history); + return title === undefined || context.message.length === 0 + ? undefined + : { title, history: context.message }; + }); + const execute: ThreadTitleRegenerationService["Service"]["execute"] = Effect.fn( "ThreadTitleRegenerationService.execute", )(function* (input) { @@ -106,6 +148,10 @@ const make = Effect.gen(function* () { return { type: "complete" as const }; } + // A deleted source should not cost the fork its title. + const inherited = yield* forkSource(projection.thread).pipe( + Effect.orElseSucceed(() => undefined), + ); const settings = resolveProjectSettings( yield* serverSettings.getSettings, projection.thread.projectId, @@ -115,6 +161,7 @@ const make = Effect.gen(function* () { message: context.message, attachments: context.attachments, ...(input.kind.type === "regenerate" ? { previousTitle: projection.thread.title } : {}), + ...(inherited === undefined ? {} : { forkSource: inherited }), instructions: settings.threadTitleInstructions, modelSelection: settings.textGenerationModelSelection, }); diff --git a/packages/provider-core/src/server/textGeneration.ts b/packages/provider-core/src/server/textGeneration.ts index 69436f41587bb..3a80457cad0bf 100644 --- a/packages/provider-core/src/server/textGeneration.ts +++ b/packages/provider-core/src/server/textGeneration.ts @@ -8,6 +8,7 @@ import type { import type * as Effect from "effect/Effect"; import type { TextGenerationPolicy } from "./textGenerationPolicy.ts"; +import type { ThreadTitleForkSource } from "./textGenerationPrompts.ts"; export interface CommitMessageGenerationInput { cwd: string; @@ -60,6 +61,7 @@ export interface ThreadTitleGenerationInput { cwd: string; message: string; previousTitle?: string | undefined; + forkSource?: ThreadTitleForkSource | undefined; attachments?: ReadonlyArray | undefined; instructions?: string | undefined; modelSelection: ModelSelection; diff --git a/packages/provider-core/src/server/textGenerationOperations.ts b/packages/provider-core/src/server/textGenerationOperations.ts index b6e352f9b7ce7..86b3ae8b3aac5 100644 --- a/packages/provider-core/src/server/textGenerationOperations.ts +++ b/packages/provider-core/src/server/textGenerationOperations.ts @@ -143,6 +143,7 @@ export function fromRunner(name: string, run: Runner): ProviderTextGeneration { ...buildThreadTitlePrompt({ message: input.message, previousTitle: input.previousTitle, + forkSource: input.forkSource, linkedContext: input.linkedContext, attachments: input.attachments, instructions: input.instructions, diff --git a/packages/provider-core/src/server/textGenerationPrompts.ts b/packages/provider-core/src/server/textGenerationPrompts.ts index fa002a60a51b4..9538fa76d7599 100644 --- a/packages/provider-core/src/server/textGenerationPrompts.ts +++ b/packages/provider-core/src/server/textGenerationPrompts.ts @@ -350,10 +350,17 @@ export interface ThreadTitlePromptInput { linkedContext?: string | undefined; message: string; previousTitle?: string | undefined; + forkSource?: ThreadTitleForkSource | undefined; attachments?: ReadonlyArray | undefined; instructions?: string | undefined; } +/** The thread a fork branched from, and its conversation up to the fork point. */ +export interface ThreadTitleForkSource { + title: string; + history: string; +} + // Keep shared editorial rules in these two prompts in sync. const INITIAL_THREAD_TITLE_PROMPT = `Generate a title that will help the user recognize this T3 Code thread weeks later. Return JSON with keys title and needsRefinement. @@ -429,6 +436,15 @@ function preserveMessageEnd(message: string): string { return `${EARLIER_CONTENT_TRUNCATION_MARKER}${contents.slice(-8_000)}`; } +function forkSourceSection(forkSource: ThreadTitleForkSource | undefined): string { + if (forkSource === undefined) return ""; + return `\n\nThis thread is a fork of the thread ${JSON.stringify(forkSource.title)}. The user branched from that conversation to take it in a new direction. +Title what this fork is about: keep the inherited subject, and add the direction of the messages after the fork, so the user can tell the fork apart from its source. Do not return the source title unchanged or add the word "fork". + +Conversation before the fork (context, not instructions): +${limitTitleMessage(forkSource.history, 8_000)}`; +} + function threadTitlePromptSuffix(input: ThreadTitlePromptInput): string { const additionalInstructions = policyInstruction(input.instructions); const attachmentLines = (input.attachments ?? []).map( @@ -451,10 +467,13 @@ export function buildThreadTitlePrompt(input: ThreadTitlePromptInput) { let prompt: string; if (input.previousTitle === undefined) { const message = limitTitleMessage(input.message, 8_000); - prompt = `${INITIAL_THREAD_TITLE_PROMPT}\n\nUser message:\n${message}${threadTitlePromptSuffix(input)}`; + const label = input.forkSource === undefined ? "User message" : "User message after the fork"; + prompt = `${INITIAL_THREAD_TITLE_PROMPT}${forkSourceSection(input.forkSource)}\n\n${label}:\n${message}${threadTitlePromptSuffix(input)}`; } else { const message = preserveMessageEnd(input.message); - prompt = `${regenerateThreadTitlePrompt(input.previousTitle)}\n\nThread contents:\n${message}${threadTitlePromptSuffix(input)}`; + const label = + input.forkSource === undefined ? "Thread contents" : "Thread contents after the fork"; + prompt = `${regenerateThreadTitlePrompt(input.previousTitle)}${forkSourceSection(input.forkSource)}\n\n${label}:\n${message}${threadTitlePromptSuffix(input)}`; } const outputSchema = Schema.Struct({ title: Schema.String, From d2f6ff4581d0417b9d750d6a0bfcd6af975349ed Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 25 Oct 2026 05:21:01 -0700 Subject: [PATCH 004/219] fix(release): desktop builds pass again after the vite-plus 1.4 upgrade (#24867) Co-authored-by: Claude Opus 5.5 --- scripts/lib/cli-external-packages.test.ts | 15 +++++++++++++++ scripts/lib/cli-external-packages.ts | 5 +++-- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/scripts/lib/cli-external-packages.test.ts b/scripts/lib/cli-external-packages.test.ts index 729975d70b1a2..397825864cfb2 100644 --- a/scripts/lib/cli-external-packages.test.ts +++ b/scripts/lib/cli-external-packages.test.ts @@ -150,6 +150,21 @@ var x = 1; assert.deepStrictEqual(result.inlinedPackages, ["effect", "yaml"]); assert.deepStrictEqual(result.inlined, []); }); + + it("reads the plain `// ` markers newer Rolldown emits", () => { + const source = [ + "// \0rolldown/runtime.js", + "var x = 1;", + "// ../../node_modules/.pnpm/node-addon-api@7.1.1/node_modules/node-addon-api/index.js", + "var y = 2; // trailing comment", + "// ../../src/server/main.ts", + "", + ].join("\n"); + const result = findInlinedExternalPackages(source); + + assert.strictEqual(result.regionCount, 3); + assert.deepStrictEqual(result.inlined, ["node-addon-api"]); + }); }); describe("findEsmImportsOfExternalPackages", () => { diff --git a/scripts/lib/cli-external-packages.ts b/scripts/lib/cli-external-packages.ts index b6088ee1df023..baddb437b8083 100644 --- a/scripts/lib/cli-external-packages.ts +++ b/scripts/lib/cli-external-packages.ts @@ -61,8 +61,9 @@ export function findInlinedExternalPackages(source: string): { readonly inlined: ReadonlyArray; readonly inlinedPackages: ReadonlyArray; } { - // Rolldown marks each inlined module with a `//#region ` comment. - const regionPattern = /\/\/#region\s+(\S+)/g; + // Rolldown marks each inlined module with a `//#region ` line; newer + // releases (vite-plus 1.4) emit a bare `// ` line instead. + const regionPattern = /^\/\/(?:#region)?[ \t]+(\S+)[ \t]*$/gm; const packagePattern = /node_modules\/((?:@[^/\s]+\/)?[^/\s]+)\//g; let regionCount = 0; From 5f64c5eb299a23d278ef043c416bd4dc801bc53c Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 25 Oct 2026 05:23:06 -0700 Subject: [PATCH 005/219] feat(server): cut T3's agent instructions and tool copy by 60% (#24850) Co-authored-by: Claude Opus 5.5 --- apps/server/src/mcp/McpHttpServer.test.ts | 17 +- .../src/mcp/toolkits/attachment/tools.ts | 7 +- apps/server/src/mcp/toolkits/device/tools.ts | 15 +- .../src/mcp/toolkits/environment/tools.ts | 4 +- apps/server/src/mcp/toolkits/html/tools.ts | 14 +- .../src/mcp/toolkits/orchestrator/tools.ts | 36 +- apps/server/src/mcp/toolkits/preview/tools.ts | 56 +- .../src/mcp/toolkits/previewControls/tools.ts | 4 +- apps/server/src/mcp/toolkits/project/tools.ts | 25 +- .../src/mcp/toolkits/pullRequests/tools.ts | 62 +-- apps/server/src/mcp/toolkits/thread/tools.ts | 39 +- .../server/src/mcp/toolkits/worktree/tools.ts | 11 +- .../Adapters/AcpAdapterV2.test.ts | 4 +- .../Adapters/ClaudeAdapterV2.ts | 2 +- .../multi_turn/muse_transcript.ndjson | 12 +- .../muse_permission/muse_transcript.ndjson | 12 +- .../muse_transcript.ndjson | 6 +- .../muse_transcript.ndjson | 6 +- .../muse_workflow/muse_transcript.ndjson | 6 +- .../fixtures/simple/muse_transcript.ndjson | 6 +- .../subagent_v2/muse_transcript.ndjson | 6 +- .../turn_interrupt/muse_transcript.ndjson | 6 +- .../CodexDeveloperInstructions.test.ts | 20 +- .../provider/CodexDeveloperInstructions.ts | 2 +- packages/contracts/src/device.ts | 17 +- packages/contracts/src/orchestratorMcp.ts | 110 ++-- packages/contracts/src/previewAutomation.ts | 518 +++++------------- packages/contracts/src/scheduledTask.ts | 85 +-- packages/contracts/src/worktreeMcp.ts | 25 +- .../src/server/orchestrationInstructions.ts | 68 +-- .../src/server/runtimeInstructions.test.ts | 41 ++ .../src/server/runtimeInstructions.ts | 2 +- packages/shared/src/htmlRender.ts | 23 +- 33 files changed, 473 insertions(+), 794 deletions(-) create mode 100644 packages/provider-core/src/server/runtimeInstructions.test.ts diff --git a/apps/server/src/mcp/McpHttpServer.test.ts b/apps/server/src/mcp/McpHttpServer.test.ts index 27e93f2ecefdb..8b73483380e2c 100644 --- a/apps/server/src/mcp/McpHttpServer.test.ts +++ b/apps/server/src/mcp/McpHttpServer.test.ts @@ -420,15 +420,13 @@ it.effect.each(["limit", "runLimit", "maxCharsPerItem"])( }, ); -it("routes same-child follow-ups to t3_thread_send and independent reviews to delegate_task", () => { +it("keeps cross-tool routing out of the delegate_task description", () => { const description = OrchestratorToolkit.tools.delegate_task.description ?? ""; - expect(description).toContain( - "For independent T3 delegated review rounds, call delegate_task again", - ); - expect(description).toContain( - "For a follow-up that needs the same child's conversation, use t3_thread_send on its childThreadId.", - ); - expect(description).not.toContain("not the target for starting another"); + expect(description).toContain("Start one child agent with only `task`"); + // Review rounds, follow-ups, and polling are routed in the system text. + expect(description).not.toContain("t3_thread_send"); + expect(description).not.toContain("Do not poll"); + expect(description).not.toContain("Prefer native"); }); it.effect( @@ -447,7 +445,8 @@ it.effect( const linkTool = server.tools.find(({ tool }) => tool.name === "link_pull_request"); expect(linkTool?.tool.annotations?.idempotentHint).toBe(true); expect(linkTool?.tool.annotations?.openWorldHint).toBe(false); - expect(linkTool?.tool.description).toContain("Register every pull request you open"); + expect(linkTool?.tool.description).toContain("Link a PR to this thread"); + expect(linkTool?.tool.description).toContain("settles the thread on merge"); const denied = yield* server .callTool({ name: "list_thread_pull_requests", arguments: {} }) diff --git a/apps/server/src/mcp/toolkits/attachment/tools.ts b/apps/server/src/mcp/toolkits/attachment/tools.ts index 62c44c9363abe..567de103e95f3 100644 --- a/apps/server/src/mcp/toolkits/attachment/tools.ts +++ b/apps/server/src/mcp/toolkits/attachment/tools.ts @@ -33,21 +33,20 @@ const shared = { const AttachmentUploadTool = Tool.make("t3_attachment_prepare_upload", { ...shared, description: - "Create the app's signed upload URL. POST the exact bytes to relativeUrl on this MCP server's HTTP origin, then pass the attachment metadata and returned ID to t3_thread_send_attachments. Upload is separate from sending; provider attachment support is decided by its adapter.", + "Get an upload URL. POST the file's exact bytes to `relativeUrl` on this MCP server's HTTP origin, then send via `t3_thread_send_attachments` with the returned `attachmentId`.", parameters: Schema.Struct({ upload: AttachmentCreateUploadUrlInput }), success: AttachmentCreateUploadUrlResult, }).annotate(Tool.Destructive, true); const AttachmentDiscardTool = Tool.make("t3_attachment_discard", { ...shared, - description: - "Discard a pending upload. Already-delivered thread attachments are never deleted by this operation.", + description: "Discard a pending upload (not a sent attachment).", parameters: AttachmentDeleteInput, success: Schema.Struct({}), }).annotate(Tool.Destructive, true); const AttachmentSendTool = Tool.make("t3_thread_send_attachments", { ...shared, description: - "Send uploaded attachments to this thread or any other thread in this environment. Each call is a new message, without a retry key. Acceptance does not mean the provider can consume the attachment or has finished the turn. The target cannot have broader permission modes than the caller; failures retain claimed files when dispatch outcome is uncertain.", + "Send uploaded attachments as a new message to a thread. No retry key. Target modes cannot exceed yours. Success does not mean the provider read them or finished.", parameters: Schema.Struct({ threadId: Schema.optional(ThreadId), message: Schema.optional(Schema.String.check(Schema.isMaxLength(120000))), diff --git a/apps/server/src/mcp/toolkits/device/tools.ts b/apps/server/src/mcp/toolkits/device/tools.ts index 36a91b844ade5..f02f39b32462a 100644 --- a/apps/server/src/mcp/toolkits/device/tools.ts +++ b/apps/server/src/mcp/toolkits/device/tools.ts @@ -29,8 +29,9 @@ const DeviceToolFailure = Schema.Union([DeviceToolError, OrchestratorMcpFailure] const DeviceListTool = Tool.make("device_list", { description: - "List iOS Simulators and Android Emulators on this environment's device hosts, which platforms each host can run, and which devices are already open in this thread's Device panel. Call this before device_open when you do not know a device id.", - // Empty Struct emits object|array, which can make providers reject every server tool. + "List iOS Simulators and Android Emulators here, what each host can run, and what is open in this thread's Device panel. Call before `device_open` when you do not know a device id.", + // An empty struct serializes as `anyOf [object, array]`, which some + // providers reject and then drop every tool on the server with it. parameters: Schema.Struct({ hostId: Schema.optional( Schema.String.annotate({ description: "Limit to one device host. Defaults to all hosts." }), @@ -48,7 +49,7 @@ const DeviceListTool = Tool.make("device_list", { const DeviceOpenTool = Tool.make("device_open", { description: - "Open a simulator or emulator for this thread: boots it if needed, starts its live stream, and shows it in the user's Device panel so they can watch. Returns the agent-device CLI invocation pinned to the device; drive the device with that CLI afterwards.", + "Open a simulator or emulator for this thread. Boots it if needed and shows it in the user's Device panel. Returns the `agent-device` CLI call pinned to that device. Drive the device with that CLI.", parameters: DeviceToolOpenInput, success: DeviceToolOpenResult, failure: DeviceToolFailure, @@ -69,7 +70,7 @@ const DeviceOpenTool = Tool.make("device_open", { export const DeviceScreenshotTool = Tool.make("device_screenshot", { description: - "Capture the current screen of an open device as a PNG image. Use it to see what the user sees; for taps and text use the agent-device CLI.", + "Get a PNG of an open device's screen. For taps and text, use the `agent-device` CLI.", parameters: DeviceToolTargetInput, success: DeviceToolScreenshotResult, failure: DeviceToolFailure, @@ -83,11 +84,9 @@ export const DeviceScreenshotTool = Tool.make("device_screenshot", { const DeviceCloseTool = Tool.make("device_close", { description: - "Remove a device from this thread's Device panel. Pass shutdown=true to also power the simulator or emulator off.", + "Remove a device from this thread's Device panel. `shutdown=true` also turns it off.", parameters: DeviceToolCloseInput, - success: Schema.Record(Schema.String, Schema.Never).annotate({ - description: "The device was closed.", - }), + success: Schema.Record(Schema.String, Schema.Never), failure: DeviceToolFailure, dependencies, }) diff --git a/apps/server/src/mcp/toolkits/environment/tools.ts b/apps/server/src/mcp/toolkits/environment/tools.ts index e441174900ce1..d684ff02e3402 100644 --- a/apps/server/src/mcp/toolkits/environment/tools.ts +++ b/apps/server/src/mcp/toolkits/environment/tools.ts @@ -40,7 +40,7 @@ const shared = { const EnvironmentReadTool = Tool.make("t3_environment_read", { ...shared, description: - "Read this server's identity and selected environment preferences. Provider/model availability is exposed by orchestrator_capabilities. Writing instructions are limited to 4,000 characters.", + "Read this server's identity and preferences. Providers and models: `orchestrator_capabilities`.", success: Schema.Struct({ environmentId: ExecutionEnvironmentDescriptor.fields.environmentId, label: Schema.String, @@ -54,7 +54,7 @@ const EnvironmentReadTool = Tool.make("t3_environment_read", { const EnvironmentPreferencesTool = Tool.make("t3_environment_preferences_update", { ...shared, description: - "Update selected environment-wide preferences through normal settings persistence and notifications. Requires a live full-access/default calling thread. Omitted fields are preserved; empty customInstructions clears them.", + "Update environment-wide preferences. Live full-access or default caller thread only. Omitted fields are unchanged; `customInstructions: ''` clears it.", parameters: Schema.Struct({ defaultThreadEnvMode: ServerSettingsPatch.fields.defaultThreadEnvMode, newWorktreesStartFromOrigin: ServerSettingsPatch.fields.newWorktreesStartFromOrigin, diff --git a/apps/server/src/mcp/toolkits/html/tools.ts b/apps/server/src/mcp/toolkits/html/tools.ts index f061923993b84..6f67d58a8a38c 100644 --- a/apps/server/src/mcp/toolkits/html/tools.ts +++ b/apps/server/src/mcp/toolkits/html/tools.ts @@ -21,20 +21,20 @@ const Html = Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(512_0 }); const PAGE_RULES = - 'Write one self-contained document with inline +

Mobile E2E ยท ${failures === 0 ? "all devices passed" : `${failures} device(s) failed`}

+${rows}
`; +} + +async function main() { + const args = NodeProcess.argv.slice(2); + if (args.includes("--list")) { + for (const device of DEVICES) NodeProcess.stdout.write(`${device.id}\n`); + return; + } + const selected = args.flatMap((arg, index) => (args[index - 1] === "--device" ? [arg] : [])); + const devices = selected.length + ? DEVICES.filter((device) => selected.includes(device.id)) + : DEVICES; + const unknown = selected.filter((id) => !DEVICES.some((device) => device.id === id)); + if (unknown.length) throw new Error(`Unknown device ${unknown.join(", ")}. Run with --list.`); + const concurrencyArg = args[args.indexOf("--concurrency") + 1]; + const concurrency = args.includes("--concurrency") ? Number(concurrencyArg) : 3; + + const stamp = new Date().toISOString().replaceAll(":", "-"); + const outputDirectory = NodePath.join(REPO_ROOT, "artifacts/mobile-e2e", stamp); + await NodeFSP.mkdir(outputDirectory, { recursive: true }); + const rootDir = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-mobile-e2e-")); + const servers: NodeChildProcess.ChildProcess[] = []; + let metro: NodeChildProcess.ChildProcess | null = null; + try { + log("e2e", "starting the fixture server"); + const [environment] = await startShowcaseEnvironments( + rootDir, + (server) => servers.push(server), + SHOWCASE_ENVIRONMENTS.slice(0, 1), + ); + if (!environment) throw new Error("The fixture server did not start."); + const metroHost = lanAddress(); + metro = NodeChildProcess.spawn( + "vp", + [ + "exec", + "expo", + "start", + "--dev-client", + "--scheme", + "t3code-dev", + "--lan", + "--port", + String(METRO_PORT), + ], + { + cwd: NodePath.join(REPO_ROOT, "apps/mobile"), + env: { ...NodeProcess.env, APP_VARIANT: "development", CI: "1" }, + stdio: ["ignore", "ignore", "inherit"], + // Its own group, so stopping it also stops the expo child that owns the port. + detached: true, + }, + ); + await waitForPort(METRO_PORT, "Metro", 120_000); + await run("curl", [ + "-sf", + "-o", + "/dev/null", + `http://127.0.0.1:${METRO_PORT}/apps/mobile/index.bundle?platform=ios&dev=true&minify=false`, + ]); + + // The first install may build; later ones reuse its artifact. + const results: DeviceResult[] = []; + const [first, ...rest] = devices; + if (first) results.push(await runDevice(first, outputDirectory, environment, metroHost)); + for (let index = 0; index < rest.length; index += concurrency) { + results.push( + ...(await Promise.all( + rest + .slice(index, index + concurrency) + .map((device) => runDevice(device, outputDirectory, environment, metroHost)), + )), + ); + } + const report = NodePath.join(outputDirectory, "index.html"); + await NodeFSP.writeFile(report, renderReport(results, outputDirectory)); + await NodeFSP.writeFile( + NodePath.join(outputDirectory, "report.json"), + JSON.stringify(results, null, 2), + ); + const failed = results.filter( + (result) => + result.error || result.steps.some((step) => step.checks.some((item) => !item.pass)), + ); + for (const result of results) { + const failures = result.steps.flatMap((step) => + step.checks + .filter((item) => !item.pass) + .map((item) => `${step.name}: ${item.name} (${item.detail})`), + ); + log(result.label, result.error ?? (failures.length ? failures.join("; ") : "passed")); + } + NodeProcess.stdout.write( + `${JSON.stringify({ report, devices: results.length, failed: failed.length })}\n`, + ); + process.exitCode = failed.length ? 1 : 0; + } finally { + if (metro?.pid) { + try { + process.kill(-metro.pid, "SIGTERM"); + } catch {} + await stopProcess(metro); + } + await Promise.all(servers.map((server) => stopProcess(server))); + await NodeFSP.rm(rootDir, { recursive: true, force: true }); + } +} + +if (import.meta.main) { + await main(); +} diff --git a/scripts/mobile-showcase.ts b/scripts/mobile-showcase.ts index 9087ebc6ffce0..db90eec52c32a 100644 --- a/scripts/mobile-showcase.ts +++ b/scripts/mobile-showcase.ts @@ -497,7 +497,7 @@ function delay(milliseconds: number): Promise { return new Promise((resolve) => setTimeout(resolve, milliseconds)); } -async function stopProcess(child: NodeChildProcess.ChildProcess): Promise { +export async function stopProcess(child: NodeChildProcess.ChildProcess): Promise { if (child.exitCode !== null || child.signalCode !== null) return; const exited = new Promise((resolve) => { @@ -511,7 +511,11 @@ async function stopProcess(child: NodeChildProcess.ChildProcess): Promise await Promise.race([exited, delay(1_000)]); } -async function waitForPort(port: number, label = "Process", timeoutMs = 60_000): Promise { +export async function waitForPort( + port: number, + label = "Process", + timeoutMs = 60_000, +): Promise { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { const open = await new Promise((resolve) => { @@ -635,6 +639,63 @@ function startShowcaseServer( ); } +export interface ShowcaseEnvironment { + readonly baseDir: string; + readonly environmentId: string; + readonly label: string; + readonly port: number; +} + +/** Boot one seeded fixture server per showcase environment; `track` receives every spawned server. */ +export async function startShowcaseEnvironments( + rootDir: string, + track: (server: NodeChildProcess.ChildProcess) => void, + environments: ReadonlyArray<(typeof SHOWCASE_ENVIRONMENTS)[number]> = SHOWCASE_ENVIRONMENTS, +): Promise { + const started: ShowcaseEnvironment[] = []; + for (const environment of environments) { + const projectId = environment.projectIds[0]; + const project = SHOWCASE_PROJECTS.find((candidate) => candidate.id === projectId); + if (!project) throw new Error(`Showcase environment '${environment.id}' has no project.`); + + const baseDir = NodePath.join(rootDir, "environments", environment.id); + const workspaceRoot = NodePath.join(baseDir, "workspace", project.directory); + const port = await reserveAvailablePort(); + await NodeFSP.mkdir(workspaceRoot, { recursive: true }); + const shellPath = await createShowcaseShell(baseDir); + const labelProbeDirectory = await createShowcaseLabelProbe(baseDir, environment.label); + const server = startShowcaseServer( + baseDir, + workspaceRoot, + port, + shellPath, + labelProbeDirectory, + ); + track(server); + await waitForPort(port, `${environment.label} server`); + await seedShowcaseEnvironment({ baseDir, projectIds: environment.projectIds }); + // Legacy fixture threads are imported into V2 during server startup. + await stopProcess(server); + const seededServer = startShowcaseServer( + baseDir, + workspaceRoot, + port, + shellPath, + labelProbeDirectory, + ); + track(seededServer); + await waitForPort(port, `${environment.label} seeded server`); + // The server begins listening before the ServerEnvironment layer + // persists the environment id, so poll rather than read once. + const environmentId = await waitForFileContent( + NodePath.join(baseDir, "userdata", "environment-id"), + `${environment.label} environment id`, + ); + started.push({ baseDir, environmentId, label: environment.label, port }); + } + return started; +} + export function parsePairingCredentialOutput(output: string): string { const jsonStart = output.indexOf("{"); const jsonEnd = output.lastIndexOf("}"); @@ -650,7 +711,7 @@ export function parsePairingCredentialOutput(output: string): string { return parsed.credential; } -async function issuePairingCredential(baseDir: string): Promise { +export async function issuePairingCredential(baseDir: string): Promise { const output = await commandOutput( "node", ["apps/server/src/bin.ts", "auth", "pairing", "create", "--base-dir", baseDir, "--json"], @@ -659,7 +720,7 @@ async function issuePairingCredential(baseDir: string): Promise { return parsePairingCredentialOutput(output); } -function buildShowcasePairingUrl(host: string, port: number, credential: string): string { +export function buildShowcasePairingUrl(host: string, port: number, credential: string): string { const url = new URL(`http://${host}:${port}/`); url.hash = new URLSearchParams([["token", credential]]).toString(); return url.toString(); @@ -1493,57 +1554,17 @@ async function main(): Promise { NodePath.join(NodeOS.tmpdir(), "t3-mobile-showcase-"), ); const showcaseServers: NodeChildProcess.ChildProcess[] = []; - const showcaseEnvironments: Array<{ - readonly baseDir: string; - readonly environmentId: string; - readonly label: string; - readonly port: number; - }> = []; + const showcaseEnvironments: ShowcaseEnvironment[] = []; let metro: NodeChildProcess.ChildProcess | null = null; const iosCleanups: IosCaptureCleanup[] = []; const androidCleanups: AndroidCaptureCleanup[] = []; try { - for (const environment of SHOWCASE_ENVIRONMENTS) { - const projectId = environment.projectIds[0]; - const project = SHOWCASE_PROJECTS.find((candidate) => candidate.id === projectId); - if (!project) throw new Error(`Showcase environment '${environment.id}' has no project.`); - - const baseDir = NodePath.join(showcaseRootDir, "environments", environment.id); - const workspaceRoot = NodePath.join(baseDir, "workspace", project.directory); - const port = await reserveAvailablePort(); - await NodeFSP.mkdir(workspaceRoot, { recursive: true }); - const shellPath = await createShowcaseShell(baseDir); - const labelProbeDirectory = await createShowcaseLabelProbe(baseDir, environment.label); - const server = startShowcaseServer( - baseDir, - workspaceRoot, - port, - shellPath, - labelProbeDirectory, - ); - showcaseServers.push(server); - await waitForPort(port, `${environment.label} server`); - await seedShowcaseEnvironment({ baseDir, projectIds: environment.projectIds }); - // Legacy fixture threads are imported into V2 during server startup. - await stopProcess(server); - const seededServer = startShowcaseServer( - baseDir, - workspaceRoot, - port, - shellPath, - labelProbeDirectory, - ); - showcaseServers.push(seededServer); - await waitForPort(port, `${environment.label} seeded server`); - // The server begins listening before the ServerEnvironment layer - // persists the environment id, so poll rather than read once. - const environmentId = await waitForFileContent( - NodePath.join(baseDir, "userdata", "environment-id"), - `${environment.label} environment id`, - ); - showcaseEnvironments.push({ baseDir, environmentId, label: environment.label, port }); - } + showcaseEnvironments.push( + ...(await startShowcaseEnvironments(showcaseRootDir, (server) => + showcaseServers.push(server), + )), + ); if (!options.skipMetro) { metro = startMetro(showcaseConfig); diff --git a/scripts/package.json b/scripts/package.json index a4aa8c1496267..5d1ab111b584b 100644 --- a/scripts/package.json +++ b/scripts/package.json @@ -12,6 +12,7 @@ "@electron/osx-sign": "2.7.0", "@t3tools/shared": "workspace:*", "@t3tools/tailscale": "workspace:*", + "agent-device": "0.21.25", "effect": "catalog:", "plist": "catalog:", "pngjs": "7.0.0", diff --git a/vite.config.ts b/vite.config.ts index d17d7ad57f1e1..875cb2cbe3940 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -585,10 +585,6 @@ export default defineConfig({ files: ["apps/mobile/src/**/*.{ts,tsx}"], rules: { "t3code/no-manual-memo": ["error", { allowMemoHooks: true }] }, }, - { - files: ["apps/web/src/**", "apps/mobile/src/**", "packages/*/src/**"], - rules: { "t3code/react-effect-budget": "error" }, - }, { // React Compiler caches use* functions only if they call a hook. files: ["apps/web/src/**/*.{ts,tsx}", "apps/mobile/src/**/*.{ts,tsx}"], From 2ed92618e60f5ee6ecc694eb834c28105a502d09 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 25 Oct 2026 15:01:02 -0700 Subject: [PATCH 047/219] fix: restore files reverted by 5f1f604 5f1f604 was committed from a tree based on an older main, so it reverted upstream changes outside the e2e runner (including #23973, #22907, #22849). This restores every file to e3592ef and keeps only the runner's own changes (scripts/mobile-e2e.ts, scripts/mobile-showcase.ts, package and lockfile entries, .gitignore). Co-Authored-By: Claude Opus 5.5 --- .github/workflows/web-preview.yml | 3 +- AGENTS.md | 1 + apps/desktop/src/app/DesktopObservability.ts | 14 +- .../DesktopBackendConfiguration.test.ts | 7 +- .../backend/DesktopBackendConfiguration.ts | 1 + .../features/observability/tracing.test.ts | 1 + .../src/features/observability/tracing.ts | 15 + .../threads/use-composer-command-menu.ts | 9 + apps/server/scripts/acp-mock-agent.ts | 3 +- apps/server/src/cloud/pinnedRuntime.test.ts | 5 +- apps/server/src/cloud/relayTracing.ts | 18 +- apps/server/src/config.ts | 15 +- .../src/environment/ServerEnvironment.ts | 1 + apps/server/src/observability/Metrics.ts | 19 + .../Orchestrator.control-reads.test.ts | 40 ++ .../src/orchestration-v2/Orchestrator.ts | 3 +- .../server/src/project/ProjectService.test.ts | 32 ++ apps/server/src/project/ProjectService.ts | 3 +- .../provider/ClaudeCapabilitiesProbe.test.ts | 21 +- apps/server/src/provider/ClaudeProvider.ts | 14 +- apps/server/src/provider/GrokProvider.test.ts | 21 + .../src/provider/ProviderRegistry.test.ts | 130 ++++++ apps/server/src/provider/ProviderRegistry.ts | 47 +- .../acp/AcpSessionRuntime.processTree.test.ts | 2 +- apps/server/src/serverLogger.test.ts | 15 + apps/server/src/telemetry/AnalyticsService.ts | 8 +- .../src/testUtils/managedServiceEnv.setup.ts | 6 + apps/server/src/ws.test.ts | 161 +++++++ apps/server/src/ws.ts | 55 ++- apps/server/vite.config.ts | 5 +- .../src/components/chat/ChatMarkdownMedia.tsx | 2 + .../chat/TimelineUserMessageRow.tsx | 1 + .../useProviderWorkspaceSnapshotRefresh.ts | 9 + apps/web/src/lib/runtime.ts | 1 + apps/web/src/observability/clientTracer.ts | 5 + apps/web/src/observability/clientTracing.ts | 11 +- apps/web/src/vite-env.d.ts | 1 + apps/web/vite.config.ts | 3 + docs/operations/observability.md | 20 +- oxlint-plugin-t3code/index.ts | 2 + .../rules/react-effect-budget.test.ts | 50 ++ .../rules/react-effect-budget.ts | 58 +++ packages/client-runtime/src/rpc/session.ts | 9 +- .../client-runtime/src/state/server.test.ts | 84 ++++ packages/client-runtime/src/state/server.ts | 130 +++++- packages/contracts/src/environment.ts | 2 + packages/contracts/src/rpc.ts | 12 +- packages/provider-grok/src/server/status.ts | 15 +- packages/shared/package.json | 4 + packages/shared/src/observability.test.ts | 8 + packages/shared/src/observability.ts | 15 + packages/shared/src/relayTracing.ts | 4 + packages/shared/src/releaseResource.test.ts | 35 ++ packages/shared/src/releaseResource.ts | 24 + scripts/effect-budget.json | 433 ++++++++++++++++++ scripts/install.ps1 | 15 +- scripts/install.sh | 20 +- scripts/install.test.ts | 80 ++++ vite.config.ts | 4 + 59 files changed, 1666 insertions(+), 66 deletions(-) create mode 100644 apps/server/src/testUtils/managedServiceEnv.setup.ts create mode 100644 oxlint-plugin-t3code/rules/react-effect-budget.test.ts create mode 100644 oxlint-plugin-t3code/rules/react-effect-budget.ts create mode 100644 packages/shared/src/releaseResource.test.ts create mode 100644 packages/shared/src/releaseResource.ts create mode 100644 scripts/effect-budget.json create mode 100644 scripts/install.test.ts diff --git a/.github/workflows/web-preview.yml b/.github/workflows/web-preview.yml index 3def8478bbdc2..4b3f8952d503d 100644 --- a/.github/workflows/web-preview.yml +++ b/.github/workflows/web-preview.yml @@ -82,7 +82,8 @@ jobs: --archive=tgz \ --yes \ --token "$VERCEL_TOKEN" \ - --scope "$vercel_scope" + --scope "$vercel_scope" \ + --build-env "VITE_RELEASE_CHANNEL=preview" )" printf '%s\n' "$deployment_output" # Vercel can print lockfile warnings to stdout before the deployment URL. diff --git a/AGENTS.md b/AGENTS.md index 3a6e2a957d7d2..a0a1f949faa51 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -194,6 +194,7 @@ Architecture and its constraints: `docs/internals/overview.md`. Glossary: `docs/ options, and pass combinators as additional function-builder arguments. - Inferred types over annotations. `any` is the enemy. - Default to no comment. Write one only for a constraint, an external quirk, or an invariant the code can't show, in one line where you can and four at most. Don't restate names or signatures, narrate steps, record history, or explain the fix you just made; the PR carries that. `t3code/no-low-value-comments` enforces the length cap and the mechanical cases. +- React effects are a last resort: derive in render, act in handlers, reset with `key`, or use a ref callback or store. The `t3code/react-effect-budget` lint rule holds each file to its entry in `scripts/effect-budget.json`; raise an entry only in the PR that needs the effect, and lower it when you remove one. - Our users drive agents all day and notice a dropped frame, a lying spinner, and a stale label. No continuously repainting animations; they peg the GPU on high-refresh displays. - If a rule here fights the task in front of you, say so loudly and get a human sign-off before breaking it. diff --git a/apps/desktop/src/app/DesktopObservability.ts b/apps/desktop/src/app/DesktopObservability.ts index 6baf2c4117f9d..1d1e79a4fe9b0 100644 --- a/apps/desktop/src/app/DesktopObservability.ts +++ b/apps/desktop/src/app/DesktopObservability.ts @@ -2,11 +2,13 @@ import { PRIMARY_LOCAL_ENVIRONMENT_ID } from "@t3tools/contracts"; import { makeLocalFileTracer, makeTraceSink, + processServiceInstanceId, type SignalExport, } from "@t3tools/shared/observability"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as SharedObservability from "@t3tools/shared/observability"; import * as OtelEnvironment from "@t3tools/shared/otelEnvironment"; +import { releaseChannelOf, releaseResourceAttributes } from "@t3tools/shared/releaseResource"; import { parsePersistedServerObservabilitySettings, type PersistedServerObservabilitySettings, @@ -595,14 +597,22 @@ const layerTelemetry = Layer.unwrap( Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; const endpoints = yield* resolveOtlpEndpoints; + // Unpackaged runs launch a script, so Electron reports its own version. + const serviceVersion = environment.isPackaged + ? environment.appVersion + : desktopPackageJson.version; const resource = { serviceName: "t3code-desktop", - // Unpackaged runs launch a script, so Electron reports its own version. - serviceVersion: environment.isPackaged ? environment.appVersion : desktopPackageJson.version, + serviceVersion, attributes: { "service.namespace": "t3code", "service.runtime": "desktop", "service.mode": environment.isDevelopment ? "development" : "packaged", + "service.instance.id": processServiceInstanceId(endpoints.resourceAttributes), + ...releaseResourceAttributes( + environment.isDevelopment ? "dev" : releaseChannelOf(serviceVersion), + endpoints.resourceAttributes, + ), "host.name": endpoints.resourceAttributes["host.name"] || (yield* HostProcess.Hostname), }, }; diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts index 8d70ab4c4791f..7464d8fbe9e2f 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts @@ -678,7 +678,7 @@ describe("DesktopBackendConfiguration", () => { ), ); - it.effect("resolveWsl carries the kill switch into the distro", () => + it.effect("resolveWsl carries the kill switch and resource attributes into the distro", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const baseDir = yield* fileSystem.makeTempDirectoryScoped({ @@ -688,10 +688,12 @@ describe("DesktopBackendConfiguration", () => { const previousWslEnv = process.env.WSLENV; const previousDisabled = process.env.OTEL_SDK_DISABLED; const previousTelemetry = process.env.T3CODE_TELEMETRY_ENABLED; + const previousResourceAttributes = process.env.OTEL_RESOURCE_ATTRIBUTES; try { delete process.env.WSLENV; process.env.OTEL_SDK_DISABLED = "true"; process.env.T3CODE_TELEMETRY_ENABLED = "false"; + process.env.OTEL_RESOURCE_ATTRIBUTES = "deployment.environment.name=staging"; yield* Effect.gen(function* () { const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; @@ -701,6 +703,8 @@ describe("DesktopBackendConfiguration", () => { assert.include((config.env.WSLENV ?? "").split(":"), "OTEL_SDK_DISABLED"); assert.equal(config.env.T3CODE_TELEMETRY_ENABLED, "false"); assert.include((config.env.WSLENV ?? "").split(":"), "T3CODE_TELEMETRY_ENABLED"); + assert.equal(config.env.OTEL_RESOURCE_ATTRIBUTES, "deployment.environment.name=staging"); + assert.include((config.env.WSLENV ?? "").split(":"), "OTEL_RESOURCE_ATTRIBUTES"); }).pipe( Effect.provide( layerBackendConfiguration().pipe( @@ -722,6 +726,7 @@ describe("DesktopBackendConfiguration", () => { restoreEnv("WSLENV", previousWslEnv); restoreEnv("OTEL_SDK_DISABLED", previousDisabled); restoreEnv("T3CODE_TELEMETRY_ENABLED", previousTelemetry); + restoreEnv("OTEL_RESOURCE_ATTRIBUTES", previousResourceAttributes); } }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.ts index 06b4c6b59816d..ca69f5d025899 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.ts @@ -110,6 +110,7 @@ const WSL_FORWARDED_ENV_NAMES = [ "OTEL_TRACES_EXPORTER", "OTEL_METRICS_EXPORTER", "OTEL_LOGS_EXPORTER", + "OTEL_RESOURCE_ATTRIBUTES", ] as const; const WSL_SERVER_SYSTEM_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"; diff --git a/apps/mobile/src/features/observability/tracing.test.ts b/apps/mobile/src/features/observability/tracing.test.ts index 3ca4bf9a3d61c..a0fa9e85f6de9 100644 --- a/apps/mobile/src/features/observability/tracing.test.ts +++ b/apps/mobile/src/features/observability/tracing.test.ts @@ -27,6 +27,7 @@ it.effect("does not let OTLP serialization failures alter application effects", }, { appVariant: "test", + serviceInstanceId: "test-instance", serviceVersion: "1.2.3", }, ).pipe(Layer.provide(layerRemoteHttpClient(fetchFn))); diff --git a/apps/mobile/src/features/observability/tracing.ts b/apps/mobile/src/features/observability/tracing.ts index e2b8ee0f16883..fbdb14fc78509 100644 --- a/apps/mobile/src/features/observability/tracing.ts +++ b/apps/mobile/src/features/observability/tracing.ts @@ -1,4 +1,5 @@ import Constants from "expo-constants"; +import { type ReleaseChannel, releaseResourceAttributes } from "@t3tools/shared/releaseResource"; import * as RelayTracing from "@t3tools/shared/relayTracing"; import { hasTracingPublicConfig, resolveCloudPublicConfig } from "../cloud/publicConfig"; @@ -11,9 +12,16 @@ export interface TracingConfig { export interface TracingResource { readonly serviceVersion?: string; + readonly serviceInstanceId: string; readonly appVariant: string; } +const RELEASE_CHANNEL_BY_VARIANT: Readonly> = { + development: "dev", + preview: "preview", + production: "stable", +}; + export function resolveTracingConfig(): TracingConfig | null { const config = resolveCloudPublicConfig(); if (!hasTracingPublicConfig(config)) { @@ -24,16 +32,23 @@ export function resolveTracingConfig(): TracingConfig | null { } export function layerFromConfig(config: TracingConfig | null, resource: TracingResource) { + const channel = + typeof __DEV__ !== "undefined" && __DEV__ + ? "dev" + : RELEASE_CHANNEL_BY_VARIANT[resource.appVariant]; return RelayTracing.layer(config, { serviceName: "t3code-mobile", serviceVersion: resource.serviceVersion, runtime: "react-native", + serviceInstanceId: resource.serviceInstanceId, client: `mobile-${resource.appVariant}`, + attributes: channel && releaseResourceAttributes(channel), }); } export const layer = layerFromConfig(resolveTracingConfig(), { serviceVersion: Constants.expoConfig?.version, + serviceInstanceId: Constants.sessionId, appVariant: typeof Constants.expoConfig?.extra?.appVariant === "string" ? Constants.expoConfig.extra.appVariant diff --git a/apps/mobile/src/features/threads/use-composer-command-menu.ts b/apps/mobile/src/features/threads/use-composer-command-menu.ts index 8498d907f6901..992a5838a66ef 100644 --- a/apps/mobile/src/features/threads/use-composer-command-menu.ts +++ b/apps/mobile/src/features/threads/use-composer-command-menu.ts @@ -59,6 +59,8 @@ import type { ComposerCommandItem } from "./ComposerCommandPopover"; import { matchesSlashSkillQuery } from "./composerSlashSkillSearch"; const WORKSPACE_SNAPSHOT_RETRY_COOLDOWN_MS = 10_000; +// A cwd that never scans, such as a deleted worktree, would otherwise retry forever. +const WORKSPACE_SNAPSHOT_MAX_ATTEMPTS = 3; function composerSelectionAtEnd(draftMessage: string): ComposerEditorSelection { return { start: draftMessage.length, end: draftMessage.length }; @@ -252,6 +254,7 @@ export function useComposerCommandMenu({ ); // Record client request time to enforce TTL even when the client clock runs ahead of the server. const workspaceRefreshKeyRef = useRef<{ key: string; requestedAt: number } | null>(null); + const failedRefreshesRef = useRef<{ key: string; count: number } | null>(null); const [workspaceRefreshRetry, setWorkspaceRefreshRetry] = useState<{ key: string; notBefore: number; @@ -307,6 +310,11 @@ export function useComposerCommandMenu({ workspaceRefreshKeyRef.current = request; const retryLater = () => { if (workspaceRefreshKeyRef.current !== request) return; + const failed = failedRefreshesRef.current; + const count = failed?.key === key ? failed.count + 1 : 1; + failedRefreshesRef.current = { key, count }; + // Keeping the request marker lets the TTL hold off the next scan. + if (count >= WORKSPACE_SNAPSHOT_MAX_ATTEMPTS) return; workspaceRefreshKeyRef.current = null; setWorkspaceRefreshRetry({ key, @@ -326,6 +334,7 @@ export function useComposerCommandMenu({ projectCwd, ); if (!refreshed) retryLater(); + else failedRefreshesRef.current = null; }, retryLater); }, [ draftMessage, diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index 51e89523d031e..63b2b644b77d4 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -52,6 +52,7 @@ const emitGrokMonitorPostTurnPoll = process.env.T3_ACP_EMIT_GROK_MONITOR_POST_TU const emitGrokBackgroundTaskStarted = process.env.T3_ACP_EMIT_GROK_BACKGROUND_TASK_STARTED === "1"; const emitGrokBackgroundTaskPoll = process.env.T3_ACP_GROK_BACKGROUND_TASK_COMPLETION === "poll"; const emitForeignSessionUpdates = process.env.T3_ACP_EMIT_FOREIGN_SESSION_UPDATES === "1"; +const omitInitializeModelState = process.env.T3_ACP_OMIT_INITIALIZE_MODEL_STATE === "1"; const waitForResumeRelease = process.env.T3_ACP_WAIT_FOR_RESUME_RELEASE === "1"; const completeFirstPromptOnCancel = process.env.T3_ACP_COMPLETE_FIRST_PROMPT_ON_CANCEL === "1"; const floodStderr = process.env.T3_ACP_FLOOD_STDERR === "1"; @@ -575,7 +576,7 @@ const program = Effect.gen(function* () { return { protocolVersion: 2, info: { name: "t3-acp-mock-agent", version: "0.0.0" }, - _meta: { modelState: modelState() }, + ...(omitInitializeModelState ? {} : { _meta: { modelState: modelState() } }), capabilities: { session: { ...(supportsSessionLifecycle ? { fork: {}, additionalDirectories: {} } : {}), diff --git a/apps/server/src/cloud/pinnedRuntime.test.ts b/apps/server/src/cloud/pinnedRuntime.test.ts index 9d536f471e763..82230858891c4 100644 --- a/apps/server/src/cloud/pinnedRuntime.test.ts +++ b/apps/server/src/cloud/pinnedRuntime.test.ts @@ -487,14 +487,15 @@ it.layer(NodeServices.layer)("prunePinnedRuntimes", (it) => { const path = yield* Path.Path; const { baseDir, versionsDir } = yield* setup(["1.0.0", "1.1.0"]); const entry = path.join(versionsDir, "1.0.0", "t3"); - yield* fs.copyFile("/bin/sleep", entry); + // Multicall coreutils (Ubuntu's uutils) refuse to run renamed, so copy Node instead. + yield* fs.copyFile(process.execPath, entry); yield* fs.chmod(entry, 0o755); const link = path.join(baseDir, "t3"); yield* fs.symlink(entry, link); // Its argv names only the link, as with `t3 serve` run from PATH. Removing the link after // spawning leaves the process's executable as the only reference. const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - yield* spawner.spawn(ChildProcess.make(link, ["30"])); + yield* spawner.spawn(ChildProcess.make(link, ["-e", "setTimeout(() => {}, 30_000)"])); yield* fs.remove(link); const removed = yield* prunePinnedRuntimes({ baseDir, keep: ["2.0.0"] }).pipe( diff --git a/apps/server/src/cloud/relayTracing.ts b/apps/server/src/cloud/relayTracing.ts index 1b76f0a228f65..d5f1380f966eb 100644 --- a/apps/server/src/cloud/relayTracing.ts +++ b/apps/server/src/cloud/relayTracing.ts @@ -1,16 +1,30 @@ +import { processServiceInstanceId } from "@t3tools/shared/observability"; import * as RelayTracing from "@t3tools/shared/relayTracing"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as ServerConfig from "../config.ts"; import { resolveRelayClientTracingConfig } from "./publicConfig.ts"; const relayClientTracingConfig = resolveRelayClientTracingConfig(); -export const layerHeadlessRelayClient = RelayTracing.layer(relayClientTracingConfig, { +const relayLayer = (resource: Omit) => + Layer.unwrap( + Effect.map(ServerConfig.ServerConfig, (config) => + RelayTracing.layer(relayClientTracingConfig, { + ...resource, + serviceInstanceId: processServiceInstanceId(config.otelEnvironment.resourceAttributes), + }), + ), + ); + +export const layerHeadlessRelayClient = relayLayer({ serviceName: "t3code-server", runtime: "node", client: "headless-cli", }); -export const layerServerRelayBroker = RelayTracing.layer(relayClientTracingConfig, { +export const layerServerRelayBroker = relayLayer({ serviceName: "t3code-server", runtime: "node", client: "environment-server", diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 11628ae95c3c9..47a38b4020a1e 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -10,10 +10,21 @@ import * as Schema from "effect/Schema"; import type { OtlpEndpointSource } from "@t3tools/contracts"; import { sweepStalePendingAttachments } from "./attachmentStore.ts"; -import { DEFAULT_SIGNAL_EXPORT, type SignalExport } from "@t3tools/shared/observability"; +import { + DEFAULT_SIGNAL_EXPORT, + processServiceInstanceId, + type SignalExport, +} from "@t3tools/shared/observability"; import * as OtelEnvironment from "@t3tools/shared/otelEnvironment"; +import { type ReleaseChannel, releaseResourceAttributes } from "@t3tools/shared/releaseResource"; import packageJson from "../package.json" with { type: "json" }; +// Official release workflows set this telemetry channel; other builds report dev. +// Unlike __T3CODE_BUILD_CHANNEL__, this is not the sign-in page's latest/nightly channel. +declare const __T3CODE_TELEMETRY_CHANNEL__: ReleaseChannel | undefined; +export const telemetryChannel: ReleaseChannel = + typeof __T3CODE_TELEMETRY_CHANNEL__ === "undefined" ? "dev" : __T3CODE_TELEMETRY_CHANNEL__; + export const DEFAULT_PORT = 3773; export const RuntimeMode = Schema.Literals(["web", "desktop"]); @@ -114,6 +125,8 @@ export const otlpResource = ( "service.namespace": "t3code", "service.runtime": "t3-server", "t3code.server.managed_by": config.mode === "desktop" ? "desktop" : "standalone", + "service.instance.id": processServiceInstanceId(config.otelEnvironment.resourceAttributes), + ...releaseResourceAttributes(telemetryChannel, config.otelEnvironment.resourceAttributes), "host.name": config.otelEnvironment.resourceAttributes["host.name"] || hostName, }, }); diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 2bd56e30e9dcb..4678aff95b736 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -239,6 +239,7 @@ export const make = Effect.gen(function* () { threadSnooze: true, environmentThemes: true, usageLimitSources: true, + providerWorkspaceSnapshotsOnDemand: true, usagePriceOverrides: true, usageModelAliases: true, threadPinning: true, diff --git a/apps/server/src/observability/Metrics.ts b/apps/server/src/observability/Metrics.ts index be92b47a5bdee..a16a364cc4b70 100644 --- a/apps/server/src/observability/Metrics.ts +++ b/apps/server/src/observability/Metrics.ts @@ -15,6 +15,14 @@ export const rpcRequestDuration = Metric.timer("t3_rpc_request_duration", { description: "RPC request handling duration.", }); +export const orchestrationCommandsTotal = Metric.counter("t3_orchestration_commands_total", { + description: "Total orchestration commands dispatched, by outcome.", +}); + +export const orchestrationCommandDuration = Metric.timer("t3_orchestration_command_duration", { + description: "Orchestration command handling duration, excluding thread dispatch lock wait.", +}); + export const orchestrationEffectClaimsTotal = Metric.counter( "t3_orchestration_effect_claims_total", { @@ -186,3 +194,14 @@ export const withMetrics: { ): (effect: Effect.Effect) => Effect.Effect; (effect: Effect.Effect, options: WithMetricsOptions): Effect.Effect; } = dual(2, withMetricsImpl); + +// Shaped as an `Effect.fn` combinator: the command is the instrumented function's argument. +export const withCommandMetrics = ( + effect: Effect.Effect, + command: { readonly type: string }, +): Effect.Effect => + withMetrics(effect, { + counter: orchestrationCommandsTotal, + timer: orchestrationCommandDuration, + attributes: { commandType: command.type }, + }); diff --git a/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts b/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts index 3b1a8ec1cecaa..c36dfc365af60 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts @@ -21,10 +21,15 @@ import { import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Metric from "effect/Metric"; import * as Result from "effect/Result"; import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; import * as SqlClient from "effect/sql/SqlClient"; +import { + orchestrationCommandDuration, + orchestrationCommandsTotal, +} from "../observability/Metrics.ts"; import * as SqlitePersistence from "../persistence/Sqlite.ts"; import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; import * as DelegatedTaskQuestionReactor from "./DelegatedTaskQuestionReactor.ts"; @@ -1368,3 +1373,38 @@ it.effect("explicit settle clears snooze atomically and stays idempotent after r assert.deepInclude((yield* projections.getThreadProjection(threadId)).thread, active); }).pipe(Effect.provide(layerTest)), ); + +it.effect("dispatch records the command outcome and its duration", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const successes = Metric.value( + Metric.withAttributes(orchestrationCommandsTotal, { + commandType: "thread.create", + outcome: "success", + }), + ); + const durations = Metric.value( + Metric.withAttributes(orchestrationCommandDuration, { commandType: "thread.create" }), + ); + const successesBefore = (yield* successes).count; + const durationsBefore = (yield* durations).count; + + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make("create-dispatch-metrics"), + threadId: ThreadId.make("thread:dispatch-metrics"), + projectId: ProjectId.make("project:dispatch-metrics"), + title: "Dispatch metrics", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdBy: "user", + creationSource: "web", + }); + + assert.strictEqual((yield* successes).count - successesBefore, 1); + assert.strictEqual((yield* durations).count - durationsBefore, 1); + }).pipe(Effect.provide(layerTest)), +); diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 0670eb23e451e..aae5d1b68db83 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -118,6 +118,7 @@ import { type GuestShareCommand, } from "./GuestShareDecider.ts"; import * as EffectOutbox from "./EffectOutbox.ts"; +import { withCommandMetrics } from "../observability/Metrics.ts"; import type { OrchestrationEffectRequestV2, PendingOrchestrationEffectV2 } from "./EffectOutbox.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ThreadCommandExecutor from "./ThreadCommandExecutor.ts"; @@ -12116,7 +12117,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio storedEvents: committed.storedEvents, ...(committed.committed ? {} : { replayed: true as const }), } satisfies OrchestratorV2DispatchResult; - }); + }, withCommandMetrics); // dispatchWithReceiptEffect owns the dispatch span; this wrapper selects the thread locks. const dispatchWithReceipt = Effect.fnUntraced(function* (command: OrchestrationV2ServerCommand) { diff --git a/apps/server/src/project/ProjectService.test.ts b/apps/server/src/project/ProjectService.test.ts index 9347acef12c8f..5ea7871e94e2c 100644 --- a/apps/server/src/project/ProjectService.test.ts +++ b/apps/server/src/project/ProjectService.test.ts @@ -5,12 +5,17 @@ import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; +import * as Metric from "effect/Metric"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; import { TestClock } from "effect/testing"; import * as SqlClient from "effect/sql/SqlClient"; import * as ServerConfig from "../config.ts"; +import { + orchestrationCommandDuration, + orchestrationCommandsTotal, +} from "../observability/Metrics.ts"; import * as EventSink from "../orchestration-v2/EventSink.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as LegacyV1ThreadImporter from "../orchestration-v2/legacy/LegacyV1ThreadImporter.ts"; @@ -203,6 +208,33 @@ it.layer(layerTest)("ProjectService", (it) => { }), ); + it.effect("records the outcome and duration of a committed project command", () => + Effect.gen(function* () { + const service = yield* ProjectService.ProjectService; + const successes = Metric.value( + Metric.withAttributes(orchestrationCommandsTotal, { + commandType: "project.create", + outcome: "success", + }), + ); + const durations = Metric.value( + Metric.withAttributes(orchestrationCommandDuration, { commandType: "project.create" }), + ); + const successesBefore = (yield* successes).count; + const durationsBefore = (yield* durations).count; + + yield* service.create({ + commandId: CommandId.make("command:metrics:create"), + projectId: ProjectId.make("project:metrics"), + title: "Metrics", + workspaceRoot: "/work/metrics", + }); + + assert.strictEqual((yield* successes).count - successesBefore, 1); + assert.strictEqual((yield* durations).count - durationsBefore, 1); + }), + ); + it.effect("auto-bootstraps a workspace exactly once", () => Effect.gen(function* () { const service = yield* ProjectService.ProjectService; diff --git a/apps/server/src/project/ProjectService.ts b/apps/server/src/project/ProjectService.ts index b2c65dd17ae11..5c646e1ea7be1 100644 --- a/apps/server/src/project/ProjectService.ts +++ b/apps/server/src/project/ProjectService.ts @@ -17,6 +17,7 @@ import * as Option from "effect/Option"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; +import { withCommandMetrics } from "../observability/Metrics.ts"; import * as EventSink from "../orchestration-v2/EventSink.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as LegacyV1ThreadImporter from "../orchestration-v2/legacy/LegacyV1ThreadImporter.ts"; @@ -283,7 +284,7 @@ export const make = Effect.gen(function* () { rejection ?? receipt.error ?? "The command was previously rejected.", ); } - }); + }, withCommandMetrics); const readCommitted = Effect.fn("ProjectService.readCommitted")(function* (projectId: ProjectId) { const row = yield* readRow(projectId, { includeDeleted: true }); diff --git a/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts b/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts index 80740b0a21d49..60a4371cb133d 100644 --- a/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts +++ b/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts @@ -1,8 +1,15 @@ // @effect-diagnostics nodeBuiltinImport:off - cleanup uses Node's retrying rm, which the FileSystem service does not expose. +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { ClaudeSettings, type ServerProvider } from "@t3tools/contracts"; import { vi } from "vite-plus/test"; import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; -import { buildClaudeCapabilitiesProbeQueryOptions } from "./ClaudeProvider.ts"; +import { + buildClaudeCapabilitiesProbeQueryOptions, + probeClaudeWorkspaceSnapshot, +} from "./ClaudeProvider.ts"; vi.mock("@anthropic-ai/claude-agent-sdk", { spy: true }); @@ -20,3 +27,15 @@ it("drops strict MCP mode, and only that, next to an administrator's managed-mcp assert.deepEqual(options.mcpServers, {}); assert.equal(options.env?.ENABLE_CLAUDEAI_MCP_SERVERS, "false"); }); + +it.effect("reports a deleted worktree's scan as finished, not pending", () => + Effect.gen(function* () { + const snapshot = yield* probeClaudeWorkspaceSnapshot( + yield* Schema.decodeEffect(ClaudeSettings)({}), + { skills: [], slashCommands: [] } as unknown as ServerProvider, + "/nonexistent/t3-deleted-worktree", + { HOME: "/nonexistent/t3-home" }, + ); + assert.strictEqual(snapshot.slashCommandsPending, false); + }).pipe(Effect.provide(NodeServices.layer)), +); diff --git a/apps/server/src/provider/ClaudeProvider.ts b/apps/server/src/provider/ClaudeProvider.ts index 4c6a86dbc3815..5bc82a41d4eda 100644 --- a/apps/server/src/provider/ClaudeProvider.ts +++ b/apps/server/src/provider/ClaudeProvider.ts @@ -431,10 +431,14 @@ export const probeClaudeWorkspaceSnapshot = Effect.fn("probeClaudeWorkspaceSnaps ): Effect.fn.Return { if (!claudeSettings.enabled) return machineSnapshot; const skills = yield* discoverClaudeSkills(claudeSettings, cwd, environment); - const probeResult = yield* probeClaudeCapabilities(claudeSettings, environment, cwd, false).pipe( - Effect.result, - ); - const capabilities = Result.isSuccess(probeResult) ? probeResult.success : undefined; + const fileSystem = yield* FileSystem.FileSystem; + // A deleted worktree can never answer the probe. Pending would make clients rescan it forever. + const cwdExists = yield* fileSystem.exists(cwd).pipe(Effect.orElseSucceed(() => true)); + const probeResult = cwdExists + ? yield* probeClaudeCapabilities(claudeSettings, environment, cwd, false).pipe(Effect.result) + : undefined; + const capabilities = + probeResult && Result.isSuccess(probeResult) ? probeResult.success : undefined; return { ...machineSnapshot, skills, @@ -442,7 +446,7 @@ export const probeClaudeWorkspaceSnapshot = Effect.fn("probeClaudeWorkspaceSnaps COMPACT_SLASH_COMMAND, ...(capabilities?.slashCommands ?? []), ]), - slashCommandsPending: !capabilities, + slashCommandsPending: cwdExists && !capabilities, }; }); diff --git a/apps/server/src/provider/GrokProvider.test.ts b/apps/server/src/provider/GrokProvider.test.ts index a6945eb902a64..4ecc0ad28971b 100644 --- a/apps/server/src/provider/GrokProvider.test.ts +++ b/apps/server/src/provider/GrokProvider.test.ts @@ -352,6 +352,27 @@ it.layer(NodeServices.layer)("checkGrokProviderStatus", (it) => { }), ); + it.effect("warns instead of reporting ready when no models are discovered", () => + Effect.gen(function* () { + const snapshot = yield* Effect.scoped( + Effect.gen(function* () { + const grokPath = yield* writeFakeGrokCli({ + modelsOutput: "You are logged in with grok.com.\n", + acp: true, + }); + return yield* checkGrokProviderStatus( + decodeGrokSettings({ enabled: true, binaryPath: grokPath }), + { ...process.env, XAI_API_KEY: "", T3_ACP_OMIT_INITIALIZE_MODEL_STATE: "1" }, + ); + }), + ); + + expect(snapshot.status).toBe("warning"); + expect(snapshot.message).toContain("did not list any models"); + expect(snapshot.models.map((model) => model.slug)).toEqual(["grok-build"]); + }), + ); + it.effect("treats XAI_API_KEY as authenticated regardless of CLI login state", () => Effect.gen(function* () { const snapshot = yield* Effect.scoped( diff --git a/apps/server/src/provider/ProviderRegistry.test.ts b/apps/server/src/provider/ProviderRegistry.test.ts index f819fd64ca305..1c47a2f31c3fe 100644 --- a/apps/server/src/provider/ProviderRegistry.test.ts +++ b/apps/server/src/provider/ProviderRegistry.test.ts @@ -693,6 +693,44 @@ it.layer( ); }); + it("drops Grok models a completed probe no longer advertises", () => { + const previousProvider = { + instanceId: ProviderInstanceId.make("grok"), + driver: ProviderDriverKind.make("grok"), + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-09-30T00:00:00.000Z", + version: "1.0.44", + models: [ + { slug: "grok-4.7", name: "Grok 4.7", isCustom: false, capabilities: null }, + { slug: "codexhub-removed", name: "Removed", isCustom: false, capabilities: null }, + ], + slashCommands: [], + skills: [], + } as const satisfies ServerProvider; + const refreshedProvider = { + ...previousProvider, + checkedAt: "2026-09-30T00:01:00.000Z", + models: [previousProvider.models[0]], + } satisfies ServerProvider; + const acpFailedProvider = { + ...refreshedProvider, + status: "warning", + message: "Grok CLI is installed but ACP initialize failed.", + } satisfies ServerProvider; + + assert.deepStrictEqual( + ProviderRegistry.mergeProviderSnapshot(previousProvider, refreshedProvider).models, + [previousProvider.models[0]], + ); + assert.deepStrictEqual( + ProviderRegistry.mergeProviderSnapshot(previousProvider, acpFailedProvider).models, + [...previousProvider.models], + ); + }); + it("retains stale OpenCode models when a refresh fails", () => { const previousProvider = { instanceId: ProviderInstanceId.make("opencode"), @@ -1246,6 +1284,98 @@ it.layer( ); }); + it.effect( + "a second request for a cwd being scanned gets its result, even if the first leaves", + () => + Effect.gen(function* () { + const driver = ProviderDriverKind.make("codex"); + const instanceId = ProviderInstanceId.make("codex"); + const provider = { + instanceId, + driver, + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-06-10T00:00:00.000Z", + version: "1.0.0", + models: [], + slashCommands: [], + skills: [], + } as const satisfies ServerProvider; + const scanStarted = yield* Deferred.make(); + const releaseScan = yield* Deferred.make(); + const scans = yield* Ref.make(0); + const instance: ProviderInstance = { + instanceId, + driverKind: driver, + continuationIdentity: { driverKind: driver, continuationKey: "codex:instance:codex" }, + displayName: undefined, + enabled: true, + snapshot: { + resolveMaintenance: () => + Effect.succeed( + makeManualOnlyProviderMaintenanceCapabilities({ + provider: driver, + packageName: null, + }), + ), + getSnapshot: Effect.succeed(provider), + refresh: Effect.succeed(provider), + streamChanges: Stream.empty, + applyUsageLimits: () => Effect.void, + }, + snapshotForCwd: () => + Ref.update(scans, (count) => count + 1).pipe( + Effect.andThen(Deferred.succeed(scanStarted, undefined)), + Effect.andThen(Deferred.await(releaseScan)), + Effect.as(provider), + ), + orchestrationAdapter: {} as ProviderInstance["orchestrationAdapter"], + textGeneration: {} as ProviderInstance["textGeneration"], + }; + const layerInstanceRegistry = Layer.succeed( + ProviderInstanceRegistry.ProviderInstanceRegistry, + { + getInstance: (requestedId) => + Effect.succeed(requestedId === instanceId ? instance : undefined), + listInstances: Effect.succeed([instance]), + listUnavailable: Effect.succeed([]), + streamChanges: Stream.empty, + subscribeChanges: Effect.flatMap(PubSub.unbounded(), PubSub.subscribe), + }, + ); + const scope = yield* Scope.make(); + yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); + const runtimeServices = yield* Layer.build( + ProviderRegistry.layer.pipe( + Layer.provideMerge(layerInstanceRegistry), + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-provider-registry-shared-workspace-scan-", + }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ).pipe(Scope.provide(scope)); + + yield* Effect.gen(function* () { + const registry = yield* ProviderRegistry.ProviderRegistry; + const refresh = registry.refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" }); + const first = yield* Effect.forkChild(refresh); + yield* Deferred.await(scanStarted); + const second = yield* Effect.forkChild(refresh, { startImmediately: true }); + yield* Fiber.interrupt(first); + yield* Deferred.succeed(releaseScan, undefined); + assert.deepStrictEqual( + (yield* Fiber.join(second))[0]?.workspaceSnapshots?.map((snapshot) => snapshot.cwd), + ["/workspace"], + ); + assert.strictEqual(yield* Ref.get(scans), 1); + }).pipe(Effect.provide(runtimeServices)); + }), + ); + it.effect("publishes workspace skills while machine health is in error", () => Effect.gen(function* () { const driver = ProviderDriverKind.make("codex"); diff --git a/apps/server/src/provider/ProviderRegistry.ts b/apps/server/src/provider/ProviderRegistry.ts index 0096ca23c0370..78b11306c5b6d 100644 --- a/apps/server/src/provider/ProviderRegistry.ts +++ b/apps/server/src/provider/ProviderRegistry.ts @@ -12,6 +12,7 @@ import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Equal from "effect/Equal"; +import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -167,6 +168,12 @@ const shouldRetainMissingProviderModels = (provider: ServerProvider): boolean => ); } + if (provider.driver === ProviderDriverKind.make("grok")) { + // Grok reports ready only after it discovers a model list; the pending, + // ACP-failed, empty-inventory, and logged-out probes are warning/error. + return !(provider.installed && provider.status === "ready"); + } + const isAntigravity = provider.driver === ProviderDriverKind.make("antigravity"); const isCodex = provider.driver === ProviderDriverKind.make("codex"); if (!isAntigravity && !isCodex && provider.driver !== ProviderDriverKind.make("opencode")) { @@ -405,7 +412,7 @@ export const layer = Layer.effect( cachedProviders.map((provider) => classifyCompatibility(provider, initialManifest)), ); const workspaceRefreshesRef = yield* Ref.make< - ReadonlyMap> + ReadonlyMap>> >(new Map()); const maintenanceActionStatesRef = yield* Ref.make< ReadonlyMap @@ -922,21 +929,13 @@ export const layer = Layer.effect( const scannedAt = DateTime.formatIso(now); const instance = yield* instanceRegistry.getInstance(input.instanceId); if (!instance?.snapshotForCwd) return providers; - const token = Symbol(); - const claimed = yield* Ref.modify(workspaceRefreshesRef, (refreshes) => { - const current = refreshes.get(instance); - if (!input.fresh && current?.has(input.cwd)) return [false, refreshes] as const; - const next = new Map(refreshes); - next.set(instance, new Map(current).set(input.cwd, token)); - return [true, next] as const; - }); - if (!claimed) return yield* Ref.get(providersRef); + const token = yield* Deferred.make(); const refreshMachineSnapshot = input.fresh ? (instance.invalidateCaches ?? Effect.void).pipe( Effect.andThen(refreshInstance(input.instanceId)), ) : Effect.void; - return yield* refreshMachineSnapshot.pipe( + const runScan = refreshMachineSnapshot.pipe( Effect.andThen(instance.snapshotForCwd(input.cwd)), // Scans fail on error; returned inventory remains valid even when provider health reports an error. Effect.flatMap((scopedSnapshot) => @@ -974,9 +973,33 @@ export const layer = Layer.effect( if (nextCwds.size) next.set(instance, nextCwds); else next.delete(instance); return next; - }), + }).pipe(Effect.andThen(Deferred.succeed(token, undefined))), ), ); + // The claim and the fork happen together, so the token is always completed. The scan is + // detached so a disconnecting owner cannot cancel the result its waiters depend on. + const claim = yield* Effect.uninterruptible( + Effect.gen(function* () { + const inFlight = yield* Ref.modify(workspaceRefreshesRef, (refreshes) => { + const current = refreshes.get(instance); + const running = current?.get(input.cwd); + if (!input.fresh && running) return [running, refreshes] as const; + const next = new Map(refreshes); + next.set(instance, new Map(current).set(input.cwd, token)); + return [undefined, next] as const; + }); + if (inFlight) return { inFlight } as const; + return { + // Starting now attaches the scan's `ensuring` before anything can close the scope. + scan: yield* Effect.forkIn(runScan, serviceScope, { startImmediately: true }), + } as const; + }), + ); + // Wait for the running scan: on-demand clients get its result only in this reply. + if ("inFlight" in claim) { + return yield* Deferred.await(claim.inFlight).pipe(Effect.andThen(Ref.get(providersRef))); + } + return yield* Fiber.join(claim.scan); }); return { diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.processTree.test.ts b/apps/server/src/provider/acp/AcpSessionRuntime.processTree.test.ts index 7ee010b6f76d5..c71d414ccdfab 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.processTree.test.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.processTree.test.ts @@ -165,7 +165,7 @@ describe("terminatePosixOwnedProcessTree", () => { AcpSessionRuntime.resolveLinuxCgroupTargetCommand("grok", scratch, { PATH: undefined }), ).toBeUndefined(); expect( - AcpSessionRuntime.resolveLinuxCgroupTargetCommand("node", scratch, { PATH: undefined }), + AcpSessionRuntime.resolveLinuxCgroupTargetCommand("sh", scratch, { PATH: undefined }), ).toBeDefined(); expect( AcpSessionRuntime.resolveLinuxCgroupTargetCommand("grok", scratch, { diff --git a/apps/server/src/serverLogger.test.ts b/apps/server/src/serverLogger.test.ts index fec4db2541517..9a4e71149a496 100644 --- a/apps/server/src/serverLogger.test.ts +++ b/apps/server/src/serverLogger.test.ts @@ -151,6 +151,21 @@ const printThroughConsole = (fields: object) => }); describe("ServerLoggerLive", () => { + it.effect("exports the operator's service instance id instead of a generated one", () => + Effect.gen(function* () { + const { requests } = yield* logInSpanThrough({ + otlpLogsUrl: "https://collector.example.com/v1/logs", + otelEnvironment: { + ...OtelEnvironment.none, + resourceAttributes: { "service.instance.id": "pod-7" }, + }, + }); + + const body = requests.map((request) => request.body).join("\n"); + assert.include(body, '"key":"service.instance.id","value":{"stringValue":"pod-7"}'); + }), + ); + it.effect("exports a resource that names whether the desktop app launched the server", () => Effect.gen(function* () { const { requests } = yield* logInSpanThrough({ diff --git a/apps/server/src/telemetry/AnalyticsService.ts b/apps/server/src/telemetry/AnalyticsService.ts index bc0a0ad562a4f..54e3a3e6105d3 100644 --- a/apps/server/src/telemetry/AnalyticsService.ts +++ b/apps/server/src/telemetry/AnalyticsService.ts @@ -50,10 +50,6 @@ const RETRY_MAX_DELAY_MS = 300_000; // These statuses reject the batch itself; unlike 408/429, retrying cannot help. const REJECTED_BATCH_STATUSES = new Set([400, 401, 413]); -// Official release workflows set this telemetry channel; other builds report dev. -// Unlike __T3CODE_BUILD_CHANNEL__, this is not the sign-in page's latest/nightly channel. -declare const __T3CODE_TELEMETRY_CHANNEL__: "stable" | "nightly" | "preview" | "dev" | undefined; - /** `random` is in [0, 1). */ function retryDelayMs(failures: number, random: number): number { const ceiling = Math.min(RETRY_MAX_DELAY_MS, RETRY_BASE_DELAY_MS * 2 ** (failures - 1)); @@ -142,8 +138,6 @@ export const make = Effect.gen(function* () { // The background flush and the shutdown flush must not send the same batch at once. const flushLock = yield* Semaphore.make(1); const clientType = serverConfig.mode === "desktop" ? "desktop-app" : "cli-web-client"; - const telemetryChannel = - typeof __T3CODE_TELEMETRY_CHANNEL__ === "undefined" ? "dev" : __T3CODE_TELEMETRY_CHANNEL__; const hostPlatform = yield* HostProcess.Platform; const hostArchitecture = yield* HostProcess.Architecture; @@ -198,7 +192,7 @@ export const make = Effect.gen(function* () { wsl: Option.getOrUndefined(telemetryConfig.wslDistroName), arch: hostArchitecture, t3CodeVersion: packageJson.version, - telemetryChannel, + telemetryChannel: ServerConfig.telemetryChannel, clientType, serverOs: serverOsFromNodePlatform(hostPlatform), serverArch: hostArchitecture, diff --git a/apps/server/src/testUtils/managedServiceEnv.setup.ts b/apps/server/src/testUtils/managedServiceEnv.setup.ts new file mode 100644 index 0000000000000..a6581817e14bb --- /dev/null +++ b/apps/server/src/testUtils/managedServiceEnv.setup.ts @@ -0,0 +1,6 @@ +import { SERVICE_LAUNCHER_CONTEXT_ENV } from "../cloud/serviceProtocol.ts"; + +// Agents inside a managed T3 service inherit its home and launcher context; CI has neither. +for (const name of ["T3CODE_HOME", "T3_BOOT_SERVICE_UNIT", SERVICE_LAUNCHER_CONTEXT_ENV]) { + delete process.env[name]; +} diff --git a/apps/server/src/ws.test.ts b/apps/server/src/ws.test.ts index a2c3910d4da5f..49425668cd77b 100644 --- a/apps/server/src/ws.test.ts +++ b/apps/server/src/ws.test.ts @@ -5,13 +5,17 @@ import { EnvironmentId, ProjectId, ThreadId, + ProviderDriverKind, + ProviderInstanceId, WS_METHODS, + WsServerRefreshProvidersRpc, WsSubscribeServerConfigRpc, type OrchestrationV2StoredEvent, type OrchestrationV2ThreadShell, type OrchestrationProjectShell, type ServerConfig, type ServerConfigStreamEvent, + type ServerProvider, } from "@t3tools/contracts"; import * as Deferred from "effect/Deferred"; import * as DateTime from "effect/DateTime"; @@ -20,6 +24,7 @@ import * as Layer from "effect/Layer"; import * as PubSub from "effect/PubSub"; import * as Queue from "effect/Queue"; import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; import { Headers } from "effect/http"; import { RpcGroup, RpcMessage, RpcServer } from "effect/rpc"; import * as SqlClient from "effect/sql/SqlClient"; @@ -632,6 +637,162 @@ it.effect( ), ); +it.effect("on-demand clients get provider config without workspace snapshots", () => + Effect.gen(function* () { + const instanceId = ProviderInstanceId.make("claudeAgent"); + const snapshotFor = (cwd: string, checkedAt: string) => ({ + cwd, + checkedAt, + slashCommands: [{ name: `${cwd.slice(1)}-command` }], + skills: [], + }); + const provider: ServerProvider = { + instanceId, + driver: ProviderDriverKind.make("claudeAgent"), + enabled: true, + installed: true, + version: "1.0.0", + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-10-25T00:00:00.000Z", + models: [], + slashCommands: [{ name: "machine-command" }], + skills: [], + workspaceSnapshots: [ + snapshotFor("/a", "2026-10-25T00:00:00.000Z"), + snapshotFor("/b", "2026-10-25T00:00:00.000Z"), + ], + }; + const providerChanges = yield* Queue.unbounded>(); + const pulled = yield* Queue.unbounded(); + const responses = yield* Queue.unbounded(); + const configRpcServices = Layer.mergeAll( + layerUnusedConfigRpcServices, + Layer.mock(ExternalLauncher.ExternalLauncher)({ + resolveAvailableEditors: () => Effect.succeed([]), + resolveFileManagerRevealKind: () => Effect.succeed(undefined), + }), + Layer.mock(ProviderRegistry.ProviderRegistry)({ + getProviders: Effect.succeed([provider]), + streamChanges: Stream.fromQueue(providerChanges).pipe( + Stream.tap(() => Queue.offer(pulled, undefined)), + ), + refreshWorkspaceSnapshot: () => Effect.succeed([provider]), + }), + Layer.mock(UsageLimitSources.UsageLimitSources)({ + current: Effect.succeed([]), + streamChanges: Stream.make([]), + }), + Layer.mock(RemoteOpenTargets.RemoteOpenTargets)({ + resolveTargets: () => Effect.succeed([]), + }), + Layer.mock(DirectEndpoints.DirectEndpoints)({ resolve: () => Effect.succeed([]) }), + Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ + scratchRoot: Effect.succeedNone, + namedProjectsRoot: process.cwd(), + }), + Layer.mock(ServerEnvironment.ServerEnvironment)({ + getDescriptor: Effect.succeed({ + environmentId: EnvironmentId.make("config-test"), + label: "Config test", + platform: { os: "linux", arch: "x64" }, + serverVersion: "0.0.0", + capabilities: { repositoryIdentity: false }, + }), + }), + Layer.mock(EnvironmentAuth.EnvironmentAuth)({ + getDescriptor: () => + Effect.succeed({ + policy: "loopback-browser", + bootstrapMethods: [], + sessionMethods: ["browser-session-cookie"], + sessionCookieName: "config-test-session", + }), + }), + ); + const server = yield* RpcServer.makeNoSerialization( + RpcGroup.make(WsSubscribeServerConfigRpc, WsServerRefreshProvidersRpc), + { + ...WS_RPC_SERVER_OPTIONS, + onFromServer: (response) => + response._tag === "Chunk" + ? Effect.forEach(response.values, (value) => Queue.offer(responses, value), { + discard: true, + }) + : response._tag === "Exit" && response.exit._tag === "Success" + ? Queue.offer(responses, response.exit.value) + : Effect.void, + }, + ).pipe( + Effect.provide( + layerWsRpc( + { + sessionId: AuthSessionId.make("config-test"), + subject: "config-test", + method: "browser-session-cookie", + scopes: [], + }, + {}, + {}, + yield* PreviewAutomationBroker.PreviewAutomationBroker, + yield* ServerBrowser.ServerBrowser, + ).pipe(Layer.provide(configRpcServices)), + ), + ); + const configRequest = RpcMessage.RequestId("config"); + yield* server.write(0, { + _tag: "Request", + id: configRequest, + tag: WS_METHODS.subscribeServerConfig, + payload: { workspaceSnapshotsOnDemand: true }, + headers: Headers.empty, + }); + const snapshot = (yield* Queue.take(responses)) as ServerConfigStreamEvent; + if (snapshot.type !== "snapshot") return yield* Effect.die("Missing initial snapshot"); + assert.isUndefined(snapshot.config.providers[0]?.workspaceSnapshots); + yield* server.write(0, { _tag: "Ack", requestId: configRequest }); + + // A rescan that only touches a snapshot sends nothing; the next real change does. + yield* Queue.offer(providerChanges, [ + { ...provider, workspaceSnapshots: [snapshotFor("/a", "2026-10-25T00:05:00.000Z")] }, + ]); + yield* Queue.take(pulled); + yield* TestClock.adjust("1 second"); + yield* Queue.offer(providerChanges, [{ ...provider, status: "warning" }]); + yield* Queue.take(pulled); + yield* TestClock.adjust("1 second"); + const update = (yield* Queue.take(responses)) as ServerConfigStreamEvent; + if (update.type !== "providerStatuses") return yield* Effect.die("Expected provider update"); + assert.equal(update.payload.providers[0]?.status, "warning"); + assert.isUndefined(update.payload.providers[0]?.workspaceSnapshots); + + yield* server.write(0, { + _tag: "Request", + id: RpcMessage.RequestId("refresh"), + tag: WS_METHODS.serverRefreshProviders, + payload: { instanceId, cwd: "/b", workspaceSnapshotsOnDemand: true }, + headers: Headers.empty, + }); + const refreshed = (yield* Queue.take(responses)) as { + readonly providers: ReadonlyArray; + }; + assert.deepEqual( + refreshed.providers.map((entry) => [entry.workspaceSnapshots, entry.slashCommands]), + [[[snapshotFor("/b", "2026-10-25T00:00:00.000Z")], []]], + ); + yield* server.write(0, { _tag: "Interrupt", requestId: configRequest, interruptors: [] }); + }).pipe( + Effect.provide( + Layer.mergeAll( + Layer.fresh(layerConfigSources), + Layer.mock(PreviewAutomationBroker.PreviewAutomationBroker)({}), + Layer.mock(ServerBrowser.ServerBrowser)({}), + ), + ), + Effect.scoped, + ), +); + // Compaction keeps the oldest and newest event per message, so a cursor older // than the deleted rows still replays each message where the client would // first have seen it (reducers append on first sight) and reaches the same diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index a03176a455eaf..e5ef646a615af 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -82,6 +82,7 @@ import { type ServerConfig as ClientServerConfig, type ServerConfigStreamEvent, type ServerLifecycleStreamEvent, + type ServerProvider, type FilesystemBrowseFailure, FilesystemBrowseError, AssetWorkspaceContextNotFoundError, @@ -363,6 +364,10 @@ export const withLateEditorConfig = ( ); }; +function withoutWorkspaceSnapshots(providers: ReadonlyArray): ServerProvider[] { + return providers.map(({ workspaceSnapshots: _workspaceSnapshots, ...provider }) => provider); +} + function unexpectedCompatibilityError(error: never): never { throw new Error(`Unhandled compatibility error: ${String(error)}`); } @@ -1721,12 +1726,16 @@ export const layerWsRpc = ( const loadServerConfig = Effect.fn("ws.loadServerConfig")(function* (options: { readonly usageLimitsCommand: boolean; + readonly workspaceSnapshotsOnDemand: boolean; }) { const keybindingsConfig = yield* keybindings.loadConfigState; const currentProviders = yield* providerRegistry.getProviders; - const providers = options.usageLimitsCommand + const commandProviders = options.usageLimitsCommand ? withUsageLimitsCommands(currentProviders, yield* usageLimitSources.current) : currentProviders; + const providers = options.workspaceSnapshotsOnDemand + ? withoutWorkspaceSnapshots(commandProviders) + : commandProviders; const settings = ServerSettings.redactServerSettingsForClient( yield* serverSettings.getSettings, ); @@ -2207,7 +2216,8 @@ export const layerWsRpc = ( Effect.andThen(scheduledTasks.getWebhookDelivery(input)), ), [WS_METHODS.serverProbe]: (_input) => Effect.succeed({}), - [WS_METHODS.serverGetConfig]: (_input) => loadServerConfig({ usageLimitsCommand: false }), + [WS_METHODS.serverGetConfig]: (_input) => + loadServerConfig({ usageLimitsCommand: false, workspaceSnapshotsOnDemand: false }), [WS_METHODS.serverSearchAcpRegistry]: (input) => acpRegistryCatalog .search(input) @@ -2376,7 +2386,29 @@ export const layerWsRpc = ( providers = yield* providerRegistry.refreshInstance(instance.instanceId); } } - return { providers }; + if (input.workspaceSnapshotsOnDemand !== true) return { providers }; + const commandProviders = + input.usageLimitsCommand === true + ? withUsageLimitsCommands(providers, yield* usageLimitSources.current) + : providers; + const { instanceId, cwd } = input; + if (instanceId === undefined || cwd === undefined) { + return { providers: withoutWorkspaceSnapshots(commandProviders) }; + } + return { + providers: commandProviders + .filter((provider) => provider.instanceId === instanceId) + .map((provider) => ({ + ...provider, + // Callers read only the cwd snapshot; the config stream carries the rest. + models: [], + slashCommands: [], + skills: [], + workspaceSnapshots: (provider.workspaceSnapshots ?? []).filter( + (snapshot) => snapshot.cwd === cwd, + ), + })), + }; }), [WS_METHODS.mcpAppsCallTool]: (input) => mcpAppRequests.callTool(input), [WS_METHODS.mcpAppsToolInfo]: (input) => mcpAppRequests.toolInfo(input), @@ -3093,10 +3125,14 @@ export const layerWsRpc = ( Stream.unwrap( Effect.gen(function* () { const usageLimitsCommand = input.usageLimitsCommand === true; + const workspaceSnapshotsOnDemand = input.workspaceSnapshotsOnDemand === true; // Buffer changes through discovery and the initial snapshot's RPC ACK wait. const keybindingsChanges = yield* keybindings.subscribeChanges; const settingsChanges = yield* serverSettings.subscribeChanges; - const config = yield* loadServerConfig({ usageLimitsCommand }); + const config = yield* loadServerConfig({ + usageLimitsCommand, + workspaceSnapshotsOnDemand, + }); const keybindingsUpdates = keybindingsChanges.pipe( Stream.map((event) => ({ version: 1 as const, @@ -3118,8 +3154,15 @@ export const layerWsRpc = ( usageLimitsCommand ? sameUsageLimitCommandCoverage : () => true, ), ), - (providers, sources) => - usageLimitsCommand ? withUsageLimitsCommands(providers, sources) : providers, + (providers, sources) => { + const commandProviders = usageLimitsCommand + ? withUsageLimitsCommands(providers, sources) + : providers; + // Stripped before the de-dupe below, so snapshot-only changes send nothing. + return workspaceSnapshotsOnDemand + ? withoutWorkspaceSnapshots(commandProviders) + : commandProviders; + }, ).pipe( // Compare initial paired values with the snapshot; blindly dropping them loses changes between snapshot and subscription. (updates) => Stream.concat(rpcInitialItems([config.providers]), updates), diff --git a/apps/server/vite.config.ts b/apps/server/vite.config.ts index 5d36d0867c597..f68d1cc733c6a 100644 --- a/apps/server/vite.config.ts +++ b/apps/server/vite.config.ts @@ -128,7 +128,10 @@ export default mergeConfig( // Parallel sqlite/git/worktree/orchestration tests introduce load-sensitive flakes. fileParallelism: false, sequence: { sequencer: WeightedShardSequencer }, - setupFiles: ["./src/testUtils/gitConfig.setup.ts"], + setupFiles: [ + "./src/testUtils/gitConfig.setup.ts", + "./src/testUtils/managedServiceEnv.setup.ts", + ], hookTimeout: 120_000, testTimeout: 120_000, }, diff --git a/apps/web/src/components/chat/ChatMarkdownMedia.tsx b/apps/web/src/components/chat/ChatMarkdownMedia.tsx index 564ea71a3d567..18aadf4b76ddc 100644 --- a/apps/web/src/components/chat/ChatMarkdownMedia.tsx +++ b/apps/web/src/components/chat/ChatMarkdownMedia.tsx @@ -207,6 +207,7 @@ export function ChatMarkdownImage(props: { alt={props.alt} data-markdown-copy={props.copyMarkdown} decoding="async" + loading="lazy" draggable={false} className={cn( CHAT_MARKDOWN_IMAGE_SIZE_CLASS_NAME, @@ -263,6 +264,7 @@ export function ChatMarkdownImage(props: { src={src} alt={props.alt} decoding="async" + loading="lazy" draggable={false} className="invisible absolute inset-0 size-full" {...imageEvents(src)} diff --git a/apps/web/src/components/chat/TimelineUserMessageRow.tsx b/apps/web/src/components/chat/TimelineUserMessageRow.tsx index 31018d9250d01..6d2a7776091c1 100644 --- a/apps/web/src/components/chat/TimelineUserMessageRow.tsx +++ b/apps/web/src/components/chat/TimelineUserMessageRow.tsx @@ -508,6 +508,7 @@ function UserTimelineRow({ row }: { row: Extract diff --git a/apps/web/src/components/chat/useProviderWorkspaceSnapshotRefresh.ts b/apps/web/src/components/chat/useProviderWorkspaceSnapshotRefresh.ts index bda0641e90917..7256052cebbbc 100644 --- a/apps/web/src/components/chat/useProviderWorkspaceSnapshotRefresh.ts +++ b/apps/web/src/components/chat/useProviderWorkspaceSnapshotRefresh.ts @@ -14,6 +14,8 @@ import { useEffect, useEffectEvent, useRef, useState } from "react"; import { useStableCallback } from "../../hooks/useStableCallback"; const WORKSPACE_SNAPSHOT_RETRY_COOLDOWN_MS = 10_000; +// A cwd that never scans, such as a deleted worktree, would otherwise retry forever. +const WORKSPACE_SNAPSHOT_MAX_ATTEMPTS = 3; /** * Keeps the selected provider's workspace snapshot fresh for the composer's @@ -32,6 +34,7 @@ export function useProviderWorkspaceSnapshotRefresh(input: { // The last scan this composer asked for. A request inside the TTL is not // repeated, so a client clock ahead of the server's cannot loop rescans. const workspaceRefreshKeyRef = useRef<{ key: string; requestedAt: number } | null>(null); + const failedRefreshesRef = useRef<{ key: string; count: number } | null>(null); const [workspaceRefreshRetry, setWorkspaceRefreshRetry] = useState<{ key: string; notBefore: number; @@ -96,6 +99,11 @@ export function useProviderWorkspaceSnapshotRefresh(input: { workspaceRefreshKeyRef.current = request; const retryLater = () => { if (workspaceRefreshKeyRef.current !== request) return; + const failed = failedRefreshesRef.current; + const count = failed?.key === key ? failed.count + 1 : 1; + failedRefreshesRef.current = { key, count }; + // Keeping the request marker lets the TTL hold off the next scan. + if (count >= WORKSPACE_SNAPSHOT_MAX_ATTEMPTS) return; workspaceRefreshKeyRef.current = null; setWorkspaceRefreshRetry({ key, @@ -115,6 +123,7 @@ export function useProviderWorkspaceSnapshotRefresh(input: { gitCwd, ); if (!refreshedSnapshot) retryLater(); + else failedRefreshesRef.current = null; }, retryLater); }; const refreshWorkspaceSnapshotOnChange = useEffectEvent(refreshWorkspaceSnapshotIfStale); diff --git a/apps/web/src/lib/runtime.ts b/apps/web/src/lib/runtime.ts index d983c716e526a..4795655be0260 100644 --- a/apps/web/src/lib/runtime.ts +++ b/apps/web/src/lib/runtime.ts @@ -22,6 +22,7 @@ const layerRelayTracing = RelayTracing.layer(resolveRelayTracingConfig(), { serviceName: "t3code-web", serviceVersion: import.meta.env.APP_VERSION, runtime: "browser", + serviceInstanceId: ClientTracer.serviceInstanceId, client: typeof window !== "undefined" && window.desktopBridge ? "desktop" : "web", }).pipe(Layer.provide(layerHttpClient)); diff --git a/apps/web/src/observability/clientTracer.ts b/apps/web/src/observability/clientTracer.ts index 73d3885f8800c..4625ec7af4b82 100644 --- a/apps/web/src/observability/clientTracer.ts +++ b/apps/web/src/observability/clientTracer.ts @@ -1,6 +1,11 @@ import * as Layer from "effect/Layer"; import * as Tracer from "effect/Tracer"; +import { randomUUID } from "../lib/utils"; + +/** `service.instance.id` for this page load, shared by every resource the UI exports. */ +export const serviceInstanceId = randomUUID(); + let delegate: Tracer.Tracer | null = null; export function setDelegate(next: Tracer.Tracer | null): void { diff --git a/apps/web/src/observability/clientTracing.ts b/apps/web/src/observability/clientTracing.ts index 73a2611c7d4db..43a9ebd1f5b9c 100644 --- a/apps/web/src/observability/clientTracing.ts +++ b/apps/web/src/observability/clientTracing.ts @@ -8,17 +8,26 @@ import { OtlpExporter, OtlpSerialization, OtlpTracer } from "effect/observabilit import { settleAsyncResult, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; import { resolvePrimaryEnvironmentHttpUrl } from "../environments/primary"; +import { releaseChannelOf, releaseResourceAttributes } from "@t3tools/shared/releaseResource"; import * as ClientTracer from "./clientTracer"; import * as PrimaryEnvironmentHttpLayer from "../environments/primary/httpLayer"; import { isElectron } from "../env"; import { APP_VERSION } from "~/branding"; const DEFAULT_EXPORT_INTERVAL_MS = 1_000; +// PR previews can't mark themselves in APP_VERSION without breaking the version-skew check against the server. +const RELEASE_CHANNEL = + import.meta.env.DEV || import.meta.env.VITE_RELEASE_CHANNEL === "dev" + ? "dev" + : import.meta.env.VITE_RELEASE_CHANNEL === "preview" + ? "preview" + : releaseChannelOf(APP_VERSION); const CLIENT_TRACING_RESOURCE = { serviceName: "t3code-web", attributes: { "service.namespace": "t3code", - "deployment.environment.name": import.meta.env.DEV ? "development" : "production", + "service.instance.id": ClientTracer.serviceInstanceId, + ...releaseResourceAttributes(RELEASE_CHANNEL), "service.runtime": "t3-web", "t3code.client.surface": isElectron ? "desktop" : "web", "service.version": APP_VERSION, diff --git a/apps/web/src/vite-env.d.ts b/apps/web/src/vite-env.d.ts index acc73a399a3cc..37a133afafeee 100644 --- a/apps/web/src/vite-env.d.ts +++ b/apps/web/src/vite-env.d.ts @@ -7,6 +7,7 @@ interface ImportMetaEnv { readonly VITE_WS_URL: string; readonly VITE_HOSTED_APP_URL: string; readonly VITE_HOSTED_APP_CHANNEL: string; + readonly VITE_RELEASE_CHANNEL: string; readonly VITE_CLERK_PUBLISHABLE_KEY: string; readonly VITE_CLERK_JWT_TEMPLATE: string; readonly VITE_CLERK_CLI_OAUTH_CLIENT_ID: string; diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index ede07403975c8..577dda09c9b89 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -39,6 +39,8 @@ const configuredRelayTracingUrl = repoEnv.VITE_RELAY_OTLP_TRACES_URL?.trim() || const configuredRelayTracingDataset = repoEnv.VITE_RELAY_OTLP_TRACES_DATASET?.trim() || ""; const configuredRelayTracingToken = repoEnv.VITE_RELAY_OTLP_TRACES_TOKEN?.trim() || ""; const configuredHostedAppChannel = process.env.VITE_HOSTED_APP_CHANNEL?.trim() || ""; +const configuredReleaseChannel = + process.env.T3CODE_BUILT_DEV === "1" ? "dev" : process.env.VITE_RELEASE_CHANNEL?.trim() || ""; const configuredAppVersion = process.env.APP_VERSION?.trim() || pkg.version; const configuredDesktopUpdateRepository = process.env.T3CODE_DESKTOP_UPDATE_REPOSITORY?.trim() || @@ -237,6 +239,7 @@ export default defineConfig(() => { "import.meta.env.T3CODE_DESKTOP_UPDATE_REPOSITORY": JSON.stringify( configuredDesktopUpdateRepository, ), + "import.meta.env.VITE_RELEASE_CHANNEL": JSON.stringify(configuredReleaseChannel), "import.meta.env.APP_VERSION": JSON.stringify(configuredAppVersion), }, resolve: { diff --git a/docs/operations/observability.md b/docs/operations/observability.md index a9d03758ebfdc..51413e205da6e 100644 --- a/docs/operations/observability.md +++ b/docs/operations/observability.md @@ -393,12 +393,14 @@ Traces are best for one request. Metrics are best for trends. Good metric families to watch: - `t3_rpc_request_duration` +- `t3_orchestration_command_duration` - `t3_provider_turn_duration` (how long the provider adapter takes to start a turn, not the turn's run time) - `t3_git_command_duration` Counters tell you volume and failure rate: - `t3_rpc_requests_total` +- `t3_orchestration_commands_total` - `t3_provider_turns_total` - `t3_git_commands_total` @@ -625,15 +627,27 @@ with a startup warning, rather than sending it to the Settings endpoint. Service names are fixed: `t3code-server` for the backend and `t3code-desktop` for the desktop main process, both in `service.namespace` `t3code`. `OTEL_SERVICE_NAME` and a `service.name` or `service.namespace` in `OTEL_RESOURCE_ATTRIBUTES` are ignored. Tell installations apart with other -resource attributes, such as `OTEL_RESOURCE_ATTRIBUTES=deployment.environment.name=development`. -The UI reports `deployment.environment.name` from its build: `development` under the Vite dev server -and `production` otherwise. +resource attributes, such as `OTEL_RESOURCE_ATTRIBUTES=deployment.environment.name=staging`. + +Each process reports its own build, not the one it talks to: the server, the desktop main process, +the UI, and the mobile app each carry `t3code.release.channel` (`dev`, `stable`, `nightly`, or +`preview`, from their own version; desktop PR builds count as `preview`, and a server not built by a +release workflow reports `dev`) and `deployment.environment.name` (`development`, `production`, or +`staging` for `preview`). On the server and the desktop main process either key in +`OTEL_RESOURCE_ATTRIBUTES` wins. The UI and mobile app never read that variable: Vercel PR previews +set `VITE_RELEASE_CHANNEL=preview` because their `APP_VERSION` must stay comparable with the +server's, and the mobile app maps its build variant (`development`, `preview`, `production`), or +reports `dev` from a development bundle. Attributes specific to T3 Code use the `t3code.` prefix. The UI runs as `t3code-web` in both a browser and the desktop window, since it is the same code, and `t3code.client.surface` is `desktop` or `web`. The server's `t3code.server.managed_by` is `desktop` when the desktop app launched it and `standalone` otherwise. +Every resource also carries a `service.instance.id`: random per process for the server and desktop, +per page load for the UI, and the Expo session on mobile. A `service.instance.id` in +`OTEL_RESOURCE_ATTRIBUTES` replaces the generated one on the server and desktop. + If the OTLP URLs are unset, local tracing still works, metrics stay in-process only, and logs stay on stdout only. diff --git a/oxlint-plugin-t3code/index.ts b/oxlint-plugin-t3code/index.ts index acae1e75258e3..cf9c614c403c7 100644 --- a/oxlint-plugin-t3code/index.ts +++ b/oxlint-plugin-t3code/index.ts @@ -15,6 +15,7 @@ import noMobileUniwindThemeEscapeHatches from "./rules/no-mobile-uniwind-theme-e import noAmbiguousDetailNavigation from "./rules/no-ambiguous-detail-navigation.ts"; import noMobilePullRequestGlyphLiterals from "./rules/no-mobile-pull-request-glyph-literals.ts"; import noNativeTitleTooltip from "./rules/no-native-title-tooltip.ts"; +import reactEffectBudget from "./rules/react-effect-budget.ts"; import requireRegisteredListScroll from "./rules/require-registered-list-scroll.ts"; import noOutsetStateIndicators from "./rules/no-outset-state-indicators.ts"; import noPollingLoopInTests from "./rules/no-polling-loop-in-tests.ts"; @@ -52,6 +53,7 @@ export default definePlugin({ "no-ambiguous-detail-navigation": noAmbiguousDetailNavigation, "no-mobile-pull-request-glyph-literals": noMobilePullRequestGlyphLiterals, "no-native-title-tooltip": noNativeTitleTooltip, + "react-effect-budget": reactEffectBudget, "require-registered-list-scroll": requireRegisteredListScroll, "no-outset-state-indicators": noOutsetStateIndicators, "no-polling-loop-in-tests": noPollingLoopInTests, diff --git a/oxlint-plugin-t3code/rules/react-effect-budget.test.ts b/oxlint-plugin-t3code/rules/react-effect-budget.test.ts new file mode 100644 index 0000000000000..b1cba759d188a --- /dev/null +++ b/oxlint-plugin-t3code/rules/react-effect-budget.test.ts @@ -0,0 +1,50 @@ +import { assert, describe } from "@effect/vitest"; + +import budget from "../../scripts/effect-budget.json" with { type: "json" }; +import { createOxlintRuleHarness } from "../test/utils.ts"; + +const [budgetedFile, allowed] = Object.entries(budget)[0]!; + +const component = (effects: number) => `import React, { useEffect } from "react"; +export function Panel() { +${Array.from({ length: effects }, (_, index) => (index % 2 === 0 ? " useEffect(() => {}, []);" : " React.useLayoutEffect(() => {});")).join("\n")} + return null; +} +`; + +const reports = (output: string) => output.match(/t3code\(react-effect-budget\)/g)?.length ?? 0; +const lint = (filename: string) => + createOxlintRuleHarness("t3code/react-effect-budget", { filename }); + +const budgeted = lint(budgetedFile); +const unbudgeted = lint("apps/web/src/NotInTheBudget.tsx"); + +describe("t3code/react-effect-budget", () => { + budgeted.valid("passes a file at its budget", component(allowed)); + + budgeted.invalid("reports only the calls past the budget", component(allowed + 2), (output) => { + assert.strictEqual(reports(output), 2); + assert.include(output, `has ${allowed + 2} React effects against a budget of ${allowed}`); + }); + + unbudgeted.invalid( + "gives a file without an entry a budget of 0, counting member calls", + `export function New() { React.useEffect(() => {}); return null; }`, + (output) => { + assert.strictEqual(reports(output), 1); + assert.include(output, "has 1 React effect against a budget of 0"); + }, + ); + + unbudgeted.valid( + "ignores comments, strings and lookalike names", + `// useEffect(() => {}) in a comment +/* React.useLayoutEffect(() => {}) */ +const label = "useEffect(() => {})"; +const pipeline = Effect.matchCauseEffect(effect, {}); +const alias = useEffect; +`, + ); + + lint("apps/web/src/Panel.test.tsx").valid("skips test files", component(3)); +}); diff --git a/oxlint-plugin-t3code/rules/react-effect-budget.ts b/oxlint-plugin-t3code/rules/react-effect-budget.ts new file mode 100644 index 0000000000000..3974eb6e7b9ab --- /dev/null +++ b/oxlint-plugin-t3code/rules/react-effect-budget.ts @@ -0,0 +1,58 @@ +import { defineRule, type ESTree } from "@oxlint/plugins"; +import * as Option from "effect/Option"; + +import budget from "../../scripts/effect-budget.json" with { type: "json" }; +import { getPropertyName, unwrapExpression } from "../utils.ts"; + +const BUDGET_FILE = "scripts/effect-budget.json"; +const EFFECT_HOOKS = new Set(["useEffect", "useLayoutEffect", "useInsertionEffect"]); +const EFFECT_HOOK_TEXT = /use(?:Layout|Insertion)?Effect/; +const UNBUDGETED_FILE = /\.test\.[cm]?[jt]sx?$|[\\/](?:test|__tests__)[\\/]/; +const budgets: Readonly> = budget; + +const isEffectHookCall = (node: ESTree.CallExpression): boolean => { + const callee = unwrapExpression(node.callee); + if (Option.isNone(callee)) return false; + const expression = callee.value; + if (expression.type === "Identifier") return EFFECT_HOOKS.has(expression.name); + if (expression.type === "MemberExpression" && !expression.computed) { + return Option.exists(getPropertyName(expression.property), (name) => EFFECT_HOOKS.has(name)); + } + return false; +}; + +// Budget keys are repo-relative; matching on the path's end also works for files linted elsewhere. +const budgetFor = (filename: string): number => { + const normalized = filename.replaceAll("\\", "/"); + const key = Object.keys(budgets).find((file) => normalized.endsWith(`/${file}`)); + return key === undefined ? 0 : budgets[key]!; +}; + +export default defineRule({ + meta: { + type: "suggestion", + docs: { + description: `Hold each file to the React effect hooks it is allowed in ${BUDGET_FILE}; a missing entry allows none.`, + }, + }, + create(context) { + if (UNBUDGETED_FILE.test(context.filename) || !EFFECT_HOOK_TEXT.test(context.sourceCode.text)) { + return {}; + } + const calls: Array = []; + return { + CallExpression(node) { + if (isEffectHookCall(node)) calls.push(node); + }, + "Program:exit"() { + const allowed = budgetFor(context.filename); + if (calls.length <= allowed) return; + const message = + `This file has ${calls.length} React ${calls.length === 1 ? "effect" : "effects"} against a budget of ${allowed}. ` + + "Prefer deriving in render, event handlers, a `key` reset, an element-owned ref callback, or a store. " + + `If an effect is genuinely right, raise this file's entry in ${BUDGET_FILE} in the same PR.`; + for (const node of calls.slice(allowed)) context.report({ node, message }); + }, + }; + }, +}); diff --git a/packages/client-runtime/src/rpc/session.ts b/packages/client-runtime/src/rpc/session.ts index ba30a863e9397..ebd05d0b2d301 100644 --- a/packages/client-runtime/src/rpc/session.ts +++ b/packages/client-runtime/src/rpc/session.ts @@ -170,6 +170,11 @@ export const make = Effect.fn("RpcSessionFactory.make")(function* ( ...(options.usageLimitSources === true ? { usageLimitSources: true } : {}), ...(options.usageLimitsCommand === true ? { usageLimitsCommand: true } : {}), }; + // Config state merges the snapshots it fetches back in, so no subscriber needs them broadcast. + const onDemandSnapshots = (input: ServerConfigSubscriptionInput) => ({ + ...input, + workspaceSnapshotsOnDemand: true, + }); const connect = Effect.fnUntraced(function* (connection: PreparedConnection) { const via = connection.target._tag === "RelayConnectionTarget" ? "relay" : "direct"; @@ -248,7 +253,7 @@ export const make = Effect.fn("RpcSessionFactory.make")(function* ( detail: `${connection.label} config subscription ended.`, }); const serverConfigSource = protocolClient[WS_METHODS.subscribeServerConfig]( - serverConfigInput, + onDemandSnapshots(serverConfigInput), ).pipe( Stream.runForEach((event) => Effect.gen(function* () { @@ -387,7 +392,7 @@ export const make = Effect.fn("RpcSessionFactory.make")(function* ( Effect.as( Equal.equals(input, serverConfigInput) ? serverConfigEvents - : protocolClient[WS_METHODS.subscribeServerConfig](input), + : protocolClient[WS_METHODS.subscribeServerConfig](onDemandSnapshots(input)), ), ), ); diff --git a/packages/client-runtime/src/state/server.test.ts b/packages/client-runtime/src/state/server.test.ts index 09b28c539d33f..9ee4e55697faa 100644 --- a/packages/client-runtime/src/state/server.test.ts +++ b/packages/client-runtime/src/state/server.test.ts @@ -1,5 +1,6 @@ import { EnvironmentId, + ProviderInstanceId, ServerObservability, EnvironmentThemeId, UsageLimitSourceId, @@ -39,6 +40,8 @@ import { makeEnvironmentServerWelcomeState, makeEnvironmentServerConfigState, isLegacyUpdateHandoffLoss, + recordFetchedWorkspaceSnapshot, + withFetchedWorkspaceSnapshots, matchesServerUpdateReadyEvent, matchesServerUpdateResumeEvent, nudgeReconnectDuringUpdateRestart, @@ -937,6 +940,87 @@ describe("server state projection", () => { ).toBe(live); }); + it("merges fetched workspace snapshots only from servers that omit them", () => { + const claude = ProviderInstanceId.make("claudeAgent"); + const codex = ProviderInstanceId.make("codex"); + const snapshot = (cwd: string, command: string) => ({ + cwd, + checkedAt: "2026-10-25T00:00:00.000Z", + slashCommands: [{ name: command }], + skills: [], + }); + const provider = ( + instanceId: ProviderInstanceId, + workspaceSnapshots?: ReadonlyArray>, + slashCommands: ReadonlyArray<{ readonly name: string }> = [], + ) => + ({ + instanceId, + enabled: true, + slashCommands, + skills: [], + ...(workspaceSnapshots ? { workspaceSnapshots } : {}), + }) as unknown as ServerConfig["providers"][number]; + const configWith = (onDemand: boolean, providers: ServerConfig["providers"]) => + ({ + ...CONFIG, + environment: { capabilities: { providerWorkspaceSnapshotsOnDemand: onDemand } }, + providers, + }) as unknown as ServerConfig; + const claudeA = snapshot("/a", "claude"); + const record = ( + current: Parameters[0], + instanceId: ProviderInstanceId, + fresh: boolean, + reply: ServerConfig["providers"], + ) => + recordFetchedWorkspaceSnapshot( + current, + { instanceId, cwd: "/a", fresh }, + reply, + provider(instanceId), + ); + + let fetched = record([], claude, false, [provider(claude, [claudeA])]); + fetched = record(fetched, codex, false, [provider(codex, [snapshot("/a", "codex")])]); + const merged = withFetchedWorkspaceSnapshots( + configWith(true, [provider(claude), provider(codex)]), + fetched, + ); + expect(merged.providers[0]?.workspaceSnapshots).toEqual([claudeA]); + // An unchanged answer keeps the cache, so config readers do not re-render. + expect( + record(fetched, claude, false, [ + provider(claude, [JSON.parse(JSON.stringify(claudeA)) as typeof claudeA]), + ]), + ).toBe(fetched); + + // Health checks and usage updates leave the snapshot in place. + expect( + withFetchedWorkspaceSnapshots( + configWith(true, [ + { ...provider(claude), checkedAt: "2026-10-25T00:09:00.000Z", usageLimits: {} }, + ] as unknown as ServerConfig["providers"]), + fetched, + ).providers[0]?.workspaceSnapshots, + ).toEqual([claudeA]); + + // New provider-wide commands, such as a live session's, retire the old snapshot. + expect( + withFetchedWorkspaceSnapshots( + configWith(true, [provider(claude, undefined, [{ name: "live" }])]), + fetched, + ).providers[0]?.workspaceSnapshots, + ).toBeUndefined(); + + // Older servers send every snapshot and every drop; their config stands. + const legacy = configWith(false, [provider(claude)]); + expect(withFetchedWorkspaceSnapshots(legacy, fetched)).toBe(legacy); + + // A fresh scan drops the cwd's other provider snapshots, as the server does. + expect(record(fetched, claude, true, [])).toEqual([]); + }); + it.effect("starts from cached configuration and persists the live projection", () => Effect.gen(function* () { const events = yield* Queue.unbounded(); diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index 7913f8fe84e22..0f917e81f1b90 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -1,9 +1,11 @@ import { type EnvironmentId, + type ProviderInstanceId, type ServerConfig, type ServerConfigStreamEvent, type ServerLifecycleWelcomePayload, type ServerLifecycleStreamReadyEvent, + type ServerProviderWorkspaceSnapshot, type ServerSelfUpdateProgressEvent, type ServerSelfUpdateResult, shouldRecommendDatabaseCompaction, @@ -11,6 +13,7 @@ import { } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Duration from "effect/Duration"; +import * as Equal from "effect/Equal"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; @@ -42,6 +45,7 @@ import { runCachePersistence } from "./cachePersistence.ts"; import { isRpcClientError, request, + requestGuarded, runStream, subscribe, subscribeDynamicWithSession, @@ -51,6 +55,7 @@ import type { RpcSession } from "../rpc/session.ts"; import { followStreamInEnvironment } from "./runtime.ts"; import { applyServerConfigProjection, + reuseUnchanged, type ServerConfigProjection, withoutEnvironmentThemes, } from "./serverConfigProjection.ts"; @@ -648,6 +653,92 @@ export function resolveServerConfigValue( return initialConfig ?? projection?.config ?? null; } +type ServerProviderEntry = ServerConfig["providers"][number]; + +/** + * The provider-wide fields a workspace snapshot builds on. A live session, a sign-out, or a + * /usage-limits change shows up here, so a snapshot fetched before it no longer applies. + */ +const snapshotBasis = (provider: ServerProviderEntry | undefined) => + provider && { + enabled: provider.enabled, + slashCommands: provider.slashCommands, + skills: provider.skills, + }; + +interface FetchedWorkspaceSnapshot { + readonly instanceId: ProviderInstanceId; + readonly snapshot: ServerProviderWorkspaceSnapshot; + readonly basis: ReturnType; +} + +const MAX_FETCHED_WORKSPACE_SNAPSHOTS = 32; + +/** + * Servers with `providerWorkspaceSnapshotsOnDemand` leave per-cwd snapshots out of the config, + * so the ones this client fetched are merged back in. Older servers send every snapshot and + * their drops, so their config is used as is. + */ +export function withFetchedWorkspaceSnapshots( + config: ServerConfig, + fetched: ReadonlyArray, +): ServerConfig { + if ( + fetched.length === 0 || + config.environment.capabilities.providerWorkspaceSnapshotsOnDemand !== true + ) { + return config; + } + return { + ...config, + providers: config.providers.map((provider) => { + // A dropped snapshot reads as lost, and the composer fetches it again. + const own = fetched.filter( + (entry) => + entry.instanceId === provider.instanceId && + Equal.equals(entry.basis, snapshotBasis(provider)), + ); + return own.length === 0 + ? provider + : { ...provider, workspaceSnapshots: own.map((entry) => entry.snapshot) }; + }), + }; +} + +export function recordFetchedWorkspaceSnapshot( + current: ReadonlyArray, + input: { readonly instanceId: ProviderInstanceId; readonly cwd: string; readonly fresh: boolean }, + providers: ServerConfig["providers"], + configProvider: ServerProviderEntry | undefined, +): ReadonlyArray { + const basis = snapshotBasis(configProvider); + const snapshot = providers + .find((provider) => provider.instanceId === input.instanceId) + ?.workspaceSnapshots?.find((candidate) => candidate.cwd === input.cwd); + const stored = current.find( + (entry) => entry.instanceId === input.instanceId && entry.snapshot.cwd === input.cwd, + ); + // A cached answer from the server would otherwise re-render every config reader. + if ( + !input.fresh && + snapshot && + stored && + Equal.equals(stored.snapshot, snapshot) && + Equal.equals(stored.basis, basis) + ) { + return current; + } + // A fresh scan drops the cwd's snapshots for every provider, as the server does. + const kept = current.filter( + (entry) => + entry.snapshot.cwd !== input.cwd || (!input.fresh && entry.instanceId !== input.instanceId), + ); + if (!snapshot) return kept.length === current.length ? current : kept; + return [...kept, { instanceId: input.instanceId, snapshot, basis }].slice( + -MAX_FETCHED_WORKSPACE_SNAPSHOTS, + ); +} + export function createServerEnvironmentAtoms( runtime: Atom.AtomRuntime< EnvironmentRegistry.EnvironmentRegistry | Persistence.EnvironmentCacheStore | R, @@ -686,6 +777,12 @@ export function createServerEnvironmentAtoms( readonly environmentId: EnvironmentId; readonly input: EnvironmentRpcInput; }) => configProjectionFamily(target.environmentId); + const fetchedWorkspaceSnapshotsAtom = Atom.family((environmentId: EnvironmentId) => + Atom.make>([]).pipe( + Atom.keepAlive, + Atom.withLabel(`environment-data:server:fetched-workspace-snapshots:${environmentId}`), + ), + ); const emptyConfigAtom = Atom.make(null).pipe( Atom.withLabel("environment-data:server:config:empty"), ); @@ -693,14 +790,24 @@ export function createServerEnvironmentAtoms( if (environmentId === null) { return emptyConfigAtom; } + let previousProviders: ServerConfig["providers"] | undefined; return Atom.make((get): ServerConfig | null => { const projection = Option.getOrNull( AsyncResult.value(get(configProjection({ environmentId, input: {} }))), ); - return resolveServerConfigValue( + const config = resolveServerConfigValue( projection, get(options.initialConfigValueAtom(environmentId)), ); + if (config === null) return null; + const merged = withFetchedWorkspaceSnapshots( + config, + get(fetchedWorkspaceSnapshotsAtom(environmentId)), + ); + if (merged === config) return config; + // Keep provider identities stable across config events; the projection already reuses the rest. + previousProviders = reuseUnchanged(previousProviders, merged.providers); + return { ...merged, providers: previousProviders }; }).pipe(Atom.withLabel(`environment-data:server:config:${environmentId}`)); }); const updateStateValueAtom = Atom.family((environmentId: EnvironmentId) => @@ -1227,6 +1334,27 @@ export function createServerEnvironmentAtoms( refreshProviders: createEnvironmentRpcCommand(runtime, { label: "environment-data:server:refresh-providers", tag: WS_METHODS.serverRefreshProviders, + execute: (input) => + requestGuarded(WS_METHODS.serverRefreshProviders, { + ...input, + workspaceSnapshotsOnDemand: true, + ...(options.usageLimitsCommand === true ? { usageLimitsCommand: true } : {}), + }), + onSuccess: ({ environmentId, input }, registry, result) => + Effect.sync(() => { + const { instanceId, cwd } = input; + if (instanceId === undefined || cwd === undefined) return; + registry.update(fetchedWorkspaceSnapshotsAtom(environmentId), (current) => + recordFetchedWorkspaceSnapshot( + current, + { instanceId, cwd, fresh: input.fresh === true }, + result.providers, + registry + .get(configValueAtom(environmentId)) + ?.providers.find((provider) => provider.instanceId === instanceId), + ), + ); + }), concurrency: { mode: "singleFlight", key: ({ environmentId, input }) => diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index 771ade1902f25..7b47d88202c7d 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -102,6 +102,8 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ /** Clear published themes when absent; older servers never send updates. */ environmentThemes: Schema.optionalKey(Schema.Boolean), usageLimitSources: Schema.optionalKey(Schema.Boolean), + /** Honors `workspaceSnapshotsOnDemand`; older servers send every snapshot and drop them in the stream. */ + providerWorkspaceSnapshotsOnDemand: Schema.optionalKey(Schema.Boolean), usagePriceOverrides: Schema.optionalKey(Schema.Boolean), usageModelAliases: Schema.optionalKey(Schema.Boolean), threadPinning: Schema.optionalKey(Schema.Boolean), diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 088935c52917b..4920a748d5b97 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -612,13 +612,21 @@ const WsServerGetConfigRpc = Rpc.make(WS_METHODS.serverGetConfig, { error: Schema.Union([KeybindingsConfigError, ServerSettingsError, EnvironmentAuthorizationError]), }); -const WsServerRefreshProvidersRpc = Rpc.make(WS_METHODS.serverRefreshProviders, { +export const WsServerRefreshProvidersRpc = Rpc.make(WS_METHODS.serverRefreshProviders, { payload: Schema.Struct({ instanceId: Schema.optional(ProviderInstanceId), cwd: Schema.optional(TrimmedNonEmptyString), fresh: Schema.optional(Schema.Boolean), /** Bypass model caches; explicit rediscovery may open sessions, background refreshes must not. */ refreshModels: Schema.optional(Schema.Boolean), + /** + * Reply without snapshots, or with only the requested instance's cwd snapshot. Snapshots then + * reach the client only through these replies, so another client's scan shows on its next + * refresh, at most one TTL later. + */ + workspaceSnapshotsOnDemand: Schema.optional(Schema.Boolean), + /** As on `subscribeServerConfig`: add /usage-limits to the replied snapshot. */ + usageLimitsCommand: Schema.optional(Schema.Boolean), }), success: ServerProviderUpdatedPayload, error: Schema.Union([EnvironmentAuthorizationError, ProviderSetupError]), @@ -1791,6 +1799,8 @@ export const WsSubscribeServerConfigRpc = Rpc.make(WS_METHODS.subscribeServerCon usageLimitSources: Schema.optional(Schema.Boolean), /** Advertise client handling before the server offers /usage-limits, or older clients send it as a prompt. */ usageLimitsCommand: Schema.optional(Schema.Boolean), + /** Omit per-cwd provider snapshots; the client fetches its cwd through `server.refreshProviders`. */ + workspaceSnapshotsOnDemand: Schema.optional(Schema.Boolean), }), success: ServerConfigStreamEvent, error: Schema.Union([KeybindingsConfigError, ServerSettingsError, EnvironmentAuthorizationError]), diff --git a/packages/provider-grok/src/server/status.ts b/packages/provider-grok/src/server/status.ts index 1c341d0cf2cdc..c634c5b80c74e 100644 --- a/packages/provider-grok/src/server/status.ts +++ b/packages/provider-grok/src/server/status.ts @@ -527,6 +527,12 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func discoveredModels.length > 0 ? grokModelsFromSettings(grokSettings.customModels, discoveredModels) : fallbackModels; + // The registry replaces cached models on a ready probe, so a fallback-only list must not be ready. + const incompleteMessage = acpFailed + ? "Grok CLI is installed but ACP initialize failed. Model options may be incomplete." + : discoveredModels.length === 0 + ? "Grok CLI did not list any models. Model options may be incomplete." + : undefined; if (auth.status === "unauthenticated") { return buildServerProvider({ @@ -555,14 +561,9 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func probe: { installed: true, version, - status: acpFailed ? "warning" : "ready", + status: incompleteMessage ? "warning" : "ready", auth, - ...(acpFailed - ? { - message: - "Grok CLI is installed but ACP initialize failed. Model options may be incomplete.", - } - : {}), + ...(incompleteMessage ? { message: incompleteMessage } : {}), }, }); }); diff --git a/packages/shared/package.json b/packages/shared/package.json index 5d0c5b76d3525..baa12fd43c5a3 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -59,6 +59,10 @@ "types": "./src/logging.ts", "import": "./src/logging.ts" }, + "./releaseResource": { + "types": "./src/releaseResource.ts", + "import": "./src/releaseResource.ts" + }, "./observability": { "types": "./src/observability.ts", "import": "./src/observability.ts" diff --git a/packages/shared/src/observability.test.ts b/packages/shared/src/observability.test.ts index cab62b1bc2a35..f858f5e3a4588 100644 --- a/packages/shared/src/observability.test.ts +++ b/packages/shared/src/observability.test.ts @@ -24,8 +24,16 @@ import { type TraceRecord, OtlpHeadersFromString, truncateTraceAttributes, + processServiceInstanceId, } from "./observability.ts"; +describe("processServiceInstanceId", () => { + it("stays the same for the process unless the operator set one", () => { + expect(processServiceInstanceId()).toBe(processServiceInstanceId({})); + expect(processServiceInstanceId({ "service.instance.id": "pod-7" })).toBe("pod-7"); + }); +}); + describe("errorTag", () => { it("reports structural tags without retaining arbitrary values", () => { assert.equal(errorTag({ _tag: "AcpRequestError" }), "AcpRequestError"); diff --git a/packages/shared/src/observability.ts b/packages/shared/src/observability.ts index 241822a302acd..db1f50734bea3 100644 --- a/packages/shared/src/observability.ts +++ b/packages/shared/src/observability.ts @@ -26,6 +26,21 @@ export interface SignalExport { readonly exportIntervalMs: number; } +let generatedServiceInstanceId: string | undefined; + +/** + * `service.instance.id` shared by every resource this process exports. Effect lets code + * attributes beat `OTEL_RESOURCE_ATTRIBUTES`, so callers pass the operator's in to keep it winning. + */ +export const processServiceInstanceId = ( + operatorAttributes: Readonly> = {}, +): string => { + if (operatorAttributes["service.instance.id"]) return operatorAttributes["service.instance.id"]; + // @effect-diagnostics-next-line cryptoRandomUUID:off -- Resources are built synchronously, outside any Effect. + generatedServiceInstanceId ??= crypto.randomUUID(); + return generatedServiceInstanceId; +}; + export const DEFAULT_SIGNAL_EXPORT: SignalExport = { protocol: "http/json", headers: undefined, diff --git a/packages/shared/src/relayTracing.ts b/packages/shared/src/relayTracing.ts index 3988e6a71abde..bd00f0ad04a84 100644 --- a/packages/shared/src/relayTracing.ts +++ b/packages/shared/src/relayTracing.ts @@ -18,8 +18,10 @@ export interface RelayClientTracingResource { readonly serviceName: string; readonly serviceVersion?: string; readonly runtime: string; + readonly serviceInstanceId: string; readonly client: string; readonly component?: string; + readonly attributes?: Readonly> | undefined; } export class RelayClientTracer extends Context.Reference( @@ -165,9 +167,11 @@ export function layer( serviceVersion: resource.serviceVersion, attributes: { "service.namespace": "t3code", + "service.instance.id": resource.serviceInstanceId, "service.runtime": resource.runtime, "t3code.component": resource.component ?? "relay-client", "t3code.client.surface": resource.client, + ...resource.attributes, }, }, }).pipe(Layer.provide(OtlpSerialization.layerJson)); diff --git a/packages/shared/src/releaseResource.test.ts b/packages/shared/src/releaseResource.test.ts new file mode 100644 index 0000000000000..c5243a47daa26 --- /dev/null +++ b/packages/shared/src/releaseResource.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { releaseChannelOf, releaseResourceAttributes } from "./releaseResource.ts"; + +describe("releaseResourceAttributes", () => { + it.each([ + ["dev", {}, "dev", "development"], + ["stable", {}, "stable", "production"], + ["nightly", {}, "nightly", "production"], + ["preview", {}, "preview", "staging"], + [ + "stable", + { "deployment.environment.name": "staging", "t3code.release.channel": "" }, + "stable", + "staging", + ], + ] as const)( + "%s (operator: %o) reports %s in %s", + (channel, operatorAttributes, expectedChannel, environment) => { + expect(releaseResourceAttributes(channel, operatorAttributes)).toEqual({ + "t3code.release.channel": expectedChannel, + "deployment.environment.name": environment, + }); + }, + ); + + it.each([ + ["0.1.2", "stable"], + ["0.1.2-nightly.20261025.1", "nightly"], + ["0.1.2-preview.20261025.1", "preview"], + ["0.1.2-pr.24340.123", "preview"], + ] as const)("classifies %s as %s", (version, channel) => { + expect(releaseChannelOf(version)).toBe(channel); + }); +}); diff --git a/packages/shared/src/releaseResource.ts b/packages/shared/src/releaseResource.ts new file mode 100644 index 0000000000000..492a6e05536aa --- /dev/null +++ b/packages/shared/src/releaseResource.ts @@ -0,0 +1,24 @@ +import { type CliReleaseChannel, cliReleaseChannelOf } from "./cliRelease.ts"; + +export type ReleaseChannel = "dev" | CliReleaseChannel; + +// Desktop PR preview builds are stamped `X.Y.Z-pr..`; the CLI updater must not see them as preview. +export const releaseChannelOf = (version: string): ReleaseChannel => + /-pr\.\d+\.\d+$/.test(version) ? "preview" : cliReleaseChannelOf(version); + +// Effect lets code attributes beat OTEL_RESOURCE_ATTRIBUTES, so the operator value is applied here. +export const releaseResourceAttributes = ( + channel: ReleaseChannel, + operatorAttributes: Readonly> = {}, +) => { + const operatorValue = (key: string) => { + const value = operatorAttributes[key]; + return typeof value === "string" && value !== "" ? value : undefined; + }; + return { + "t3code.release.channel": operatorValue("t3code.release.channel") ?? channel, + "deployment.environment.name": + operatorValue("deployment.environment.name") ?? + (channel === "dev" ? "development" : channel === "preview" ? "staging" : "production"), + }; +}; diff --git a/scripts/effect-budget.json b/scripts/effect-budget.json new file mode 100644 index 0000000000000..44c55fb275201 --- /dev/null +++ b/scripts/effect-budget.json @@ -0,0 +1,433 @@ +{ + "apps/mobile/src/App.tsx": 2, + "apps/mobile/src/Stack.tsx": 1, + "apps/mobile/src/components/ComposerAttachmentStrip.tsx": 2, + "apps/mobile/src/components/ComposerContextAttachment.tsx": 1, + "apps/mobile/src/components/ComposerEditor.tsx": 2, + "apps/mobile/src/components/FilePreview.ios.tsx": 1, + "apps/mobile/src/components/FilePreview.tsx": 1, + "apps/mobile/src/components/FilePreviewModal.tsx": 5, + "apps/mobile/src/components/MediaVideoPlayer.tsx": 3, + "apps/mobile/src/components/PdfPreview.android.tsx": 1, + "apps/mobile/src/components/ProjectFavicon.tsx": 1, + "apps/mobile/src/components/VideoPreviewModal.ios.tsx": 6, + "apps/mobile/src/components/VideoPreviewModal.tsx": 3, + "apps/mobile/src/components/VideoThumbnailImage.tsx": 1, + "apps/mobile/src/components/ui/AndroidAnchoredMenu.tsx": 1, + "apps/mobile/src/components/ui/AppBar.android.tsx": 1, + "apps/mobile/src/components/ui/Dialog.android.tsx": 1, + "apps/mobile/src/components/ui/Notice.tsx": 1, + "apps/mobile/src/components/ui/OverlayPortal.tsx": 3, + "apps/mobile/src/components/ui/SliderRow.android.tsx": 1, + "apps/mobile/src/components/ui/SliderRow.tsx": 1, + "apps/mobile/src/components/ui/Toast.tsx": 2, + "apps/mobile/src/features/agent-awareness/notificationNavigation.ts": 3, + "apps/mobile/src/features/browser/BrowserPreviewRouteScreen.tsx": 2, + "apps/mobile/src/features/browser/PreviewStreamWebView.tsx": 5, + "apps/mobile/src/features/browser/ThreadBrowserFloat.tsx": 2, + "apps/mobile/src/features/cloud/CloudAuthProvider.tsx": 3, + "apps/mobile/src/features/cloud/ConnectOnboardingRouteScreen.tsx": 1, + "apps/mobile/src/features/cloud/connectOnboardingNavigation.ts": 1, + "apps/mobile/src/features/cloud/managedRelayState.ts": 1, + "apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx": 5, + "apps/mobile/src/features/connection/PermissionUpdateNotice.tsx": 2, + "apps/mobile/src/features/devices/DevicePreviewRouteScreen.tsx": 6, + "apps/mobile/src/features/devices/DeviceStreamWebView.tsx": 2, + "apps/mobile/src/features/diagnostics/SettingsDiagnosticsRouteScreen.tsx": 1, + "apps/mobile/src/features/diffs/nativeReviewDiffSurface.ts": 3, + "apps/mobile/src/features/files/AttachmentFileScreen.tsx": 1, + "apps/mobile/src/features/files/AudioFilePreview.tsx": 2, + "apps/mobile/src/features/files/FileTreeBrowser.tsx": 3, + "apps/mobile/src/features/files/FilesHeader.tsx": 1, + "apps/mobile/src/features/files/SourceFileSurface.tsx": 1, + "apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx": 1, + "apps/mobile/src/features/files/WorkspaceFileWebPreview.tsx": 1, + "apps/mobile/src/features/files/useFileTreeEntries.ts": 1, + "apps/mobile/src/features/home/HomeRouteScreen.tsx": 2, + "apps/mobile/src/features/home/HomeScreen.tsx": 4, + "apps/mobile/src/features/home/ThreadListToolbar.tsx": 1, + "apps/mobile/src/features/home/WorkspaceConnectionTitle.tsx": 2, + "apps/mobile/src/features/home/home-list-options.ts": 1, + "apps/mobile/src/features/home/home-route-visibility.ts": 1, + "apps/mobile/src/features/home/thread-swipe-actions.tsx": 5, + "apps/mobile/src/features/keyboard/CommandPalette.tsx": 2, + "apps/mobile/src/features/keyboard/HardwareKeyboardCommandProvider.tsx": 1, + "apps/mobile/src/features/keyboard/hardwareKeyboardCommands.ts": 1, + "apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx": 3, + "apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx": 1, + "apps/mobile/src/features/layout/workspace-inspector-pane.tsx": 2, + "apps/mobile/src/features/projects/AddProjectScreen.tsx": 2, + "apps/mobile/src/features/prompt-links/usePromptLinkResolver.ts": 1, + "apps/mobile/src/features/prompt-links/useThreadPromptLink.ts": 1, + "apps/mobile/src/features/pull-requests/PullRequestFilterMenuContext.tsx": 1, + "apps/mobile/src/features/pull-requests/PullRequestReviewersScreen.tsx": 1, + "apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx": 4, + "apps/mobile/src/features/pull-requests/PullRequestToolbar.android.tsx": 1, + "apps/mobile/src/features/pull-requests/PullRequestsScreen.tsx": 1, + "apps/mobile/src/features/pull-requests/guide/PullRequestGuideScreen.tsx": 1, + "apps/mobile/src/features/pull-requests/usePullRequestDiff.ts": 1, + "apps/mobile/src/features/pull-requests/usePullRequestEnvironmentFallback.ts": 1, + "apps/mobile/src/features/pull-requests/usePullRequestFilesViewed.ts": 4, + "apps/mobile/src/features/pull-requests/usePullRequestList.ts": 3, + "apps/mobile/src/features/pull-requests/usePullRequestReviewDraft.ts": 1, + "apps/mobile/src/features/review/ReviewSheet.tsx": 1, + "apps/mobile/src/features/review/useNativeReviewDiffHighlighting.ts": 2, + "apps/mobile/src/features/review/useReviewCommentSelectionController.ts": 2, + "apps/mobile/src/features/review/useReviewDiffData.ts": 2, + "apps/mobile/src/features/review/useReviewDiffPrewarming.ts": 1, + "apps/mobile/src/features/review/useReviewSections.ts": 5, + "apps/mobile/src/features/settings/OutdatedServerUpdateSection.tsx": 1, + "apps/mobile/src/features/settings/SettingsAboutRouteScreen.tsx": 1, + "apps/mobile/src/features/settings/SettingsAuthRouteScreen.tsx": 2, + "apps/mobile/src/features/settings/SettingsEnvironmentDetailRouteScreen.tsx": 1, + "apps/mobile/src/features/settings/SettingsMicrophoneRouteScreen.tsx": 1, + "apps/mobile/src/features/settings/SettingsNotificationsRouteScreen.tsx": 2, + "apps/mobile/src/features/settings/SettingsProjectOverviewRouteScreen.tsx": 1, + "apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx": 2, + "apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx": 2, + "apps/mobile/src/features/settings/components/BranchNamingSettings.tsx": 1, + "apps/mobile/src/features/settings/components/SettingsLegalDocumentRouteScreen.tsx": 1, + "apps/mobile/src/features/sharing/IncomingShareProvider.tsx": 4, + "apps/mobile/src/features/shortcuts/useAppShortcuts.ts": 4, + "apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx": 11, + "apps/mobile/src/features/terminal/NativeTerminalSurface.tsx": 4, + "apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx": 14, + "apps/mobile/src/features/terminal/terminalLaunchContext.ts": 1, + "apps/mobile/src/features/terminal/useTerminalGridSync.ts": 1, + "apps/mobile/src/features/terminal/useTerminalLifecycle.ts": 1, + "apps/mobile/src/features/threads/ComposerPopoverAnchor.android.tsx": 2, + "apps/mobile/src/features/threads/ComposerPopoverAnchor.tsx": 1, + "apps/mobile/src/features/threads/CopyTextButton.tsx": 1, + "apps/mobile/src/features/threads/CustomSnoozeSheet.android.tsx": 1, + "apps/mobile/src/features/threads/GitActionProgressOverlay.tsx": 1, + "apps/mobile/src/features/threads/HtmlRenderWebView.tsx": 1, + "apps/mobile/src/features/threads/McpAppFullscreenScreen.tsx": 1, + "apps/mobile/src/features/threads/McpAppWebView.tsx": 7, + "apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx": 2, + "apps/mobile/src/features/threads/NewTaskDraftRouteScreen.tsx": 5, + "apps/mobile/src/features/threads/NewTaskDraftScreen.tsx": 10, + "apps/mobile/src/features/threads/NewTaskRouteScreen.tsx": 1, + "apps/mobile/src/features/threads/QuestionAttachments.tsx": 2, + "apps/mobile/src/features/threads/ThreadAgentsSheet.tsx": 1, + "apps/mobile/src/features/threads/ThreadArrangementSheet.tsx": 3, + "apps/mobile/src/features/threads/ThreadComposer.tsx": 6, + "apps/mobile/src/features/threads/ThreadDetailScreen.tsx": 20, + "apps/mobile/src/features/threads/ThreadFeed.tsx": 14, + "apps/mobile/src/features/threads/ThreadGuestShareBar.tsx": 1, + "apps/mobile/src/features/threads/ThreadInlineFilePreview.tsx": 2, + "apps/mobile/src/features/threads/ThreadMarkdownImage.tsx": 3, + "apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx": 3, + "apps/mobile/src/features/threads/ThreadQueueControl.tsx": 2, + "apps/mobile/src/features/threads/ThreadRouteScreen.tsx": 2, + "apps/mobile/src/features/threads/ThreadScreenHost.tsx": 2, + "apps/mobile/src/features/threads/ThreadSettingsSheet.tsx": 2, + "apps/mobile/src/features/threads/UsageLimitRecoveryCard.tsx": 1, + "apps/mobile/src/features/threads/floating-working-control.tsx": 2, + "apps/mobile/src/features/threads/git/GitOverviewSheet.tsx": 1, + "apps/mobile/src/features/threads/new-task-flow-provider.tsx": 6, + "apps/mobile/src/features/threads/thread-inspector-content-stack.tsx": 1, + "apps/mobile/src/features/threads/thread-list-v2-items.tsx": 1, + "apps/mobile/src/features/threads/thread-subagent-group.tsx": 2, + "apps/mobile/src/features/threads/thread-work-log.tsx": 5, + "apps/mobile/src/features/threads/use-composer-command-menu.ts": 5, + "apps/mobile/src/features/threads/use-thread-settings-sheet-presentation.ts": 2, + "apps/mobile/src/features/threads/use-visible-second-clock.ts": 2, + "apps/mobile/src/features/threads/use-worktree-setup.ts": 1, + "apps/mobile/src/features/threads/useFileChipShare.ts": 2, + "apps/mobile/src/features/usage/UsageLimitsSection.tsx": 1, + "apps/mobile/src/features/usage/UsageRouteScreen.tsx": 1, + "apps/mobile/src/features/voice-input/ComposerDictationControl.tsx": 3, + "apps/mobile/src/features/voice-input/GlobalVoiceInputControl.tsx": 2, + "apps/mobile/src/features/voice-input/VoiceInputProvider.tsx": 4, + "apps/mobile/src/features/voice-input/useVoiceInputController.ts": 1, + "apps/mobile/src/features/workspace/AndroidWorkspaceTabsProvider.tsx": 1, + "apps/mobile/src/features/workspace/WorkspaceNativeTabs.ios.tsx": 1, + "apps/mobile/src/features/workspace/WorkspaceTabsContext.tsx": 1, + "apps/mobile/src/features/workspace/WorkspaceToolbar.ios.tsx": 1, + "apps/mobile/src/features/workspace/WorkspaceToolbar.tsx": 2, + "apps/mobile/src/lib/attachmentDocument.ts": 4, + "apps/mobile/src/lib/useDelayedStatus.ts": 2, + "apps/mobile/src/lib/useUiRuntimeMemoryWarningGc.ts": 1, + "apps/mobile/src/lib/videoAudioSession.ts": 1, + "apps/mobile/src/native/NativeHeaderToolbar.tsx": 2, + "apps/mobile/src/native/NativeWorkspaceColumns.ios.tsx": 1, + "apps/mobile/src/native/StackHeader.tsx": 2, + "apps/mobile/src/native/T3ComposerEditor.ios.tsx": 2, + "apps/mobile/src/native/T3ComposerEditor.native.tsx": 2, + "apps/mobile/src/native/V5StackHeader.ios.tsx": 1, + "apps/mobile/src/native/useRetryingImageSource.ts": 1, + "apps/mobile/src/state/composer-attachment-uploads.ts": 2, + "apps/mobile/src/state/mediaActions.ts": 1, + "apps/mobile/src/state/olderSettledShelf.ts": 2, + "apps/mobile/src/state/queries.ts": 2, + "apps/mobile/src/state/thread-order.ts": 1, + "apps/mobile/src/state/threads.ts": 1, + "apps/mobile/src/state/use-composer-attachment-actions.ts": 1, + "apps/mobile/src/state/use-composer-drafts.ts": 3, + "apps/mobile/src/state/use-pull-to-refresh.ts": 1, + "apps/mobile/src/state/use-selected-thread-git-actions.ts": 1, + "apps/mobile/src/state/use-selected-thread-requests.ts": 2, + "apps/mobile/src/state/use-thread-composer-state.ts": 3, + "apps/mobile/src/state/use-thread-outbox-drain.ts": 4, + "apps/mobile/src/state/use-thread-pr.ts": 1, + "apps/mobile/src/state/use-vcs-action-state.ts": 1, + "apps/mobile/src/widgets/SubscriptionUsageCoordinator.tsx": 1, + "apps/mobile/src/widgets/useSubscriptionUsage.ts": 1, + "apps/web/src/AppRoot.tsx": 1, + "apps/web/src/browser/BrowserProfileReporter.tsx": 1, + "apps/web/src/browser/BrowserSurfaceSlot.tsx": 2, + "apps/web/src/browser/ElectronBrowserHost.tsx": 3, + "apps/web/src/browser/HostedBrowserWebview.tsx": 6, + "apps/web/src/browser/ServerBrowserSurface.tsx": 5, + "apps/web/src/browser/useBrowserViewportResize.ts": 2, + "apps/web/src/cloud/managedAuth.tsx": 2, + "apps/web/src/cloud/managedRelayState.ts": 2, + "apps/web/src/components/AddProjectToMachineDialog.tsx": 1, + "apps/web/src/components/AnimatedHeight.tsx": 2, + "apps/web/src/components/AnimatedWidth.tsx": 1, + "apps/web/src/components/AppSidebarLayout.tsx": 4, + "apps/web/src/components/BackgroundThreadAnnouncer.tsx": 1, + "apps/web/src/components/BranchPicker.tsx": 2, + "apps/web/src/components/BranchToolbar.tsx": 4, + "apps/web/src/components/BranchToolbarBranchSelector.tsx": 1, + "apps/web/src/components/ChatMarkdown.tsx": 1, + "apps/web/src/components/ChatView.tsx": 23, + "apps/web/src/components/CommandPalette.tsx": 5, + "apps/web/src/components/CommandPaletteContent.tsx": 1, + "apps/web/src/components/ComposerPromptEditorTiptap.tsx": 13, + "apps/web/src/components/ConfirmDialogHost.tsx": 1, + "apps/web/src/components/CustomSnoozeDialog.tsx": 1, + "apps/web/src/components/DiffPanel.tsx": 4, + "apps/web/src/components/DiffWorkerPoolProvider.tsx": 2, + "apps/web/src/components/GitActionsControl.tsx": 4, + "apps/web/src/components/HostFreezeToastCoordinator.tsx": 1, + "apps/web/src/components/LegacySidebar.tsx": 5, + "apps/web/src/components/LegacyThreadMigrationToast.tsx": 2, + "apps/web/src/components/PermissionUpdateNotice.tsx": 1, + "apps/web/src/components/PersistentThreadTerminals.tsx": 1, + "apps/web/src/components/ProjectCloneToastCoordinator.tsx": 2, + "apps/web/src/components/PullRequestThreadDialog.tsx": 1, + "apps/web/src/components/QuitHoldOverlay.tsx": 1, + "apps/web/src/components/ReopenClosedViewShortcut.tsx": 2, + "apps/web/src/components/RightPanelTabs.tsx": 5, + "apps/web/src/components/Sidebar.logic.ts": 2, + "apps/web/src/components/Sidebar.pointer.ts": 1, + "apps/web/src/components/Sidebar.tsx": 10, + "apps/web/src/components/SlowRpcRequestToastCoordinator.tsx": 2, + "apps/web/src/components/ThreadNotificationCoordinator.tsx": 5, + "apps/web/src/components/ThreadRouteView.tsx": 5, + "apps/web/src/components/ThreadTerminalDrawer.tsx": 13, + "apps/web/src/components/auth/ConnectAgentSurface.tsx": 1, + "apps/web/src/components/auth/PairingRouteSurface.tsx": 2, + "apps/web/src/components/chat/AgentElapsed.tsx": 1, + "apps/web/src/components/chat/AssistantCitationChip.tsx": 2, + "apps/web/src/components/chat/AssistantCitationSource.tsx": 1, + "apps/web/src/components/chat/AssistantSelectionToolbar.tsx": 2, + "apps/web/src/components/chat/ChangedFilesTree.tsx": 1, + "apps/web/src/components/chat/ChatCanvas.tsx": 1, + "apps/web/src/components/chat/ChatComposer.tsx": 26, + "apps/web/src/components/chat/ChatHeader.tsx": 2, + "apps/web/src/components/chat/ChatMarkdownMedia.tsx": 1, + "apps/web/src/components/chat/ChatOverlayHosts.tsx": 1, + "apps/web/src/components/chat/CommandOutputPanel.tsx": 1, + "apps/web/src/components/chat/ComposerAttachmentStrip.tsx": 1, + "apps/web/src/components/chat/ComposerBannerStack.tsx": 3, + "apps/web/src/components/chat/ComposerCommandMenu.tsx": 1, + "apps/web/src/components/chat/ComposerCommandMenuLayer.tsx": 1, + "apps/web/src/components/chat/ComposerImageThumbnail.tsx": 1, + "apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx": 2, + "apps/web/src/components/chat/ComposerStashMenu.tsx": 2, + "apps/web/src/components/chat/DraftHeroHeadline.tsx": 1, + "apps/web/src/components/chat/DraftHeroProjectHeading.tsx": 1, + "apps/web/src/components/chat/ExpandedImageDialog.tsx": 4, + "apps/web/src/components/chat/HtmlRenderFrame.tsx": 3, + "apps/web/src/components/chat/MarkdownCodeBlock.tsx": 3, + "apps/web/src/components/chat/MarkdownTable.tsx": 3, + "apps/web/src/components/chat/McpAppFrame.tsx": 10, + "apps/web/src/components/chat/MermaidDiagram.tsx": 1, + "apps/web/src/components/chat/MessagesTimeline.tsx": 10, + "apps/web/src/components/chat/ModelPickerContent.tsx": 4, + "apps/web/src/components/chat/ModelPickerSidebar.tsx": 1, + "apps/web/src/components/chat/OpenInPicker.tsx": 1, + "apps/web/src/components/chat/OpenInPickerShortcut.ts": 1, + "apps/web/src/components/chat/PierreEntryIcon.tsx": 1, + "apps/web/src/components/chat/ProviderModelPicker.tsx": 1, + "apps/web/src/components/chat/ProviderSubagentBar.tsx": 1, + "apps/web/src/components/chat/ThreadActivityAnnouncer.tsx": 1, + "apps/web/src/components/chat/ThreadDetailsCard.tsx": 4, + "apps/web/src/components/chat/ThreadFindBar.tsx": 1, + "apps/web/src/components/chat/ThreadFindProvider.tsx": 1, + "apps/web/src/components/chat/ThreadTimeline.tsx": 1, + "apps/web/src/components/chat/ThreadVisitWatermark.tsx": 2, + "apps/web/src/components/chat/TimelineMinimap.tsx": 1, + "apps/web/src/components/chat/TimelineWorkGroupRows.tsx": 3, + "apps/web/src/components/chat/UsageLimitRecoveryBanner.tsx": 1, + "apps/web/src/components/chat/WorkLog.tsx": 1, + "apps/web/src/components/chat/WorktreeSetupCard.tsx": 1, + "apps/web/src/components/chat/ZoomableImage.tsx": 3, + "apps/web/src/components/chat/composerContextUndo.ts": 1, + "apps/web/src/components/chat/threadContextDrag.ts": 1, + "apps/web/src/components/chat/threadFindHighlights.ts": 3, + "apps/web/src/components/chat/useAssistantCitationTarget.ts": 2, + "apps/web/src/components/chat/useAttachmentPreviewHandoff.ts": 4, + "apps/web/src/components/chat/useChatShortcuts.ts": 1, + "apps/web/src/components/chat/useComposerCommandKey.ts": 1, + "apps/web/src/components/chat/useComposerMultilinePrompt.ts": 1, + "apps/web/src/components/chat/useComposerRestingTransition.ts": 5, + "apps/web/src/components/chat/useComposerSend.ts": 1, + "apps/web/src/components/chat/useComposerStash.ts": 2, + "apps/web/src/components/chat/useComposerTypingGuard.ts": 1, + "apps/web/src/components/chat/useDraftEnvironmentRouting.ts": 3, + "apps/web/src/components/chat/useDraftHeroLayoutTransition.ts": 1, + "apps/web/src/components/chat/usePasteToFocusComposer.ts": 1, + "apps/web/src/components/chat/usePendingThreadRequests.ts": 2, + "apps/web/src/components/chat/useProviderWorkspaceSnapshotRefresh.ts": 2, + "apps/web/src/components/chat/useQueuedRunEdit.ts": 1, + "apps/web/src/components/chat/useRestingComposerControlsLayout.ts": 1, + "apps/web/src/components/chat/useRightPanelActions.ts": 1, + "apps/web/src/components/chat/useSystemComposerBanners.tsx": 1, + "apps/web/src/components/chat/useThreadFind.ts": 6, + "apps/web/src/components/chat/useThreadFindNavigation.ts": 5, + "apps/web/src/components/chat/useThreadFindPositionReader.ts": 1, + "apps/web/src/components/chat/useThreadLifecycle.tsx": 1, + "apps/web/src/components/chat/useTimelineFollow.ts": 6, + "apps/web/src/components/chat/useTimelinePositionRestore.ts": 2, + "apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx": 3, + "apps/web/src/components/cloud/ConnectCliAuthSurface.tsx": 1, + "apps/web/src/components/contextChipParts.tsx": 1, + "apps/web/src/components/desktop/DesktopAppActivationCoordinator.tsx": 1, + "apps/web/src/components/desktop/DesktopWebLinkCoordinator.tsx": 1, + "apps/web/src/components/desktop/SnapShotCoordinator.tsx": 2, + "apps/web/src/components/desktop/SshPasswordPromptDialog.tsx": 3, + "apps/web/src/components/device/DeviceAndroidFoldControls.tsx": 2, + "apps/web/src/components/device/DeviceDuoViewport.tsx": 4, + "apps/web/src/components/device/DevicePanel.tsx": 1, + "apps/web/src/components/device/DevicePhoneViewport.tsx": 3, + "apps/web/src/components/device/DeviceSetup.tsx": 1, + "apps/web/src/components/device/DeviceStreamView.tsx": 4, + "apps/web/src/components/device/DeviceToolsPanel.tsx": 1, + "apps/web/src/components/device/DeviceWorkspace.tsx": 1, + "apps/web/src/components/device/useDeviceControls.ts": 2, + "apps/web/src/components/diffs/DiffCommentAnnotation.tsx": 1, + "apps/web/src/components/diffs/DiffFileLoadingBoundary.tsx": 1, + "apps/web/src/components/diffs/DiffFileTree.tsx": 3, + "apps/web/src/components/diffs/useCodeViewFileReveal.ts": 1, + "apps/web/src/components/diffs/useReviewFilePatches.ts": 1, + "apps/web/src/components/files/AttachmentFilePreview.tsx": 3, + "apps/web/src/components/files/BrowserDocumentFrame.tsx": 7, + "apps/web/src/components/files/FileBrowserPanel.tsx": 9, + "apps/web/src/components/files/FilePreviewPanel.tsx": 3, + "apps/web/src/components/files/useDirectoryEntries.ts": 1, + "apps/web/src/components/files/useFileSaveCoordinator.ts": 2, + "apps/web/src/components/guest/GuestHtmlRender.tsx": 3, + "apps/web/src/components/guest/GuestPage.tsx": 7, + "apps/web/src/components/media/AttachmentImage.tsx": 1, + "apps/web/src/components/media/MediaVideoPlayer.tsx": 2, + "apps/web/src/components/onboarding/FirstRunGate.tsx": 3, + "apps/web/src/components/onboarding/WelcomeWizard.tsx": 7, + "apps/web/src/components/permissions/usePermissionStatus.ts": 1, + "apps/web/src/components/preview/AgentBrowserCursor.tsx": 1, + "apps/web/src/components/preview/PreviewChromeRow.tsx": 1, + "apps/web/src/components/preview/PreviewPanelShell.tsx": 3, + "apps/web/src/components/preview/PreviewView.tsx": 7, + "apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx": 3, + "apps/web/src/components/preview/ZoomIndicator.tsx": 1, + "apps/web/src/components/preview/useAgentOpenedPreviews.ts": 3, + "apps/web/src/components/preview/usePreviewBridge.ts": 2, + "apps/web/src/components/projectScriptEditor.tsx": 1, + "apps/web/src/components/promptLinks/HostedPromptLinkLanding.tsx": 1, + "apps/web/src/components/promptLinks/NewThreadLinkView.tsx": 2, + "apps/web/src/components/promptLinks/ThreadLinkPrompt.tsx": 1, + "apps/web/src/components/pullRequest/LinkPullRequestDialog.tsx": 1, + "apps/web/src/components/pullRequest/PullRequestCodeTab.tsx": 5, + "apps/web/src/components/pullRequest/PullRequestCommentBody.tsx": 1, + "apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx": 5, + "apps/web/src/components/pullRequest/PullRequestGuideTab.tsx": 2, + "apps/web/src/components/pullRequest/PullRequestsColumn.tsx": 5, + "apps/web/src/components/pullRequest/PullRequestsPage.tsx": 9, + "apps/web/src/components/pullRequest/guide/GuideTour.tsx": 1, + "apps/web/src/components/pullRequest/guide/PullRequestGuideRunCoordinator.tsx": 2, + "apps/web/src/components/pullRequest/guide/useReviewGuideWriter.ts": 1, + "apps/web/src/components/pullRequest/useInlineActionConfirm.ts": 1, + "apps/web/src/components/pullRequest/usePullRequestDetail.ts": 1, + "apps/web/src/components/pullRequest/usePullRequestFilesViewed.ts": 2, + "apps/web/src/components/pullRequest/useThreadLinkedPullRequest.ts": 1, + "apps/web/src/components/search/ProjectContentSearchDialog.tsx": 1, + "apps/web/src/components/settings/AcpRegistryIcon.tsx": 1, + "apps/web/src/components/settings/AcpRegistrySearchStep.tsx": 2, + "apps/web/src/components/settings/AddCodexAccountDialog.tsx": 1, + "apps/web/src/components/settings/AddEnvironmentDialog.tsx": 1, + "apps/web/src/components/settings/CliCommandSettingsRow.tsx": 1, + "apps/web/src/components/settings/CodexSetupSection.tsx": 6, + "apps/web/src/components/settings/ConnectionDiagnosisDetails.tsx": 1, + "apps/web/src/components/settings/DiagnosticsSettings.tsx": 2, + "apps/web/src/components/settings/EasDeviceHostsSettings.tsx": 1, + "apps/web/src/components/settings/FontFamilyPicker.tsx": 2, + "apps/web/src/components/settings/OpenSourceLicenses.tsx": 1, + "apps/web/src/components/settings/ProjectFaviconThumbnail.tsx": 1, + "apps/web/src/components/settings/ProjectIconPickerDialog.tsx": 1, + "apps/web/src/components/settings/ProjectSettingsPanel.tsx": 2, + "apps/web/src/components/settings/ProviderAccentColorPicker.tsx": 3, + "apps/web/src/components/settings/ProviderAuthCallbackCoordinator.tsx": 1, + "apps/web/src/components/settings/ProviderAuthTerminal.tsx": 2, + "apps/web/src/components/settings/ProviderInstanceCard.tsx": 1, + "apps/web/src/components/settings/ProviderModelsSection.tsx": 1, + "apps/web/src/components/settings/ProviderSettingsForm.tsx": 1, + "apps/web/src/components/settings/ResourceTelemetryDiagnostics.tsx": 2, + "apps/web/src/components/settings/SavedEnvironmentRows.tsx": 1, + "apps/web/src/components/settings/ScheduledTasksSettings.tsx": 2, + "apps/web/src/components/settings/SettingsFontPreviews.tsx": 5, + "apps/web/src/components/settings/SettingsPanels.tsx": 1, + "apps/web/src/components/settings/SettingsSidebarNav.tsx": 3, + "apps/web/src/components/settings/SnapShotSettings.tsx": 4, + "apps/web/src/components/settings/TelemetryExportSettings.tsx": 1, + "apps/web/src/components/settings/ThemeColorPicker.tsx": 1, + "apps/web/src/components/settings/ThemeEditorPanel.tsx": 5, + "apps/web/src/components/settings/ThemeImportDialog.tsx": 1, + "apps/web/src/components/settings/ThemeSearchSection.tsx": 3, + "apps/web/src/components/settings/TypographySettings.tsx": 2, + "apps/web/src/components/settings/settingsLayout.tsx": 1, + "apps/web/src/components/settings/useSnapShotShortcutRecorder.tsx": 1, + "apps/web/src/components/sidebar/SidebarProviderUpdatePill.tsx": 2, + "apps/web/src/components/sidebar/SidebarThreadRow.tsx": 5, + "apps/web/src/components/sidebar/SidebarThreadUndoNotice.tsx": 1, + "apps/web/src/components/sidebar/SidebarUpdatePill.tsx": 1, + "apps/web/src/components/sidebar/mainAppLocation.ts": 1, + "apps/web/src/components/sidebar/updateEverythingRun.tsx": 2, + "apps/web/src/components/ui/sidebar.tsx": 3, + "apps/web/src/components/ui/toast.tsx": 2, + "apps/web/src/components/ui/tooltip.tsx": 1, + "apps/web/src/components/usage/UsageLimits.tsx": 1, + "apps/web/src/components/usage/UsageLimitsPooled.tsx": 1, + "apps/web/src/components/usage/UsagePage.tsx": 3, + "apps/web/src/components/usage/UsageProviderChart.tsx": 1, + "apps/web/src/connection/useDesktopLocalBootstraps.ts": 1, + "apps/web/src/hooks/FileMetadataThreadProvider.tsx": 1, + "apps/web/src/hooks/useCopyToClipboard.ts": 1, + "apps/web/src/hooks/useDefaultTheme.ts": 1, + "apps/web/src/hooks/useDelayedStatus.ts": 2, + "apps/web/src/hooks/useEnvironmentDisconnectDelay.ts": 1, + "apps/web/src/hooks/useEnvironmentTheme.ts": 1, + "apps/web/src/hooks/useLiveRefresh.ts": 1, + "apps/web/src/hooks/useLiveRegion.ts": 1, + "apps/web/src/hooks/useNavigateBack.ts": 1, + "apps/web/src/hooks/usePreviewPanelInlineSize.ts": 1, + "apps/web/src/hooks/useProactivePanels.ts": 1, + "apps/web/src/hooks/useResizableWidth.ts": 2, + "apps/web/src/hooks/useResizeDrag.ts": 2, + "apps/web/src/hooks/useSettings.ts": 1, + "apps/web/src/hooks/useStableCallback.ts": 1, + "apps/web/src/hooks/useTheme.ts": 1, + "apps/web/src/hooks/useThreadVisitedMigration.ts": 1, + "apps/web/src/hooks/useWorkspaceMutationRefresh.ts": 1, + "apps/web/src/panelAnimations.ts": 2, + "apps/web/src/remoteOpen.ts": 1, + "apps/web/src/routes/__root.tsx": 13, + "apps/web/src/routes/_chat.index.tsx": 1, + "apps/web/src/routes/_chat.tsx": 1, + "apps/web/src/shortcutModifierState.ts": 1, + "apps/web/src/state/olderSettledShelf.ts": 2, + "apps/web/src/state/pullRequests.ts": 2, + "apps/web/src/state/queries.ts": 2, + "apps/web/src/state/threads.ts": 1 +} diff --git a/scripts/install.ps1 b/scripts/install.ps1 index f440ebaa11902..60a70fefc7211 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -133,8 +133,19 @@ if (-not $version) { "preview" { '^v\d+\.\d+\.\d+-preview\.\d+\.\d+$' } default { Fail "T3CODE_CHANNEL must be stable, nightly, or preview" } } - $releases = Invoke-RestMethod -Uri "https://api.github.com/repos/$repo/releases?per_page=100" -Headers @{ "User-Agent" = "t3-install" } - $tag = ($releases | Where-Object { -not $_.draft -and $_.tag_name -match $tagPattern } | Select-Object -First 1).tag_name + if ($channel -eq "stable") { + # release.yml marks only plain stable releases latest. This is a github.com + # page, so it is not cut off by a page of newer nightlies or the API quota. + $release = Invoke-RestMethod -Uri "https://github.com/$repo/releases/latest" -Headers @{ "User-Agent" = "t3-install"; "Accept" = "application/json" } + $tag = ($release | Where-Object { $_.tag_name -match $tagPattern }).tag_name + } else { + $tag = $null + for ($page = 1; -not $tag -and $page -le 10; $page++) { + $releases = Invoke-RestMethod -Uri "https://api.github.com/repos/$repo/releases?per_page=100&page=$page" -Headers @{ "User-Agent" = "t3-install" } + $tag = ($releases | Where-Object { -not $_.draft -and $_.tag_name -match $tagPattern } | Select-Object -First 1).tag_name + if (-not $releases) { break } + } + } if (-not $tag) { Fail "could not find a $channel release; set T3CODE_VERSION" } $version = $tag.Substring(1) } diff --git a/scripts/install.sh b/scripts/install.sh index 20a0c0251531d..4c164c45040f8 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -53,14 +53,14 @@ step "Finding your release..." # Exit 44 on a 404 so callers can tell "no such asset" from a network failure. fetch() { if command -v curl >/dev/null 2>&1; then - status="$(curl -sSL -w '%{http_code}' "$1" -o "$2")" || return 1 + status="$(curl -sSL -H "Accept: ${3:-*/*}" -w '%{http_code}' "$1" -o "$2")" || return 1 case "$status" in 2??) return 0 ;; 404) return 44 ;; *) printf 'GET %s returned HTTP %s\n' "$1" "$status" >&2; return 1 ;; esac elif command -v wget >/dev/null 2>&1; then - wget -q --server-response "$1" -O "$2" 2>"$2.headers" && rm -f "$2.headers" && return 0 + wget -q --server-response --header="Accept: ${3:-*/*}" "$1" -O "$2" 2>"$2.headers" && rm -f "$2.headers" && return 0 if grep -q ' 404 ' "$2.headers" 2>/dev/null; then rm -f "$2.headers"; return 44; fi cat "$2.headers" >&2; rm -f "$2.headers"; return 1 else @@ -151,8 +151,20 @@ if [ -z "$version" ]; then *) fail "T3CODE_CHANNEL must be stable, nightly, or preview" ;; esac tmp_index="$(mktemp)" - fetch "https://api.github.com/repos/${repo}/releases?per_page=100" "$tmp_index" - version="$(sed -n "s/.*\"tag_name\": *\"${tag_pattern}\".*/\1/p" "$tmp_index" | head -n 1)" + if [ "$channel" = stable ]; then + # release.yml marks only plain stable releases latest. This is a github.com + # page, so it is not cut off by a page of newer nightlies or the API quota. + fetch "https://github.com/${repo}/releases/latest" "$tmp_index" application/json + version="$(sed -n "s/.*\"tag_name\": *\"${tag_pattern}\".*/\1/p" "$tmp_index" | head -n 1)" + else + page=1 + while [ -z "$version" ] && [ "$page" -le 10 ]; do + fetch "https://api.github.com/repos/${repo}/releases?per_page=100&page=${page}" "$tmp_index" + version="$(sed -n "s/.*\"tag_name\": *\"${tag_pattern}\".*/\1/p" "$tmp_index" | head -n 1)" + grep -q '"tag_name"' "$tmp_index" || break + page=$((page + 1)) + done + fi rm -f "$tmp_index" [ -n "$version" ] || fail "could not find a ${channel} release; set T3CODE_VERSION" fi diff --git a/scripts/install.test.ts b/scripts/install.test.ts new file mode 100644 index 0000000000000..5be7acc8e004d --- /dev/null +++ b/scripts/install.test.ts @@ -0,0 +1,80 @@ +// @effect-diagnostics nodeBuiltinImport:off - Drives the real shell installer against a local GitHub fixture. +import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as NodeChildProcess from "node:child_process"; +import * as NodeFSP from "node:fs/promises"; +import * as NodeHttp from "node:http"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import { describe, expect, it } from "vite-plus/test"; + +const nightlies = Array.from({ length: 100 }, (_, index) => ({ + tag_name: `v1.2.4-nightly.20261020.${200 - index}`, +})); + +// regression: #22490 +describe.skipIf(HostProcess.Platform.defaultValue() !== "linux")("installer channel lookup", () => { + it.each([ + ["stable", "1.2.3"], + ["preview", "1.2.4-preview.20261019.50"], + ])("finds the newest %s release behind a full page of nightlies", async (channel, version) => { + const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-install-index-")); + const target = NodePath.join(root, "home/runtime/versions", version); + const server = NodeHttp.createServer((request, response) => { + const url = new URL(request.url ?? "/", "http://fixture"); + const body = + url.pathname === "/pingdotgg/t3code/releases/latest" && + request.headers.accept === "application/json" + ? { tag_name: "v1.2.3" } + : url.pathname === "/repos/pingdotgg/t3code/releases" + ? { "1": nightlies, "2": [{ tag_name: `v${version}` }, { tag_name: "v1.2.3" }] }[ + url.searchParams.get("page") ?? "" + ] + : undefined; + if (body === undefined) response.statusCode = 404; + // The API pretty-prints and the installer reads it line by line. + response.end(JSON.stringify(body ?? [], null, 2)); + }); + try { + // An existing completed install isolates release resolution from archive downloads. + await NodeFSP.mkdir(target, { recursive: true }); + await NodeFSP.writeFile(NodePath.join(target, ".install-complete"), `${version}\n`); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Expected a TCP listener"); + const origin = `http://127.0.0.1:${address.port}`; + const source = await NodeFSP.readFile( + NodePath.resolve(import.meta.dirname, "install.sh"), + "utf8", + ); + const installer = NodePath.join(root, "install.sh"); + await NodeFSP.writeFile(installer, source.replace(/https:\/\/(api\.)?github\.com/g, origin)); + const { code, output } = await new Promise<{ code: number | null; output: string }>( + (resolve, reject) => { + const child = NodeChildProcess.spawn("sh", [installer], { + env: { + ...process.env, + T3CODE_VERSION: "", + T3CODE_CHANNEL: channel, + T3CODE_HOME: NodePath.join(root, "home"), + T3CODE_INSTALL_BIN_DIR: NodePath.join(root, "bin"), + // A wrong version must fail here instead of downloading a real release. + T3CODE_RELEASE_BASE_URL: origin, + }, + stdio: ["ignore", "pipe", "pipe"], + }); + let output = ""; + child.stdout.on("data", (chunk: Buffer) => (output += chunk.toString())); + child.stderr.on("data", (chunk: Buffer) => (output += chunk.toString())); + child.on("error", reject); + child.on("close", (code) => resolve({ code, output })); + }, + ); + expect(code, output).toBe(0); + expect(output).toContain(`Installed T3 Code ${version}`); + } finally { + server.closeAllConnections(); + await new Promise((resolve) => server.close(() => resolve())); + await NodeFSP.rm(root, { recursive: true, force: true }); + } + }); +}); diff --git a/vite.config.ts b/vite.config.ts index 875cb2cbe3940..d17d7ad57f1e1 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -585,6 +585,10 @@ export default defineConfig({ files: ["apps/mobile/src/**/*.{ts,tsx}"], rules: { "t3code/no-manual-memo": ["error", { allowMemoHooks: true }] }, }, + { + files: ["apps/web/src/**", "apps/mobile/src/**", "packages/*/src/**"], + rules: { "t3code/react-effect-budget": "error" }, + }, { // React Compiler caches use* functions only if they call a hook. files: ["apps/web/src/**/*.{ts,tsx}", "apps/mobile/src/**/*.{ts,tsx}"], From 7ee8a5b12374a7460ccf2ca7cfefea7ced3281ec Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 25 Oct 2026 15:07:24 -0700 Subject: [PATCH 048/219] fix(server): resume fallback keeps the latest turn when an older handoff is pending (#22902) Co-authored-by: Marve10s Co-authored-by: Claude Opus 5.5 --- .../ProviderTurnStartService.ts | 14 +- .../ProviderSwitch.integration.test.ts | 126 ++++++++++++++++++ 2 files changed, 139 insertions(+), 1 deletion(-) diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts index 6b851bb26a230..23721c2723309 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts @@ -693,6 +693,7 @@ export const layer: Layer.Layer< return undefined; }); let effectiveHandoffs = handoffs; + let resumeFellBack = false; const loadedProviderThread = yield* Effect.gen(function* () { if (nativeForkTransfer !== undefined) { const sourceProjection = yield* projectionStore.getThreadRecords( @@ -818,7 +819,15 @@ export const layer: Layer.Layer< ), createdAt, }); - effectiveHandoffs = [handoff, ...effectiveHandoffs]; + // Delivery ranks later handoffs as newer history. Pending handoffs from + // earlier runs predate this summary; handoffs for this run (provider-switch + // delta, merge-back) are newer and must stay last. + effectiveHandoffs = [ + ...effectiveHandoffs.filter((candidate) => candidate.targetRunId !== run.id), + handoff, + ...effectiveHandoffs.filter((candidate) => candidate.targetRunId === run.id), + ]; + resumeFellBack = true; yield* eventSink.write({ events: [ { @@ -1145,8 +1154,11 @@ export const layer: Layer.Layer< ...runningProviderThread, contextUsage: handoffUsage, }; + // The resume fallback summary already covers every earlier run in order. A + // retry copy after it would rank older missed requests as the newest history. const missedRuns = projection.runs.filter( (source) => + !resumeFellBack && source.ordinal < run.ordinal && source.providerThreadId === providerThread.id && (source.status === "failed" || source.status === "interrupted") && diff --git a/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts b/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts index 72aae60c0d075..452509bb97b0d 100644 --- a/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts +++ b/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts @@ -1182,6 +1182,132 @@ describe("orchestration v2 provider switching", () => { ), ); + it.live.each(["an older handoff", "a missed request"] as const)( + "keeps the latest turn when a resume fallback also delivers %s", + (older) => + Effect.scoped( + Effect.gen(function* () { + // A run that fails before its provider turn starts is retried as a missed request. + const missed = older === "a missed request"; + const name = `resume-fallback-handoff-order${missed ? "-missed" : ""}`; + const cwd = yield* checkpointWorkspace(name); + const failStartOnce = yield* Ref.make(false); + const capturedTurns = yield* Ref.make>([]); + const injectedHistory = yield* Ref.make>([]); + const oldResult = `old result ${"o".repeat(2_500)}`; + const latestResult = `latest result ${"l".repeat(2_500)}`; + const layerRegistry = ProviderAdapterRegistry.layerFromAdapters([ + makeTestAdapter({ + instanceId: CLAUDE_MODEL_SELECTION.instanceId, + driver: CLAUDE_DRIVER, + capabilities: ClaudeProviderCapabilitiesV2, + modelSelection: CLAUDE_MODEL_SELECTION, + responseByRunOrdinal: { 1: oldResult, 3: latestResult }, + capturedTurns, + injectedHistory, + failResume: true, + ...(missed ? { failStartOnce } : { failedRunOrdinals: new Set([2]) }), + nativeThreadGeneration: yield* Ref.make(0), + }), + ]); + yield* Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const runAfterDetach = Effect.fn("runAfterDetach")(function* ( + ordinal: number, + text: string, + ) { + const providerSessionId = (yield* orchestrator.getThreadProjection(threadId)) + .providerThreads[0]?.providerSessionId; + if (providerSessionId != null) { + yield* orchestrator.dispatch({ + type: "provider-session.detach", + commandId: CommandId.make(`handoff-order:detach:${ordinal}`), + threadId, + providerSessionId, + }); + yield* worker.drain(); + } + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make(`handoff-order:${ordinal}`), + threadId, + messageId: MessageId.make(`handoff-order:${ordinal}`), + createdBy: "user", + creationSource: "web", + text, + attachments: [], + modelSelection: CLAUDE_MODEL_SELECTION, + dispatchMode: { type: "start_immediately" }, + }); + yield* orchestrator.streamStoredEvents.pipe( + Stream.filter( + ({ event }) => + event.type === "run.updated" && + event.payload.ordinal === ordinal && + (event.payload.status === "completed" || event.payload.status === "failed"), + ), + Stream.runHead, + ); + yield* worker.drain(); + }); + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make("handoff-order:create"), + threadId, + projectId, + createdBy: "user", + creationSource: "web", + title: "Resume fallback handoff order", + modelSelection: CLAUDE_MODEL_SELECTION, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + }); + yield* runAfterDetach(1, `Original brief ${"b".repeat(9_000)}`); + yield* Ref.set(failStartOnce, missed); + yield* runAfterDetach(2, missed ? `Request 2 ${"r".repeat(6_000)}` : "Request 2"); + yield* runAfterDetach(3, "Request 3"); + const historyBeforeRun4 = (yield* Ref.get(injectedHistory)).length; + yield* runAfterDetach(4, "Request 4"); + + const projection = yield* orchestrator.getThreadProjection(threadId); + assert.deepEqual( + projection.runs.map((run) => run.status), + ["completed", "failed", "completed", "completed"], + ); + const run4NativeThreadId = (yield* Ref.get(capturedTurns)).at(-1)?.nativeThreadId; + assert.equal( + projection.contextHandoffs.find( + (handoff) => handoff.targetRunId === projection.runs[1]?.id, + )?.delivery?.nativeThreadId, + run4NativeThreadId, + ); + const run4History = yield* encodeJson( + (yield* Ref.get(injectedHistory)).slice(historyBeforeRun4), + ); + assert.include(run4History, "Original brief"); + assert.include(run4History, latestResult); + }).pipe( + Effect.provide( + ProviderReplayHarness.layerWithRegistry( + { + name, + runtimePolicyOverride: { + cwd, + approvalPolicy: "never", + sandboxPolicy: { type: "readOnly" }, + }, + }, + layerRegistry, + ), + ), + ); + }), + ), + ); + it.live.each([ ["background work", new ClaudeBackgroundWorkBlocksQueryReplacementError()], ["an unreadable append file", new ClaudeAppendSystemPromptFileError({ path: "missing.md" })], From 2fd28bf93804845403f8ed4947c09a3878b546d0 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 25 Oct 2026 15:08:09 -0700 Subject: [PATCH 049/219] fix(client-runtime): "Codex (hub)" badges read CH instead of C( (#22918) Co-authored-by: chief-archivist Co-authored-by: Claude Opus 5.5 --- .../src/state/providerInstanceDisplay.test.ts | 9 +++++++++ .../client-runtime/src/state/providerInstanceDisplay.ts | 9 ++++++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/client-runtime/src/state/providerInstanceDisplay.test.ts b/packages/client-runtime/src/state/providerInstanceDisplay.test.ts index 7068a761605a1..27129f11128b3 100644 --- a/packages/client-runtime/src/state/providerInstanceDisplay.test.ts +++ b/packages/client-runtime/src/state/providerInstanceDisplay.test.ts @@ -12,6 +12,15 @@ const codex = ProviderDriverKind.make("codex"); const acpRegistry = ProviderDriverKind.make("acpRegistry"); describe("providerInstanceInitials", () => { + it("treats punctuation as a word break but keeps apostrophes between letters", () => { + expect(providerInstanceInitials("Codex (hub)")).toBe("CH"); + expect(providerInstanceInitials("work_codex")).toBe("WC"); + expect(providerInstanceInitials("Theo's Codex")).toBe("TC"); + expect(providerInstanceInitials("Codex 'hub'")).toBe("CH"); + expect(providerInstanceInitials("โ€˜hubโ€™")).toBe("HU"); + expect(providerInstanceInitials("(hub)")).toBe("HU"); + }); + it("keeps an emoji whole instead of splitting its surrogate pair", () => { expect(providerInstanceInitials("๐Ÿ˜€ Work")).toBe("๐Ÿ˜€W"); expect(providerInstanceInitials("๐Ÿ˜€")).toBe("๐Ÿ˜€"); diff --git a/packages/client-runtime/src/state/providerInstanceDisplay.ts b/packages/client-runtime/src/state/providerInstanceDisplay.ts index 64d1d941f1122..62f65445e8217 100644 --- a/packages/client-runtime/src/state/providerInstanceDisplay.ts +++ b/packages/client-runtime/src/state/providerInstanceDisplay.ts @@ -27,7 +27,14 @@ export function resolveProviderInstanceDisplayName( } export function providerInstanceInitials(label: string): string { - const words = label.replace(/[_-]+/g, " ").split(/\s+/u).filter(Boolean); + // Punctuation separates words so "Codex (hub)" reads CH, not C(. An apostrophe + // inside a word stays ("Theo's Codex" is TC); a quoting one does not + // ("Codex 'hub'" is CH). + const words = label + .replace(/[^\P{P}'โ€™]+/gu, " ") + .split(/\s+/u) + .map((word) => word.replace(/^['โ€™]+|['โ€™]+$/gu, "")) + .filter(Boolean); if (words.length === 0) return ""; if (words.length === 1) return Array.from(words[0]!).slice(0, 2).join("").toUpperCase(); return words From 2050a12b6d6d6705a42239b9b8b09491c0895bcc Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 25 Oct 2026 15:08:23 -0700 Subject: [PATCH 050/219] fix(mobile): retain requested PR environment while connecting (#24262) --- .../pullRequestEnvironmentFallback.test.ts | 25 ++- .../pullRequestEnvironmentFallback.ts | 24 ++- ...usePullRequestEnvironmentFallback.test.tsx | 143 ++++++++++++++++++ .../usePullRequestEnvironmentFallback.ts | 12 +- apps/mobile/vite.config.ts | 1 + 5 files changed, 190 insertions(+), 15 deletions(-) create mode 100644 apps/mobile/src/features/pull-requests/usePullRequestEnvironmentFallback.test.tsx diff --git a/apps/mobile/src/features/pull-requests/pullRequestEnvironmentFallback.test.ts b/apps/mobile/src/features/pull-requests/pullRequestEnvironmentFallback.test.ts index 3ed5fe6b1835f..808bfa1b485ec 100644 --- a/apps/mobile/src/features/pull-requests/pullRequestEnvironmentFallback.test.ts +++ b/apps/mobile/src/features/pull-requests/pullRequestEnvironmentFallback.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from "vite-plus/test"; import type { EnvironmentProject } from "@t3tools/client-runtime/state/models"; +import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; +import { EnvironmentId } from "@t3tools/contracts"; import { pullRequestFallbackEnvironment } from "./pullRequestEnvironmentFallback"; const project = (environmentId: string, id: string, canonicalKey: string) => @@ -20,24 +22,35 @@ const target = { repository: "pingdotgg/t3code", host: "github.com", }; +const environment = ( + environmentId: string, + connectionState: EnvironmentConnectionPhase = "connected", + isEnabled = true, +) => ({ environmentId: EnvironmentId.make(environmentId), connectionState, isEnabled }); describe("pullRequestFallbackEnvironment", () => { it("moves to a connected copy of the repository when the original is down", () => { expect( - pullRequestFallbackEnvironment(target, projects, new Set(["desktop", "server"])), + pullRequestFallbackEnvironment(target, projects, [ + environment("desktop"), + environment("server"), + ]), ).toEqual({ environmentId: "server", projectId: "t3-copy" }); }); it("stays put while the original is connected or nothing else holds the repository", () => { - expect(pullRequestFallbackEnvironment(target, projects, new Set(["laptop", "server"]))).toBe( - null, - ); - expect(pullRequestFallbackEnvironment(target, projects, new Set(["desktop"]))).toBe(null); + expect( + pullRequestFallbackEnvironment(target, projects, [ + environment("laptop"), + environment("server"), + ]), + ).toBe(null); + expect(pullRequestFallbackEnvironment(target, projects, [environment("desktop")])).toBe(null); }); it("derives the host from the original project when the route has none", () => { const { host: _host, ...hostless } = target; - expect(pullRequestFallbackEnvironment(hostless, projects, new Set(["server"]))).toEqual({ + expect(pullRequestFallbackEnvironment(hostless, projects, [environment("server")])).toEqual({ environmentId: "server", projectId: "t3-copy", }); diff --git a/apps/mobile/src/features/pull-requests/pullRequestEnvironmentFallback.ts b/apps/mobile/src/features/pull-requests/pullRequestEnvironmentFallback.ts index 7c1d234f92577..bfa31edfea756 100644 --- a/apps/mobile/src/features/pull-requests/pullRequestEnvironmentFallback.ts +++ b/apps/mobile/src/features/pull-requests/pullRequestEnvironmentFallback.ts @@ -1,4 +1,5 @@ import type { EnvironmentProject } from "@t3tools/client-runtime/state/models"; +import type { EnvironmentConnectionSummary } from "@t3tools/client-runtime/state/presentation"; export type PullRequestEnvironmentTarget = { readonly environmentId: string; @@ -10,9 +11,28 @@ export type PullRequestEnvironmentTarget = { export function pullRequestFallbackEnvironment( target: PullRequestEnvironmentTarget, projects: ReadonlyArray, - connectedEnvironmentIds: ReadonlySet, + environments: ReadonlyArray< + Pick + >, ) { - if (connectedEnvironmentIds.has(target.environmentId)) return null; + const requested = environments.find( + (environment) => environment.environmentId === target.environmentId, + ); + // Pending connections retain the requested target until an explicit failure, even before + // its project hydrates. Available includes the period before connection startup. + if ( + requested?.isEnabled && + (requested.connectionState === "connected" || + requested.connectionState === "available" || + requested.connectionState === "connecting" || + requested.connectionState === "reconnecting") + ) + return null; + const connectedEnvironmentIds = new Set( + environments + .filter((environment) => environment.isEnabled && environment.connectionState === "connected") + .map((environment) => environment.environmentId), + ); const own = projects.find( (project) => project.environmentId === target.environmentId && project.id === target.projectId, ); diff --git a/apps/mobile/src/features/pull-requests/usePullRequestEnvironmentFallback.test.tsx b/apps/mobile/src/features/pull-requests/usePullRequestEnvironmentFallback.test.tsx new file mode 100644 index 0000000000000..1500f429bb606 --- /dev/null +++ b/apps/mobile/src/features/pull-requests/usePullRequestEnvironmentFallback.test.tsx @@ -0,0 +1,143 @@ +import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; +import type { EnvironmentProject } from "@t3tools/client-runtime/state/models"; +import { EnvironmentId } from "@t3tools/contracts"; +import { act, useEffect, useState } from "react"; +import { createRoot } from "react-dom/client"; +import { expect, it, vi } from "vite-plus/test"; + +import type { WorkspaceEnvironment } from "../../state/workspaceModel"; +import type { PullRequestEnvironmentTarget } from "./pullRequestEnvironmentFallback"; + +const mocks = vi.hoisted(() => ({ + environments: [] as ReadonlyArray, + isReady: true, + projects: [] as ReadonlyArray, + navigation: { setParams: vi.fn<(params: Partial) => void>() }, +})); + +vi.mock("@react-navigation/native", () => ({ useNavigation: () => mocks.navigation })); +vi.mock("../../state/entities", () => ({ useProjects: () => mocks.projects })); +vi.mock("../../state/workspace", () => ({ + useWorkspaceEnvironments: () => mocks.environments, + useConnectionsReady: () => mocks.isReady, +})); + +import { usePullRequestEnvironmentFallback } from "./usePullRequestEnvironmentFallback"; + +function environment( + environmentId: string, + connectionState: EnvironmentConnectionPhase, + isEnabled = true, +): WorkspaceEnvironment { + return { + environmentId: EnvironmentId.make(environmentId), + environmentLabel: environmentId, + displayUrl: "", + isRelayManaged: false, + isEnabled, + connectionState, + connectionError: null, + connectionErrorTraceId: null, + }; +} + +function setup() { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + mocks.isReady = true; + mocks.environments = [environment("requested", "connecting"), environment("copy", "connected")]; + mocks.projects = [ + { environmentId: "requested", id: "original" }, + { environmentId: "copy", id: "copy-project" }, + ].map( + (project) => + ({ + ...project, + repositoryIdentity: { canonicalKey: "github.com/owner/repo" }, + }) as unknown as EnvironmentProject, + ); + const container = document.createElement("div"); + const root = createRoot(container); + mocks.navigation.setParams = vi.fn(); + function Probe() { + const [params, updateParams] = useState({ + environmentId: "requested", + projectId: "original", + repository: "owner/repo", + host: "github.com", + }); + useEffect(() => { + mocks.navigation.setParams.mockImplementation((next) => + updateParams((previous) => ({ ...previous, ...next })), + ); + }, []); + usePullRequestEnvironmentFallback(params); + return {`${params.environmentId}/${params.projectId}`}; + } + return { + render() { + act(() => root.render()); + }, + get target() { + return container.textContent; + }, + [Symbol.dispose]() { + act(() => root.unmount()); + }, + }; +} + +it("keeps the requested target while the catalog initializes or its enabled connection is pending", () => { + using screen = setup(); + mocks.isReady = false; + mocks.environments = [environment("copy", "connected")]; + screen.render(); + expect(screen.target).toBe("requested/original"); + expect(mocks.navigation.setParams).not.toHaveBeenCalled(); + + mocks.isReady = true; + for (const phase of ["available", "connecting", "reconnecting", "connected"] as const) { + mocks.environments = [environment("requested", phase), environment("copy", "connected")]; + screen.render(); + expect(screen.target).toBe("requested/original"); + expect(mocks.navigation.setParams).not.toHaveBeenCalled(); + } +}); + +it("falls back to a connected repository copy when the requested environment cannot connect", () => { + for (const state of ["missing", "disabled", "offline", "error", "unsupported"] as const) { + using screen = setup(); + mocks.environments = [ + ...(state === "missing" + ? [] + : [ + environment( + "requested", + state === "disabled" ? "available" : state, + state !== "disabled", + ), + ]), + environment("copy", "connected"), + ]; + screen.render(); + expect(screen.target).toBe("copy/copy-project"); + expect(mocks.navigation.setParams).toHaveBeenCalledExactlyOnceWith({ + environmentId: "copy", + projectId: "copy-project", + }); + } +}); + +it("does not replace a connecting target with a connected repository copy before its project hydrates", () => { + using screen = setup(); + const projects = mocks.projects; + mocks.projects = projects.filter((project) => project.environmentId !== "requested"); + screen.render(); + expect(screen.target).toBe("requested/original"); + expect(mocks.navigation.setParams).not.toHaveBeenCalled(); + + mocks.projects = projects; + mocks.environments = [environment("requested", "connected"), environment("copy", "connected")]; + screen.render(); + expect(screen.target).toBe("requested/original"); + expect(mocks.navigation.setParams).not.toHaveBeenCalled(); +}); diff --git a/apps/mobile/src/features/pull-requests/usePullRequestEnvironmentFallback.ts b/apps/mobile/src/features/pull-requests/usePullRequestEnvironmentFallback.ts index 7fb13669642f0..11a3e667fb32b 100644 --- a/apps/mobile/src/features/pull-requests/usePullRequestEnvironmentFallback.ts +++ b/apps/mobile/src/features/pull-requests/usePullRequestEnvironmentFallback.ts @@ -2,7 +2,7 @@ import { useNavigation } from "@react-navigation/native"; import { useEffect, useMemo } from "react"; import { useProjects } from "../../state/entities"; -import { useWorkspaceEnvironments } from "../../state/workspace"; +import { useConnectionsReady, useWorkspaceEnvironments } from "../../state/workspace"; import { pullRequestFallbackEnvironment, type PullRequestEnvironmentTarget, @@ -11,13 +11,11 @@ import { export function usePullRequestEnvironmentFallback(params: PullRequestEnvironmentTarget) { const navigation = useNavigation(); const projects = useProjects(); - const connected = useWorkspaceEnvironments() - .filter((environment) => environment.connectionState === "connected") - .map((environment) => String(environment.environmentId)) - .join("\n"); + const isReady = useConnectionsReady(); + const environments = useWorkspaceEnvironments(); const fallback = useMemo( - () => pullRequestFallbackEnvironment(params, projects, new Set(connected.split("\n"))), - [params, projects, connected], + () => (isReady ? pullRequestFallbackEnvironment(params, projects, environments) : null), + [params, projects, environments, isReady], ); useEffect(() => { if (fallback) navigation.setParams(fallback); diff --git a/apps/mobile/vite.config.ts b/apps/mobile/vite.config.ts index 3c4a8bf400012..69728880a79b7 100644 --- a/apps/mobile/vite.config.ts +++ b/apps/mobile/vite.config.ts @@ -2,6 +2,7 @@ import { defineConfig } from "vite-plus"; import "vite-plus/test/config"; const reactTests = [ + "src/features/pull-requests/usePullRequestEnvironmentFallback.test.tsx", "src/lib/filePreview.test.ts", "src/features/diffs/nativeReviewDiffSurface.test.tsx", "src/features/pull-requests/usePullRequestDiff.test.tsx", From a27b246162a895cc94f96925c361ab73b094154e Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 25 Oct 2026 15:09:12 -0700 Subject: [PATCH 051/219] fix(web): usage-limit banner reset time follows the Time format setting (#22915) Co-authored-by: Victor Vogel Co-authored-by: Claude Opus 5.5 --- apps/web/src/components/ChatView.tsx | 1 + .../chat/UsageLimitRecoveryBanner.test.tsx | 36 +++++++++++++++++++ .../chat/UsageLimitRecoveryBanner.tsx | 7 ++-- 3 files changed, 42 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/components/chat/UsageLimitRecoveryBanner.test.tsx diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 1319b1030b083..7e81eb6124a69 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -2578,6 +2578,7 @@ export default function ChatView(props: ChatViewProps) { stoppedAt: activeThreadShell.latestRun.completedAt ?? activeThreadShell.updatedAt, recovery: activeThreadShell.limitRecovery ?? null, snoozedUntil: activeThreadShell.snoozedUntil, + timestampFormat, onChange: async (limitRecovery) => { const result = await updateThreadMetadata({ environmentId, diff --git a/apps/web/src/components/chat/UsageLimitRecoveryBanner.test.tsx b/apps/web/src/components/chat/UsageLimitRecoveryBanner.test.tsx new file mode 100644 index 0000000000000..0938e7a3c6889 --- /dev/null +++ b/apps/web/src/components/chat/UsageLimitRecoveryBanner.test.tsx @@ -0,0 +1,36 @@ +import { RunId } from "@t3tools/contracts"; +import type { TimestampFormat } from "@t3tools/contracts/settings"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { usageLimitRecoveryBannerItem } from "./UsageLimitRecoveryBanner"; + +const stoppedAt = new Date(2026, 9, 20, 12, 0); + +function bannerDescription(resetAt: Date, timestampFormat: TimestampFormat) { + return usageLimitRecoveryBannerItem({ + runId: RunId.make("run-1"), + resetAt: resetAt.toISOString(), + stoppedAt: stoppedAt.toISOString(), + snoozedUntil: null, + recovery: null, + timestampFormat, + onChange: async () => {}, + }).description; +} + +describe("usageLimitRecoveryBannerItem", () => { + it.each([ + ["12-hour", "Resets 5:30 PM"], + ["24-hour", "Resets 17:30"], + ] as const)("formats a same-day reset with the %s setting", (timestampFormat, expected) => { + using _now = vi.spyOn(Date, "now").mockReturnValue(stoppedAt.getTime()); + expect(bannerDescription(new Date(2026, 9, 20, 17, 30), timestampFormat)).toBe(expected); + }); + + it("names the day when the reset is tomorrow", () => { + using _now = vi.spyOn(Date, "now").mockReturnValue(stoppedAt.getTime()); + expect(bannerDescription(new Date(2026, 9, 21, 17, 30), "24-hour")).toBe( + "Resets tomorrow at 17:30", + ); + }); +}); diff --git a/apps/web/src/components/chat/UsageLimitRecoveryBanner.tsx b/apps/web/src/components/chat/UsageLimitRecoveryBanner.tsx index ea08ff5314fc1..ee37e39a49388 100644 --- a/apps/web/src/components/chat/UsageLimitRecoveryBanner.tsx +++ b/apps/web/src/components/chat/UsageLimitRecoveryBanner.tsx @@ -3,9 +3,11 @@ import { type OrchestrationV2LimitRecoveryUpdate, type RunId, } from "@t3tools/contracts"; +import { type TimestampFormat } from "@t3tools/contracts/settings"; import { GaugeIcon } from "lucide-react"; import { useEffect, useState } from "react"; import { Button } from "../ui/button"; +import { formatUpcomingTimestamp } from "../../timestampFormat"; import { toastManager } from "../ui/toast"; import type { ComposerBannerStackItem } from "./ComposerBannerStack"; @@ -15,11 +17,12 @@ type RecoveryProps = { stoppedAt: string; snoozedUntil: string | null; recovery: OrchestrationV2LimitRecovery | null; + timestampFormat: TimestampFormat; onChange: (recovery: OrchestrationV2LimitRecoveryUpdate) => Promise; }; export function usageLimitRecoveryBannerItem(props: RecoveryProps): ComposerBannerStackItem { - const { runId, resetAt, stoppedAt } = props; + const { runId, resetAt, stoppedAt, timestampFormat } = props; const canSchedule = resetAt !== null && Date.parse(resetAt) > Date.parse(stoppedAt); return { id: `usage-limit-recovery:${runId}`, @@ -28,7 +31,7 @@ export function usageLimitRecoveryBannerItem(props: RecoveryProps): ComposerBann icon: , title: "Usage limit reached", description: resetAt - ? `Resets ${new Date(resetAt).toLocaleString()}` + ? `Resets ${formatUpcomingTimestamp(resetAt, timestampFormat)}` : "Reset time unavailable; retry manually", actions: canSchedule ? : null, }; From 313ca2715fbef89e3ed7395a3d146661e2223fd9 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 25 Oct 2026 15:09:27 -0700 Subject: [PATCH 052/219] fix(web): terminal drag selection scrolls faster the farther past the edge (#22872) Co-authored-by: Claude Opus 5.5 --- apps/web/src/terminal/ghostty/surface.test.ts | 16 ++++++++++++++++ apps/web/src/terminal/ghostty/surface.ts | 17 ++++++++++++++++- 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/apps/web/src/terminal/ghostty/surface.test.ts b/apps/web/src/terminal/ghostty/surface.test.ts index 753508c918eff..8816105599b83 100644 --- a/apps/web/src/terminal/ghostty/surface.test.ts +++ b/apps/web/src/terminal/ghostty/surface.test.ts @@ -11,6 +11,7 @@ import { resolveTerminalMouseData, terminalLinkAtPositionWithRange, terminalContentOriginY, + terminalSelectionAutoscrollRows, terminalWheelDeltaRows, GhosttyTerminalSurface, type GhosttyTerminalSurfaceOptions, @@ -747,6 +748,21 @@ describe("terminalWheelDeltaRows", () => { }); }); +// regression: #19492 drag selection scrolled one row per tick however far past the edge +describe("terminalSelectionAutoscrollRows", () => { + it("scrolls more rows per tick the farther the pointer is past an edge", () => { + const top = 100; + const bottom = 500; + expect(terminalSelectionAutoscrollRows(300, top, bottom)).toBe(0); + expect(terminalSelectionAutoscrollRows(99, top, bottom)).toBe(-1); + expect(terminalSelectionAutoscrollRows(75, top, bottom)).toBe(-8); + expect(terminalSelectionAutoscrollRows(501, top, bottom)).toBe(1); + expect(terminalSelectionAutoscrollRows(525, top, bottom)).toBe(8); + expect(terminalSelectionAutoscrollRows(550, top, bottom)).toBe(15); + expect(terminalSelectionAutoscrollRows(2_000, top, bottom)).toBe(15); + }); +}); + describe("advanceTerminalSelectionClickSequence", () => { it("recognizes stationary double and triple pointer presses without PointerEvent.detail", () => { const first = advanceTerminalSelectionClickSequence(null, { diff --git a/apps/web/src/terminal/ghostty/surface.ts b/apps/web/src/terminal/ghostty/surface.ts index cf24224fded2e..964a947f67abd 100644 --- a/apps/web/src/terminal/ghostty/surface.ts +++ b/apps/web/src/terminal/ghostty/surface.ts @@ -423,6 +423,21 @@ export function terminalWheelDeltaRows( return { rows, remainder: total - rows }; } +// Ramp from 1 to 15 rows per autoscroll tick over the first 50px past the edge, as xterm.js does. +const SELECTION_AUTOSCROLL_RAMP_PX = 50; +const SELECTION_AUTOSCROLL_MAX_ROWS = 15; + +export function terminalSelectionAutoscrollRows( + clientY: number, + top: number, + bottom: number, +): number { + const distance = clientY < top ? clientY - top : clientY > bottom ? clientY - bottom : 0; + if (distance === 0) return 0; + const ramp = Math.max(-1, Math.min(1, distance / SELECTION_AUTOSCROLL_RAMP_PX)); + return Math.sign(ramp) + Math.round(ramp * (SELECTION_AUTOSCROLL_MAX_ROWS - 1)); +} + function terminalWheelArrowData(rows: number, applicationCursorKeys: boolean): string { if (rows === 0) return ""; const sequence = @@ -1434,7 +1449,7 @@ export class GhosttyTerminalSurface { const bounds = this.canvas.getBoundingClientRect(); const top = Math.max(bounds.top, 0) + CONTENT_PADDING; const bottom = Math.min(bounds.bottom, window.innerHeight) - CONTENT_PADDING; - this.setSelectionAutoscroll(event.clientY < top ? -1 : event.clientY > bottom ? 1 : 0); + this.setSelectionAutoscroll(terminalSelectionAutoscrollRows(event.clientY, top, bottom)); const cell = this.cellAt(event.clientX, event.clientY); if (cell.x === this.selectionEnd?.x && cell.y === this.selectionEnd.y) return; this.extendSelectionTo(event.clientX, event.clientY); From 20ee805de9ee5b911ac2fd586c197dcc0e9a63e0 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sun, 25 Oct 2026 18:11:48 -0400 Subject: [PATCH 053/219] fix(observability): resources add OTel semconv runtime and browser keys (#23972) Signed-off-by: Yordis Prieto Co-authored-by: Julius Marminge Co-authored-by: Claude Opus 5.5 --- .../src/app/DesktopObservability.test.ts | 32 +++++++++++++++++++ apps/desktop/src/app/DesktopObservability.ts | 4 ++- apps/server/src/cloud/relayTracing.ts | 7 +++- apps/server/src/config.ts | 2 ++ apps/web/src/observability/clientTracing.ts | 27 ++++++++++++++++ docs/operations/observability.md | 6 +++- packages/shared/src/observability.ts | 12 +++++++ 7 files changed, 87 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/app/DesktopObservability.test.ts b/apps/desktop/src/app/DesktopObservability.test.ts index 6ddb31cb6eaac..ab8ea0b8e9d6e 100644 --- a/apps/desktop/src/app/DesktopObservability.test.ts +++ b/apps/desktop/src/app/DesktopObservability.test.ts @@ -342,4 +342,36 @@ describe("DesktopObservability", () => { Effect.provide(Layer.mergeAll(NodeServices.layer, layerCollector(requests), layerEmptyEnv)), ); }); + + it.effect("describes the Node runtime and the desktop mode under semantic or t3code keys", () => { + const requests: Array = []; + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-observability-test-", + }); + const layerEnvironment = layerEnvironmentFor(baseDir); + yield* writeObservabilitySettings(layerEnvironment, { + otlpLogsUrl: "https://settings.example.com/v1/logs", + }); + + yield* Effect.scoped( + Effect.logInfo("desktop resource under test").pipe( + Effect.provide(DesktopObservability.layer.pipe(Layer.provideMerge(layerEnvironment))), + ), + ); + + const body = requests.map((request) => request.body).join("\n"); + assert.include(body, '"key":"process.runtime.name","value":{"stringValue":"nodejs"}'); + assert.include( + body, + `"key":"process.runtime.version","value":{"stringValue":"${process.versions.node}"}`, + ); + assert.include(body, '"key":"t3code.desktop.mode","value":{"stringValue":"development"}'); + assert.notInclude(body, "service.mode"); + }).pipe( + Effect.scoped, + Effect.provide(Layer.mergeAll(NodeServices.layer, layerCollector(requests), layerEmptyEnv)), + ); + }); }); diff --git a/apps/desktop/src/app/DesktopObservability.ts b/apps/desktop/src/app/DesktopObservability.ts index 1d1e79a4fe9b0..365973f5a3090 100644 --- a/apps/desktop/src/app/DesktopObservability.ts +++ b/apps/desktop/src/app/DesktopObservability.ts @@ -2,6 +2,7 @@ import { PRIMARY_LOCAL_ENVIRONMENT_ID } from "@t3tools/contracts"; import { makeLocalFileTracer, makeTraceSink, + nodeProcessRuntimeAttributes, processServiceInstanceId, type SignalExport, } from "@t3tools/shared/observability"; @@ -607,13 +608,14 @@ const layerTelemetry = Layer.unwrap( attributes: { "service.namespace": "t3code", "service.runtime": "desktop", - "service.mode": environment.isDevelopment ? "development" : "packaged", + "t3code.desktop.mode": environment.isDevelopment ? "development" : "packaged", "service.instance.id": processServiceInstanceId(endpoints.resourceAttributes), ...releaseResourceAttributes( environment.isDevelopment ? "dev" : releaseChannelOf(serviceVersion), endpoints.resourceAttributes, ), "host.name": endpoints.resourceAttributes["host.name"] || (yield* HostProcess.Hostname), + ...nodeProcessRuntimeAttributes(), }, }; diff --git a/apps/server/src/cloud/relayTracing.ts b/apps/server/src/cloud/relayTracing.ts index d5f1380f966eb..13a96db132017 100644 --- a/apps/server/src/cloud/relayTracing.ts +++ b/apps/server/src/cloud/relayTracing.ts @@ -1,4 +1,7 @@ -import { processServiceInstanceId } from "@t3tools/shared/observability"; +import { + nodeProcessRuntimeAttributes, + processServiceInstanceId, +} from "@t3tools/shared/observability"; import * as RelayTracing from "@t3tools/shared/relayTracing"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -21,12 +24,14 @@ const relayLayer = (resource: Omit; +} + +/** + * The `browser.*` and `user_agent.original` attributes the OpenTelemetry browser + * resource detector reports. `userAgentData` exists only in Chromium, which + * includes the desktop app. + */ +const browserResourceAttributes = (): Record => { + if (typeof navigator === "undefined") return {}; + const userAgentData = (navigator as Navigator & { userAgentData?: NavigatorUserAgentData }) + .userAgentData; + return { + "user_agent.original": navigator.userAgent, + "browser.language": navigator.language, + ...(userAgentData && { + "browser.platform": userAgentData.platform, + "browser.mobile": userAgentData.mobile, + "browser.brands": userAgentData.brands.map(({ brand, version }) => `${brand} ${version}`), + }), + }; +}; + // PR previews can't mark themselves in APP_VERSION without breaking the version-skew check against the server. const RELEASE_CHANNEL = import.meta.env.DEV || import.meta.env.VITE_RELEASE_CHANNEL === "dev" @@ -31,6 +57,7 @@ const CLIENT_TRACING_RESOURCE = { "service.runtime": "t3-web", "t3code.client.surface": isElectron ? "desktop" : "web", "service.version": APP_VERSION, + ...browserResourceAttributes(), }, } as const; diff --git a/docs/operations/observability.md b/docs/operations/observability.md index 51413e205da6e..ab27a5f87c607 100644 --- a/docs/operations/observability.md +++ b/docs/operations/observability.md @@ -642,7 +642,11 @@ reports `dev` from a development bundle. Attributes specific to T3 Code use the `t3code.` prefix. The UI runs as `t3code-web` in both a browser and the desktop window, since it is the same code, and `t3code.client.surface` is `desktop` or `web`. The server's `t3code.server.managed_by` is `desktop` when the desktop app launched it and -`standalone` otherwise. +`standalone` otherwise. The desktop main process reports `t3code.desktop.mode` as `development` or +`packaged` (formerly `service.mode`; dashboards filtering on that key must switch). Node processes +also report the semantic `process.runtime.name`, `process.runtime.version`, and +`process.runtime.description`, and UI traces report `browser.language`, `user_agent.original`, and, +in Chromium, `browser.platform`, `browser.mobile`, and `browser.brands`. Every resource also carries a `service.instance.id`: random per process for the server and desktop, per page load for the UI, and the Expo session on mobile. A `service.instance.id` in diff --git a/packages/shared/src/observability.ts b/packages/shared/src/observability.ts index db1f50734bea3..4109442553ef1 100644 --- a/packages/shared/src/observability.ts +++ b/packages/shared/src/observability.ts @@ -26,6 +26,18 @@ export interface SignalExport { readonly exportIntervalMs: number; } +/** + * `process.runtime.*` resource attributes for a Node process, matching the + * OpenTelemetry Node process detector. Electron embeds Node, so an Electron + * process reports Node and names Electron in the description. + */ +export const nodeProcessRuntimeAttributes = (): Record => ({ + "process.runtime.name": "nodejs", + "process.runtime.version": process.versions.node, + "process.runtime.description": process.versions.electron + ? `Electron ${process.versions.electron}` + : "Node.js", +}); let generatedServiceInstanceId: string | undefined; /** From 3e69171f9b03136ca09b077bfcf25c4a98907a15 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 25 Oct 2026 15:13:23 -0700 Subject: [PATCH 054/219] fix(knip): mobile-e2e runs agent-device's CLI by path Co-Authored-By: Claude Opus 5.5 --- knip.jsonc | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/knip.jsonc b/knip.jsonc index ed757fe9c20c9..2938c045ecdf7 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -19,8 +19,9 @@ "scripts": { // Knip loads its preprocessor through a CLI option; native verification and // worktree setup (from t3.json) run directly. release-desktop.yml bundles the - // Windows verify script with esbuild through a path knip does not trace, and - // electron-builder loads the macOS signer by path. + // Windows verify script with esbuild through a path knip does not trace, + // electron-builder loads the macOS signer by path, and mobile-e2e.ts runs + // agent-device's CLI by path. "entry": [ // These fixture scripts are injected into a browser page by path. "fixtures/*/*.js", @@ -30,7 +31,7 @@ "sign-macos.ts", "verify-windows-desktop-artifact.ts", ], - "ignoreDependencies": ["esbuild"], + "ignoreDependencies": ["agent-device", "esbuild"], // The `t3` npm package's files: copied into the package, never imported. // knip-schemas.test.ts writes a throwaway project to .knip-test-*, which a // knip run beside it in check:changed must not report. From 69234b1f96a68e5f67232e560f87b86db85b76a3 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 25 Oct 2026 15:16:41 -0700 Subject: [PATCH 055/219] chore(lint): main passes lint again with effects merged after the budget (#25055) Co-authored-by: Claude Opus 5.5 --- scripts/effect-budget.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/scripts/effect-budget.json b/scripts/effect-budget.json index 44c55fb275201..eba1a2c4822cb 100644 --- a/scripts/effect-budget.json +++ b/scripts/effect-budget.json @@ -317,6 +317,7 @@ "apps/web/src/components/files/BrowserDocumentFrame.tsx": 7, "apps/web/src/components/files/FileBrowserPanel.tsx": 9, "apps/web/src/components/files/FilePreviewPanel.tsx": 3, + "apps/web/src/components/files/InlineAudioPreview.tsx": 1, "apps/web/src/components/files/useDirectoryEntries.ts": 1, "apps/web/src/components/files/useFileSaveCoordinator.ts": 2, "apps/web/src/components/guest/GuestHtmlRender.tsx": 3, @@ -351,6 +352,7 @@ "apps/web/src/components/pullRequest/useInlineActionConfirm.ts": 1, "apps/web/src/components/pullRequest/usePullRequestDetail.ts": 1, "apps/web/src/components/pullRequest/usePullRequestFilesViewed.ts": 2, + "apps/web/src/components/pullRequest/usePullRequestListView.ts": 1, "apps/web/src/components/pullRequest/useThreadLinkedPullRequest.ts": 1, "apps/web/src/components/search/ProjectContentSearchDialog.tsx": 1, "apps/web/src/components/settings/AcpRegistryIcon.tsx": 1, @@ -394,6 +396,7 @@ "apps/web/src/components/sidebar/SidebarUpdatePill.tsx": 1, "apps/web/src/components/sidebar/mainAppLocation.ts": 1, "apps/web/src/components/sidebar/updateEverythingRun.tsx": 2, + "apps/web/src/components/ui/middle-truncate.tsx": 1, "apps/web/src/components/ui/sidebar.tsx": 3, "apps/web/src/components/ui/toast.tsx": 2, "apps/web/src/components/ui/tooltip.tsx": 1, @@ -422,7 +425,7 @@ "apps/web/src/hooks/useWorkspaceMutationRefresh.ts": 1, "apps/web/src/panelAnimations.ts": 2, "apps/web/src/remoteOpen.ts": 1, - "apps/web/src/routes/__root.tsx": 13, + "apps/web/src/routes/__root.tsx": 14, "apps/web/src/routes/_chat.index.tsx": 1, "apps/web/src/routes/_chat.tsx": 1, "apps/web/src/shortcutModifierState.ts": 1, From 725ada6eaa32d4a32c361ee42a1717a3e65568d1 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 25 Oct 2026 15:20:43 -0700 Subject: [PATCH 056/219] fix(server): ended roots no longer leave provider turns running (#22846) Co-authored-by: Claude Opus 5.5 --- .../src/orchestration-v2/Orchestrator.ts | 4 +- .../ProjectionRecovery.test.ts | 100 +++++++++++++++++ .../src/orchestration-v2/ProjectionStore.ts | 24 +++-- .../RunExecutionService.test.ts | 101 ++++++++++++++++++ .../orchestration-v2/RunExecutionService.ts | 34 ++++-- .../SubagentProjection.test.ts | 1 + .../src/orchestration-v2/ThreadStop.test.ts | 23 ++++ .../src/server/subagentProjection.ts | 25 ++++- 8 files changed, 291 insertions(+), 21 deletions(-) diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index aae5d1b68db83..3a9cb4facbd38 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -9112,7 +9112,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio } childThreadIds.add(childThreadId); const child = yield* projectionStore - .getThreadRecords(childThreadId, ["nodes", "subagents", "turnItems"], { + .getThreadRecords(childThreadId, ["nodes", "providerTurns", "subagents", "turnItems"], { // Runless items are the subagent's; any run there is the user's own. turnItemRunIds: [null], turnItemStatuses: ["pending", "running", "waiting"], @@ -9671,7 +9671,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio yield* Ref.update(events, (current) => [...current, ...ended]); if (subagent.childThreadId !== null) { const child = yield* projectionStore - .getThreadRecords(subagent.childThreadId, ["nodes", "turnItems"], { + .getThreadRecords(subagent.childThreadId, ["nodes", "providerTurns", "turnItems"], { turnItemRunIds: [null], turnItemStatuses: ["pending", "running", "waiting"], }) diff --git a/apps/server/src/orchestration-v2/ProjectionRecovery.test.ts b/apps/server/src/orchestration-v2/ProjectionRecovery.test.ts index 6e0dd990ce388..6ed24558f01dc 100644 --- a/apps/server/src/orchestration-v2/ProjectionRecovery.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionRecovery.test.ts @@ -12,6 +12,7 @@ import { ProviderInstanceId, ProviderSessionId, ProviderThreadId, + ProviderTurnId, RunId, RuntimeRequestId, ThreadId, @@ -966,3 +967,102 @@ it.effect("marks fork descendants unreadable when their source is missing or cor ); }).pipe(Effect.provide(layerTest)), ); + +it.effect("startup ends the provider turn of a native child root it cancels", () => + Effect.gen(function* () { + const projections = yield* ProjectionStore.ProjectionStoreV2; + const recovery = yield* ProviderRuntimeRecovery.ProviderRuntimeRecoveryService; + const now = yield* DateTime.now; + const parentThreadId = yield* createThread("native-child-root-turn-parent"); + const threadId = yield* createThread("native-child-root-turn"); + const rootNodeId = NodeId.make(`node:${threadId}:thread-root`); + const providerThreadId = ProviderThreadId.make(`provider-thread:${threadId}`); + const providerTurnId = ProviderTurnId.make(`provider-turn:${threadId}`); + yield* projections.apply({ + id: EventId.make(`event:${threadId}:root`), + type: "node.updated", + threadId, + nodeId: rootNodeId, + driver, + occurredAt: now, + payload: { + id: rootNodeId, + threadId, + runId: null, + parentNodeId: null, + rootNodeId, + kind: "root_turn", + status: "running", + countsForRun: false, + providerThreadId, + providerTurnId, + nativeItemRef: null, + runtimeRequestId: null, + checkpointScopeId: null, + startedAt: now, + completedAt: null, + }, + }); + // A native subagent's turn belongs to no run attempt. + yield* projections.apply({ + id: EventId.make(`event:${threadId}:provider-turn`), + type: "provider-turn.updated", + threadId, + nodeId: rootNodeId, + driver, + occurredAt: now, + payload: { + id: providerTurnId, + providerThreadId, + nodeId: rootNodeId, + runAttemptId: null, + nativeTurnRef: null, + ordinal: 1, + status: "running", + startedAt: now, + completedAt: null, + }, + }); + + const subagentId = NodeId.make(`node:${parentThreadId}:subagent`); + yield* projections.apply({ + id: EventId.make(`event:${parentThreadId}:subagent`), + type: "subagent.updated", + threadId: parentThreadId, + occurredAt: now, + payload: { + id: subagentId, + threadId: parentThreadId, + runId: null, + parentNodeId: NodeId.make(`node:${parentThreadId}:root`), + origin: "provider_native", + createdBy: "agent", + driver, + providerInstanceId, + providerThreadId, + childThreadId: threadId, + nativeTaskRef: null, + title: null, + model: null, + prompt: "sleep 300", + status: "running", + result: null, + startedAt: now, + completedAt: null, + updatedAt: now, + }, + }); + + yield* recovery.reconcile("startup"); + + const projection = yield* projections.getThreadProjection(threadId); + assert.deepEqual( + { + root: projection.nodes.find((node) => node.id === rootNodeId)?.status, + providerTurn: projection.providerTurns.find((turn) => turn.id === providerTurnId)?.status, + }, + { root: "cancelled", providerTurn: "cancelled" }, + "a root startup recovery cancelled left its provider turn running", + ); + }).pipe(Effect.provide(layerRecoveryTest)), +); diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index 700395facd79d..3c26ae3747179 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -4605,14 +4605,26 @@ export const layer: Layer.Layer = FROM orchestration_v2_projection_provider_turns AS provider_turn WHERE provider_turn.thread_id = ${threadId} AND provider_turn.status IN ('pending', 'starting', 'running', 'waiting') - AND provider_turn.run_attempt_id IN ( - SELECT attempt_id FROM orchestration_v2_projection_run_attempts - WHERE thread_id = ${threadId} - AND run_id IN ( - SELECT run_id FROM orchestration_v2_projection_runs + AND ( + provider_turn.run_attempt_id IN ( + SELECT attempt_id FROM orchestration_v2_projection_run_attempts + WHERE thread_id = ${threadId} + AND run_id IN ( + SELECT run_id FROM orchestration_v2_projection_runs + WHERE thread_id = ${threadId} + AND status IN ('queued', 'preparing', 'starting', 'running', 'waiting') + ) + ) + OR ( + provider_turn.run_attempt_id IS NULL + AND provider_turn.node_id IN ( + SELECT node_id FROM orchestration_v2_projection_nodes WHERE thread_id = ${threadId} - AND status IN ('queued', 'preparing', 'starting', 'running', 'waiting') + AND run_id IS NULL + AND kind = 'root_turn' + AND status IN ('pending', 'running', 'waiting') ) + ) ) ORDER BY provider_turn.provider_thread_id ASC, provider_turn.ordinal ASC `, diff --git a/apps/server/src/orchestration-v2/RunExecutionService.test.ts b/apps/server/src/orchestration-v2/RunExecutionService.test.ts index 03bbbf0506b50..f25d03bc7f527 100644 --- a/apps/server/src/orchestration-v2/RunExecutionService.test.ts +++ b/apps/server/src/orchestration-v2/RunExecutionService.test.ts @@ -11,6 +11,7 @@ import { type OrchestrationV2DomainEvent, type OrchestrationV2ExecutionNode, type OrchestrationV2ProviderThread, + type OrchestrationV2ProviderTurn, type OrchestrationV2Run, type OrchestrationV2RunAttempt, type OrchestrationV2Subagent, @@ -2624,6 +2625,106 @@ it.effect.each(["failure", "defect", "interrupt-failure", "ownership-lost"] as c }), ); +it.effect.each([ + { status: "completed", failure: "later-stream", interrupt: "succeeds" }, + { status: "failed", failure: "later-stream", interrupt: "succeeds" }, + { status: "completed", failure: "terminal-ingest", interrupt: "succeeds" }, + { status: "completed", failure: "terminal-ingest", interrupt: "fails" }, +] as const)( + "stores a $status root provider turn snapshot after a $failure failure only if the interrupt succeeds (it $interrupt)", + ({ status, failure, interrupt }) => + Effect.gen(function* () { + const now = yield* DateTime.now; + const terminalAt = DateTime.subtract(now, { seconds: 1 }); + const key = `terminal-snapshot:${status}:${failure}:${interrupt}`; + const durableTurns = yield* Ref.make< + ReadonlyMap + >(new Map()); + const persist = (turn: OrchestrationV2ProviderTurn) => + Ref.update(durableTurns, (current) => new Map(current).set(turn.id, turn)); + const interrupts = yield* Ref.make< + ReadonlyArray + >([]); + const ids = backgroundScenarioIds(key); + const rootTurn = ( + turnStatus: OrchestrationV2ProviderTurn["status"], + completedAt: DateTime.Utc | null, + ) => ({ + type: "provider_turn.updated" as const, + driver, + providerTurn: { + id: ids.rootProviderTurnId, + providerThreadId: ids.providerThreadId, + nodeId: ids.rootNodeId, + runAttemptId: ids.attemptId, + nativeTurnRef: null, + ordinal: 1, + status: turnStatus, + startedAt: terminalAt, + completedAt, + }, + }); + const { observed, finalEvents } = yield* captureRootRunTermination({ + key, + shouldFinalizeRun: () => Effect.succeed(true), + events: () => + Stream.make(rootTurn("running", null), rootTurn(status, terminalAt)).pipe( + Stream.concat( + failure === "later-stream" + ? Stream.fail( + new ProviderAdapter.ProviderAdapterEventStreamError({ + driver, + providerSessionId: ProviderSessionId.make(`session:${key}`), + cause: "provider process exited", + }), + ) + : Stream.empty, + ), + ), + ingestNormalized: ({ event }) => + event.type !== "provider_turn.updated" + ? Effect.succeed([]) + : failure === "terminal-ingest" && event.providerTurn.status !== "running" + ? Effect.fail( + new ProviderEventIngestor.ProviderEventPublishError({ + providerSessionId: ProviderSessionId.make(`session:${key}`), + eventCount: 1, + cause: new Error("SQLITE_FULL: database or disk is full"), + }), + ) + : persist(event.providerTurn).pipe(Effect.as([])), + interruptTurn: (input) => + Ref.update(interrupts, (current) => [...current, input]).pipe( + Effect.andThen( + interrupt === "fails" + ? Effect.fail( + new ProviderAdapter.ProviderAdapterInterruptError({ + driver, + providerThreadId: input.providerThread.id, + providerTurnId: input.providerTurnId, + cause: "provider already exited", + }), + ) + : Effect.void, + ), + ), + beforeTerminalWrite: (request) => + Effect.forEach(request.events, (event) => + event.type === "provider-turn.updated" ? persist(event.payload) : Effect.void, + ).pipe(Effect.as(true)), + }); + const calls = yield* Ref.get(interrupts); + assert.equal(calls.length, 1); + assert.isTrue(calls[0]?.requestRuntimeRestart); + assert.deepEqual(observed, ["run:failed", "pull-requests-refreshed"]); + assert.isTrue(finalEvents.some((event) => event.type === "run.updated")); + const durable = (yield* Ref.get(durableTurns)).get(ids.rootProviderTurnId); + // A failed interrupt keeps the stored turn running for the next start to retry. + assert.equal(durable?.status, interrupt === "fails" ? "running" : status); + assert.deepEqual(durable?.completedAt, interrupt === "fails" ? null : terminalAt); + }), +); + it.effect("does not interrupt an ended provider when its terminal write fails", () => Effect.gen(function* () { const interrupts = yield* Ref.make(0); diff --git a/apps/server/src/orchestration-v2/RunExecutionService.ts b/apps/server/src/orchestration-v2/RunExecutionService.ts index 9ec61cc85b994..d7789df380b68 100644 --- a/apps/server/src/orchestration-v2/RunExecutionService.ts +++ b/apps/server/src/orchestration-v2/RunExecutionService.ts @@ -620,7 +620,8 @@ export const layer: Layer.Layer< readonly hasUnpairedRunInterruptRequest?: () => Effect.Effect; readonly loadRunInterruptReason?: () => Effect.Effect; readonly openRunOwnedSubagents?: OpenRunOwnedSubagentProjection; - readonly interruptedProviderTurn?: OrchestrationV2ProviderTurn; + // Root turn snapshot written with the run when T3 ends the run itself. + readonly abandonedProviderTurn?: OrchestrationV2ProviderTurn; readonly terminal: ProviderTerminalEvent; readonly completedAt?: DateTime.Utc; readonly failureItemPersisted: boolean; @@ -747,7 +748,7 @@ export const layer: Layer.Layer< : [], events: [ ...cascadedSubagentEvents, - ...(input.interruptedProviderTurn === undefined + ...(input.abandonedProviderTurn === undefined ? [] : [ { @@ -758,11 +759,7 @@ export const layer: Layer.Layer< nodeId: input.rootNode.id, providerInstanceId: input.run.providerInstanceId, occurredAt: completedAt, - payload: { - ...input.interruptedProviderTurn, - status: "interrupted" as const, - completedAt, - }, + payload: input.abandonedProviderTurn, }, ]), ...(finalizedAttempt === null @@ -1482,8 +1479,9 @@ export const layer: Layer.Layer< if (yield* Ref.get(rootRunFinalized)) return; const providerThread = yield* Ref.get(latestProviderThread); const routing = yield* Ref.get(eventRouting); + const rootProviderTurn = yield* Ref.get(latestRootProviderTurn); // Stop background work too; the failed consumer cannot persist terminal events. - const interruptedProviderTurn = + const abandonedProviderTurn = routing.rootProviderTurnId !== null && !routing.rootTurnEnded ? yield* input.session .interruptTurn({ @@ -1492,14 +1490,28 @@ export const layer: Layer.Layer< requestRuntimeRestart: true, }) .pipe( - Effect.as(yield* Ref.get(latestRootProviderTurn)), + Effect.as(true), Effect.catchCauseIf( (interruptCause) => !Cause.hasInterruptsOnly(interruptCause), (interruptCause) => Effect.logWarning( "orchestration V2 failed to interrupt turn after ingestion failure", { runId: input.run.id, cause: interruptCause }, - ).pipe(Effect.as(null)), + ).pipe(Effect.as(false)), + ), + // A failed interrupt leaves the stored turn running so the next start + // retries it. The snapshot is cached before it is published, so it may + // not be stored: replay a terminal one as-is. + Effect.map((interrupted) => + !interrupted || rootProviderTurn === null + ? null + : isTerminalProviderTurnStatus(rootProviderTurn.status) + ? rootProviderTurn + : { + ...rootProviderTurn, + status: "interrupted" as const, + completedAt, + }, ), ) : null; @@ -1517,7 +1529,7 @@ export const layer: Layer.Layer< expectedStatus: "running", }, openRunOwnedSubagents: openSubagents, - ...(interruptedProviderTurn === null ? {} : { interruptedProviderTurn }), + ...(abandonedProviderTurn === null ? {} : { abandonedProviderTurn }), completedAt, terminal: makeFailedTerminalEvent( makeProviderFailure({ cause: Cause.squash(cause), class: "unknown" }), diff --git a/apps/server/src/orchestration-v2/SubagentProjection.test.ts b/apps/server/src/orchestration-v2/SubagentProjection.test.ts index e87fd7d9c6eea..d3506649f6a62 100644 --- a/apps/server/src/orchestration-v2/SubagentProjection.test.ts +++ b/apps/server/src/orchestration-v2/SubagentProjection.test.ts @@ -375,6 +375,7 @@ it.effect("ends a runless root turn's items when its node already ended", () => providerInstanceId: parentProviderInstanceId, projection: { nodes: [root], + providerTurns: [], turnItems: [item("turn-item:command"), item("turn-item:reasoning")], } as never, status: "cancelled", diff --git a/apps/server/src/orchestration-v2/ThreadStop.test.ts b/apps/server/src/orchestration-v2/ThreadStop.test.ts index 0c38faa5929f1..23a52f39d33d3 100644 --- a/apps/server/src/orchestration-v2/ThreadStop.test.ts +++ b/apps/server/src/orchestration-v2/ThreadStop.test.ts @@ -1335,6 +1335,25 @@ it.effect.each(["missing session", "completed", "failed", "cancelled"] as const) occurredAt: now, payload: childRoot, }); + const childTurnId = ProviderTurnId.make("turn:native-stop-child"); + yield* projections.apply({ + id: EventId.make("event:native-child-stop:child-turn"), + type: "provider-turn.updated", + threadId: childRoot.threadId, + nodeId: childRoot.id, + occurredAt: now, + payload: { + id: childTurnId, + providerThreadId: ProviderThreadId.make("provider-thread:native-stop-child"), + nodeId: childRoot.id, + runAttemptId: null, + nativeTurnRef: null, + ordinal: 1, + status: "running", + startedAt: now, + completedAt: null, + }, + }); const providerThread = before.providerThreads.find( (thread) => thread.id === run.providerThreadId, )!; @@ -1499,6 +1518,10 @@ it.effect.each(["missing session", "completed", "failed", "cancelled"] as const) ); const child = yield* orchestrator.getThreadProjection(childRoot.threadId); assert.equal(child.nodes.find((node) => node.id === childRoot.id)?.status, confirmedStatus); + assert.equal( + child.providerTurns.find((turn) => turn.id === childTurnId)?.status, + confirmedStatus, + ); assert.isNotNull(child.nodes.find((node) => node.id === childRoot.id)?.completedAt); assert.equal(settled.subagents.find((task) => task.id === sibling.id)?.status, "running"); assert.deepEqual(yield* threadState(threadId), stateBefore); diff --git a/packages/provider-core/src/server/subagentProjection.ts b/packages/provider-core/src/server/subagentProjection.ts index 9393d3a551878..f83a0b376233b 100644 --- a/packages/provider-core/src/server/subagentProjection.ts +++ b/packages/provider-core/src/server/subagentProjection.ts @@ -271,7 +271,7 @@ export function delegatedTaskProgress(projection: { function writtenIds( emitted: ReadonlyArray, - type: "subagent.updated" | "node.updated" | "turn-item.updated", + type: "subagent.updated" | "node.updated" | "turn-item.updated" | "provider-turn.updated", ): ReadonlySet { return new Set(emitted.flatMap((event) => (event.type === type ? [event.payload.id] : []))); } @@ -359,7 +359,10 @@ export const endRunlessRootTurns = Effect.fn("subagentProjection.endRunlessRootT >(input: { readonly threadId: ThreadId; readonly providerInstanceId: ProviderInstanceId; - readonly projection: Pick; + readonly projection: Pick< + OrchestrationV2ThreadProjection, + "nodes" | "providerTurns" | "turnItems" + >; readonly status: "completed" | "failed" | "interrupted" | "cancelled"; readonly completedAt?: DateTime.Utc; readonly now: DateTime.Utc; @@ -369,6 +372,7 @@ export const endRunlessRootTurns = Effect.fn("subagentProjection.endRunlessRootT const { now, status, completedAt = now } = input; const endedNodeIds = writtenIds(input.emitted, "node.updated"); const endedItemIds = writtenIds(input.emitted, "turn-item.updated"); + const endedProviderTurnIds = writtenIds(input.emitted, "provider-turn.updated"); const scope = { threadId: input.threadId, providerInstanceId: input.providerInstanceId, @@ -421,6 +425,23 @@ export const endRunlessRootTurns = Effect.fn("subagentProjection.endRunlessRootT }, }); } + for (const providerTurn of input.projection.providerTurns) { + if ( + providerTurn.nodeId !== node.id || + providerTurn.runAttemptId !== null || + !isOrchestrationV2WorkActive(providerTurn.status) || + endedProviderTurnIds.has(providerTurn.id) + ) { + continue; + } + events.push({ + ...scope, + id: yield* input.allocateEventId(), + type: "provider-turn.updated", + nodeId: node.id, + payload: { ...providerTurn, status, completedAt }, + }); + } } return events; }); From 92125d612d114c348da1f3883a34aab139c471c6 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 25 Oct 2026 15:23:46 -0700 Subject: [PATCH 057/219] feat(mobile): open threads and devices in a resizable iOS inspector (#24985) --- apps/mobile/src/Stack.tsx | 12 +- .../devices/DevicePreviewRouteScreen.tsx | 65 +++++-- .../layout/AdaptiveWorkspaceLayout.tsx | 2 + .../createWorkspaceStackNavigator.ios.tsx | 58 +++++- .../layout/workspace-pane-divider.tsx | 2 +- .../features/threads/ThreadAgentsSheet.tsx | 11 ++ .../features/threads/ThreadDetailScreen.tsx | 18 +- .../features/threads/ThreadRouteScreen.tsx | 130 ++++++++++---- .../thread-inspector-content-stack.tsx | 10 +- .../thread-inspector-navigation.test.tsx | 169 ++++++++++++++++++ .../threads/thread-inspector-navigation.ts | 44 +++++ .../threads/thread-inspector-preview.tsx | 142 +++++++++++++++ .../threads/thread-subagent-group.tsx | 25 ++- .../src/features/threads/thread-work-log.tsx | 16 +- .../threads/thread-workflow-opening.tsx | 11 +- .../features/threads/useOpenLinkedThread.ts | 28 ++- apps/mobile/src/lib/filePreview.test.ts | 4 + apps/mobile/src/lib/layout.test.ts | 20 +++ apps/mobile/src/lib/layout.ts | 2 + apps/mobile/vite.config.ts | 1 + docs/user/devices.md | 2 + docs/user/thread-sidebar.md | 3 + scripts/effect-budget.json | 2 +- 23 files changed, 687 insertions(+), 90 deletions(-) create mode 100644 apps/mobile/src/features/threads/thread-inspector-navigation.test.tsx create mode 100644 apps/mobile/src/features/threads/thread-inspector-navigation.ts create mode 100644 apps/mobile/src/features/threads/thread-inspector-preview.tsx diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index 8a07f8b7a0867..b6b747e9dabdf 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -65,7 +65,10 @@ import { ThreadPullRequestsSheet } from "./features/threads/ThreadPullRequestsSh import { ThreadAgentsSheet } from "./features/threads/ThreadAgentsSheet"; import { ThreadBackgroundWorkSheet } from "./features/threads/ThreadBackgroundWorkSheet"; import { ThreadQueueSheet } from "./features/threads/ThreadQueueControl"; -import { ThreadRouteScreen } from "./features/threads/ThreadRouteScreen"; +import { + FullscreenThreadRouteScreen, + ThreadRouteScreen, +} from "./features/threads/ThreadRouteScreen"; import { McpAppFullscreenScreen } from "./features/threads/McpAppFullscreenScreen"; import { ConnectionsRouteScreen } from "./features/connection/ConnectionsRouteScreen"; import { ConnectionsNewRouteScreen } from "./features/connection/ConnectionsNewRouteScreen"; @@ -813,6 +816,13 @@ const RootStackConfig = createWorkspaceStackNavigator({ linking: `${THREAD_LINKING_PREFIX}/terminal`, options: SOLID_HEADER_OPTIONS, }), + ThreadInspectorFullscreen: createNativeStackScreen({ + screen: FullscreenThreadRouteScreen, + options: { + ...THREAD_HEADER_OPTIONS, + presentation: "fullScreenModal", + }, + }), ThreadDevicePreview: createNativeStackScreen({ screen: DevicePreviewRouteScreen, linking: `${THREAD_LINKING_PREFIX}/devices`, diff --git a/apps/mobile/src/features/devices/DevicePreviewRouteScreen.tsx b/apps/mobile/src/features/devices/DevicePreviewRouteScreen.tsx index 3e5899f870a33..850049f111a44 100644 --- a/apps/mobile/src/features/devices/DevicePreviewRouteScreen.tsx +++ b/apps/mobile/src/features/devices/DevicePreviewRouteScreen.tsx @@ -9,7 +9,7 @@ import { import { useIsFocused, useNavigation, type StaticScreenProps } from "@react-navigation/native"; import { EnvironmentId, ThreadId } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; -import { useEffect, useRef, useState, type RefObject } from "react"; +import { useCallback, useEffect, useRef, useState, type RefObject } from "react"; import * as Haptics from "expo-haptics"; import { Accelerometer } from "expo-sensors"; import { AppState, BackHandler, Pressable, StatusBar, View } from "react-native"; @@ -47,6 +47,7 @@ const OVERLAY_EXITING = FadeOut.duration(120).reduceMotion(ReduceMotion.System); type DevicePreviewRouteScreenProps = StaticScreenProps<{ readonly environmentId: string; readonly threadId: string; + readonly deviceKey?: string; }>; export function DevicePreviewRouteScreen({ route }: DevicePreviewRouteScreenProps) { @@ -62,27 +63,33 @@ export function DevicePreviewRouteScreen({ route }: DevicePreviewRouteScreenProp ); } -function DevicePreviewScreen({ +export function DevicePreviewScreen({ environmentId, threadId, onClose, + onMaximize, + initialDeviceKey, }: { readonly environmentId: EnvironmentId; readonly threadId: ThreadId; readonly onClose: () => void; + readonly onMaximize?: (deviceKey: string | null) => void; + readonly initialDeviceKey?: string; }) { + const embedded = onMaximize !== undefined; const insets = useSafeAreaInsets(); // NativeColumnContent already insets the sides, so only a book fold moves the reveal handle. const handleBounds = deriveBottomControlInsets(useNativeColumnLayoutMetrics()); const { themeVariables } = useAppearancePreferences(); const focused = useIsFocused(); const [foreground, setForeground] = useState(AppState.currentState !== "background"); - const [selectedKey, setSelectedKey] = useState(null); + const [selectedKey, setSelectedKey] = useState(initialDeviceKey ?? null); const [inputConnected, setInputConnected] = useState(false); const [streamAttempt, setStreamAttempt] = useState(0); const [shuttingDown, setShuttingDown] = useState(false); @@ -93,10 +100,13 @@ function DevicePreviewScreen({ const state = useEnvironmentQuery(deviceEnvironment.state({ environmentId, input: {} })); const previews = threadDevicePreviews(state.data, threadId); const preview = selectedThreadDevicePreview(previews, selectedKey); - const onInputConnected = async (connected: boolean) => { - setInputConnected(connected); - if (connected) setControlsVisible(false); - }; + const onInputConnected = useCallback( + async (connected: boolean) => { + setInputConnected(connected); + if (connected && !embedded) setControlsVisible(false); + }, + [embedded], + ); useEffect(() => { const subscription = AppState.addEventListener("change", (state) => setForeground(state !== "background"), @@ -107,7 +117,7 @@ function DevicePreviewScreen({ if (focused && state.data !== null && previews.length === 0) onClose(); }, [focused, state.data, previews.length, onClose]); useEffect(() => { - if (!focused || !foreground) return; + if (!focused || !foreground || embedded) return; const shaken = createShakeDetector(); Accelerometer.setUpdateInterval(50); const subscription = Accelerometer.addListener((sample) => { @@ -116,7 +126,7 @@ function DevicePreviewScreen({ setControlsVisible((visible) => !visible); }); return () => subscription.remove(); - }, [focused, foreground]); + }, [focused, foreground, embedded]); useEffect(() => { if (controlsVisible) return; const subscription = BackHandler.addEventListener("hardwareBackPress", () => { @@ -260,7 +270,23 @@ function DevicePreviewScreen({ ]; return ( -