diff --git a/apps/desktop/src/app/DesktopClerk.test.ts b/apps/desktop/src/app/DesktopClerk.test.ts index 76306bfbf227..b4aef409337e 100644 --- a/apps/desktop/src/app/DesktopClerk.test.ts +++ b/apps/desktop/src/app/DesktopClerk.test.ts @@ -35,9 +35,16 @@ import * as ElectronApp from "../electron/ElectronApp.ts"; import * as ElectronShell from "../electron/ElectronShell.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; import * as DesktopClerk from "./DesktopClerk.ts"; +import * as DesktopWebLinks from "./DesktopWebLinks.ts"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; import * as DesktopPreReadyFileSystem from "./DesktopPreReadyFileSystem.ts"; +/** Clerk forwards web links; tests that are not about links ignore them. */ +const ignoreWebLinks = DesktopWebLinks.DesktopWebLinks.of({ + receive: () => Effect.void, + setRendererReady: () => Effect.void, +}); + const layerDesktopClerk = ( isDevelopment = true, events: string[] = [], @@ -220,11 +227,12 @@ describe("DesktopClerk", () => { assert.isTrue(Exit.isSuccess(exit)); assert.equal(quit.mock.calls.length, 0); - assert.deepEqual(registeredEvents, ["open-url", "second-instance"]); + assert.deepEqual(registeredEvents, ["open-url", "open-file", "second-instance"]); }).pipe( Effect.provide(layerDesktopClerk()), Effect.provideService(ElectronApp.ElectronApp, electronApp), Effect.provideService(ElectronWindow.ElectronWindow, electronWindow), + Effect.provideService(DesktopWebLinks.DesktopWebLinks, ignoreWebLinks), ); }); @@ -253,6 +261,7 @@ describe("DesktopClerk", () => { Effect.provide(layerDesktopClerk()), Effect.provideService(ElectronApp.ElectronApp, electronApp), Effect.provideService(ElectronWindow.ElectronWindow, electronWindow), + Effect.provideService(DesktopWebLinks.DesktopWebLinks, ignoreWebLinks), ); }); }); @@ -299,6 +308,7 @@ it.effect( Effect.provide(layerDesktopClerk()), Effect.provideService(ElectronApp.ElectronApp, electronApp), Effect.provideService(ElectronWindow.ElectronWindow, electronWindow), + Effect.provideService(DesktopWebLinks.DesktopWebLinks, ignoreWebLinks), ); }, ); @@ -381,6 +391,54 @@ it.effect.each(["startup", "open-url"] as const)( ElectronWindow.ElectronWindow, {} as ElectronWindow.ElectronWindow["Service"], ), + Effect.provideService(DesktopWebLinks.DesktopWebLinks, ignoreWebLinks), ); }).pipe(Effect.scoped), ); + +it.effect("hands a web link to the renderer and leaves other links alone", () => + Effect.gen(function* () { + storageMock.mockReturnValue(storageAdapter); + createClerkBridgeMock.mockReturnValue({ cleanup: vi.fn(), isPrimaryInstance: true }); + const listeners = new Map void>(); + const electronApp = { + whenReady: Effect.void, + on: (name: string, listener: (...args: unknown[]) => void) => + Effect.sync(() => { + listeners.set(name, listener); + }), + } as unknown as ElectronApp.ElectronApp["Service"]; + const received: Array = []; + yield* Effect.gen(function* () { + const clerk = yield* DesktopClerk.DesktopClerk; + yield* clerk.configure; + const open = (url: string) => { + const event = { preventDefault: vi.fn() }; + listeners.get("open-url")!(event, url); + return event.preventDefault.mock.calls.length; + }; + // macOS hands the default browser every web link. + assert.strictEqual(open("https://example.com/page"), 1); + assert.strictEqual(open("http://localhost:3000/"), 1); + // Anything else is not a web page; Electron keeps its own handling. + assert.strictEqual(open("mailto:hello@example.com"), 0); + yield* Effect.yieldNow; + assert.deepStrictEqual(received, ["https://example.com/page", "http://localhost:3000/"]); + }).pipe( + Effect.provide(layerDesktopClerk(true, [], "darwin")), + Effect.provideService(HostProcess.Arguments, ["t3"]), + Effect.provideService(ElectronApp.ElectronApp, electronApp), + Effect.provideService( + ElectronWindow.ElectronWindow, + {} as ElectronWindow.ElectronWindow["Service"], + ), + Effect.provideService( + DesktopWebLinks.DesktopWebLinks, + DesktopWebLinks.DesktopWebLinks.of({ + receive: (url) => Effect.sync(() => void received.push(url)), + setRendererReady: () => Effect.void, + }), + ), + ); + }).pipe(Effect.scoped), +); diff --git a/apps/desktop/src/app/DesktopClerk.ts b/apps/desktop/src/app/DesktopClerk.ts index f1a7f31174ab..08d3def1a689 100644 --- a/apps/desktop/src/app/DesktopClerk.ts +++ b/apps/desktop/src/app/DesktopClerk.ts @@ -1,4 +1,5 @@ import { createClerkBridge } from "@clerk/electron"; +import * as NodeURL from "node:url"; import { storage } from "@clerk/electron/storage"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; @@ -18,6 +19,7 @@ import * as ElectronProtocol from "../electron/ElectronProtocol.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; import * as DesktopUserData from "./DesktopUserData.ts"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; +import * as DesktopWebLinks from "./DesktopWebLinks.ts"; declare const __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__: string | undefined; @@ -53,7 +55,10 @@ export class DesktopClerk extends Context.Service< readonly configure: Effect.Effect< void, never, - ElectronApp.ElectronApp | ElectronWindow.ElectronWindow | Scope.Scope + | ElectronApp.ElectronApp + | ElectronWindow.ElectronWindow + | DesktopWebLinks.DesktopWebLinks + | Scope.Scope >; } >()("@t3tools/desktop/app/DesktopClerk") {} @@ -125,6 +130,7 @@ export const make = Effect.gen(function* () { configure: Effect.gen(function* () { const electronApp = yield* ElectronApp.ElectronApp; const electronWindow = yield* ElectronWindow.ElectronWindow; + const webLinks = yield* DesktopWebLinks.DesktopWebLinks; const context = yield* Effect.context(); const runPromise = Effect.runPromiseWith(context); @@ -184,8 +190,21 @@ export const make = Effect.gen(function* () { }; const args = yield* HostProcess.Arguments; args.some((value) => startProviderAuthHandoff(value)); + // As the default browser, macOS hands T3 Code every web link through the same event. + const openWebLink = (url: string) => { + if (!DesktopWebLinks.isWebLink(url)) return false; + void runPromise(webLinks.receive(url)); + return true; + }; yield* electronApp.on("open-url", (event: { preventDefault: () => void }, url: string) => { - if (startProviderAuthHandoff(url) || resumeProviderAuth(url)) event.preventDefault(); + if (startProviderAuthHandoff(url) || resumeProviderAuth(url) || openWebLink(url)) + event.preventDefault(); + }); + // A browser opens HTML files too, which macOS hands over by path. + yield* electronApp.on("open-file", (event: { preventDefault: () => void }, path: string) => { + if (!DesktopWebLinks.isWebPageFile(path)) return; + event.preventDefault(); + void runPromise(webLinks.receive(NodeURL.pathToFileURL(path).href)); }); yield* electronApp.on("second-instance", (_event: unknown, argv: readonly string[]) => { if (argv?.some((value) => startProviderAuthHandoff(value) || resumeProviderAuth(value))) diff --git a/apps/desktop/src/app/DesktopWebLinks.test.ts b/apps/desktop/src/app/DesktopWebLinks.test.ts new file mode 100644 index 000000000000..302fe5f1b4ca --- /dev/null +++ b/apps/desktop/src/app/DesktopWebLinks.test.ts @@ -0,0 +1,93 @@ +// @effect-diagnostics nodeBuiltinImport:off - Stands in for an Electron webContents. +import { assert, describe, it } from "@effect/vitest"; +import * as NodeEvents from "node:events"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; + +import * as ElectronWindow from "../electron/ElectronWindow.ts"; +import { WEB_LINK_OPEN_CHANNEL } from "../ipc/channels.ts"; +import * as DesktopWebLinks from "./DesktopWebLinks.ts"; + +const makeWindow = () => { + const sent: Array = []; + const revealed: Array = []; + const webContents = Object.assign(new NodeEvents.EventEmitter(), { + isDestroyed: () => false, + send: (channel: string, url: string) => { + if (channel === WEB_LINK_OPEN_CHANNEL) sent.push(url); + }, + }); + const window = { webContents } as unknown as Electron.BrowserWindow; + /** The web app starts loading a new page, as a reload does. */ + const reload = () => + webContents.emit("did-start-navigation", { isMainFrame: true, isSameDocument: false }); + const layer = DesktopWebLinks.layer.pipe( + Layer.provide( + Layer.succeed( + ElectronWindow.ElectronWindow, + ElectronWindow.ElectronWindow.of({ + currentMainOrFirst: Effect.succeed(Option.some(window)), + reveal: (target: Electron.BrowserWindow) => Effect.sync(() => void revealed.push(target)), + } as unknown as ElectronWindow.ElectronWindow["Service"]), + ), + ), + ); + return { sent, revealed, layer, reload }; +}; + +describe("DesktopWebLinks", () => { + it("treats only http and https as web links", () => { + assert.isTrue(DesktopWebLinks.isWebLink("https://example.com/")); + assert.isTrue(DesktopWebLinks.isWebLink("http://localhost:3000/a?b=1")); + assert.isFalse(DesktopWebLinks.isWebLink("t3code://app/welcome")); + assert.isFalse(DesktopWebLinks.isWebLink("mailto:hello@example.com")); + assert.isFalse(DesktopWebLinks.isWebLink("not a url")); + }); + + it("opens HTML files the way a browser does, and nothing else by path", () => { + assert.isTrue(DesktopWebLinks.isWebPageFile("/Users/me/report.html")); + assert.isTrue(DesktopWebLinks.isWebPageFile("/tmp/page.HTM")); + assert.isTrue(DesktopWebLinks.isWebPageFile("/tmp/page.xhtml")); + assert.isFalse(DesktopWebLinks.isWebPageFile("/tmp/notes.md")); + assert.isFalse(DesktopWebLinks.isWebPageFile("/tmp/html")); + }); + + it.effect("holds links until the renderer listens, then delivers them in order", () => { + const { sent, revealed, layer } = makeWindow(); + return Effect.gen(function* () { + const webLinks = yield* DesktopWebLinks.DesktopWebLinks; + // The link that launched the app arrives before the web app has loaded. + yield* webLinks.receive("https://example.com/first"); + yield* webLinks.receive("https://example.com/second"); + assert.deepStrictEqual(sent, []); + + yield* webLinks.setRendererReady(true); + assert.deepStrictEqual(sent, ["https://example.com/first", "https://example.com/second"]); + assert.strictEqual(revealed.length, 1); + + yield* webLinks.receive("https://example.com/third"); + assert.deepStrictEqual(sent.at(-1), "https://example.com/third"); + + // A reloading web app stops listening; links wait for it again. + yield* webLinks.setRendererReady(false); + yield* webLinks.receive("https://example.com/fourth"); + assert.strictEqual(sent.length, 3); + }).pipe(Effect.provide(layer)); + }); + + it.effect("holds links that arrive while the web app reloads until it listens again", () => { + const { sent, layer, reload } = makeWindow(); + return Effect.gen(function* () { + const webLinks = yield* DesktopWebLinks.DesktopWebLinks; + yield* webLinks.setRendererReady(true); + // The old page never says it stopped; the reload itself ends its listening. + reload(); + yield* webLinks.receive("https://example.com/during-reload"); + assert.deepStrictEqual(sent, []); + + yield* webLinks.setRendererReady(true); + assert.deepStrictEqual(sent, ["https://example.com/during-reload"]); + }).pipe(Effect.provide(layer)); + }); +}); diff --git a/apps/desktop/src/app/DesktopWebLinks.ts b/apps/desktop/src/app/DesktopWebLinks.ts new file mode 100644 index 000000000000..44d90534d009 --- /dev/null +++ b/apps/desktop/src/app/DesktopWebLinks.ts @@ -0,0 +1,97 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; + +import * as ElectronWindow from "../electron/ElectronWindow.ts"; +import { WEB_LINK_OPEN_CHANNEL } from "../ipc/channels.ts"; + +/** An http(s) link the operating system handed to T3 Code as the default browser. */ +export const isWebLink = (value: string) => { + if (!URL.canParse(value)) return false; + const { protocol } = new URL(value); + return protocol === "http:" || protocol === "https:"; +}; + +/** An HTML file the operating system asked T3 Code to open, as browsers are asked to. */ +export const isWebPageFile = (path: string) => /\.x?html?$/i.test(path); + +/** + * Web links and HTML files macOS opens with T3 Code once it is the default + * browser. Files arrive as file:// URLs. They can + * arrive before the app is ready (the link that launched it) or while the web + * app reloads, so they wait until the renderer says it is listening. + */ +export class DesktopWebLinks extends Context.Service< + DesktopWebLinks, + { + /** Queues a link and delivers it as soon as the renderer is listening. */ + readonly receive: (url: string) => Effect.Effect; + /** The renderer started or stopped listening; listening flushes queued links. */ + readonly setRendererReady: (ready: boolean) => Effect.Effect; + } +>()("@t3tools/desktop/app/DesktopWebLinks") {} + +const make = Effect.gen(function* () { + const electronWindow = yield* ElectronWindow.ElectronWindow; + const pending: Array = []; + /** The page that said it is listening; a reload or crash replaces it, and it must say so again. */ + let listening: Electron.WebContents | null = null; + let detachListening: (() => void) | null = null; + + const stopListening = () => { + detachListening?.(); + detachListening = null; + listening = null; + }; + + const flush = Effect.gen(function* () { + if (listening === null || pending.length === 0) return; + const window = yield* electronWindow.currentMainOrFirst; + if (Option.isNone(window) || window.value.webContents !== listening) return; + if (listening.isDestroyed()) { + stopListening(); + return; + } + for (const url of pending.splice(0)) listening.send(WEB_LINK_OPEN_CHANNEL, url); + yield* electronWindow.reveal(window.value); + }); + + const startListening = Effect.gen(function* () { + const window = yield* electronWindow.currentMainOrFirst; + if (Option.isNone(window) || window.value.webContents.isDestroyed()) return; + const webContents = window.value.webContents; + if (listening === webContents) return; + stopListening(); + listening = webContents; + // Links that arrive while a new page loads wait for it to listen again. + const onNavigation = ( + event: Electron.Event, + ) => { + if (event.isMainFrame && !event.isSameDocument) stopListening(); + }; + webContents.on("did-start-navigation", onNavigation); + webContents.on("render-process-gone", stopListening); + webContents.once("destroyed", stopListening); + detachListening = () => { + webContents.removeListener("did-start-navigation", onNavigation); + webContents.removeListener("render-process-gone", stopListening); + webContents.removeListener("destroyed", stopListening); + }; + }); + + return DesktopWebLinks.of({ + receive: (url) => + Effect.suspend(() => { + pending.push(url); + return flush; + }).pipe(Effect.withSpan("DesktopWebLinks.receive")), + setRendererReady: (ready) => + (ready ? startListening : Effect.sync(stopListening)).pipe( + Effect.andThen(flush), + Effect.withSpan("DesktopWebLinks.setRendererReady"), + ), + }); +}); + +export const layer = Layer.effect(DesktopWebLinks, make); diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index a67424a795a9..caed223e90e8 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -70,6 +70,7 @@ import { } from "./methods/snapShot.ts"; import * as PreviewIpc from "./methods/preview.ts"; import * as AppActivationIpc from "./methods/appActivation.ts"; +import * as WebLinksIpc from "./methods/webLinks.ts"; import { completeLegacyLocalStorage, takeLegacyLocalStorage, @@ -88,6 +89,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handle(AppActivationIpc.setReady); yield* ipc.handle(AppActivationIpc.complete); + yield* ipc.handle(WebLinksIpc.setReady); yield* ipc.handleSync(takeLegacyLocalStorage); yield* ipc.handle(completeLegacyLocalStorage); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index e6b936c100ba..a6b617385b57 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -10,6 +10,8 @@ export const PROBE_REMOTE_EDITORS_CHANNEL = "desktop:probe-remote-editors"; export const MENU_ACTION_CHANNEL = "desktop:menu-action"; export const PASTE_AS_TEXT_CHANNEL = "desktop:paste-as-text"; export const SNAP_SHOT_EVENT_CHANNEL = "desktop:snap-shot-event"; +export const WEB_LINK_OPEN_CHANNEL = "desktop:web-link-open"; +export const WEB_LINK_READY_CHANNEL = "desktop:web-link-ready"; export const QUIT_SHORTCUT_CHANNEL = "desktop:quit-shortcut"; export const TRACKPAD_SCROLL_END_CHANNEL = "desktop:trackpad-scroll-end"; export const GET_WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:get-window-fullscreen-state"; diff --git a/apps/desktop/src/ipc/methods/webLinks.ts b/apps/desktop/src/ipc/methods/webLinks.ts new file mode 100644 index 000000000000..bda6affc0741 --- /dev/null +++ b/apps/desktop/src/ipc/methods/webLinks.ts @@ -0,0 +1,16 @@ +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import * as DesktopWebLinks from "../../app/DesktopWebLinks.ts"; +import * as IpcChannels from "../channels.ts"; +import * as DesktopIpc from "../DesktopIpc.ts"; + +export const setReady = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.WEB_LINK_READY_CHANNEL, + payload: Schema.Boolean, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.webLinks.setReady")(function* (ready) { + const webLinks = yield* DesktopWebLinks.DesktopWebLinks; + yield* webLinks.setRendererReady(ready); + }), +}); diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 806b038d7696..ef00c4d66ed8 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -35,6 +35,7 @@ import * as ElectronUpdater from "./electron/ElectronUpdater.ts"; import * as ElectronWindow from "./electron/ElectronWindow.ts"; import * as DesktopApp from "./app/DesktopApp.ts"; import * as DesktopAppActivation from "./app/DesktopAppActivation.ts"; +import * as DesktopWebLinks from "./app/DesktopWebLinks.ts"; import * as DesktopAppIdentity from "./app/DesktopAppIdentity.ts"; import * as DesktopConnectionCatalogStore from "./app/DesktopConnectionCatalogStore.ts"; import * as DesktopClerk from "./app/DesktopClerk.ts"; @@ -214,6 +215,7 @@ const layerDesktopLocalEnvironmentAuth = DesktopLocalEnvironmentAuth.layer.pipe( const layerDesktopApplication = Layer.mergeAll( DesktopLifecycle.layer, layerDesktopAppActivation, + DesktopWebLinks.layer, DesktopApplicationMenu.layer, DesktopLinuxUrlHandler.layer, DesktopCliCommand.layer, diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 12b68f186089..efd49ffa3864 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -285,6 +285,18 @@ contextBridge.exposeInMainWorld("desktopBridge", { ipcRenderer.removeListener(IpcChannels.UPDATE_STATE_CHANNEL, wrappedListener); }; }, + webLinks: { + setReady: (ready) => ipcRenderer.invoke(IpcChannels.WEB_LINK_READY_CHANNEL, ready), + onOpen: (listener) => { + const wrappedListener = (_event: Electron.IpcRendererEvent, url: unknown) => { + if (typeof url === "string") listener(url); + }; + ipcRenderer.on(IpcChannels.WEB_LINK_OPEN_CHANNEL, wrappedListener); + return () => { + ipcRenderer.removeListener(IpcChannels.WEB_LINK_OPEN_CHANNEL, wrappedListener); + }; + }, + }, appActivation: { setReady: (ready) => ipcRenderer.invoke(IpcChannels.DESKTOP_APP_ACTIVATION_READY_CHANNEL, ready), diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index ec4b2565e174..aaaea6382509 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -101,6 +101,7 @@ import { } from "./features/settings/ScheduledTaskPickerScreens"; import { ScheduledTaskEditorProvider } from "./features/settings/scheduled-task-editor"; import { SettingsKeyboardRouteScreen } from "./features/settings/SettingsKeyboardRouteScreen"; +import { SettingsVoiceInputRouteScreen } from "./features/settings/SettingsVoiceInputRouteScreen"; import { SettingsLegalRouteScreen } from "./features/settings/SettingsLegalRouteScreen"; import { SettingsOpenSourceLicenseRouteScreen, @@ -310,6 +311,11 @@ const SettingsContentStack = createV5SheetStackNavigator({ title: "Keyboard", }, }), + SettingsVoiceInput: createNativeStackScreen({ + screen: SettingsVoiceInputRouteScreen, + linking: "voice-input", + options: { title: "Voice input" }, + }), SettingsFollowUp: createNativeStackScreen({ screen: SettingsFollowUpRouteScreen, linking: "follow-ups", diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index 1f474a9d56b8..4366d10f8abd 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -156,6 +156,9 @@ function SettingsIndexSections() { {Platform.OS === "ios" ? ( ) : null} + {Platform.OS === "ios" ? ( + + ) : null} diff --git a/apps/mobile/src/features/settings/SettingsVoiceInputRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsVoiceInputRouteScreen.tsx new file mode 100644 index 000000000000..fcab9d91ecc8 --- /dev/null +++ b/apps/mobile/src/features/settings/SettingsVoiceInputRouteScreen.tsx @@ -0,0 +1,56 @@ +import { useAtomSet, useAtomValue } from "@effect/atom-react"; +import { AsyncResult } from "effect/reactivity"; +import { ScrollView } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; +import { SettingsChoiceRow } from "./components/SettingsChoiceRow"; +import { SettingsSection } from "./components/SettingsSection"; + +// Locales Apple's on-device transcriber supports; its native module does not expose the list. +const LANGUAGES = [ + { locale: undefined, label: "Automatic", description: "Uses the app's language." }, + { locale: "en-US", label: "English (US)", description: "English (United States)" }, + { locale: "en-GB", label: "English (UK)", description: "English (United Kingdom)" }, + { locale: "es-ES", label: "Español (España)", description: "Spanish (Spain)" }, + { locale: "es-MX", label: "Español (México)", description: "Spanish (Mexico)" }, + { locale: "fr-FR", label: "Français", description: "French" }, + { locale: "de-DE", label: "Deutsch", description: "German" }, + { locale: "it-IT", label: "Italiano", description: "Italian" }, + { locale: "pt-BR", label: "Português (Brasil)", description: "Portuguese (Brazil)" }, + { locale: "ja-JP", label: "日本語", description: "Japanese" }, + { locale: "ko-KR", label: "한국어", description: "Korean" }, + { locale: "zh-CN", label: "中文(普通话)", description: "Chinese (Mandarin)" }, +] as const; + +export function SettingsVoiceInputRouteScreen() { + const insets = useSafeAreaInsets(); + const savePreferences = useAtomSet(updateMobilePreferencesAtom); + const selectedLocale = useAtomValue(mobilePreferencesAtom, (result) => + AsyncResult.isSuccess(result) ? result.value.voiceInputLanguage : null, + ); + + return ( + + + {LANGUAGES.map((option, index) => ( + 0} + disabled={selectedLocale === null} + onPress={() => savePreferences({ voiceInputLanguage: option.locale })} + /> + ))} + + + ); +} diff --git a/apps/mobile/src/features/settings/components/settings-sheet-targets.ts b/apps/mobile/src/features/settings/components/settings-sheet-targets.ts index ad0197d634d8..4b7c5e9b8316 100644 --- a/apps/mobile/src/features/settings/components/settings-sheet-targets.ts +++ b/apps/mobile/src/features/settings/components/settings-sheet-targets.ts @@ -13,6 +13,7 @@ export type SettingsSheetTarget = | "SettingsEnvironmentMaintenance" | "SettingsProviderAccounts" | "SettingsKeyboard" + | "SettingsVoiceInput" | "SettingsFollowUp" | "SettingsScheduledTasks" | "SettingsProjectGrouping" diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 9229c117d8da..a3d7a6f4e0c6 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -1,5 +1,5 @@ import { useAtomValue } from "@effect/atom-react"; -import { useThreadReportedModelSelection } from "../../state/entities"; +import { useChildThreadInputs, useThreadReportedModelSelection } from "../../state/entities"; import { UsageLimitRecoveryCard } from "./UsageLimitRecoveryCard"; import { useNavigation } from "@react-navigation/native"; import type { WorktreeSetupCardProps } from "./worktree-setup-card"; @@ -400,6 +400,10 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread const controlInsets = deriveBottomControlInsets(nativeMetrics); const agentLabel = `${props.selectedThread.modelSelection.instanceId} agent`; const selectedThreadKey = scopedThreadKey(props.environmentId, props.selectedThread.id); + const childThreadInputs = useChildThreadInputs({ + environmentId: props.environmentId, + threadId: props.selectedThread.id, + }); const composerError = useAtomValue(threadComposerErrorsAtom)[selectedThreadKey]?.message ?? null; const queuedCount = useThreadQueuedCount({ environmentId: props.environmentId, @@ -507,6 +511,24 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread if (threadSyncLabel !== null) { return { kind: "syncing", label: threadSyncLabel }; } + const childInput = childThreadInputs[0]; + if (childInput && contentPresentationKind === "ready") { + return { + kind: "child-input", + label: + childThreadInputs.length === 1 + ? "Subagent needs input" + : `${childThreadInputs.length} subagents need input`, + accessibilityLabel: `Open question from ${childInput.title}`, + onPress: () => { + Keyboard.dismiss(); + navigation.navigate("Thread", { + environmentId: props.environmentId, + threadId: childInput.id, + }); + }, + }; + } if (props.isCompacting && contentPresentationKind === "ready") { return { kind: "compacting" }; } diff --git a/apps/mobile/src/features/threads/floating-working-control.tsx b/apps/mobile/src/features/threads/floating-working-control.tsx index f21a83f09a17..1fb87ce1c725 100644 --- a/apps/mobile/src/features/threads/floating-working-control.tsx +++ b/apps/mobile/src/features/threads/floating-working-control.tsx @@ -160,15 +160,16 @@ export function FloatingWorkingControl(props: { hasAgents || hasQueue || (props.devicePreview !== null && props.browserPreview !== null); - // The queue, agents, and reconnect labels have separate tap targets. - const statusInteractive = props.status?.kind === "connection"; + // The queue, agents, questions, and reconnect labels have separate tap targets. + const statusInteractive = + props.status?.kind === "connection" || props.status?.kind === "child-input"; const capsuleInteractive = statusInteractive || hasQueue || hasAgents || hasPreview; // The host stays centered on the capsule, but its measurement constraint // comes from the overlay, independent of the capsule's current width. const statusContent = props.status !== null ? ( @@ -372,6 +373,23 @@ function FloatingStatusLabel(props: { if (props.status.kind === "compacting") { return ; } + if (props.status.kind === "child-input") { + return ( + + + + {props.status.label} + + + ); + } if (props.status.kind === "connection") { return ( void; + } // The turn settled while background work it started still runs. `waiting` // is false when only commands remain, such as a dev server: the agent is done. | { diff --git a/apps/mobile/src/features/voice-input/VoiceInputProvider.tsx b/apps/mobile/src/features/voice-input/VoiceInputProvider.tsx index ebd628e4bad4..23e9d99a5d47 100644 --- a/apps/mobile/src/features/voice-input/VoiceInputProvider.tsx +++ b/apps/mobile/src/features/voice-input/VoiceInputProvider.tsx @@ -1,3 +1,4 @@ +import { useAtomValue } from "@effect/atom-react"; import { AudioModule, RecordingPresets, @@ -7,6 +8,7 @@ import { type RecorderState, type RecordingStatus, } from "expo-audio"; +import { AsyncResult } from "effect/reactivity"; import { File } from "expo-file-system"; import { activateKeepAwakeAsync, deactivateKeepAwake } from "expo-keep-awake"; import { @@ -23,6 +25,7 @@ import { AppState, Platform } from "react-native"; import { useSharedValue } from "react-native-reanimated"; import { getLocalVoiceTranscriber } from "../../native/voiceTranscription"; +import { mobilePreferencesAtom } from "../../state/preferences"; import { getNativeShowcaseScene } from "../showcase/nativeShowcaseScene"; import { VOICE_RECORDING_LIMIT_SECONDS, @@ -112,6 +115,13 @@ function useVoiceInputRuntime() { const audioLevelsRef = useRef(Array(VOICE_WAVEFORM_SAMPLE_COUNT).fill(0)); const audioLevels = useSharedValue(audioLevelsRef.current); const sessionRef = useRef(null); + const language = useAtomValue(mobilePreferencesAtom, (result) => + AsyncResult.isSuccess(result) ? result.value.voiceInputLanguage : undefined, + ); + const languageRef = useRef(language); + useEffect(() => { + languageRef.current = language; + }, [language]); const recorderRef = useRef | null>(null); if (!sessionRef.current || !recorderRef.current) { @@ -130,7 +140,7 @@ function useVoiceInputRuntime() { recorderRef.current = recorder; sessionRef.current = new VoiceInputSession({ recorder, - getTranscriber: getLocalVoiceTranscriber, + getTranscriber: () => getLocalVoiceTranscriber(languageRef.current), requestPermission: async () => { const permission = await requestRecordingPermissionsAsync(); return { granted: permission.granted, canAskAgain: permission.canAskAgain }; diff --git a/apps/mobile/src/native/voiceTranscription.ios.test.ts b/apps/mobile/src/native/voiceTranscription.ios.test.ts index b08e32cdb6f6..f60b43bce93f 100644 --- a/apps/mobile/src/native/voiceTranscription.ios.test.ts +++ b/apps/mobile/src/native/voiceTranscription.ios.test.ts @@ -74,6 +74,16 @@ describe("getLocalVoiceTranscriber", () => { expect(mocks.transcribe).toHaveBeenCalledWith(audio, "sv-SE"); }); + it("uses the chosen language instead of the app's locale", async () => { + mocks.prepare.mockResolvedValue("es-ES"); + const prepared = await getLocalVoiceTranscriber("es-ES")!.prepare({ + signal: new AbortController().signal, + }); + + expect(mocks.prepare).toHaveBeenCalledWith("es-ES"); + expect(prepared.locale).toBe("es-ES"); + }); + it("does not start native transcription after cancellation during a file read", async () => { const enteredRead = deferred(); const readResult = deferred(); diff --git a/apps/mobile/src/native/voiceTranscription.ios.ts b/apps/mobile/src/native/voiceTranscription.ios.ts index 216b9e958dd6..e8d22674a148 100644 --- a/apps/mobile/src/native/voiceTranscription.ios.ts +++ b/apps/mobile/src/native/voiceTranscription.ios.ts @@ -33,8 +33,8 @@ function getNativeErrorCode(error: unknown): string | undefined { return typeof error.code === "string" ? error.code : undefined; } -export function getLocalVoiceTranscriber(): VoiceTranscriber | null { - const locale = getDeviceLocale(); +export function getLocalVoiceTranscriber(language?: string): VoiceTranscriber | null { + const locale = language ?? getDeviceLocale(); if (!AppleTranscription.isAvailable(locale)) return null; return { prepare: (options) => prepareVoiceTranscription(locale, options) }; } diff --git a/apps/mobile/src/native/voiceTranscription.ts b/apps/mobile/src/native/voiceTranscription.ts index e003064ae3f8..47dd2df817d3 100644 --- a/apps/mobile/src/native/voiceTranscription.ts +++ b/apps/mobile/src/native/voiceTranscription.ts @@ -1,5 +1,5 @@ import type { VoiceTranscriber } from "@t3tools/client-runtime/voice-input"; -export function getLocalVoiceTranscriber(): VoiceTranscriber | null { +export function getLocalVoiceTranscriber(_language?: string): VoiceTranscriber | null { return null; } diff --git a/apps/mobile/src/persistence/mobile-preferences.ts b/apps/mobile/src/persistence/mobile-preferences.ts index ee96bce2d5ce..fee633d2285a 100644 --- a/apps/mobile/src/persistence/mobile-preferences.ts +++ b/apps/mobile/src/persistence/mobile-preferences.ts @@ -36,6 +36,8 @@ export interface Preferences { * message sent during a running turn queues behind it or steers it. */ readonly followUpBehavior?: FollowUpBehavior; + /** Locale for on-device voice input. Unset follows the app's locale. iOS only. */ + readonly voiceInputLanguage?: string; /** @deprecated Kept temporarily so older OTA bundles retain the selected mode. */ readonly projectGroupingEnabled?: boolean; readonly projectGroupingMode?: SidebarProjectGroupingMode; @@ -107,6 +109,7 @@ function sanitizePreferences(parsed: Preferences): Preferences { collapsedProjectGroups?: readonly string[]; composerEnterBehavior?: ComposerEnterBehavior; followUpBehavior?: FollowUpBehavior; + voiceInputLanguage?: string; projectGroupingEnabled?: boolean; projectGroupingMode?: SidebarProjectGroupingMode; planModeEnabled?: boolean; @@ -172,6 +175,9 @@ function sanitizePreferences(parsed: Preferences): Preferences { if (parsed.followUpBehavior === "queue" || parsed.followUpBehavior === "steer") { preferences.followUpBehavior = parsed.followUpBehavior; } + if (typeof parsed.voiceInputLanguage === "string") { + preferences.voiceInputLanguage = parsed.voiceInputLanguage; + } if (typeof parsed.projectGroupingEnabled === "boolean") { preferences.projectGroupingEnabled = parsed.projectGroupingEnabled; } diff --git a/apps/mobile/src/state/entities.ts b/apps/mobile/src/state/entities.ts index 0d3b33d582d5..b8e42b0cb99e 100644 --- a/apps/mobile/src/state/entities.ts +++ b/apps/mobile/src/state/entities.ts @@ -98,6 +98,10 @@ export function useThreadShell(ref: ScopedThreadRef | null): EnvironmentThreadSh ); } +export function useChildThreadInputs(ref: ScopedThreadRef) { + return useAtomValue(environmentThreadShells.childThreadInputsAtom(ref)); +} + /** `markdown` with each thread link labeled by the thread's current title in `environmentId`. */ export function useLiveThreadLinkLabels(markdown: string, environmentId: EnvironmentId): string { const titles = useAtomValue( diff --git a/apps/server/src/auth/RpcAuthorization.test.ts b/apps/server/src/auth/RpcAuthorization.test.ts index 0359b07b3941..d46609edce77 100644 --- a/apps/server/src/auth/RpcAuthorization.test.ts +++ b/apps/server/src/auth/RpcAuthorization.test.ts @@ -403,3 +403,44 @@ it.effect("separates host file URLs from readable attachment URLs", () => expect(handled).toBe(1); }).pipe(Effect.scoped), ); + +it.effect("denies manual cleanup before the handler without settings permission", () => + Effect.gen(function* () { + const group = WsRpcGroup.omit( + ...[...WsRpcGroup.requests.keys()].filter( + ( + tag, + ): tag is Exclude< + keyof typeof RPC_REQUIRED_SCOPES, + typeof WS_METHODS.serverRunStorageCleanup + > => tag !== WS_METHODS.serverRunStorageCleanup, + ), + ); + let handled = false; + const client = yield* RpcTest.makeClient(group).pipe( + Effect.provide( + Layer.mergeAll( + group.toLayerHandler(WS_METHODS.serverRunStorageCleanup, () => + Effect.sync(() => { + handled = true; + return { + trigger: "manual" as const, + startedAt: "2026-10-09T12:00:00.000Z", + finishedAt: "2026-10-09T12:00:00.000Z", + entries: [], + counts: { removed: 0, kept: 0, failed: 0 }, + omittedCount: 0, + bytesFreed: 0, + }; + }), + ), + RpcAuthorization.layer([AuthOrchestrationReadScope]), + ), + ), + ); + expect(yield* client[WS_METHODS.serverRunStorageCleanup]({}).pipe(Effect.flip)).toMatchObject({ + requiredPermission: AuthSettingsWriteScope, + }); + expect(handled).toBe(false); + }).pipe(Effect.scoped), +); diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index a33d2f3317aa..511fd77cd147 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -81,6 +81,7 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.serverCommitDesktopUpdate]: AuthEnvironmentMaintainScope, [WS_METHODS.serverUpsertKeybinding]: AuthSettingsWriteScope, [WS_METHODS.serverRemoveKeybinding]: AuthSettingsWriteScope, + [WS_METHODS.serverGetStorageCleanupReport]: AuthOrchestrationReadScope, [WS_METHODS.serverGetSettings]: AuthOrchestrationReadScope, [WS_METHODS.serverUpdateSettings]: AuthSettingsWriteScope, [WS_METHODS.serverSearchAcpRegistry]: AuthOrchestrationReadScope, diff --git a/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts b/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts index b7cf965f57ca..bdb3e02785d8 100644 --- a/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts +++ b/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts @@ -48,6 +48,7 @@ function makeProjection(): ProjectionCheckpointContext { function layerFor(input: { readonly projection: Effect.Effect; readonly diffCheckpoints?: CheckpointStore.CheckpointStore["Service"]["diffCheckpoints"]; + readonly authoredPaths?: ReadonlySet; }) { return CheckpointDiffQuery.layer.pipe( Layer.provide( @@ -57,6 +58,7 @@ function layerFor(input: { }), Layer.mock(CheckpointStore.CheckpointStore)({ diffCheckpoints: input.diffCheckpoints ?? (() => Effect.succeed("diff")), + listAuthoredPaths: () => Effect.succeed(input.authoredPaths ?? null), }), ), ), @@ -93,6 +95,34 @@ it.effect("computes V2 run diffs from projected checkpoint scopes", () => { }).pipe(Effect.provide(layer)); }); +it.effect("leaves Git imports out of large turn diffs", () => { + const ownPaths = Array.from({ length: 1_000 }, (_, index) => `src/module-${index}/file.ts`); + const diffCheckpoints = vi.fn((input: CheckpointStore.DiffCheckpointsInput) => + Effect.succeed( + input.format === "numstat" + ? [...ownPaths, "upstream.ts"].map((path) => `1\t0\t${path}\0`).join("") + : (input.filePaths ?? ["all"]).join("\n"), + ), + ); + const layer = layerFor({ + projection: Effect.succeed(makeProjection()), + diffCheckpoints, + authoredPaths: new Set(ownPaths), + }); + + return Effect.gen(function* () { + const query = yield* CheckpointDiffQuery.CheckpointDiffQuery; + yield* query.getFullThreadDiff({ threadId, toTurnCount: 2 }); + + const patchPaths = diffCheckpoints.mock.calls + .map(([call]) => call) + .filter((call) => call.format !== "numstat") + .map((call) => call.filePaths ?? ["all"]); + assert.isAbove(patchPaths.length, 1); + assert.sameMembers(patchPaths.flat(), ownPaths); + }).pipe(Effect.provide(layer)); +}); + it.effect("preserves the typed missing-thread error contract", () => { const layer = layerFor({ projection: Effect.fail(new OrchestratorProjectionError({ threadId })), diff --git a/apps/server/src/checkpointing/CheckpointDiffQuery.ts b/apps/server/src/checkpointing/CheckpointDiffQuery.ts index 37414f35bfb6..37ba07ca12c3 100644 --- a/apps/server/src/checkpointing/CheckpointDiffQuery.ts +++ b/apps/server/src/checkpointing/CheckpointDiffQuery.ts @@ -31,6 +31,31 @@ import { type CheckpointServiceError, } from "./Errors.ts"; import * as CheckpointStore from "./CheckpointStore.ts"; +import { isGitImport, parseTurnDiffFilesFromNumstat, type TurnDiffFileSummary } from "./Diffs.ts"; + +// Windows limits a command line to 32,767 characters. Leave room for the rest of git's arguments. +const MAX_PATHSPEC_CHARS = 24_000; + +/** Splits files into pathspec lists that each fit on one git command line. Renames keep both paths together. */ +function batchFilePaths(files: ReadonlyArray) { + const batches: string[][] = []; + let batch: string[] = []; + let length = 0; + for (const file of files) { + const paths = file.previousPath === undefined ? [file.path] : [file.previousPath, file.path]; + // Each pathspec also carries a `:(top,literal)` prefix and a separator. + const size = paths.reduce((total, path) => total + path.length + 16, 0); + if (batch.length > 0 && length + size > MAX_PATHSPEC_CHARS) { + batches.push(batch); + batch = []; + length = 0; + } + batch.push(...paths); + length += size; + } + if (batch.length > 0) batches.push(batch); + return batches; +} /** Service tag for checkpoint diff queries. */ export class CheckpointDiffQuery extends Context.Service< @@ -182,15 +207,40 @@ export const make = Effect.gen(function* () { }); } - const diff = yield* checkpointStore - .diffCheckpoints({ - cwd: toScope.cwd, - fromCheckpointRef, - toCheckpointRef: toCheckpoint.ref, - fallbackFromToHead: false, - ignoreWhitespace, - }) - .pipe(Effect.withSpan("checkpoint.turnDiff.diffCheckpoints")); + const comparison = { + cwd: toScope.cwd, + fromCheckpointRef, + toCheckpointRef: toCheckpoint.ref, + fallbackFromToHead: false, + ignoreWhitespace, + }; + // Leave out files that a pull, merge, or rebase brought in, matching the turn's file summary. + // Filtering is optional: when it cannot run, the complete diff still loads. + const authoredPaths = yield* checkpointStore + .listAuthoredPaths(comparison) + .pipe(Effect.orElseSucceed(() => null)); + const files = + authoredPaths === null + ? [] + : yield* checkpointStore.diffCheckpoints({ ...comparison, format: "numstat" }).pipe( + Effect.map(parseTurnDiffFilesFromNumstat), + Effect.orElseSucceed(() => []), + ); + const retainedFiles = files.filter((file) => !isGitImport(file, authoredPaths)); + // Select retained paths before generating a patch, so imported bulk cannot exhaust its output limit. + const diff = + retainedFiles.length === files.length + ? yield* checkpointStore + .diffCheckpoints(comparison) + .pipe(Effect.withSpan("checkpoint.turnDiff.diffCheckpoints")) + : (yield* Effect.forEach( + batchFilePaths(retainedFiles), + (filePaths) => + checkpointStore + .diffCheckpoints({ ...comparison, filePaths }) + .pipe(Effect.withSpan("checkpoint.turnDiff.diffCheckpoints")), + { concurrency: 4 }, + )).join(""); const turnDiff = buildTurnDiffResult(input, diff); if (!isTurnDiffResult(turnDiff)) { diff --git a/apps/server/src/checkpointing/CheckpointStore.test.ts b/apps/server/src/checkpointing/CheckpointStore.test.ts index 2846b86e1b88..b345535fade7 100644 --- a/apps/server/src/checkpointing/CheckpointStore.test.ts +++ b/apps/server/src/checkpointing/CheckpointStore.test.ts @@ -57,15 +57,22 @@ function writeTextFile( function git( cwd: string, args: ReadonlyArray, + options: { readonly committedAt?: string; readonly allowNonZeroExit?: boolean } = {}, ): Effect.Effect { return Effect.gen(function* () { - const process = yield* VcsProcess.VcsProcess; - const result = yield* process.run({ + const vcsProcess = yield* VcsProcess.VcsProcess; + const result = yield* vcsProcess.run({ operation: "CheckpointStore.test.git", command: "git", cwd, args, timeoutMs: 10_000, + ...(options.committedAt === undefined + ? {} + : { env: { ...process.env, GIT_COMMITTER_DATE: options.committedAt } }), + ...(options.allowNonZeroExit === undefined + ? {} + : { allowNonZeroExit: options.allowNonZeroExit }), }); return result.stdout.trim(); }); @@ -371,12 +378,12 @@ it.layer(layerTest)("CheckpointStore.layer", (it) => { ); const expectedFiles = [ { path: "binary.bin", additions: 0, deletions: 0 }, - { path: "copied.txt", additions: 0, deletions: 0 }, + { path: "copied.txt", previousPath: "copy-source.txt", additions: 0, deletions: 0 }, { path: "copy-source.txt", additions: 1, deletions: 0 }, { path: "deleted.txt", additions: 0, deletions: 1 }, { path: "empty.txt", additions: 0, deletions: 0 }, { path: addedPath, additions: 2, deletions: 0 }, - { path: renamedPath, additions: 1, deletions: 1 }, + { path: renamedPath, previousPath: "rename-old.txt", additions: 1, deletions: 1 }, ].toSorted((left, right) => left.path.localeCompare(right.path)); expect(firstSummary).toEqual(expectedFiles); @@ -441,5 +448,122 @@ it.layer(layerTest)("CheckpointStore.layer", (it) => { ]); }), ); + it.effect("limits a diff to repository paths from a subdirectory workspace", () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + yield* initRepoWithCommit(tmp); + const fileSystem = yield* FileSystem.FileSystem; + const cwd = NodePath.join(tmp, "app"); + yield* fileSystem.makeDirectory(cwd); + const checkpointStore = yield* CheckpointStore.CheckpointStore; + const threadId = ThreadId.make("checkpoint-file-paths-subdirectory"); + const refs = { + cwd, + fromCheckpointRef: checkpointRefForThreadTurn(threadId, 0), + toCheckpointRef: checkpointRefForThreadTurn(threadId, 1), + }; + yield* checkpointStore.captureCheckpoint({ cwd, checkpointRef: refs.fromCheckpointRef }); + yield* writeTextFile(NodePath.join(cwd, "kept.txt"), "kept\n"); + yield* writeTextFile(NodePath.join(tmp, "README.md"), "# hidden\n"); + yield* checkpointStore.captureCheckpoint({ cwd, checkpointRef: refs.toCheckpointRef }); + + const patch = yield* checkpointStore.diffCheckpoints({ + ...refs, + ignoreWhitespace: false, + filePaths: ["app/kept.txt"], + }); + expect(patch).toContain("+kept"); + expect(patch).not.toContain("hidden"); + }), + ); + }); + + describe("listAuthoredPaths", () => { + it.effect("leaves out paths that only a merged upstream changed", () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + yield* initRepoWithCommit(tmp); + const checkpointStore = yield* CheckpointStore.CheckpointStore; + const threadId = ThreadId.make("checkpoint-authored-paths"); + const refs = { + cwd: tmp, + fromCheckpointRef: checkpointRefForThreadTurn(threadId, 0), + toCheckpointRef: checkpointRefForThreadTurn(threadId, 1), + }; + const commitBeforeTurn = (file: string, contents: string) => + Effect.gen(function* () { + yield* writeTextFile(NodePath.join(tmp, file), contents); + yield* git(tmp, ["add", "."]); + yield* git(tmp, ["commit", "-m", file], { committedAt: "2020-01-01T00:00:00Z" }); + }); + + yield* git(tmp, ["checkout", "-b", "upstream"]); + yield* commitBeforeTurn("upstream.txt", "upstream\n"); + yield* commitBeforeTurn("shared.txt", "upstream\n"); + yield* git(tmp, ["checkout", "-"]); + yield* commitBeforeTurn("shared.txt", "ours\n"); + yield* checkpointStore.captureCheckpoint({ + cwd: tmp, + checkpointRef: refs.fromCheckpointRef, + }); + + yield* checkpointStore.captureCheckpoint({ cwd: tmp, checkpointRef: refs.toCheckpointRef }); + expect(yield* checkpointStore.listAuthoredPaths(refs)).toBeNull(); + + // The turn merges upstream, fixes the conflict by taking upstream, and edits one more file. + yield* git(tmp, ["merge", "upstream", "--no-edit"], { allowNonZeroExit: true }); + yield* writeTextFile(NodePath.join(tmp, "shared.txt"), "upstream\n"); + yield* git(tmp, ["commit", "-am", "merge upstream"]); + yield* writeTextFile(NodePath.join(tmp, "README.md"), "# edited\n"); + yield* checkpointStore.captureCheckpoint({ cwd: tmp, checkpointRef: refs.toCheckpointRef }); + + const numstat = yield* checkpointStore.diffCheckpoints({ + ...refs, + ignoreWhitespace: false, + format: "numstat", + }); + expect(parseTurnDiffFilesFromNumstat(numstat).map((file) => file.path)).toEqual([ + "README.md", + "shared.txt", + "upstream.txt", + ]); + expect(yield* checkpointStore.listAuthoredPaths(refs)).toEqual( + new Set(["README.md", "shared.txt"]), + ); + }), + ); + + it.effect("leaves out paths that only a rebase onto upstream brought in", () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + yield* initRepoWithCommit(tmp); + const checkpointStore = yield* CheckpointStore.CheckpointStore; + const threadId = ThreadId.make("checkpoint-authored-paths-rebase"); + const refs = { + cwd: tmp, + fromCheckpointRef: checkpointRefForThreadTurn(threadId, 0), + toCheckpointRef: checkpointRefForThreadTurn(threadId, 1), + }; + const commitBeforeTurn = (file: string) => + Effect.gen(function* () { + yield* writeTextFile(NodePath.join(tmp, file), `${file}\n`); + yield* git(tmp, ["add", "."]); + yield* git(tmp, ["commit", "-m", file], { committedAt: "2020-01-01T00:00:00Z" }); + }); + + yield* git(tmp, ["checkout", "-b", "upstream"]); + yield* commitBeforeTurn("upstream.txt"); + yield* git(tmp, ["checkout", "-"]); + yield* commitBeforeTurn("feature.txt"); + yield* checkpointStore.captureCheckpoint({ + cwd: tmp, + checkpointRef: refs.fromCheckpointRef, + }); + yield* git(tmp, ["rebase", "upstream"]); + yield* checkpointStore.captureCheckpoint({ cwd: tmp, checkpointRef: refs.toCheckpointRef }); + + expect(yield* checkpointStore.listAuthoredPaths(refs)).toEqual(new Set(["feature.txt"])); + }), + ); }); }); diff --git a/apps/server/src/checkpointing/CheckpointStore.ts b/apps/server/src/checkpointing/CheckpointStore.ts index 0c9e60d76a65..78844ec70dfe 100644 --- a/apps/server/src/checkpointing/CheckpointStore.ts +++ b/apps/server/src/checkpointing/CheckpointStore.ts @@ -40,6 +40,14 @@ export interface DiffCheckpointsInput { readonly fallbackFromToHead?: boolean; readonly ignoreWhitespace: boolean; readonly format?: "patch" | "numstat"; + /** Limits the diff to these exact paths. An empty list yields an empty diff. */ + readonly filePaths?: ReadonlyArray; +} + +export interface ListAuthoredPathsInput { + readonly cwd: string; + readonly fromCheckpointRef: CheckpointRef; + readonly toCheckpointRef: CheckpointRef; } export interface DeleteCheckpointRefsInput { @@ -87,6 +95,19 @@ export class CheckpointStore extends Context.Service< input: DiffCheckpointsInput, ) => Effect.Effect; + /** + * List paths changed by work done after the "from" checkpoint: uncommitted + * edits at either checkpoint, commits made after "from", and commits that + * left HEAD. Commits a pull, merge, or rebase brought in are older than + * "from", so their paths are not listed. + * + * Returns null when HEAD did not move or a checkpoint does not record HEAD. + * Then every changed path belongs to the turn. + */ + readonly listAuthoredPaths: ( + input: ListAuthoredPathsInput, + ) => Effect.Effect | null, CheckpointStoreError>; + /** * Delete the provided checkpoint refs. * @@ -150,6 +171,13 @@ export const make = Effect.gen(function* () { return yield* checkpoints.diffCheckpoints(input); }); + const listAuthoredPaths: CheckpointStore["Service"]["listAuthoredPaths"] = Effect.fn( + "listAuthoredPaths", + )(function* (input) { + const checkpoints = yield* resolveCheckpoints("CheckpointStore.listAuthoredPaths", input.cwd); + return yield* checkpoints.listAuthoredPaths(input); + }); + const deleteCheckpointRefs: CheckpointStore["Service"]["deleteCheckpointRefs"] = Effect.fn( "deleteCheckpointRefs", )(function* (input) { @@ -166,6 +194,7 @@ export const make = Effect.gen(function* () { hasCheckpointRef, restoreCheckpoint, diffCheckpoints, + listAuthoredPaths, deleteCheckpointRefs, }); }); diff --git a/apps/server/src/checkpointing/Diffs.test.ts b/apps/server/src/checkpointing/Diffs.test.ts index 6b7f1a875690..441643fbda63 100644 --- a/apps/server/src/checkpointing/Diffs.test.ts +++ b/apps/server/src/checkpointing/Diffs.test.ts @@ -15,7 +15,7 @@ describe("parseTurnDiffFilesFromNumstat", () => { ]); }); - it("uses destination paths for renames and copies", () => { + it("preserves both paths for renames and copies", () => { const numstat = [ "0\t0\t", "src/old.ts", @@ -29,8 +29,8 @@ describe("parseTurnDiffFilesFromNumstat", () => { expect(parseTurnDiffFilesFromNumstat(numstat)).toEqual([ { path: "other.ts", additions: 1, deletions: 0 }, - { path: "src/copied.ts", additions: 2, deletions: 1 }, - { path: "src/new.ts", additions: 0, deletions: 0 }, + { path: "src/copied.ts", previousPath: "src/source.ts", additions: 2, deletions: 1 }, + { path: "src/new.ts", previousPath: "src/old.ts", additions: 0, deletions: 0 }, ]); }); @@ -46,7 +46,9 @@ describe("parseTurnDiffFilesFromNumstat", () => { const path = " café\tline\r\nname.txt "; const numstat = `3\t2\t\0old\tname\n.txt\0${path}\0`; - expect(parseTurnDiffFilesFromNumstat(numstat)).toEqual([{ path, additions: 3, deletions: 2 }]); + expect(parseTurnDiffFilesFromNumstat(numstat)).toEqual([ + { path, previousPath: "old\tname\n.txt", additions: 3, deletions: 2 }, + ]); expect(parseTurnDiffFilesFromNumstat(`1\t0\t${path}\0`)).toEqual([ { path, additions: 1, deletions: 0 }, ]); diff --git a/apps/server/src/checkpointing/Diffs.ts b/apps/server/src/checkpointing/Diffs.ts index b7951009118e..24aa3b6879e9 100644 --- a/apps/server/src/checkpointing/Diffs.ts +++ b/apps/server/src/checkpointing/Diffs.ts @@ -1,5 +1,6 @@ export interface TurnDiffFileSummary { readonly path: string; + readonly previousPath?: string; readonly additions: number; readonly deletions: number; } @@ -15,8 +16,10 @@ export function parseTurnDiffFilesFromNumstat(numstat: string): ReadonlyArray left.path.localeCompare(right.path)); } + +/** + * True when Git brought this file in and no work in the range touched it. + * `authoredPaths` comes from `CheckpointStore.listAuthoredPaths`; null means nothing was imported. + * A rename stays visible when either side was authored, because the summary shows it as one file. + */ +export function isGitImport( + file: TurnDiffFileSummary, + authoredPaths: ReadonlySet | null, +): boolean { + return ( + authoredPaths !== null && + !authoredPaths.has(file.path) && + (file.previousPath === undefined || !authoredPaths.has(file.previousPath)) + ); +} diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 7fc4a66464e9..60589ac9106f 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -228,6 +228,7 @@ export const make = Effect.gen(function* () { threadSettlement: true, threadAutoSettlement: true, storageCleanup: true, + storageCleanupRun: true, projectWorktreeCleanup: true, worktreesDirectory: true, threadRestartContinuation: true, diff --git a/apps/server/src/observability/RpcInstrumentation.ts b/apps/server/src/observability/RpcInstrumentation.ts index b1abaf8d465c..2ad903ff792a 100644 --- a/apps/server/src/observability/RpcInstrumentation.ts +++ b/apps/server/src/observability/RpcInstrumentation.ts @@ -54,6 +54,8 @@ const RPC_AGGREGATES = { [WS_METHODS.serverCommitDesktopUpdate]: "server", [WS_METHODS.serverUpsertKeybinding]: "server", [WS_METHODS.serverRemoveKeybinding]: "server", + [WS_METHODS.serverRunStorageCleanup]: "server", + [WS_METHODS.serverGetStorageCleanupReport]: "server", [WS_METHODS.serverGetSettings]: "server", [WS_METHODS.serverUpdateSettings]: "server", [WS_METHODS.serverSearchAcpRegistry]: "server", diff --git a/apps/server/src/orchestration-v2/CheckpointCaptureService.test.ts b/apps/server/src/orchestration-v2/CheckpointCaptureService.test.ts index bb7619f6a8ab..9b24161b54e3 100644 --- a/apps/server/src/orchestration-v2/CheckpointCaptureService.test.ts +++ b/apps/server/src/orchestration-v2/CheckpointCaptureService.test.ts @@ -277,6 +277,7 @@ it.layer(layerProjectionStoreTest)("CheckpointCaptureServiceV2", (it) => { IdAllocator.layer, NodeCrypto.layer, Layer.mock(CheckpointStore.CheckpointStore)({ + listAuthoredPaths: () => Effect.succeed(null), isGitRepository: () => Effect.succeed(true), captureCheckpoint: () => Effect.void, hasCheckpointRef: () => diff --git a/apps/server/src/orchestration-v2/CheckpointScopeOwnership.test.ts b/apps/server/src/orchestration-v2/CheckpointScopeOwnership.test.ts index b29eb33b69a2..33a7490a0245 100644 --- a/apps/server/src/orchestration-v2/CheckpointScopeOwnership.test.ts +++ b/apps/server/src/orchestration-v2/CheckpointScopeOwnership.test.ts @@ -219,6 +219,7 @@ it.effect("resolves the thread baseline after a second root run replaces scope o getCheckpointContext: () => Effect.succeed(context), }), Layer.mock(CheckpointStore.CheckpointStore)({ + listAuthoredPaths: () => Effect.succeed(null), diffCheckpoints: (input) => { assert.equal(input.cwd, "/prepared-repo"); assert.equal(input.fromCheckpointRef, checkpointRefs[0]); diff --git a/apps/server/src/orchestration-v2/CheckpointService.ts b/apps/server/src/orchestration-v2/CheckpointService.ts index 913e53658e2c..ee3316311eac 100644 --- a/apps/server/src/orchestration-v2/CheckpointService.ts +++ b/apps/server/src/orchestration-v2/CheckpointService.ts @@ -19,7 +19,7 @@ import * as KeyedLock from "@t3tools/shared/KeyedLock"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; -import { parseTurnDiffFilesFromNumstat } from "../checkpointing/Diffs.ts"; +import { isGitImport, parseTurnDiffFilesFromNumstat } from "../checkpointing/Diffs.ts"; import * as CheckpointStore from "../checkpointing/CheckpointStore.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; @@ -440,33 +440,49 @@ export const layer: Layer.Layer< }).pipe(Effect.as(false)), ), ); + const refs = { + cwd: input.scope.cwd, + fromCheckpointRef: previousCheckpointRef, + toCheckpointRef: checkpointRef, + }; + // A pull or rebase can change thousands of files the turn did not write. + // Keep only the files the turn's own work touched. const files = previousExists - ? yield* checkpointStore - .diffCheckpoints({ - cwd: input.scope.cwd, - fromCheckpointRef: previousCheckpointRef, - toCheckpointRef: checkpointRef, + ? yield* Effect.all([ + checkpointStore.diffCheckpoints({ + ...refs, fallbackFromToHead: false, ignoreWhitespace: false, format: "numstat", - }) - .pipe( - Effect.map((diff) => - parseTurnDiffFilesFromNumstat(diff).map((file) => ({ - path: file.path, - kind: "modified", - additions: file.additions, - deletions: file.deletions, - })), - ), + }), + checkpointStore.listAuthoredPaths(refs).pipe( Effect.catch((cause) => - Effect.logWarning("orchestration V2 checkpoint diff summary failed", { + Effect.logWarning("orchestration V2 checkpoint authored paths failed", { scopeId: input.scope.id, checkpointRef, cause: String(cause), - }).pipe(Effect.as([])), + }).pipe(Effect.as(null)), ), - ) + ), + ]).pipe( + Effect.map(([diff, authoredPaths]) => + parseTurnDiffFilesFromNumstat(diff) + .filter((file) => !isGitImport(file, authoredPaths)) + .map((file) => ({ + path: file.path, + kind: "modified", + additions: file.additions, + deletions: file.deletions, + })), + ), + Effect.catch((cause) => + Effect.logWarning("orchestration V2 checkpoint diff summary failed", { + scopeId: input.scope.id, + checkpointRef, + cause: String(cause), + }).pipe(Effect.as([])), + ), + ) : []; return makeCheckpoint({ diff --git a/apps/server/src/preview/PortScanner.test.ts b/apps/server/src/preview/PortScanner.test.ts index 8a6bf87d4699..8aae21d8cf71 100644 --- a/apps/server/src/preview/PortScanner.test.ts +++ b/apps/server/src/preview/PortScanner.test.ts @@ -7,7 +7,6 @@ import { type DiscoveredLocalServer, } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; -import * as Net from "@t3tools/shared/Net"; import * as Cause from "effect/Cause"; import * as Clock from "effect/Clock"; import * as Duration from "effect/Duration"; @@ -44,17 +43,7 @@ const layerTestProcessRunner = Layer.succeed(ProcessRunner.ProcessRunner, { run: processProbeFailure, }); -let integrationListeningPort: number | null = null; - -const layerTestIntegrationNet = Layer.succeed(Net.NetService, { - canListenOnHost: () => Effect.succeed(true), - isPortAvailableOnLoopback: (port) => Effect.sync(() => port !== integrationListeningPort), - hasListenerOnHost: (port) => Effect.sync(() => port === integrationListeningPort), - reserveLoopbackPort: () => Effect.succeed(40_000), - findAvailablePort: (preferred) => Effect.succeed(preferred), -}); - -/** A host without `/proc`, so a missing `lsof` falls back to common ports. */ +/** A host without `/proc`, so a missing `lsof` leaves only configured URLs. */ const layerNoProc = FileSystem.layerNoop({}); const layerProbeFailure = ( @@ -67,13 +56,6 @@ const layerProbeFailure = ( Layer.mergeAll( fileSystem, Layer.succeed(ProcessRunner.ProcessRunner, { run }), - Layer.succeed(Net.NetService, { - canListenOnHost: () => Effect.succeed(true), - isPortAvailableOnLoopback: () => Effect.succeed(true), - hasListenerOnHost: () => Effect.succeed(false), - reserveLoopbackPort: () => Effect.succeed(40_000), - findAvailablePort: (preferred) => Effect.succeed(preferred), - }), Layer.succeed(HostProcess.Platform, "linux"), FetchHttpClient.layer.pipe(Layer.provide(Layer.succeed(FetchHttpClient.Fetch, fetch))), ), @@ -85,7 +67,6 @@ const layerTestPortDiscovery = PortScanner.layer.pipe( Layer.mergeAll( layerNoProc, layerTestProcessRunner, - layerTestIntegrationNet, Layer.succeed(HostProcess.Platform, "win32"), FetchHttpClient.layer, ), @@ -94,9 +75,20 @@ const layerTestPortDiscovery = PortScanner.layer.pipe( const LSOF_TEST_PORT = 43_123; +/** The scanner with `processIds` registered to a T3 terminal, so their listeners get probes. */ +const ownedScanner = (processIds: ReadonlyArray = [1234]) => + Effect.tap(PortScanner.PortDiscovery, (scanner) => + scanner.registerTerminalProcesses({ + threadId: "scanner-thread", + terminalId: "scanner-terminal", + processIds, + }), + ); + const layerLsofScanner = (input: { readonly pid: () => number; readonly fetch: typeof globalThis.fetch; + readonly stdout?: () => string; }) => PortScanner.layer.pipe( Layer.provide( @@ -105,7 +97,7 @@ const layerLsofScanner = (input: { Layer.succeed(ProcessRunner.ProcessRunner, { run: () => Effect.succeed({ - stdout: `p${input.pid()}\ncnode\nn*:${LSOF_TEST_PORT}\n`, + stdout: input.stdout?.() ?? `p${input.pid()}\ncnode\nn*:${LSOF_TEST_PORT}\n`, stderr: "", code: null, timedOut: false, @@ -115,13 +107,6 @@ const layerLsofScanner = (input: { stderrInvalidUtf8: false, }), }), - Layer.succeed(Net.NetService, { - canListenOnHost: () => Effect.succeed(true), - isPortAvailableOnLoopback: () => Effect.succeed(true), - hasListenerOnHost: () => Effect.succeed(false), - reserveLoopbackPort: () => Effect.succeed(40_000), - findAvailablePort: (preferred) => Effect.succeed(preferred), - }), Layer.succeed(HostProcess.Platform, "linux"), FetchHttpClient.layer.pipe( Layer.provide(Layer.succeed(FetchHttpClient.Fetch, input.fetch)), @@ -153,73 +138,45 @@ const closeServer = (server: NodeNet.Server): Effect.Effect => }); const openCommonDevServer = Effect.fn("PortScannerTest.openCommonDevServer")(function* ( - ports: ReadonlyArray, onConnection: (socket: NodeNet.Socket) => void, ) { - for (const port of ports) { - const server = yield* openServer(port, onConnection); - if (server !== null) return { port, server }; + const server = yield* openServer(0, onConnection); + const address = server?.address(); + if (!server || !address || typeof address === "string") { + return yield* Effect.die(new Error("Could not open the preview scanner test listener")); } - return yield* Effect.die( - new Error("No common development port was available for the preview scanner test"), - ); + return { port: address.port, server }; }); const commonDevServer = Effect.acquireRelease( - openCommonDevServer(PortScanner.COMMON_DEV_PORTS, (socket) => { + openCommonDevServer((socket) => { socket.once("data", () => { socket.end("HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: 5\r\n\r\nhello"); }); - }).pipe( - Effect.tap(({ port }) => - Effect.sync(() => { - integrationListeningPort = port; - }), - ), - ), - ({ server }) => - closeServer(server).pipe( - Effect.ensuring( - Effect.sync(() => { - integrationListeningPort = null; - }), - ), - ), + }), + ({ server }) => closeServer(server), ); const commonNonHttpServer = Effect.acquireRelease( - openCommonDevServer(PortScanner.COMMON_DEV_PORTS.toReversed(), (socket) => { + openCommonDevServer((socket) => { socket.on("error", () => undefined); socket.once("data", () => socket.end("MYSQL\r\n\r\n")); - }).pipe( - Effect.tap(({ port }) => - Effect.sync(() => { - integrationListeningPort = port; - }), - ), - ), - ({ server }) => - closeServer(server).pipe( - Effect.ensuring( - Effect.sync(() => { - integrationListeningPort = null; - }), - ), - ), + }), + ({ server }) => closeServer(server), ); /** * Integration tests against a real TCP listener. We provide the Windows host - * platform so the tests exercise the TCP-probe fallback without depending on - * `lsof` being installed. + * platform with a failing listener probe so the tests exercise configured URLs + * without depending on `lsof` being installed. */ -effectIt.layer(layerTestPortDiscovery)("PortDiscovery integration (TCP probe fallback)", (it) => { +effectIt.layer(layerTestPortDiscovery)("PortDiscovery integration (configured URLs)", (it) => { it.effect( - "scan() returns an HTTP server we just opened on a curated dev port", + "scan() returns a configured HTTP server we just opened", Effect.fn("PortScannerTest.scanFindsCommonDevServer")(function* () { const { port } = yield* commonDevServer; const scanner = yield* PortScanner.PortDiscovery; - const result = yield* scanner.scan(); + const result = yield* scanner.scan([`http://localhost:${port}`]); const found = result.find((server) => server.port === port); expect(found).toBeDefined(); expect(found?.host).toBe("localhost"); @@ -231,7 +188,7 @@ effectIt.layer(layerTestPortDiscovery)("PortDiscovery integration (TCP probe fal Effect.fn("PortScannerTest.scanExcludesNonHttpServer")(function* () { const { port } = yield* commonNonHttpServer; const scanner = yield* PortScanner.PortDiscovery; - const result = yield* scanner.scan(); + const result = yield* scanner.scan([`http://localhost:${port}`]); expect(result.some((server) => server.port === port)).toBe(false); }), ); @@ -242,10 +199,12 @@ effectIt.layer(layerTestPortDiscovery)("PortDiscovery integration (TCP probe fal const { port } = yield* commonDevServer; const received: number[] = []; const scanner = yield* PortScanner.PortDiscovery; - yield* scanner.subscribe({ configuredUrls: [], initialSnapshot: [] }, (servers) => - Effect.sync(() => { - for (const server of servers) received.push(server.port); - }), + yield* scanner.subscribe( + { configuredUrls: [`http://localhost:${port}`], initialSnapshot: [] }, + (servers) => + Effect.sync(() => { + for (const server of servers) received.push(server.port); + }), ); yield* scanner.retain; expect(received).toContain(port); @@ -265,7 +224,7 @@ effectIt.effect("revalidates a successful HTML probe after its cache entry expir const layer = layerLsofScanner({ pid: () => 1234, fetch: fetchFn }); return Effect.gen(function* () { - const scanner = yield* PortScanner.PortDiscovery; + const scanner = yield* ownedScanner(); expect(yield* scanner.scan()).toHaveLength(1); expect(yield* scanner.scan()).toHaveLength(1); expect(requests).toEqual([`http://localhost:${LSOF_TEST_PORT}/`]); @@ -499,7 +458,7 @@ effectIt.effect("shares a configured root probe with discovered-root classificat const layer = layerLsofScanner({ pid: () => 1234, fetch: fetchFn }); return Effect.gen(function* () { - const scanner = yield* PortScanner.PortDiscovery; + const scanner = yield* ownedScanner(); expect(yield* scanner.scan([rootUrl])).toHaveLength(1); expect(requests).toEqual([rootUrl]); @@ -528,7 +487,7 @@ effectIt.effect("starts fresh cache entries after the probing batch completes", const layer = layerLsofScanner({ pid: () => 1234, fetch: fetchFn }); yield* Effect.gen(function* () { - const scanner = yield* PortScanner.PortDiscovery; + const scanner = yield* ownedScanner(); expect(yield* scanner.scan()).toHaveLength(1); expect(yield* scanner.scan()).toHaveLength(1); expect(requests).toHaveLength(1); @@ -548,7 +507,7 @@ effectIt.effect("caches a failed web probe until its bounded cache entry expires const layer = layerLsofScanner({ pid: () => 1234, fetch: fetchFn }); return Effect.gen(function* () { - const scanner = yield* PortScanner.PortDiscovery; + const scanner = yield* ownedScanner(); expect(yield* scanner.scan()).toHaveLength(0); expect(yield* scanner.scan()).toHaveLength(0); expect(requests).toHaveLength(2); @@ -573,7 +532,7 @@ effectIt.effect("falls back to HTTPS and does not follow redirects while probing const layer = layerLsofScanner({ pid: () => 1234, fetch: fetchFn }); return Effect.gen(function* () { - const scanner = yield* PortScanner.PortDiscovery; + const scanner = yield* ownedScanner(); const servers = yield* scanner.scan(); expect(servers).toHaveLength(1); expect(servers[0]?.url).toBe(`https://localhost:${LSOF_TEST_PORT}`); @@ -581,6 +540,37 @@ effectIt.effect("falls back to HTTPS and does not follow redirects while probing }).pipe(Effect.provide(layer)); }); +effectIt.effect("probes a port only while T3 terminals own every listener on it", () => { + const owned = `p1234\ncnode\nn[::1]:${LSOF_TEST_PORT}\n`; + const unowned = `p5678\ncrpc\nn127.0.0.1:${LSOF_TEST_PORT}\n`; + let stdout = unowned; + const requests: string[] = []; + const fetchFn = ((input: Parameters[0]) => { + requests.push(String(input)); + return Promise.resolve(new Response("app", { headers: { "content-type": "text/html" } })); + }) as typeof globalThis.fetch; + const layer = layerLsofScanner({ pid: () => 1234, fetch: fetchFn, stdout: () => stdout }); + + return Effect.gen(function* () { + const scanner = yield* ownedScanner(); + expect(yield* scanner.scan()).toHaveLength(0); + stdout = `${owned}${unowned}`; + expect(yield* scanner.scan()).toHaveLength(0); + expect(requests).toEqual([]); + + stdout = owned; + expect(yield* scanner.scan()).toHaveLength(1); + expect(requests).toEqual([`http://localhost:${LSOF_TEST_PORT}/`]); + + yield* scanner.unregisterTerminal({ + threadId: "scanner-thread", + terminalId: "scanner-terminal", + }); + expect(yield* scanner.scan()).toHaveLength(0); + expect(requests).toHaveLength(1); + }).pipe(Effect.provide(layer)); +}); + effectIt.effect( "excludes HTTP errors, non-navigation responses, and successful non-documents", () => { @@ -592,7 +582,7 @@ effectIt.effect( const layer = layerLsofScanner({ pid: () => pid, fetch: fetchFn }); return Effect.gen(function* () { - const scanner = yield* PortScanner.PortDiscovery; + const scanner = yield* ownedScanner([1, 2, 3, 4, 5, 6, 7]); expect(yield* scanner.scan()).toHaveLength(0); pid += 1; @@ -650,7 +640,7 @@ effectIt.effect("aborts HTTP and HTTPS probes when they time out", () => { const layer = layerLsofScanner({ pid: () => 1234, fetch: fetchFn }); return Effect.gen(function* () { - const scanner = yield* PortScanner.PortDiscovery; + const scanner = yield* ownedScanner(); const scanFiber = yield* Effect.forkChild(scanner.scan()); yield* TestClock.adjust(Duration.seconds(2)); expect(yield* Fiber.join(scanFiber)).toHaveLength(0); @@ -739,11 +729,10 @@ effectIt.effect( ); return Effect.gen(function* () { - const scanner = yield* PortScanner.PortDiscovery; + const scanner = yield* ownedScanner([4242]); const first = yield* scanner.scan(); + // Ports 22 and 3001 have no known owner, so they never receive probes. expect(first.map(({ port, pid, processName }) => ({ port, pid, processName }))).toEqual([ - { port: 22, pid: null, processName: null }, - { port: 3001, pid: null, processName: null }, { port: 8765, pid: 4242, processName: "python3" }, ]); yield* scanner.scan(); diff --git a/apps/server/src/preview/PortScanner.ts b/apps/server/src/preview/PortScanner.ts index 9b1d9e966333..7a59079ea9f4 100644 --- a/apps/server/src/preview/PortScanner.ts +++ b/apps/server/src/preview/PortScanner.ts @@ -8,8 +8,8 @@ * Linux without lsof: reads listening sockets from `/proc/net/tcp{,6}` and * finds their processes through `/proc//fd`. * - * Windows listener failures, or neither source: checks a curated list of - * common dev ports through the shared Net service. + * Only listeners owned by T3 terminal processes and explicitly configured + * URLs receive probes. Without listener ownership, only configured URLs do. * * Listening ports are published only after a bounded HTTP(S) probe finds a * successful HTML document or a redirect to one. @@ -26,7 +26,6 @@ import { type DiscoveredLocalServer, } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; -import * as Net from "@t3tools/shared/Net"; import { isLoopbackHost, LSOF_LOCAL_HOST_TOKENS } from "@t3tools/shared/preview"; import * as Cause from "effect/Cause"; import * as Clock from "effect/Clock"; @@ -71,10 +70,6 @@ export class PortDiscovery extends Context.Service< } >()("t3/preview/PortScanner/PortDiscovery") {} -export const COMMON_DEV_PORTS: ReadonlyArray = Object.freeze([ - 3000, 3001, 3333, 4173, 4200, 4321, 5000, 5173, 5174, 5175, 5500, 8000, 8080, 8081, 8888, 9000, -]); - const POLL_INTERVAL = Duration.seconds(3); const LSOF_TIMEOUT_MS = 5_000; /** File descriptors one `/proc` walk may read before it stops looking for socket owners. */ @@ -218,14 +213,20 @@ const parseLsofOutput = ( if (portMatch == null) continue; const url = `http://localhost:${portMatch}`; const key = `localhost:${portMatch}`; - if (seen.has(key)) continue; + const terminal = pid === null ? null : (terminalByProcessId.get(pid) ?? null); + const existing = seen.get(key); + // A port receives probes only when T3 terminals own all of its listeners. + if (existing) { + if (terminal === null) seen.set(key, { ...existing, terminal: null }); + continue; + } seen.set(key, { host: "localhost", port: portMatch, url, processName, pid, - terminal: pid === null ? null : (terminalByProcessId.get(pid) ?? null), + terminal, }); } } @@ -261,14 +262,20 @@ const parseWindowsListenerOutput = ( const pid = Number(pidRaw); if (!Number.isInteger(port) || port <= 0 || port >= 65536) continue; const normalizedPid = Number.isInteger(pid) && pid > 0 ? pid : null; - if (seen.has(port)) continue; + const terminal = + normalizedPid === null ? null : (terminalByProcessId.get(normalizedPid) ?? null); + const existing = seen.get(port); + if (existing) { + if (terminal === null) seen.set(port, { ...existing, terminal: null }); + continue; + } seen.set(port, { host: "localhost", port, url: `http://localhost:${port}`, processName: processNameRaw?.trim() || null, pid: normalizedPid, - terminal: normalizedPid === null ? null : (terminalByProcessId.get(normalizedPid) ?? null), + terminal, }); } return [...seen.values()].toSorted((left, right) => left.port - right.port); @@ -339,7 +346,6 @@ const isCommandNotFound = (error: ProcessRunner.ProcessSpawnError): boolean => /** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* PortDiscoveryMake() { - const net = yield* Net.NetService; const processRunner = yield* ProcessRunner.ProcessRunner; const fileSystem = yield* FileSystem.FileSystem; const hostPlatform = yield* HostProcess.Platform; @@ -398,7 +404,7 @@ export const make = Effect.gen(function* PortDiscoveryMake() { /** * Linux listeners from `/proc/net/tcp{,6}`, for hosts without `lsof`. Null - * when neither file is readable, so discovery falls back to common ports. + * when neither file is readable, so only configured URLs are probed. */ const scanProcListeners = Effect.fn("PortDiscovery.scanProcListeners")(function* ( terminalByProcessId: ReadonlyMap, @@ -433,44 +439,25 @@ export const make = Effect.gen(function* PortDiscoveryMake() { const seen = new Map(); for (const { port, inode } of listeners) { const owner = owners.get(inode) ?? null; + const terminal = owner === null ? null : (terminalByProcessId.get(owner.pid) ?? null); const existing = seen.get(port); - if (existing && (existing.pid !== null || owner === null)) continue; + if (existing && (existing.pid !== null || owner === null)) { + if (terminal === null) seen.set(port, { ...existing, terminal: null }); + continue; + } seen.set(port, { host: "localhost", port, url: `http://localhost:${port}`, processName: owner?.processName ?? null, pid: owner?.pid ?? null, - terminal: owner === null ? null : (terminalByProcessId.get(owner.pid) ?? null), + // A replaced listener had no known owner, so the port stays unowned. + terminal: existing ? null : terminal, }); } return [...seen.values()].toSorted((left, right) => left.port - right.port); }); - const probeCommonPorts = Effect.fn("PortDiscovery.probeCommonPorts")(function* () { - const results = yield* Effect.forEach( - COMMON_DEV_PORTS, - (port) => - net.isPortAvailableOnLoopback(port).pipe( - Effect.map((available) => ({ - port, - listening: !available, - })), - ), - { concurrency: "unbounded" }, - ); - return results - .filter((result) => result.listening) - .map((result) => ({ - host: "localhost", - port: result.port, - url: `http://localhost:${result.port}`, - processName: null, - pid: null, - terminal: null, - })); - }); - const probeWebUrl = Effect.fn("PortDiscovery.probeWebUrl")((url: string) => httpClient.get(url).pipe( Effect.map((response) => { @@ -524,6 +511,9 @@ export const make = Effect.gen(function* PortDiscoveryMake() { } for (const server of servers) { + // A listener alone does not identify its protocol. Sending HTTP or TLS to + // another app's binary RPC listener can crash it before classification. + if (server.terminal === null) continue; groups.push({ server, urls: [`http://${server.host}:${server.port}`, `https://${server.host}:${server.port}`], @@ -616,7 +606,7 @@ export const make = Effect.gen(function* PortDiscoveryMake() { const recoverProcessProbeFailure = (probe: "lsof" | "windows-listeners") => (error: ProcessRunner.ProcessRunError) => - Effect.logDebug("preview port process probe failed; falling back to common-port probes", { + Effect.logDebug("preview port process probe failed; probing configured URLs only", { cause: error, probe, platform: hostPlatform, @@ -642,7 +632,6 @@ export const make = Effect.gen(function* PortDiscoveryMake() { args: ["-NoProfile", "-NonInteractive", "-Command", command], timeout: Duration.millis(WINDOWS_LISTENER_TIMEOUT_MS), maxOutputBytes: 1024 * 1024, - outputMode: "truncate", }) .pipe( Effect.map((result) => parseWindowsListenerOutput(result.stdout, terminalByProcessId)), @@ -655,20 +644,20 @@ export const make = Effect.gen(function* PortDiscoveryMake() { }), ); if (listeners !== null) return yield* probeWebServers(listeners, configuredUrls); - return yield* probeWebServers(yield* probeCommonPorts(), configuredUrls); + return yield* probeWebServers([], configuredUrls); } const recoverLsofProbeFailure = recoverProcessProbeFailure("lsof"); if (yield* Ref.get(lsofMissingRef)) { const fromProc = yield* scanProcListeners(terminalByProcessId); - return yield* probeWebServers(fromProc ?? (yield* probeCommonPorts()), configuredUrls); + return yield* probeWebServers(fromProc ?? [], configuredUrls); } const lsofResult = yield* processRunner .run({ command: "lsof", args: ["-iTCP", "-sTCP:LISTEN", "-P", "-n", "-F", "pcn"], timeout: Duration.millis(LSOF_TIMEOUT_MS), + // Partial output could hide another app's listener on an owned port. maxOutputBytes: 1024 * 1024, - outputMode: "truncate", }) .pipe( Effect.map((result) => parseLsofOutput(result.stdout, terminalByProcessId)), @@ -686,7 +675,7 @@ export const make = Effect.gen(function* PortDiscoveryMake() { ); if (lsofResult !== null) return yield* probeWebServers(lsofResult, configuredUrls); const fromProc = yield* scanProcListeners(terminalByProcessId); - return yield* probeWebServers(fromProc ?? (yield* probeCommonPorts()), configuredUrls); + return yield* probeWebServers(fromProc ?? [], configuredUrls); }); const scanSnapshot = Effect.fn("PortDiscovery.scanSnapshot")( diff --git a/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts b/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts index 2c78ffd18662..5323b6a26673 100644 --- a/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts +++ b/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts @@ -140,6 +140,34 @@ it.layer(NodeServices.layer)("CodexHomeLayout", (it) => { }), ); + it.effect.skipIf(!symlinksSupported)( + "replaces a Codex-created local sqlite maintenance lock with the shared lock", + () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const sharedHome = yield* makeTempDir("t3code-codex-shared-"); + const shadowRoot = yield* makeTempDir("t3code-codex-shadow-root-"); + const shadowHome = path.join(shadowRoot, "shadow"); + const sharedLock = path.join(sharedHome, ".sqlite-maintenance.lock"); + const shadowLock = path.join(shadowHome, ".sqlite-maintenance.lock"); + + yield* writeTextFile(sharedLock, ""); + yield* writeTextFile(shadowLock, ""); + + const layout = yield* resolveCodexHomeLayout( + decodeCodexSettings({ + homePath: sharedHome, + shadowHomePath: shadowHome, + }), + ); + + yield* materializeCodexShadowHome(layout); + + expect(yield* fileSystem.readLink(shadowLock)).toBe(sharedLock); + }), + ); + it.effect.skipIf(!symlinksSupported)( "replaces Codex-created local MCP OAuth locks with the shared lock directory", () => diff --git a/apps/server/src/provider/Drivers/CodexHomeLayout.ts b/apps/server/src/provider/Drivers/CodexHomeLayout.ts index d38951d501ed..9d4410da9843 100644 --- a/apps/server/src/provider/Drivers/CodexHomeLayout.ts +++ b/apps/server/src/provider/Drivers/CodexHomeLayout.ts @@ -30,7 +30,9 @@ const KNOWN_SHARED_DIRECTORIES = [ const PRIVATE_ENTRY_NAMES = new Set(["auth.json", "models_cache.json"]); const SHADOW_LOCAL_ENTRY_NAMES = new Set(["log", "memories", "tmp"]); -const REPLACEABLE_SHARED_RUNTIME_DIRECTORIES = new Set(["mcp-oauth-locks"]); +// Codex creates these runtime locks itself when it starts in a shadow home before the shared +// home has one. They guard shared state, so a local copy is replaced with the shared link. +const REPLACEABLE_SHARED_RUNTIME_ENTRIES = new Set(["mcp-oauth-locks", ".sqlite-maintenance.lock"]); function resolveHomePath(path: Path.Path, home: string, value: string | undefined): string { const expanded = @@ -241,7 +243,7 @@ const ensureSymlink = Effect.fn("CodexHomeLayout.ensureSymlink")(function* (inpu ); if (state._tag === "NotSymlink") { - if (!REPLACEABLE_SHARED_RUNTIME_DIRECTORIES.has(input.entryName)) { + if (!REPLACEABLE_SHARED_RUNTIME_ENTRIES.has(input.entryName)) { return yield* new CodexShadowHomeEntryConflictError({ sharedHomePath: input.sharedHomePath, effectiveHomePath: input.effectiveHomePath, diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.processTree.test.ts b/apps/server/src/provider/acp/AcpSessionRuntime.processTree.test.ts index 81d499733caf..af7b77fd90e5 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.processTree.test.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.processTree.test.ts @@ -655,8 +655,11 @@ describe("terminatePosixOwnedProcessTree", () => { it.live("rotates more than 64 live parents without scanning retained tombstones", () => Effect.gen(function* () { + // The server fixture's process group is the runner's real pid, so a synthetic + // parent sharing it would be skipped as the server's own group. + const parentPidBase = process.pid < 500_000 ? 500_000 : 1_000; const parents = Array.from({ length: 130 }, (_, index) => - identity(1_000 + index, 100, 1_000 + index, 1_000 + index), + identity(parentPidBase + index, 100, parentPidBase + index, parentPidBase + index), ); let childListReads = 0; let identityCalls = 0; diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index e24cb335b487..98a541409380 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -547,9 +547,7 @@ const layerRuntimeCoreDependenciesBase = Layer.mergeAll( // Asks T3 Connect to deliver webhooks it held while this environment was offline. HeldHooksWaker.layer, layerThreadSettlementWorker, - Layer.effectDiscard(StorageCleanup.make.pipe(Effect.flatMap((service) => service.start()))).pipe( - Layer.provide(ProjectionStoreV2.layer), - ), + StorageCleanup.layer.pipe(Layer.provide(ProjectionStoreV2.layer)), layerThreadPullRequestWorker, Layer.effectDiscard( Effect.gen(function* () { diff --git a/apps/server/src/storageCleanup.test.ts b/apps/server/src/storageCleanup.test.ts index c66aee5302d6..a8fb530d3f05 100644 --- a/apps/server/src/storageCleanup.test.ts +++ b/apps/server/src/storageCleanup.test.ts @@ -1,4 +1,31 @@ -import { describe, expect, it } from "vite-plus/test"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Effect from "effect/Effect"; +import * as Context from "effect/Context"; +import * as Deferred from "effect/Deferred"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import type * as Path from "effect/Path"; +import type * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; +import * as Schema from "effect/Schema"; +import * as SqlClient from "effect/sql/SqlClient"; +import { + DEFAULT_SERVER_SETTINGS, + StorageCleanupReport, + type WorktreeKeepWhen, +} from "@t3tools/contracts"; +import * as ServerConfig from "./config.ts"; +import * as StorageCleanup from "./storageCleanup.ts"; +import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; +import * as GitManager from "./git/GitManager.ts"; +import * as Settings from "./serverSettings.ts"; +import * as ProjectionStore from "./orchestration-v2/ProjectionStore.ts"; +import * as ProjectStore from "./orchestration-v2/ProjectStore.ts"; +import * as Orchestrator from "./orchestration-v2/Orchestrator.ts"; +import * as TerminalManager from "./terminal/Manager.ts"; +import { ServerActivation } from "./serverActivation.ts"; +import { describe, expect, it } from "@effect/vitest"; import { ProjectId, ProviderInstanceId, @@ -16,6 +43,7 @@ import { const NOW_MS = Date.parse("2026-06-10T12:00:00.000Z"); const DAY_MS = 24 * 60 * 60 * 1_000; +const decodeCleanupReport = Schema.decodeSync(StorageCleanupReport); function at(offsetMs: number): DateTime.Utc { return DateTime.makeUnsafe(NOW_MS + offsetMs); @@ -196,3 +224,433 @@ describe("merged pull request cleanup", () => { expect(storageCleanupPullRequestMerged(pullRequest({ state: "open" }), integrated)).toBe(false); }); }); + +const cleanupFixture = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const config = yield* ServerConfig.ServerConfig; + const git = yield* GitVcsDriver.GitVcsDriver; + const repo = yield* fs.makeTempDirectoryScoped({ prefix: "cleanup-repo-" }); + const command = (cwd: string, args: string[]) => + git.execute({ operation: "cleanup-test", cwd, args }); + yield* command(repo, ["init", "-b", "main"]); + yield* command(repo, ["config", "user.email", "cleanup@example.test"]); + yield* command(repo, ["config", "user.name", "Cleanup test"]); + yield* fs.writeFileString(`${repo}/tracked.txt`, "original\n"); + yield* fs.writeFileString(`${repo}/.gitignore`, ".env\ndist/\nnode_modules/\n"); + yield* command(repo, ["add", "."]); + yield* command(repo, ["commit", "-m", "initial"]); + yield* fs.makeDirectory(config.worktreesDir, { recursive: true }); + const worktree = `${config.worktreesDir}/feature`; + yield* command(repo, ["worktree", "add", "-b", "feature", worktree]); + let settings: import("@t3tools/contracts").ServerSettings = { + ...DEFAULT_SERVER_SETTINGS, + storageCleanup: { ...DEFAULT_SERVER_SETTINGS.storageCleanup, worktreeAfterDays: 8 }, + }; + let threads = [shell({ branch: "feature", worktreePath: worktree })]; + let editBeforeRemoval = false; + const context = yield* Layer.build(StorageCleanup.layer).pipe( + Effect.provideService(GitVcsDriver.GitVcsDriver, { + ...git, + execute: (input) => + Effect.gen(function* () { + if (editBeforeRemoval && input.args.includes("remove")) + yield* fs.writeFileString(`${worktree}/tracked.txt`, "late edit\n"); + return yield* git.execute(input); + }), + }), + Effect.provideService(Settings.ServerSettingsService, { + getSettings: Effect.sync(() => settings), + subscribeChanges: Effect.succeed(Stream.empty), + } as unknown as Settings.ServerSettingsService["Service"]), + Effect.provideService(ProjectStore.ProjectStoreV2, { + listShells: () => Effect.succeed([{ id: ProjectId.make("project-1"), workspaceRoot: repo }]), + } as unknown as ProjectStore.ProjectStoreV2["Service"]), + Effect.provideService(ProjectionStore.ProjectionStoreV2, { + getShellSnapshot: (input?: { location?: string }) => + Effect.sync(() => ({ threads: input?.location === "archive" ? [] : threads })), + } as unknown as ProjectionStore.ProjectionStoreV2["Service"]), + Effect.provideService(Orchestrator.OrchestratorV2, { + streamDomainEvents: Stream.empty, + } as unknown as Orchestrator.OrchestratorV2["Service"]), + Effect.provideService(SqlClient.SqlClient, (() => + Effect.succeed([])) as unknown as SqlClient.SqlClient), + Effect.provideService(GitManager.GitManager, { + invalidateStatus: () => Effect.void, + } as unknown as GitManager.GitManager["Service"]), + Effect.provideService(TerminalManager.TerminalManager, { + subscribeMetadata: () => Effect.succeed(() => {}), + } as unknown as TerminalManager.TerminalManager["Service"]), + Effect.provideService(ServerActivation, Effect.never), + ); + return { + service: Context.get(context, StorageCleanup.StorageCleanup), + editBeforeRemoval: () => { + editBeforeRemoval = true; + }, + fs, + worktree, + config, + command, + enableFiles: () => { + settings = { + ...settings, + storageCleanup: { + ...settings.storageCleanup, + browserArtifactsAfterDays: 8, + logsAfterDays: 8, + }, + }; + }, + setPolicy: (worktreeKeepWhen: WorktreeKeepWhen) => { + settings = { ...settings, storageCleanup: { ...settings.storageCleanup, worktreeKeepWhen } }; + }, + disable: () => { + settings = { + ...settings, + storageCleanup: { ...settings.storageCleanup, worktreeAfterDays: null }, + }; + }, + setThreads: (next: typeof threads) => { + threads = next; + }, + }; +}); +const cleanupTestLayer = GitVcsDriver.layer.pipe( + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: "storage-cleanup-" })), + Layer.provideMerge(NodeServices.layer), +); +const runCleanupTest = ( + effect: Effect.Effect< + A, + E, + | FileSystem.FileSystem + | ServerConfig.ServerConfig + | GitVcsDriver.GitVcsDriver + | Path.Path + | Scope.Scope + >, +) => effect.pipe(Effect.provide(cleanupTestLayer), Effect.scoped); + +describe("storage cleanup reports and local file policies", () => { + it.live("measures nested regular files without following symlinks", () => + runCleanupTest( + Effect.gen(function* () { + const { service, fs, worktree, setPolicy } = yield* cleanupFixture; + setPolicy("tracked-changes"); + const sizes = yield* Effect.forEach(yield* fs.readDirectory(worktree), (name) => + fs.stat(`${worktree}/${name}`).pipe(Effect.map((stat) => Number(stat.size))), + ); + const outside = yield* fs.makeTempDirectoryScoped(); + yield* fs.writeFileString(`${outside}/external`, "must not count"); + yield* fs.makeDirectory(`${worktree}/node_modules/nested`, { recursive: true }); + yield* fs.writeFileString(`${worktree}/node_modules/nested/file`, "count me"); + yield* fs.symlink(outside, `${worktree}/node_modules/directory-link`); + yield* fs.symlink(`${outside}/external`, `${worktree}/node_modules/file-link`); + yield* fs.symlink(`${outside}/missing`, `${worktree}/node_modules/dangling-link`); + const report = yield* service.runNow; + const bytes = sizes.reduce((sum, size) => sum + size, 8); + expect(report.entries[0]).toMatchObject({ outcome: "removed", bytes, files: null }); + expect(report.bytesFreed).toBe(bytes); + expect(yield* fs.readFileString(`${outside}/external`)).toBe("must not count"); + }), + ), + ); + it.live("still removes a worktree when measuring its size fails", () => + runCleanupTest( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const { service, worktree } = yield* cleanupFixture.pipe( + Effect.provideService(FileSystem.FileSystem, { + ...fs, + readDirectory: (directory) => fs.readDirectory(`${directory}/missing-directory`), + }), + ); + const report = yield* service.runNow; + expect(report.entries[0]).toMatchObject({ outcome: "removed", bytes: null, files: null }); + expect(report.bytesFreed).toBe(0); + expect(yield* fs.exists(worktree)).toBe(false); + }), + ), + ); + it.live("keeps a worktree when its thread starts during size measurement", () => + runCleanupTest( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const measuring = yield* Deferred.make(); + const resume = yield* Deferred.make(); + let measuredPath: string | undefined; + const fixture = yield* cleanupFixture.pipe( + Effect.provideService(FileSystem.FileSystem, { + ...fs, + readDirectory: (directory) => + Effect.gen(function* () { + if (directory === measuredPath) { + yield* Deferred.succeed(measuring, undefined); + yield* Deferred.await(resume); + } + return yield* fs.readDirectory(directory); + }), + }), + ); + measuredPath = yield* fs.realPath(fixture.worktree); + fixture.setPolicy("tracked-changes"); + yield* fs.writeFileString(`${fixture.worktree}/notes.txt`, "untracked\n"); + const cleanup = yield* fixture.service.runNow.pipe(Effect.forkChild); + yield* Deferred.await(measuring); + fixture.setThreads([ + shell({ branch: "feature", worktreePath: fixture.worktree, status: "running" }), + ]); + yield* Deferred.succeed(resume, undefined); + const report = yield* Fiber.join(cleanup); + expect(report.entries[0]).toMatchObject({ + outcome: "kept", + reason: "Thread activity or shared worktree changed since check", + bytes: null, + }); + expect(report.bytesFreed).toBe(0); + expect(yield* fs.exists(fixture.worktree)).toBe(true); + expect(yield* fs.readFileString(`${fixture.worktree}/notes.txt`)).toBe("untracked\n"); + }), + ), + ); + it("decodes reports from servers without size fields", () => { + const report = decodeCleanupReport({ + trigger: "manual", + startedAt: "2026-06-10T12:00:00.000Z", + finishedAt: "2026-06-10T12:00:01.000Z", + entries: [ + { + kind: "worktree", + outcome: "removed", + reason: "Removed", + path: null, + threadId: null, + threadTitle: null, + }, + ], + counts: { removed: 1, kept: 0, failed: 0 }, + omittedCount: 0, + }); + expect(report.bytesFreed).toBe(0); + expect(report.entries[0]).toMatchObject({ bytes: null, files: null }); + }); + it.live.each([ + ["any-local-files", ".env", "kept"], + ["uncommitted-changes", ".env", "removed"], + ["tracked-changes", ".env", "removed"], + ["any-local-files", "notes.txt", "kept"], + ["uncommitted-changes", "notes.txt", "kept"], + ["tracked-changes", "notes.txt", "removed"], + ["any-local-files", "tracked.txt", "kept"], + ["uncommitted-changes", "tracked.txt", "kept"], + ["tracked-changes", "tracked.txt", "kept"], + ] as const)("applies %j", ([policy, file, outcome]) => + runCleanupTest( + Effect.gen(function* () { + const { service, fs, worktree, setPolicy } = yield* cleanupFixture; + setPolicy(policy); + expect(yield* service.latestReport).toBeNull(); + yield* fs.writeFileString(`${worktree}/${file}`, "local data\n"); + const sizes = yield* Effect.forEach(yield* fs.readDirectory(worktree), (name) => + fs.stat(`${worktree}/${name}`).pipe(Effect.map((stat) => Number(stat.size))), + ); + const bytes = sizes.reduce((sum, size) => sum + size, 0); + const report = yield* service.runNow; + yield* service.drain; + expect(report.trigger).toBe("manual"); + expect(report.entries.filter((entry) => entry.kind === "worktree")).toMatchObject([ + { + outcome, + path: worktree, + threadId: "thread-1", + threadTitle: "Thread", + bytes: outcome === "removed" ? bytes : null, + files: null, + }, + ]); + expect(report.bytesFreed).toBe(outcome === "removed" ? bytes : 0); + expect(yield* fs.exists(worktree)).toBe(outcome === "kept"); + expect(yield* service.latestReport).toEqual(report); + if (outcome === "kept") + expect(report.entries[0]?.reason).toContain( + file === ".env" ? "ignored files (.env)" : "uncommitted changes (1 file)", + ); + }), + ), + ); + it.live("preserves tracked edits written after the final status check", () => + runCleanupTest( + Effect.gen(function* () { + const fixture = yield* cleanupFixture; + fixture.setPolicy("tracked-changes"); + fixture.editBeforeRemoval(); + yield* fixture.fs.writeFileString(`${fixture.worktree}/notes.txt`, "untracked\n"); + const report = yield* fixture.service.runNow; + expect(report.entries[0]).toMatchObject({ outcome: "failed", bytes: null, files: null }); + expect(report.bytesFreed).toBe(0); + expect(report.entries[0]?.reason).toContain("contains modified or untracked files"); + expect(yield* fixture.fs.readFileString(`${fixture.worktree}/tracked.txt`)).toBe( + "late edit\n", + ); + expect(yield* fixture.fs.exists(`${fixture.worktree}/notes.txt`)).toBe(false); + }), + ), + ); + it.live("streams the current report and subsequent runs", () => + runCleanupTest( + Effect.gen(function* () { + const { service } = yield* cleanupFixture; + const reports = yield* service.reports.pipe( + Stream.take(2), + Stream.tap((report) => (report === null ? service.runNow : Effect.void)), + Stream.runCollect, + ); + expect(reports[0]).toBeNull(); + expect(reports[1]).toEqual(yield* service.latestReport); + expect(reports[1]?.counts).toEqual({ removed: 1, kept: 0, failed: 0 }); + }), + ), + ); + it.live("caps entries while preserving totals and prioritizing removals", () => + runCleanupTest( + Effect.gen(function* () { + const { service, fs, worktree, setThreads } = yield* cleanupFixture; + const threads = []; + for (let i = 0; i < 205; i++) { + const worktreePath = `${worktree}-${i}`; + yield* fs.makeDirectory(worktreePath); + threads.push( + shell({ + id: ThreadId.make(`thread-${i + 2}`), + branch: "feature", + worktreePath, + status: "running", + }), + ); + } + threads.push(shell({ branch: "feature", worktreePath: worktree })); + setThreads(threads); + const report = yield* service.runNow; + expect(report.entries).toHaveLength(200); + expect(report.omittedCount).toBe(6); + expect(report.counts).toEqual({ removed: 1, kept: 205, failed: 0 }); + expect(report.entries[0]?.outcome).toBe("removed"); + expect(report.entries[0]?.bytes).toBeGreaterThan(0); + expect(report.bytesFreed).toBe(report.entries[0]?.bytes); + }), + ), + ); + it.live("keeps staged edits in tracked-only mode", () => + runCleanupTest( + Effect.gen(function* () { + const { service, fs, worktree, setPolicy, command } = yield* cleanupFixture; + setPolicy("tracked-changes"); + yield* fs.writeFileString(`${worktree}/tracked.txt`, "staged\n"); + yield* command(worktree, ["add", "tracked.txt"]); + expect((yield* service.runNow).entries[0]).toMatchObject({ + outcome: "kept", + reason: "Has uncommitted changes (1 file)", + }); + expect(yield* fs.exists(worktree)).toBe(true); + }), + ), + ); + it.live( + "reports shared and running worktrees but omits disabled rules and missing directories", + () => + runCleanupTest( + Effect.gen(function* () { + const fixture = yield* cleanupFixture; + const { service, fs, worktree, setThreads } = fixture; + setThreads([shell({ branch: "feature", worktreePath: worktree, status: "running" })]); + expect((yield* service.runNow).entries[0]?.reason).toContain("Thread is running"); + setThreads([ + shell({ branch: "feature", worktreePath: worktree }), + shell({ id: ThreadId.make("thread-2"), branch: "feature", worktreePath: worktree }), + ]); + expect((yield* service.runNow).entries[0]?.reason).toBe("Shared by 2 threads"); + fixture.disable(); + expect((yield* service.runNow).entries).toEqual([]); + yield* fs.remove(worktree, { recursive: true }); + expect( + (yield* service.runNow).entries.filter((entry) => entry.kind === "worktree"), + ).toEqual([]); + }), + ), + ); + it.live("serializes concurrent requests and returns each run's report", () => + runCleanupTest( + Effect.gen(function* () { + const { service } = yield* cleanupFixture; + const reports = yield* Effect.all([service.runNow, service.runNow], { + concurrency: "unbounded", + }); + yield* service.drain; + expect( + reports.flatMap((report) => report.entries).filter((entry) => entry.kind === "worktree"), + ).toMatchObject([{ outcome: "removed" }]); + expect(yield* service.latestReport).toEqual(reports[1]); + }), + ), + ); + it.live("aggregates expired artifacts and rotated logs while retaining current logs", () => + runCleanupTest( + Effect.gen(function* () { + const { service, fs, config, enableFiles, disable } = yield* cleanupFixture; + disable(); + enableFiles(); + yield* fs.makeDirectory(config.browserArtifactsDir, { recursive: true }); + yield* fs.makeDirectory(config.logsDir, { recursive: true }); + for (const name of ["one.png", "two.webm"]) { + const file = config.browserArtifactsDir + "/" + name; + yield* fs.writeFileString(file, "capture"); + yield* fs.utimes(file, 1, 1); + } + for (const name of ["server.log.1", "server.log.2", "server.log"]) { + const file = config.logsDir + "/" + name; + yield* fs.writeFileString(file, "log"); + yield* fs.utimes(file, 1, 1); + } + const report = yield* service.runNow; + expect(report.entries.filter((entry) => entry.kind !== "worktree")).toMatchObject([ + { + kind: "browser-artifacts", + outcome: "removed", + reason: "Removed 2 browser artifacts", + bytes: 14, + files: 2, + }, + { + kind: "logs", + outcome: "removed", + reason: "Removed 2 rotated logs", + bytes: 6, + files: 2, + }, + ]); + expect(report.bytesFreed).toBe(20); + expect(yield* fs.readDirectory(config.browserArtifactsDir)).toEqual([]); + expect(yield* fs.exists(config.logsDir + "/server.log")).toBe(true); + expect(yield* fs.exists(config.logsDir + "/server.log.1")).toBe(false); + expect(yield* fs.exists(config.logsDir + "/server.log.2")).toBe(false); + }), + ), + ); + it.live("reports git failures without aborting the remaining categories", () => + runCleanupTest( + Effect.gen(function* () { + const { service, worktree, command, enableFiles } = yield* cleanupFixture; + enableFiles(); + yield* command(worktree, ["worktree", "lock", worktree]); + const report = yield* service.runNow; + expect(report.entries[0]).toMatchObject({ outcome: "failed" }); + expect(report.entries[0]?.reason).toContain("git worktree remove: fatal:"); + expect(report.entries.map((entry) => entry.kind)).toEqual([ + "worktree", + "browser-artifacts", + "logs", + ]); + }), + ), + ); +}); diff --git a/apps/server/src/storageCleanup.ts b/apps/server/src/storageCleanup.ts index 7804ed42ef10..481abe7da8cc 100644 --- a/apps/server/src/storageCleanup.ts +++ b/apps/server/src/storageCleanup.ts @@ -1,4 +1,5 @@ import { + GitCommandError, OrchestrationV2AppThreadJson, OrchestrationV2ProviderSessionJson, } from "@t3tools/contracts"; @@ -11,10 +12,14 @@ import type { ServerSettingsError, TerminalSummary, WorktreeCleanupRules, + StorageCleanupReport, + StorageCleanupReportEntry, } from "@t3tools/contracts"; import { resolveWorktreeCleanup } from "@t3tools/shared/projectSettings"; import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; -import * as Cause from "effect/Cause"; +import * as Context from "effect/Context"; +import * as Deferred from "effect/Deferred"; +import * as Layer from "effect/Layer"; import * as Clock from "effect/Clock"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; @@ -25,6 +30,7 @@ import * as Path from "effect/Path"; import type { PlatformError } from "effect/PlatformError"; import * as Schedule from "effect/Schedule"; import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; import * as ServerConfig from "./config.ts"; import * as GitManager from "./git/GitManager.ts"; @@ -49,6 +55,16 @@ const decodeCleanupSession = Schema.decodeUnknownEffect( const DAY_MS = 86_400_000; +const sentence = (text: string) => text.charAt(0).toUpperCase() + text.slice(1); +const REPORT_ENTRY_LIMIT = 200; +const isGitCommandError = Schema.is(GitCommandError); + +function cleanupFailureReason(error: { readonly message: string }) { + return isGitCommandError(error) + ? `${error.command}: ${error.detail.split(/\r?\n/)[0]}${error.reason ? ` (${error.reason})` : ""}` + : (error.message.split(/\r?\n/)[0] ?? error.message); +} + const worktreeCleanupEnabled = (rules: WorktreeCleanupRules) => rules.worktreeAfterDays !== null || rules.worktreeOnMerge || @@ -138,7 +154,17 @@ export function storageCleanupPullRequestMerged( ); } -export const make = Effect.gen(function* () { +export class StorageCleanup extends Context.Service< + StorageCleanup, + { + readonly runNow: Effect.Effect; + readonly latestReport: Effect.Effect; + readonly reports: Stream.Stream; + readonly drain: Effect.Effect; + } +>()("t3/storageCleanup") {} + +const make = Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; const settingsService = yield* Settings.ServerSettingsService; const projectStore = yield* ProjectStore.ProjectStoreV2; @@ -181,6 +207,47 @@ export const make = Effect.gen(function* () { ); }); + const measureWorktree = (worktreePath: string) => + Effect.gen(function* () { + const root = yield* fs.realPath(worktreePath); + const pending = [root]; + let entries = 1; + let bytes = 0; + while (pending.length > 0) { + const target = pending.pop()!; + // Effect's stat follows links. Probe with readLink first to get lstat + // semantics, including skipping dangling links and directory links. + const isLink = yield* fs.readLink(target).pipe( + Effect.as(true), + Effect.catchIf( + (error) => + error.cause instanceof Error && + "code" in error.cause && + error.cause.code === "EINVAL", + () => Effect.succeed(false), + ), + ); + if (isLink) continue; + if ((yield* fs.realPath(target)) !== target) return null; + const stat = yield* fs.stat(target); + if (stat.type === "File") bytes += Number(stat.size); + else if (stat.type === "Directory") { + const names = yield* fs.readDirectory(target); + entries += names.length; + if (entries > 2_000_000) return null; + for (const name of names) { + const child = path.join(target, name); + if (!inside(root, child)) return null; + pending.push(child); + } + } + } + return bytes; + }).pipe( + Effect.timeout("30 seconds"), + Effect.orElseSucceed(() => null), + ); + const readThreads = Effect.fn("StorageCleanup.readThreads")(function* () { const active = yield* projections.getShellSnapshot(); const archived = yield* projections.getShellSnapshot({ location: "archive" }); @@ -204,11 +271,74 @@ export const make = Effect.gen(function* () { return false; }); + const localChangesReason = Effect.fn("StorageCleanup.localChangesReason")(function* ( + cwd: string, + rules: WorktreeCleanupRules, + ) { + const status = yield* git.execute({ + operation: "StorageCleanup.localChanges", + cwd, + args: [ + "status", + "--porcelain=v1", + "-z", + "--ignore-submodules=none", + rules.worktreeKeepWhen === "tracked-changes" + ? "--untracked-files=no" + : "--untracked-files=all", + ], + maxOutputBytes: 64 * 1024, + }); + if (status.stdoutTruncated) return "local changes exceed the inspection limit"; + const records = status.stdout.split("\0").filter(Boolean); + let count = 0; + for (let i = 0; i < records.length; i++) { + count++; + if (/^[RC]|^.[RC]/.test(records[i]!)) i++; + } + if (count > 0) return `has uncommitted changes (${count} ${count === 1 ? "file" : "files"})`; + if (rules.worktreeKeepWhen !== "any-local-files") return null; + const ignored = yield* git.execute({ + operation: "StorageCleanup.ignoredFiles", + cwd, + args: ["ls-files", "--others", "--ignored", "--exclude-standard", "--directory", "-z"], + maxOutputBytes: 64 * 1024, + }); + if (ignored.stdoutTruncated) return "ignored files exceed the inspection limit"; + const file = ignored.stdout + .split("\0") + .find((entry) => entry !== "" && !/(^|\/)node_modules\/$/.test(entry)); + return file === undefined ? null : `has ignored files (${file})`; + }); + + const cleanupGit = Effect.fn("StorageCleanup.cleanupGit")(function* ( + cwd: string, + args: string[], + command: string, + ) { + const result = yield* git.execute({ + operation: "StorageCleanup.cleanupGit", + cwd, + args, + allowNonZeroExit: true, + timeoutMs: 300_000, + }); + if (result.exitCode !== 0) + return yield* Effect.fail( + new GitCommandError({ + operation: "StorageCleanup.cleanupGit", + command, + cwd, + detail: result.stderr.trim().split(/\r?\n/)[0]?.slice(0, 1000) || "command failed", + }), + ); + }); + const cleanWorktrees = Effect.fn("StorageCleanup.cleanWorktrees")(function* ( serverSettings: ServerSettings, now: number, + entries: StorageCleanupReportEntry[], ) { - if (!anyWorktreePolicy(serverSettings, worktreeCleanupEnabled)) return; const roots: Array = []; for (const directory of managedWorktreesDirectories( serverSettings, @@ -223,26 +353,17 @@ export const make = Effect.gen(function* () { ); if (root !== null && !isFilesystemRoot(root, path)) roots.push(root); } - if (roots.length === 0) return; - const hasDeleteRule = anyWorktreePolicy(serverSettings, (rules) => rules.worktreeOnDelete); - const deletedRows = hasDeleteRule - ? yield* sql<{ payload_json: string; workspaceRoot: string }>` + const deletedRows = yield* sql<{ payload_json: string; workspaceRoot: string }>` SELECT t.payload_json, p.workspace_root AS "workspaceRoot" FROM orchestration_v2_projection_threads t JOIN projection_projects p ON p.project_id = t.project_id WHERE t.deleted_at IS NOT NULL - ` - : []; + `; const deletedThreads = (yield* Effect.forEach(deletedRows, (row) => decodeCleanupThread(row.payload_json).pipe( Effect.map((thread) => ({ ...thread, workspaceRoot: row.workspaceRoot })), ), - )).filter( - (thread) => - thread.worktreePath !== null && - thread.branch !== null && - resolveWorktreeCleanup(serverSettings, thread.projectId).worktreeOnDelete, - ); + )).filter((thread) => thread.worktreePath !== null && thread.branch !== null); const snapshot = yield* readThreads(); const refreshedDefaultRefs = new Map>(); const groups = Map.groupBy( @@ -250,7 +371,7 @@ export const make = Effect.gen(function* () { (thread) => path.resolve(thread.worktreePath!), ); const candidates = [ - ...[...groups.values()].flatMap((group) => (group.length === 1 ? [group[0]!] : [])), + ...[...groups.values()].map((group) => group[0]!), ...deletedThreads.filter((thread) => !groups.has(path.resolve(thread.worktreePath!))), ]; for (const thread of candidates) { @@ -261,52 +382,58 @@ export const make = Effect.gen(function* () { const project = deleted ? { workspaceRoot: thread.workspaceRoot } : snapshot.projects.find((entry) => entry.id === thread.projectId); - if ( - project === undefined || - (!deleted && !storageCleanupThreadIdle(thread, now)) || - hasTerminal(worktreePath) - ) - continue; + const entry: StorageCleanupReportEntry = { + kind: "worktree", + outcome: "kept", + reason: "", + path: worktreePath, + threadId: thread.id, + threadTitle: thread.title, + bytes: null, + files: null, + }; + const keep = (reason: string) => entries.push({ ...entry, reason: sentence(reason) }); yield* Effect.gen(function* () { if (!(yield* fs.exists(worktreePath))) return; + if (deleted && !settings.worktreeOnDelete) return keep("no rules apply"); + const shared = groups.get(worktreePath)?.length ?? 0; + if (shared > 1) return keep(`shared by ${shared} threads`); + if (project === undefined) return keep("project is unavailable"); + if (!deleted && !storageCleanupThreadIdle(thread, now)) + return keep("thread is running or has pending work"); + if (hasTerminal(worktreePath)) return keep("open terminal"); // Roots are canonical, so compare canonical paths. A symlinked parent // (a linked drive) is fine; a symlinked worktree directory is not. const realPath = yield* fs.realPath(worktreePath); const realParent = yield* fs.realPath(path.dirname(worktreePath)); - if (realPath !== path.join(realParent, path.basename(worktreePath))) return; - if (!roots.some((root) => inside(root, realPath))) return; - if (yield* containsProjectRoot(worktreePath, [project, ...snapshot.projects])) return; + if (realPath !== path.join(realParent, path.basename(worktreePath))) + return keep("worktree is a symbolic link"); + if (!roots.some((root) => inside(root, realPath))) + return keep("outside the managed worktree folder"); + if (yield* containsProjectRoot(worktreePath, [project, ...snapshot.projects])) + return keep("contains a project checkout"); // A linked worktree has a .git file. Never remove a main checkout. - if ((yield* fs.stat(path.join(worktreePath, ".git"))).type !== "File") return; + if ((yield* fs.stat(path.join(worktreePath, ".git"))).type !== "File") + return keep("not a linked worktree"); const status = yield* git.statusDetailsLocal(worktreePath); - if (!status.isRepo || status.branch !== thread.branch || status.hasWorkingTreeChanges) - return; + if (!status.isRepo || status.branch !== thread.branch) + return keep("repository or branch changed"); + const changes = yield* localChangesReason(worktreePath, settings); + if (changes !== null) return keep(changes); const head = yield* git.resolveCommit({ cwd: worktreePath, revision: "HEAD" }); - const ignored = yield* git.execute({ - operation: "StorageCleanup.ignoredFiles", - cwd: worktreePath, - args: ["ls-files", "--others", "--ignored", "--exclude-standard", "--directory", "-z"], - maxOutputBytes: 64 * 1024, - }); - // Ignored files can contain secrets or local datasets. Dependency installs - // are reproducible; every other ignored path prevents automatic removal. - if ( - ignored.stdoutTruncated || - ignored.stdout - .split("\0") - .some((entry) => entry !== "" && !/(^|\/)node_modules\/$/.test(entry)) - ) - return; const old = !deleted && settings.worktreeAfterDays !== null && storageCleanupActivityAt(thread) < now - settings.worktreeAfterDays * DAY_MS; let eligible = deleted || old; + let removalReason = deleted + ? "thread was deleted" + : `inactive for ${Math.floor((now - storageCleanupActivityAt(thread)) / DAY_MS)} days`; if (!eligible && (settings.worktreeUnchanged || settings.worktreeOnMerge)) { const repositoryCwd = path.resolve(project.workspaceRoot); const remote = yield* git.resolvePrimaryRemoteName(repositoryCwd); const branch = yield* git.resolveDefaultBranchName(repositoryCwd, remote); - if (branch === null) return; + if (branch === null) return keep("default branch is unavailable"); const defaultRef = `refs/remotes/${remote}/${branch}`; const refreshed = refreshedDefaultRefs.get(repositoryCwd) ?? new Set(); if (!refreshed.has(defaultRef)) { @@ -330,6 +457,7 @@ export const make = Effect.gen(function* () { }); const integrated = ancestor.exitCode === 0; eligible = integrated && settings.worktreeUnchanged; + if (eligible) removalReason = "no commits beyond the default branch"; if (!eligible && settings.worktreeOnMerge && thread.branch !== null) { const pullRequest = yield* gitManager.branchPullRequest( { cwd: worktreePath, branch: thread.branch }, @@ -341,39 +469,49 @@ export const make = Effect.gen(function* () { headSha: head.commitSha, integrated, }); + if (eligible) removalReason = "pull request was merged"; } } - if (!eligible) return; - // Re-read after Git/host calls so a queued turn, resumed session or new - // thread sharing this path cancels the removal. + if (!eligible) + return keep( + !deleted && settings.worktreeAfterDays !== null + ? `inactive for ${Math.floor((now - storageCleanupActivityAt(thread)) / DAY_MS)} of ${settings.worktreeAfterDays} days` + : settings.worktreeOnMerge + ? "not merged" + : "has commits beyond the default branch", + ); + const bytes = yield* measureWorktree(worktreePath); + // Re-read after Git/host calls and size measurement so a queued turn, + // resumed session or new thread sharing this path cancels the removal. const latestSnapshot = yield* readThreads(); - if (yield* containsProjectRoot(worktreePath, [project, ...latestSnapshot.projects])) return; + if (yield* containsProjectRoot(worktreePath, [project, ...latestSnapshot.projects])) + return keep("contains a project checkout"); const latest = latestSnapshot.threads.filter( (entry) => entry.worktreePath !== null && path.resolve(entry.worktreePath) === worktreePath, ); - if (hasTerminal(worktreePath)) return; + if (hasTerminal(worktreePath)) return keep("open terminal"); if (deleted) { if ( latest.length > 0 || !resolveWorktreeCleanup(yield* settingsService.getSettings, thread.projectId) .worktreeOnDelete ) - return; + return keep("thread or cleanup settings changed since check"); // V2 deletion queues durable cleanup. Do not remove its checkout until // every effect has finished successfully or was explicitly cancelled. const pendingCleanup = yield* sql` SELECT 1 FROM orchestration_v2_effect_outbox WHERE thread_id = ${thread.id} AND status NOT IN ('succeeded', 'cancelled') LIMIT 1 `; - if (pendingCleanup.length > 0) return; + if (pendingCleanup.length > 0) return keep("thread deletion is still pending"); } else if ( latest.length !== 1 || latest[0]!.id !== thread.id || !storageCleanupThreadIdle(latest[0]!, now) || storageCleanupActivityAt(latest[0]!) !== storageCleanupActivityAt(thread) ) - return; + return keep("thread activity or shared worktree changed since check"); // Sessions can outlive their run and can be shared across app threads. const sessionRows = yield* sql<{ payload_json: string }>` SELECT payload_json FROM orchestration_v2_projection_provider_sessions @@ -388,32 +526,17 @@ export const make = Effect.gen(function* () { return cwd === worktreePath || inside(worktreePath, cwd); }) ) - return; + return keep("provider session is still open"); const finalStatus = yield* git.statusDetailsLocal(worktreePath); - if ( - !finalStatus.isRepo || - finalStatus.branch !== thread.branch || - finalStatus.hasWorkingTreeChanges - ) - return; + if (!finalStatus.isRepo || finalStatus.branch !== thread.branch) + return keep("repository or branch changed since check"); if ( (yield* git.resolveCommit({ cwd: worktreePath, revision: "HEAD" })).commitSha !== head.commitSha ) - return; - const finalIgnored = yield* git.execute({ - operation: "StorageCleanup.ignoredFiles", - cwd: worktreePath, - args: ["ls-files", "--others", "--ignored", "--exclude-standard", "--directory", "-z"], - maxOutputBytes: 64 * 1024, - }); - if ( - finalIgnored.stdoutTruncated || - finalIgnored.stdout - .split("\0") - .some((entry) => entry !== "" && !/(^|\/)node_modules\/$/.test(entry)) - ) - return; + return keep("commit changed since check"); + const finalChanges = yield* localChangesReason(worktreePath, settings); + if (finalChanges !== null) return keep(finalChanges); const current = resolveWorktreeCleanup( yield* settingsService.getSettings, thread.projectId, @@ -424,8 +547,17 @@ export const make = Effect.gen(function* () { current[key as keyof typeof settings] !== settings[key as keyof typeof settings], ) ) - return; - yield* git.removeWorktree({ cwd: project.workspaceRoot, path: worktreePath, force: false }); + return keep("settings changed since check"); + // Clean only untracked files; Git must still refuse removal if a tracked + // edit arrives after our last status check. + if (settings.worktreeKeepWhen === "tracked-changes") + yield* cleanupGit(worktreePath, ["clean", "-ffdx"], "git clean"); + yield* cleanupGit( + project.workspaceRoot, + ["-c", "status.showUntrackedFiles=normal", "worktree", "remove", worktreePath], + "git worktree remove", + ); + entries.push({ ...entry, outcome: "removed", reason: sentence(removalReason), bytes }); yield* gitManager.invalidateStatus(project.workspaceRoot); // Preserve branch and path: ProviderTurnStartService recreates the checkout // from that branch when the thread is resumed. @@ -433,7 +565,17 @@ export const make = Effect.gen(function* () { }).pipe( (effect) => withWorkspaceLease(worktreePath, effect), Effect.catch((error) => - Effect.logDebug("storage cleanup skipped worktree", { threadId: thread.id, error }), + Effect.gen(function* () { + entries.push({ + ...entry, + outcome: "failed", + reason: cleanupFailureReason(error), + }); + yield* Effect.logWarning("storage cleanup failed for worktree", { + threadId: thread.id, + error, + }); + }), ), ); } @@ -444,10 +586,12 @@ export const make = Effect.gen(function* () { days: number | null, now: number, rotatedLogs: boolean, + // Owned by the caller so files removed before a failure still count. + removed: { files: number; bytes: number }, ) { - if (days === null || !(yield* fs.exists(root))) return; + if (days === null || !(yield* fs.exists(root))) return removed; const realRoot = yield* fs.realPath(root); - if (realRoot !== path.resolve(root)) return; + if (realRoot !== path.resolve(root)) return removed; const visit = Effect.fn("StorageCleanup.visitFiles")(function* ( directory: string, ): Effect.fn.Return { @@ -464,40 +608,129 @@ export const make = Effect.gen(function* () { if ((rotatedLogs ? current.logsAfterDays : current.browserArtifactsAfterDays) !== days) return; yield* fs.remove(target); + removed.files++; + removed.bytes += Number(stat.size); } } } }); yield* visit(realRoot); + return removed; }); - const sweep = Effect.fn("StorageCleanup.sweep")(function* () { + const reportRef = yield* SubscriptionRef.make(null); + const sweep = Effect.fn("StorageCleanup.sweep")(function* ( + trigger: StorageCleanupReport["trigger"], + ) { + const now = yield* Clock.currentTimeMillis; const serverSettings = yield* settingsService.getSettings; const settings = serverSettings.storageCleanup; - const now = yield* Clock.currentTimeMillis; - yield* cleanWorktrees(serverSettings, now).pipe( - Effect.catch((error) => Effect.logWarning("worktree cleanup failed", { error })), - ); - yield* cleanFiles( - config.browserArtifactsDir, - settings.browserArtifactsAfterDays, - now, - false, - ).pipe( - Effect.catch((error) => Effect.logWarning("browser artifact cleanup failed", { error })), - ); - yield* cleanFiles(config.logsDir, settings.logsAfterDays, now, true).pipe( - Effect.catch((error) => Effect.logWarning("rotated log cleanup failed", { error })), + const entries: StorageCleanupReportEntry[] = []; + yield* cleanWorktrees(serverSettings, now, entries).pipe( + Effect.catch((error) => + Effect.gen(function* () { + entries.push({ + kind: "worktree", + outcome: "failed", + reason: cleanupFailureReason(error), + path: null, + threadId: null, + threadTitle: null, + bytes: null, + files: null, + }); + yield* Effect.logWarning("worktree cleanup failed", { error }); + }), + ), ); + for (const category of [ + { + kind: "browser-artifacts" as const, + root: config.browserArtifactsDir, + days: settings.browserArtifactsAfterDays, + label: "browser artifacts", + }, + { + kind: "logs" as const, + root: config.logsDir, + days: settings.logsAfterDays, + label: "rotated logs", + }, + ]) { + if (category.days === null) continue; + const removed = { files: 0, bytes: 0 }; + yield* cleanFiles(category.root, category.days, now, category.kind === "logs", removed).pipe( + Effect.map(({ files, bytes }) => + entries.push({ + kind: category.kind, + outcome: files > 0 ? "removed" : "kept", + reason: files === 0 ? "No expired files" : `Removed ${files} ${category.label}`, + path: null, + threadId: null, + threadTitle: null, + bytes: files > 0 ? bytes : null, + files: files > 0 ? files : null, + }), + ), + Effect.catch((error) => + Effect.gen(function* () { + entries.push({ + kind: category.kind, + outcome: "failed", + reason: cleanupFailureReason(error), + path: null, + threadId: null, + threadTitle: null, + bytes: removed.files > 0 ? removed.bytes : null, + files: removed.files > 0 ? removed.files : null, + }); + yield* Effect.logWarning("storage file cleanup failed", { kind: category.kind, error }); + }), + ), + ); + } + const counts = { removed: 0, kept: 0, failed: 0 }; + let bytesFreed = 0; + for (const entry of entries) { + counts[entry.outcome]++; + bytesFreed += entry.bytes ?? 0; + } + const priority = { failed: 0, removed: 1, kept: 2 }; + const latestReport: StorageCleanupReport = { + trigger, + startedAt: DateTime.formatIso(DateTime.makeUnsafe(now)), + finishedAt: DateTime.formatIso(yield* DateTime.now), + entries: entries + .sort((a, b) => priority[a.outcome] - priority[b.outcome]) + .slice(0, REPORT_ENTRY_LIMIT), + counts, + bytesFreed, + omittedCount: Math.max(0, entries.length - REPORT_ENTRY_LIMIT), + }; + yield* SubscriptionRef.set(reportRef, latestReport); + return latestReport; }); - const worker = yield* makeDrainableWorker(() => - sweep().pipe( - Effect.catchCauseIf( - (cause) => !Cause.hasInterruptsOnly(cause), - (cause) => Effect.logWarning("storage cleanup failed", { cause }), + const worker = yield* makeDrainableWorker( + (completion: Deferred.Deferred | undefined) => + sweep(completion === undefined ? "automatic" : "manual").pipe( + Effect.exit, + Effect.flatMap((exit) => + completion === undefined + ? exit.pipe( + Effect.asVoid, + Effect.catchCause((cause) => + Effect.logWarning("storage cleanup failed", { cause }), + ), + ) + : Deferred.done(completion, exit).pipe(Effect.asVoid), + ), ), - ), ); + const runNow = Effect.gen(function* () { + const completion = yield* Deferred.make(); + yield* worker.enqueue(completion); + return yield* Deferred.await(completion); + }); const start = Effect.fn("StorageCleanup.start")(function* () { const unsubscribe = yield* terminals.subscribeMetadata((event) => @@ -552,5 +785,13 @@ export const make = Effect.gen(function* () { ), ); }); - return { start, drain: worker.drain }; + yield* start(); + return StorageCleanup.of({ + runNow, + latestReport: SubscriptionRef.get(reportRef), + reports: SubscriptionRef.changes(reportRef), + drain: worker.drain, + }); }); + +export const layer = Layer.effect(StorageCleanup, make); diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index cb9270a7ed3b..e28e926d3f89 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -132,7 +132,9 @@ const layerService = (input: { Layer.provideMerge(ProviderHostLive.layer), Layer.provideMerge(Layer.mock(BackgroundPolicy.BackgroundPolicy)({})), Layer.provideMerge(Layer.mock(ServerSecretStore.ServerSecretStore)({})), - Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: input.prefix })), + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), NodePath.join(input.home, input.prefix)), + ), Layer.provideMerge(NodeServices.layer), Layer.provideMerge(Layer.succeed(HostProcess.Platform, input.platform ?? "linux")), Layer.provideMerge(ServerSettings.layerTest(input.settings)), @@ -996,6 +998,34 @@ describe("UsageService", () => { }).pipe(Effect.scoped), ); + it.live.skipIf(HostProcess.Platform.defaultValue() === "win32" || process.getuid?.() === 0)( + "reports unreadable transcripts as partial and recovers once they can be read", + () => + Effect.gen(function* () { + const { transcript, settings, home } = yield* setup; + const unreadable = NodePath.join(NodePath.dirname(transcript), "other.jsonl"); + yield* Effect.promise(async () => { + await NodeFSP.writeFile(transcript, claudeLine(1, 5)); + await NodeFSP.writeFile(unreadable, claudeLine(2, 7)); + await NodeFSP.chmod(unreadable, 0); + }); + yield* Effect.gen(function* () { + const service = yield* UsageService.make; + const partial = yield* service.readSummary(WINDOW); + assert.strictEqual(totalOutputTokens(partial), 5); + assert.strictEqual(partial.sources[0]?.status, "partial"); + + yield* Effect.promise(() => NodeFSP.chmod(unreadable, 0o600)); + const healthy = yield* service.readSummary(WINDOW); + assert.strictEqual(totalOutputTokens(healthy), 12); + assert.strictEqual(healthy.sources[0]?.status, "ok"); + assert.isNull(healthy.sources[0]?.message); + }).pipe( + Effect.provide(layerService({ prefix: "usage-service-unreadable", home, settings })), + ); + }).pipe(Effect.scoped), + ); + it.live("reprices unchanged transcripts when custom prices are added, edited, or removed", () => Effect.gen(function* () { const { transcript, settings, home } = yield* setup; @@ -1175,8 +1205,7 @@ describe("UsageService", () => { return text; }); - const restarted = yield* UsageService.make; - const summary = yield* restarted.readSummary(WINDOW); + const summary = yield* (yield* UsageService.make).readSummary(WINDOW); // The live rollout re-parses at the ultrafast rate (10 x 6); the // deleted one keeps its saved v4 usage at the standard rate (20 x 1). assert.strictEqual(totalOutputTokens(summary), 30); @@ -1189,9 +1218,6 @@ describe("UsageService", () => { yield* Effect.promise(() => NodeFSP.readFile(legacyPath, "utf8")), legacy, ); - // The migrated cache is written in the background; let it land before - // the layer removes the state directory under it. - yield* restarted.awaitPersisted; }).pipe( Effect.provide( layerService({ diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index 3b496d8f4498..2259c1d9f773 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -519,6 +519,7 @@ export const make = Effect.gen(function* () { format: TranscriptUsageFormat, ): Effect.Effect<{ readonly records: readonly UsageRecord[]; + readonly failed?: true; readonly update?: { readonly entry: CachedFile; readonly replaces: CachedFile | undefined }; }> => Effect.gen(function* () { @@ -554,6 +555,7 @@ export const make = Effect.gen(function* () { if (parsed === null) return { records: cached?.provider === provider ? [...cached.records, ...cached.tailRecords] : [], + failed: true, }; // Stored already de-duplicated within the file, which is 99% of all @@ -600,7 +602,7 @@ export const make = Effect.gen(function* () { .exists(dir) .pipe(Effect.catchCause(() => Effect.succeed(false))); if (!exists) return { provider, dir, volumeId, files: null } satisfies ScannedDir; - const files = yield* Effect.promise(() => + const { files, failedPaths } = yield* Effect.promise(() => listTranscriptFiles(dir, windowStartMs, fileName === undefined ? undefined : { fileName }), ); // A cold parse waits on disk reads, so a few files in flight read @@ -629,7 +631,20 @@ export const make = Effect.gen(function* () { } return { path, records }; }); - return { provider, dir, volumeId, files: parsedFiles } satisfies ScannedDir; + // Unread files keep their cached usage, but the total may be short. + const unread = failedPaths + read.filter((file) => file.failed).length; + return { + provider, + dir, + volumeId, + files: parsedFiles, + ...(unread > 0 + ? { + status: "partial", + message: `${unread} transcript path(s) could not be read; usage may be incomplete.`, + } + : {}), + } satisfies ScannedDir; }); const collectDirs = Effect.fn("UsageService.collectDirs")(function* ( diff --git a/apps/server/src/usage/usageTranscriptReader.ts b/apps/server/src/usage/usageTranscriptReader.ts index 16684e3a85ab..dbf0fd7aeb35 100644 --- a/apps/server/src/usage/usageTranscriptReader.ts +++ b/apps/server/src/usage/usageTranscriptReader.ts @@ -109,9 +109,9 @@ function fnv1a(buffer: Buffer): number { /** * Lists `.jsonl` transcripts under `root` last modified at or after `sinceMs`. * - * Errors on individual entries are swallowed: session files rotate and get - * removed while the walk is in flight, and a partial listing is far better than - * failing the page. + * Unreadable directories and files are counted in `failedPaths` rather than + * failing the page, so the source can report incomplete usage. Files that + * vanish between `readdir` and `stat` are ordinary rotation and not counted. * * `fileName` restricts the walk to a single basename (Grok's `updates.jsonl`). * Grok sessions also ship multi-megabyte `chat_history` and `events` logs that @@ -126,14 +126,16 @@ export async function listTranscriptFiles( root: string, sinceMs: number, options?: { readonly fileName?: string }, -): Promise { +): Promise<{ readonly files: readonly TranscriptFile[]; readonly failedPaths: number }> { const fileName = options?.fileName; const candidates: string[] = []; + let failedPaths = 0; const walk = async (dir: string): Promise => { let entries; try { entries = await NodeFSP.readdir(dir, { withFileTypes: true }); } catch { + failedPaths += 1; return; } for (const entry of entries) { @@ -156,15 +158,17 @@ export async function listTranscriptFiles( if (stats.mtimeMs >= sinceMs) { found[index] = { path, size: stats.size, mtimeMs: stats.mtimeMs }; } - } catch { - // Vanished between readdir and stat. + } catch (error) { + if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) { + failedPaths += 1; + } } } }; await Promise.all( Array.from({ length: Math.min(STAT_CONCURRENCY, candidates.length) }, statQueued), ); - return found.filter((file) => file !== undefined); + return { files: found.filter((file) => file !== undefined), failedPaths }; } /** diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index 6513d037c8dc..599269791edf 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -1049,10 +1049,18 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( } const message = `t3 checkpoint ref=${input.checkpointRef}`; + // HEAD is the parent so listAuthoredPaths can see how HEAD moved between checkpoints. const commitTreeResult = yield* execute({ operation, cwd: input.cwd, - args: [...durableWrite, "commit-tree", treeOid, "-m", message], + args: [ + ...durableWrite, + "commit-tree", + treeOid, + ...(headExists ? ["-p", "HEAD"] : []), + "-m", + message, + ], env: commitEnv, }); const commitOid = commitTreeResult.stdout.trim(); @@ -1157,6 +1165,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( diffCheckpoints: Effect.fn("GitVcsDriver.checkpoints.diffCheckpoints")(function* (input) { const operation = "GitVcsDriver.checkpoints.diffCheckpoints"; + if (input.filePaths?.length === 0) return ""; yield* Effect.annotateCurrentSpan({ "checkpoint.cwd": input.cwd, "checkpoint.from_ref": input.fromCheckpointRef, @@ -1202,6 +1211,10 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( ...(input.ignoreWhitespace ? ["--ignore-all-space"] : []), `${fromRevision}^{commit}`, `${input.toCheckpointRef}^{commit}`, + // Paths are repository-relative, while cwd can be a subdirectory. + ...(input.filePaths + ? ["--", ...input.filePaths.map((file) => `:(top,literal)${file}`)] + : []), ], allowNonZeroExit: true, maxOutputBytes: CHECKPOINT_DIFF_MAX_OUTPUT_BYTES, @@ -1221,6 +1234,55 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( return result.stdout; }), + listAuthoredPaths: Effect.fn("GitVcsDriver.checkpoints.listAuthoredPaths")(function* (input) { + const operation = "GitVcsDriver.checkpoints.listAuthoredPaths"; + const heads = yield* execute({ + operation, + cwd: input.cwd, + args: [ + "log", + "--no-walk=unsorted", + "--format=%P %ct", + `${input.fromCheckpointRef}^{commit}`, + `${input.toCheckpointRef}^{commit}`, + ], + }); + // Each line is " ". The parent is the HEAD at capture. + const [fromLine = "", toLine = ""] = heads.stdout.trimEnd().split("\n"); + const [startHead = "", capturedAt = ""] = fromLine.split(" "); + const [endHead = ""] = toLine.split(" "); + if (startHead === "" || endHead === "" || startHead === endHead) { + return null; + } + + const listPaths = (args: ReadonlyArray) => + execute({ + operation, + cwd: input.cwd, + args: [...args, "--name-only", "-z", "--no-renames", "--no-ext-diff"], + maxOutputBytes: CHECKPOINT_DIFF_MAX_OUTPUT_BYTES, + outputMode: "error", + }).pipe(Effect.map((result) => result.stdout.split("\0"))); + // Remerge diff lists a merge's paths only where the result differs from Git's + // automatic merge, so a merge keeps its conflict fixes and drops clean upstream changes. + const pathLists = yield* Effect.all( + [ + listPaths(["diff", startHead, `${input.fromCheckpointRef}^{commit}`]), + listPaths(["diff", endHead, `${input.toCheckpointRef}^{commit}`]), + listPaths(["log", "--format=", "--diff-merges=remerge", `${endHead}..${startHead}`]), + listPaths([ + "log", + "--format=", + "--diff-merges=remerge", + `--since=@${capturedAt}`, + `${startHead}..${endHead}`, + ]), + ], + { concurrency: "unbounded" }, + ); + return new Set(pathLists.flat().filter((path) => path.length > 0)); + }), + deleteCheckpointRefs: Effect.fn("GitVcsDriver.checkpoints.deleteCheckpointRefs")( function* (input) { yield* Effect.forEach( diff --git a/apps/server/src/vcs/VcsDriver.ts b/apps/server/src/vcs/VcsDriver.ts index a8680c9a54be..c124aa90b963 100644 --- a/apps/server/src/vcs/VcsDriver.ts +++ b/apps/server/src/vcs/VcsDriver.ts @@ -32,6 +32,14 @@ export interface VcsDiffCheckpointsInput { readonly fallbackFromToHead?: boolean; readonly ignoreWhitespace: boolean; readonly format?: "patch" | "numstat"; + /** Limits the diff to these exact paths. An empty list yields an empty diff. */ + readonly filePaths?: ReadonlyArray; +} + +export interface VcsListAuthoredPathsInput { + readonly cwd: string; + readonly fromCheckpointRef: CheckpointRef; + readonly toCheckpointRef: CheckpointRef; } export interface VcsDeleteCheckpointRefsInput { @@ -48,6 +56,9 @@ export interface VcsCheckpointOps { input: VcsRestoreCheckpointInput, ) => Effect.Effect; readonly diffCheckpoints: (input: VcsDiffCheckpointsInput) => Effect.Effect; + readonly listAuthoredPaths: ( + input: VcsListAuthoredPathsInput, + ) => Effect.Effect | null, VcsError>; readonly deleteCheckpointRefs: ( input: VcsDeleteCheckpointRefsInput, ) => Effect.Effect; diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 8e7a1210a507..fce520ca7e3b 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -176,6 +176,7 @@ import * as ServerSelfUpdate from "./cloud/selfUpdate.ts"; import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; import * as ServerSettings from "./serverSettings.ts"; +import * as StorageCleanup from "./storageCleanup.ts"; import * as TerminalManager from "./terminal/Manager.ts"; import { withTerminalOutputWindow } from "./terminal/OutputProtocol.ts"; import * as PreviewAutomationBroker from "./mcp/PreviewAutomationBroker.ts"; @@ -1278,6 +1279,7 @@ const layerWsRpc = ( const serverSelfUpdate = yield* ServerSelfUpdate.ServerSelfUpdate; const config = yield* ServerConfig.ServerConfig; const lifecycleEvents = yield* ServerLifecycleEvents.ServerLifecycleEvents; + const storageCleanup = yield* StorageCleanup.StorageCleanup; const serverSettings = yield* ServerSettings.ServerSettingsService; const startup = yield* ServerRuntimeStartup.ServerRuntimeStartup; const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; @@ -2382,6 +2384,8 @@ const layerWsRpc = ( const keybindingsConfig = yield* keybindings.removeKeybindingRule(rule); return { keybindings: keybindingsConfig, issues: [] }; }), + [WS_METHODS.serverRunStorageCleanup]: () => storageCleanup.runNow, + [WS_METHODS.serverGetStorageCleanupReport]: () => storageCleanup.reports, [WS_METHODS.serverGetSettings]: (_input) => serverSettings.getSettings.pipe(Effect.map(ServerSettings.redactServerSettingsForClient)), [WS_METHODS.serverUpdateSettings]: ({ patch, providerInstanceMutation }) => diff --git a/apps/web/src/browser/BrowserSurfaceSlot.tsx b/apps/web/src/browser/BrowserSurfaceSlot.tsx index c30a62274614..90246520588c 100644 --- a/apps/web/src/browser/BrowserSurfaceSlot.tsx +++ b/apps/web/src/browser/BrowserSurfaceSlot.tsx @@ -3,6 +3,7 @@ import { useLayoutEffect, useRef } from "react"; import { acquireBrowserSurface } from "./browserSurfaceStore"; +import { observeResize } from "../lib/observeResize"; export function BrowserSurfaceSlot(props: { readonly tabId: string; @@ -62,22 +63,20 @@ export function BrowserSurfaceSlot(props: { }; updateRef.current = update; update(); - const observer = new ResizeObserver(update); - observer.observe(element); + const observed: Element[] = [element]; // Inline panels animate their outer width while keeping the content at // full width. The slot moves without resizing, so measure on shell resizes too. const panel = element.closest('[data-preview-panel-mode="inline"]'); - if (panel) observer.observe(panel); + if (panel) observed.push(panel); // A sidebar opening or closing shifts the whole column, so a slot that keeps // its size and its offset inside the column (the mini player on the left) // still moves on screen. The gap element carries the sidebar's width. - for (const gap of document.querySelectorAll('[data-slot="sidebar-gap"]')) { - observer.observe(gap); - } + observed.push(...document.querySelectorAll('[data-slot="sidebar-gap"]')); + const stopObserving = observeResize(observed, update); window.addEventListener("resize", update); window.addEventListener("scroll", update, true); return () => { - observer.disconnect(); + stopObserving(); window.removeEventListener("resize", update); window.removeEventListener("scroll", update, true); if (updateRef.current === update) updateRef.current = null; diff --git a/apps/web/src/browser/ServerBrowserSurface.tsx b/apps/web/src/browser/ServerBrowserSurface.tsx index feca1cbaf794..b8f403598d0c 100644 --- a/apps/web/src/browser/ServerBrowserSurface.tsx +++ b/apps/web/src/browser/ServerBrowserSurface.tsx @@ -41,6 +41,7 @@ import { Input } from "~/components/ui/input"; import { toastManager } from "~/components/ui/toast"; import { cn } from "~/lib/utils"; import { refreshPreviewStreamAccess, usePreviewStreamAccess } from "~/state/previewStream"; +import { observeResize } from "~/lib/observeResize"; /** Chrome-row controls for a server tab; commands require current ownership. */ export interface ServerBrowserHandle { @@ -51,7 +52,7 @@ export interface ServerBrowserHandle { readonly canvas: () => HTMLCanvasElement | null; } -const RESIZE_DEBOUNCE_MS = 150; +const RESIZE_THROTTLE_MS = 150; const ACCESS_RETRY_MS = 10_000; // A recent probe answer near a new tap stands in for that tap's own answer, // which on a slow link arrives after the tap ends. @@ -333,15 +334,11 @@ export function ServerBrowserSurface(props: { const element = canvasRef.current?.parentElement; if (!element) return; let timer: ReturnType | null = null; - const measure = () => { - timer = null; - const rect = element.getBoundingClientRect(); - if (rect.width < 1 || rect.height < 1) return; - const size = { width: Math.round(rect.width), height: Math.round(rect.height) }; - const previous = sizeRef.current; - if (previous?.width === size.width && previous.height === size.height) return; - sizeRef.current = size; - if (followSize) clientRef.current?.send({ type: "resize", ...size }); + let capTimer: ReturnType | null = null; + const growCap = () => { + capTimer = null; + const size = sizeRef.current; + if (!size) return; const ratio = window.devicePixelRatio || 1; const width = Math.round(size.width * ratio); const height = Math.round(size.height * ratio); @@ -354,15 +351,31 @@ export function ServerBrowserSurface(props: { }, ); }; - const observer = new ResizeObserver(() => { - if (timer !== null) clearTimeout(timer); - // The first size connects right away; later ones settle before resizing the page. - timer = setTimeout(measure, sizeRef.current === null ? 0 : RESIZE_DEBOUNCE_MS); + const measure = () => { + timer = null; + const rect = element.getBoundingClientRect(); + if (rect.width < 1 || rect.height < 1) return; + const size = { width: Math.round(rect.width), height: Math.round(rect.height) }; + const previous = sizeRef.current; + if (previous?.width === size.width && previous.height === size.height) return; + sizeRef.current = size; + if (followSize) clientRef.current?.send({ type: "resize", ...size }); + // Outgrowing the cap reconnects the stream, so grow it once the size settles + // rather than on every throttled resize of a drag. + if (capTimer !== null) clearTimeout(capTimer); + capTimer = setTimeout(growCap, previous === null ? 0 : RESIZE_THROTTLE_MS * 2); + }; + const stopObserving = observeResize(element, () => { + // A pending measure reads the latest size when it fires, so a drag resizes + // the page at most every RESIZE_THROTTLE_MS and still ends on the final size. + if (timer !== null) return; + // The first size connects right away. + timer = setTimeout(measure, sizeRef.current === null ? 0 : RESIZE_THROTTLE_MS); }); - observer.observe(element); return () => { - observer.disconnect(); + stopObserving(); if (timer !== null) clearTimeout(timer); + if (capTimer !== null) clearTimeout(capTimer); }; }, [followSize]); diff --git a/apps/web/src/components/AnimatedHeight.tsx b/apps/web/src/components/AnimatedHeight.tsx index 719c01494841..b6c5926ad7d1 100644 --- a/apps/web/src/components/AnimatedHeight.tsx +++ b/apps/web/src/components/AnimatedHeight.tsx @@ -1,6 +1,7 @@ "use client"; import { type ReactNode, useEffect, useLayoutEffect, useRef, useState } from "react"; +import { observeResize } from "../lib/observeResize"; const HEIGHT_TRANSITION_FALLBACK_MS = 250; @@ -69,10 +70,9 @@ export function AnimatedHeight({ }; updateHeightAfterPaint(); - const resizeObserver = new ResizeObserver(updateHeightAfterPaint); - resizeObserver.observe(element); + const stopObserving = observeResize(element, updateHeightAfterPaint); return () => { - resizeObserver.disconnect(); + stopObserving(); cancelPendingFrames(); }; }, [holdHeight]); diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 4e44c114d5b8..709510c153b5 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -69,6 +69,7 @@ import { useComposerMenuProps } from "./chat/composerEventScope"; import { measureRestingComposerControls } from "./chat/restingComposerControlsMeasurement"; import { resolveRestingComposerControlsNaturalWidth } from "./composerFooterLayout"; import { cn } from "~/lib/utils"; +import { observeResize } from "~/lib/observeResize"; export interface BranchToolbarHandle { openBranchPicker: () => void; @@ -580,11 +581,10 @@ function useLabelsOverflow(element: HTMLDivElement | null): boolean { useEffect(() => { if (!element) return; - const observer = new ResizeObserver(measure); - observer.observe(element); + const stopObserving = observeResize(element, measure); document.fonts.addEventListener("loadingdone", measure); return () => { - observer.disconnect(); + stopObserving(); document.fonts.removeEventListener("loadingdone", measure); }; }, [element, measure]); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index c8c202f5cadc..06a6bacaf48c 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -241,7 +241,6 @@ import { isCommandPaletteOpen } from "../commandPaletteBus"; import { subscribeSnapShotComposerFocus } from "../lib/desktopSnapShot"; import { useMediaQuery } from "../hooks/useMediaQuery"; import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; -import { useElementWidth } from "../hooks/useElementWidth"; import { usePreviewPanelInlineSize } from "../hooks/usePreviewPanelInlineSize"; import { RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY, @@ -301,6 +300,7 @@ import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; import { AlarmClockIcon, CheckCircle2Icon, + MessageCircleIcon, PaperclipIcon, ChevronDownIcon, DownloadIcon, @@ -428,6 +428,7 @@ import { useThreadStatus, useThreadHistory, useThreadShell, + useChildThreadInputs, useThreadRefs, useThreadVisibleTurnItems, waitForThreadShell, @@ -606,6 +607,7 @@ import { ATTACHMENT_ONLY_BOOTSTRAP_PROMPT, recallableComposerPrompt, } from "./chat/composerPromptHistory"; +import { observeResize } from "~/lib/observeResize"; const EMPTY_PROVIDERS: ServerProvider[] = []; const EMPTY_PROVIDER_MODELS: ServerProvider["models"] = []; @@ -1938,7 +1940,7 @@ export default function ChatView(props: ChatViewProps) { useState>({}); const shouldUsePlanSidebarSheet = useMediaQuery(RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY); const isMobileViewport = useMediaQuery("max-sm"); - const [workspaceLayoutRef, workspaceLayoutWidth] = useElementWidth(); + const [workspaceLayoutElement, setWorkspaceLayoutElement] = useState(null); const threadPanelPopoverAnchorRef = useRef(null); // Tracks whether the user explicitly dismissed the sidebar for the active turn. // When set, the thread-change reset effect will open the sidebar instead of closing it. @@ -2255,7 +2257,7 @@ export default function ChatView(props: ChatViewProps) { // Electron hosts its own browser tabs; other clients need the environment to host them. const browserAvailable = isPreviewSupportedInRuntime() || activeEnvironmentServerBrowser; const previewPanelInlineSize = usePreviewPanelInlineSize(undefined, { - containerWidth: workspaceLayoutWidth ?? undefined, + container: workspaceLayoutElement, widthStorageKey: `t3code:preview-panel-width:${activeThreadKey}`, }); const activeThreadShell = useThreadShell(isServerThread ? activeThreadRef : null); @@ -2542,7 +2544,6 @@ export default function ChatView(props: ChatViewProps) { return { id: `project-clone:${projectId}`, variant: "info", - compact: true, priority: "activity", icon: , title: `Cloning ${name}`, @@ -2566,7 +2567,6 @@ export default function ChatView(props: ChatViewProps) { return { id: `project-clone:${projectId}`, variant: cancelled ? "warning" : "error", - compact: true, icon: , title: cancelled ? `Cancelled cloning ${name}` : `Failed to clone ${name}`, description: cancelled ? "Retry to bring in the repository." : activeProjectClone.error, @@ -6056,6 +6056,13 @@ export default function ChatView(props: ChatViewProps) { if (!activeThreadRef) return; useRightPanelStore.getState().toggleThreadPanel(activeThreadRef, threadPanelPresentation); }, [activeThreadRef, threadPanelPresentation]); + // A thread started for a link the OS opened shows its browser maximized, like a browser window. + useEffect(() => { + if (!canMaximizeRightPanel) return; + if (useRightPanelStore.getState().consumeMaximizeRequest(routeThreadRef)) { + setMaximizedRightPanelThreadKey(routeThreadKey); + } + }, [canMaximizeRightPanel, routeThreadKey, routeThreadRef]); const toggleRightPanelMaximized = useCallback(() => { if (!canMaximizeRightPanel) return; setMaximizedRightPanelThreadKey((threadKey) => @@ -7048,13 +7055,7 @@ export default function ChatView(props: ChatViewProps) { }; updateHeight(); - if (typeof ResizeObserver === "undefined") return; - - const resizeObserver = new ResizeObserver(updateHeight); - resizeObserver.observe(composerOverlayElement); - return () => { - resizeObserver.disconnect(); - }; + return observeResize(composerOverlayElement, updateHeight); }, [composerOverlayElement, publishComposerOverlayHeight, showScrollToBottom]); // Swapping the composer for the status bar (or back) changes what the // overlay holds, so rebuild the reservation from the new content. @@ -7415,6 +7416,42 @@ export default function ChatView(props: ChatViewProps) { }, [environmentId, navigate], ); + const childThreadInputs = useChildThreadInputs(activeThreadRef); + const childInputBannerItem = useMemo(() => { + const first = childThreadInputs[0]; + const parentAwaitingUser = + activePendingApproval || activePendingUserInput || activeThreadShell?.hasPendingUserInput; + if (!first || parentAwaitingUser) return null; + return { + id: `child-input:${first.id}`, + variant: "info", + priority: "activity", + icon: , + title: + childThreadInputs.length === 1 + ? "Subagent needs input" + : `${childThreadInputs.length} subagents need input`, + description: childThreadInputs.map((child, index) => ( + + {index > 0 ? ", " : null} + onOpenRelatedThread(child.id)}> + {child.title} + + + )), + actions: ( + + ), + }; + }, [ + childThreadInputs, + activePendingApproval, + activePendingUserInput, + activeThreadShell?.hasPendingUserInput, + onOpenRelatedThread, + ]); // Commands such as /compact and /goal clear run as their own turn. The draft // and its attachments stay local. @@ -7775,9 +7812,11 @@ export default function ChatView(props: ChatViewProps) { : null; const composerBannerItems = useMemo(() => { const limitRecoveryItems = limitRecoveryBanner === null ? [] : [limitRecoveryBanner]; - const backgroundWorkItems = [goalBannerItem, backgroundWorkBannerItem].filter( - (item) => item !== null, - ); + const backgroundWorkItems = [ + childInputBannerItem, + goalBannerItem, + backgroundWorkBannerItem, + ].filter((item) => item !== null); // The user asked for this one, so it leads the notice tier instead of trailing it. const usageLimitsItems = usageLimitsBanner === null ? [] : [usageLimitsBanner]; const projectCloneItems = projectCloneBannerItem === null ? [] : [projectCloneBannerItem]; @@ -7801,6 +7840,7 @@ export default function ChatView(props: ChatViewProps) { { id: `branch-mismatch:${activeBranchMismatchKey}`, variant: "info", + compact: true, icon: , title: ( @@ -7847,6 +7887,7 @@ export default function ChatView(props: ChatViewProps) { handleRestoreThreadBranch, isRestoringThreadBranch, backgroundWorkBannerItem, + childInputBannerItem, goalBannerItem, localCheckoutBranchMismatch, projectCloneBannerItem, @@ -11242,7 +11283,7 @@ export default function ChatView(props: ChatViewProps) { return (
{ - resizeObserver.disconnect(); + stopObserving(); viewport.removeEventListener("scroll", updateTabScrollState); }; }, [updateTabScrollState]); diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 1a82bae02900..f6509289d45a 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -104,9 +104,8 @@ function maxDrawerHeight(): number { return Math.max(MIN_DRAWER_HEIGHT, Math.floor(window.innerHeight * MAX_DRAWER_HEIGHT_RATIO)); } -function clampDrawerHeight(height: number): number { +function clampDrawerHeight(height: number, maxHeight = maxDrawerHeight()): number { const safeHeight = Number.isFinite(height) ? height : DEFAULT_THREAD_TERMINAL_HEIGHT; - const maxHeight = maxDrawerHeight(); return Math.min(Math.max(Math.round(safeHeight), MIN_DRAWER_HEIGHT), maxHeight); } @@ -1181,13 +1180,16 @@ export default function ThreadTerminalDrawer({ false, Schema.Boolean, ); - const controlledDrawerHeight = clampDrawerHeight(height); + // The stored height is the user's preference; the window only clamps what renders. + const controlledDrawerHeight = height; + const [windowMaxDrawerHeight, setWindowMaxDrawerHeight] = useState(maxDrawerHeight); const [drawerHeightState, setDrawerHeightState] = useState(() => ({ threadId, height: controlledDrawerHeight, })); - const drawerHeight = + const preferredDrawerHeight = drawerHeightState.threadId === threadId ? drawerHeightState.height : controlledDrawerHeight; + const drawerHeight = clampDrawerHeight(preferredDrawerHeight, windowMaxDrawerHeight); const setDrawerHeight = useCallback( (update: SetStateAction) => { setDrawerHeightState((current) => { @@ -1201,17 +1203,16 @@ export default function ThreadTerminalDrawer({ }, [controlledDrawerHeight, threadId], ); - const setDrawerHeightFromWindowResize = useEffectEvent((nextHeight: number) => { - setDrawerHeight(nextHeight); - }); const [resizeEpoch, setResizeEpoch] = useState(0); const drawerHeightRef = useRef(drawerHeight); + const preferredDrawerHeightRef = useRef(preferredDrawerHeight); const lastSyncedHeightRef = useRef(controlledDrawerHeight); const onHeightChangeRef = useRef(onHeightChange); const resizeStateRef = useRef<{ pointerId: number; startY: number; startHeight: number; + drawer: HTMLElement | null; } | null>(null); const didResizeDuringDragRef = useRef(false); @@ -1394,15 +1395,24 @@ export default function ThreadTerminalDrawer({ onHeightChangeRef.current = onHeightChange; }, [onHeightChange]); - useEffect(() => { + useLayoutEffect(() => { + // A render mid-drag (the window cap moved) must not replace the live height. + const resizeState = resizeStateRef.current; + if (resizeState) { + resizeState.drawer?.style.setProperty("height", `${drawerHeightRef.current}px`); + return; + } drawerHeightRef.current = drawerHeight; }, [drawerHeight]); + useEffect(() => { + preferredDrawerHeightRef.current = preferredDrawerHeight; + }, [preferredDrawerHeight]); + const syncHeight = useCallback((nextHeight: number) => { - const clampedHeight = clampDrawerHeight(nextHeight); - if (lastSyncedHeightRef.current === clampedHeight) return; - lastSyncedHeightRef.current = clampedHeight; - onHeightChangeRef.current(clampedHeight); + if (lastSyncedHeightRef.current === nextHeight) return; + lastSyncedHeightRef.current = nextHeight; + onHeightChangeRef.current(nextHeight); }, []); useEffect(() => { @@ -1418,26 +1428,26 @@ export default function ThreadTerminalDrawer({ pointerId: event.pointerId, startY: event.clientY, startHeight: drawerHeightRef.current, + drawer: event.currentTarget.parentElement, }; }, []); - const handleResizePointerMove = useCallback( - (event: ReactPointerEvent) => { - const resizeState = resizeStateRef.current; - if (!resizeState || resizeState.pointerId !== event.pointerId) return; - event.preventDefault(); - const clampedHeight = clampDrawerHeight( - resizeState.startHeight + (resizeState.startY - event.clientY), - ); - if (clampedHeight === drawerHeightRef.current) { - return; - } - didResizeDuringDragRef.current = true; - drawerHeightRef.current = clampedHeight; - setDrawerHeight(clampedHeight); - }, - [setDrawerHeight], - ); + // Writes the height straight to the drawer while dragging so the edge lands in + // the pointer's frame; React state commits once on release. + const handleResizePointerMove = useCallback((event: ReactPointerEvent) => { + const resizeState = resizeStateRef.current; + if (!resizeState || resizeState.pointerId !== event.pointerId) return; + event.preventDefault(); + const clampedHeight = clampDrawerHeight( + resizeState.startHeight + (resizeState.startY - event.clientY), + ); + if (clampedHeight === drawerHeightRef.current) { + return; + } + didResizeDuringDragRef.current = true; + drawerHeightRef.current = clampedHeight; + resizeState.drawer?.style.setProperty("height", `${clampedHeight}px`); + }, []); const handleResizePointerEnd = useCallback( (event: ReactPointerEvent) => { @@ -1447,13 +1457,21 @@ export default function ThreadTerminalDrawer({ if (event.currentTarget.hasPointerCapture(event.pointerId)) { event.currentTarget.releasePointerCapture(event.pointerId); } - if (!didResizeDuringDragRef.current) { + // A drag that ends where it started keeps the stored preference, which may + // be taller than the window-capped height the drag started from. + if (!didResizeDuringDragRef.current || drawerHeightRef.current === resizeState.startHeight) { + // React skips the write when the rendered height did not change, so put + // back the preference as the current window caps it. + const renderedHeight = clampDrawerHeight(preferredDrawerHeightRef.current); + drawerHeightRef.current = renderedHeight; + resizeState.drawer?.style.setProperty("height", `${renderedHeight}px`); return; } + setDrawerHeight(drawerHeightRef.current); syncHeight(drawerHeightRef.current); setResizeEpoch((value) => value + 1); }, - [syncHeight], + [setDrawerHeight, syncHeight], ); useEffect(() => { @@ -1462,33 +1480,33 @@ export default function ThreadTerminalDrawer({ } const onWindowResize = () => { - const clampedHeight = clampDrawerHeight(drawerHeightRef.current); - const changed = clampedHeight !== drawerHeightRef.current; - if (changed) { - setDrawerHeightFromWindowResize(clampedHeight); - drawerHeightRef.current = clampedHeight; - } - if (!resizeStateRef.current) { - syncHeight(clampedHeight); + // React only renders the stored preference, so clamp a live drag height here. + const resizeState = resizeStateRef.current; + const liveHeight = clampDrawerHeight(drawerHeightRef.current); + if (resizeState && liveHeight !== drawerHeightRef.current) { + drawerHeightRef.current = liveHeight; + resizeState.drawer?.style.setProperty("height", `${liveHeight}px`); } + setWindowMaxDrawerHeight(maxDrawerHeight()); setResizeEpoch((value) => value + 1); }; window.addEventListener("resize", onWindowResize); return () => { window.removeEventListener("resize", onWindowResize); }; - }, [syncHeight, visible]); + }, [visible]); useEffect(() => { if (!visible) { return; } + setWindowMaxDrawerHeight(maxDrawerHeight()); setResizeEpoch((value) => value + 1); }, [visible]); useEffect(() => { return () => { - syncHeight(drawerHeightRef.current); + syncHeight(preferredDrawerHeightRef.current); }; }, [syncHeight]); @@ -1510,6 +1528,7 @@ export default function ThreadTerminalDrawer({ onPointerMove={handleResizePointerMove} onPointerUp={handleResizePointerEnd} onPointerCancel={handleResizePointerEnd} + onLostPointerCapture={handleResizePointerEnd} /> ) : null}
@@ -1546,6 +1565,7 @@ export default function ThreadTerminalDrawer({ onPointerMove={handleResizePointerMove} onPointerUp={handleResizePointerEnd} onPointerCancel={handleResizePointerEnd} + onLostPointerCapture={handleResizePointerEnd} /> ) : null} diff --git a/apps/web/src/components/chat/AssistantCitationChip.tsx b/apps/web/src/components/chat/AssistantCitationChip.tsx index 0b4d6a962e61..9067bbc8cedb 100644 --- a/apps/web/src/components/chat/AssistantCitationChip.tsx +++ b/apps/web/src/components/chat/AssistantCitationChip.tsx @@ -6,6 +6,7 @@ import { import { Link, useNavigate } from "@tanstack/react-router"; import { ArrowUpRightIcon, PencilIcon, QuoteIcon } from "lucide-react"; import { + useCallback, useEffect, useEffectEvent, useRef, @@ -30,6 +31,7 @@ import { AssistantCitationCommentEditor } from "./AssistantCitationCommentEditor import { resolveAssistantCitationCommentDismissal } from "./assistantCitationCommentDismissal"; import { observeAssistantCitationCommentSource } from "./AssistantCitationSource"; import { composerFloatingLayerProps } from "./composerEventScope"; +import { observeResize } from "~/lib/observeResize"; export function AssistantCitationChip({ citation, @@ -242,21 +244,22 @@ export function AssistantCitationChip({ function AssistantCitationQuote({ citation }: { citation: AssistantCitation }) { const [fade, setFade] = useState({ top: false, bottom: false }); - const updateFade = (element: HTMLElement) => { + const updateFade = useCallback((element: HTMLElement) => { const top = element.scrollTop > 1; const bottom = element.scrollHeight - element.clientHeight - element.scrollTop > 1; setFade((current) => current.top === top && current.bottom === bottom ? current : { top, bottom }, ); - }; + }, []); + // Stable so a fade update during resize delivery does not resubscribe the element. + const observeFade = useCallback( + (element: HTMLDivElement | null) => + element ? observeResize(element, () => updateFade(element)) : undefined, + [updateFade], + ); return (
{ - if (!element) return; - const observer = new ResizeObserver(() => updateFade(element)); - observer.observe(element); - return () => observer.disconnect(); - }} + ref={observeFade} onScroll={(event) => updateFade(event.currentTarget)} className={cn( "max-h-64 min-h-0 space-y-3 self-stretch overflow-y-auto whitespace-pre-wrap wrap-break-word", diff --git a/apps/web/src/components/chat/AssistantCitationSource.test.ts b/apps/web/src/components/chat/AssistantCitationSource.test.ts index 8692a7ffdf66..fae6d7535c96 100644 --- a/apps/web/src/components/chat/AssistantCitationSource.test.ts +++ b/apps/web/src/components/chat/AssistantCitationSource.test.ts @@ -141,6 +141,7 @@ function createSource({ reducedMotion = false } = {}) { }; const mutationCallbacks = new Set(); let resize: ResizeObserverCallback = () => {}; + const resizeEntries: ResizeObserverEntry[] = []; vi.stubGlobal( "MutationObserver", class { @@ -159,7 +160,9 @@ function createSource({ reducedMotion = false } = {}) { constructor(callback: ResizeObserverCallback) { resize = callback; } - observe() {} + observe(target: Element) { + resizeEntries.push({ target } as ResizeObserverEntry); + } disconnect() { resize = () => {}; } @@ -298,7 +301,7 @@ function createSource({ reducedMotion = false } = {}) { root.animations.at(-1)?.finish(); await Promise.resolve(); }, - resize: () => resize([], {} as ResizeObserver), + resize: () => resize(resizeEntries, {} as ResizeObserver), setMeasured: (value: boolean) => { measured = value; }, diff --git a/apps/web/src/components/chat/AssistantCitationSource.tsx b/apps/web/src/components/chat/AssistantCitationSource.tsx index c0b5ad3a1933..e6c78f8d6412 100644 --- a/apps/web/src/components/chat/AssistantCitationSource.tsx +++ b/apps/web/src/components/chat/AssistantCitationSource.tsx @@ -6,6 +6,7 @@ import { type AssistantCitationSourceAnchor, } from "~/lib/assistantTextSelection"; import { toastManager } from "../ui/toast"; +import { observeResize } from "~/lib/observeResize"; const CITATION_PULSE_DURATION_MS = 650; // The second pulse settles into a held highlight so late glances still find the quote. @@ -303,9 +304,7 @@ export function observeAssistantCitationSource({ } }); observer.observe(scrollNode, { childList: true, characterData: true, subtree: true }); - const resizeObserver = new ResizeObserver(schedule); - resizeObserver.observe(root); - resizeObserver.observe(scrollNode); + const stopObservingResize = observeResize([root, scrollNode], schedule); const state = list.getState(); const unsubscribe = [ state.listenToPosition(itemKey, schedule), @@ -334,7 +333,7 @@ export function observeAssistantCitationSource({ pulseAnimation?.cancel(); if (frame !== null) cancelAnimationFrame(frame); observer.disconnect(); - resizeObserver.disconnect(); + stopObservingResize(); for (const stop of unsubscribe) stop(); if (activation.cancelScroll === cancelScroll) delete activation.cancelScroll; clear(); diff --git a/apps/web/src/components/chat/ChatCanvas.tsx b/apps/web/src/components/chat/ChatCanvas.tsx index c66e42f0607f..35f966565851 100644 --- a/apps/web/src/components/chat/ChatCanvas.tsx +++ b/apps/web/src/components/chat/ChatCanvas.tsx @@ -7,10 +7,10 @@ import { type ComponentProps, type CSSProperties, } from "react"; -import { flushSync } from "react-dom"; import { ChatCanvasContext } from "./ChatCanvasContext"; import { resolveChatCanvasLayout, type ChatCanvasPreview } from "./chatCanvasLayout"; import type { PreviewMiniPlayerObstacles } from "../preview/previewMiniPlayerLayout"; +import { observeResize } from "../../lib/observeResize"; /** * Owns the available conversation space. Cards only report where they sit; the @@ -91,12 +91,10 @@ export function ChatCanvas({ ); }; measure(); - const observer = new ResizeObserver(() => flushSync(measure)); - observer.observe(element); - observer.observe(probe); - if (composerOverlayElement) observer.observe(composerOverlayElement); - if (timelineElement) observer.observe(timelineElement); - return () => observer.disconnect(); + const observed: Element[] = [element, probe]; + if (composerOverlayElement) observed.push(composerOverlayElement); + if (timelineElement) observed.push(timelineElement); + return observeResize(observed, measure); }, [composerOverlayElement, timelineElement]); const context = useMemo(() => { const container = { width: measurements.width, height: measurements.height }; diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index cc4031710158..0d55c1c83e06 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -340,6 +340,7 @@ import { suppressActiveComposerScrollGesture, } from "./composerScrollGesture"; import { prepareVideoFirstFrame } from "../../lib/videoFirstFrame"; +import { observeResize } from "~/lib/observeResize"; function ComposerVideoThumbnail({ file }: { file: File }) { const setVideo = useCallback( @@ -919,10 +920,10 @@ function useComposerRestingTransition( useLayoutEffect(() => { const element = elementRef.current; - if (!element || typeof ResizeObserver === "undefined") return; + if (!element) return; const body = element.querySelector('[data-chat-composer-body="true"]'); - const observer = new ResizeObserver((entries) => { + return observeResize(body ? [element, body] : element, (entries) => { if (animationRef.current) { if (body && entries.some((entry) => entry.target === body)) { transitionToCurrentGeometry(false); @@ -959,9 +960,6 @@ function useComposerRestingTransition( ?.getBoundingClientRect().bottom ?? elementRect.bottom) - elementRect.bottom, }; }); - observer.observe(element); - if (body) observer.observe(body); - return () => observer.disconnect(); }, [restingControlsRef, transitionToCurrentGeometry]); useEffect(() => { @@ -1038,21 +1036,18 @@ function ComposerCommandMenuLayer(props: { anchor: HTMLElement | null; children: window.addEventListener("resize", updatePosition); window.addEventListener("scroll", updatePosition, true); - const observer = - typeof ResizeObserver === "undefined" ? null : new ResizeObserver(updatePosition); - if (observer) { - // The composer is centered and capped at a max width, so opening a side - // panel slides it sideways without ever resizing it. Watching the anchor - // alone would leave the menu behind; the ancestors are what shrink, and - // they resize on every frame of the panel animation. - observer.observe(anchor); - for (let element = anchor.parentElement; element; element = element.parentElement) { - observer.observe(element); - } + // The composer is centered and capped at a max width, so opening a side + // panel slides it sideways without ever resizing it. Watching the anchor + // alone would leave the menu behind; the ancestors are what shrink, and + // they resize on every frame of the panel animation. + const observed: Element[] = [anchor]; + for (let element = anchor.parentElement; element; element = element.parentElement) { + observed.push(element); } + const stopObserving = observeResize(observed, updatePosition); return () => { - observer?.disconnect(); + stopObserving(); window.removeEventListener("resize", updatePosition); window.removeEventListener("scroll", updatePosition, true); }; @@ -1200,16 +1195,19 @@ function useRestingComposerControlsLayout(host: HTMLDivElement | null, useContro useEffect(() => { const currentHost = useControlsAsHost ? controls : host; if (!currentHost || !controls) return; - const observer = new ResizeObserver(measure); + let stopObserving = () => {}; const observeControls = () => { - observer.disconnect(); - observer.observe(currentHost); - observer.observe(controls); - controls - .querySelectorAll( - "[data-resting-block], [data-composer-control-label], [data-chat-provider-model-picker-label]", - ) - .forEach((element) => observer.observe(element)); + stopObserving(); + stopObserving = observeResize( + [ + currentHost, + controls, + ...controls.querySelectorAll( + "[data-resting-block], [data-composer-control-label], [data-chat-provider-model-picker-label]", + ), + ], + measure, + ); measure(); }; observeControls(); @@ -1217,7 +1215,7 @@ function useRestingComposerControlsLayout(host: HTMLDivElement | null, useContro mutations.observe(controls, { childList: true, subtree: true, characterData: true }); document.fonts.addEventListener("loadingdone", measure); return () => { - observer.disconnect(); + stopObserving(); mutations.disconnect(); document.fonts.removeEventListener("loadingdone", measure); }; @@ -3589,8 +3587,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const initialCompactness = measureFooterCompactness(); setIsComposerPrimaryActionsCompact(initialCompactness.primaryActionsCompact); setIsComposerFooterCompact(initialCompactness.footerCompact); - if (typeof ResizeObserver === "undefined") return; - const observer = new ResizeObserver(() => { + return observeResize(composerForm, () => { const nextCompactness = measureFooterCompactness(); setIsComposerPrimaryActionsCompact((previous) => previous === nextCompactness.primaryActionsCompact @@ -3601,11 +3598,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) previous === nextCompactness.footerCompact ? previous : nextCompactness.footerCompact, ); }); - - observer.observe(composerForm); - return () => { - observer.disconnect(); - }; }, [ activeThreadId, composerFooterActionLayoutKey, @@ -5257,10 +5249,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) if (!isComposerResting || !restingActionsElement) return; const measure = () => setRestingActionsWidth(restingActionsElement.offsetWidth); measure(); - if (typeof ResizeObserver === "undefined") return; - const observer = new ResizeObserver(measure); - observer.observe(restingActionsElement); - return () => observer.disconnect(); + return observeResize(restingActionsElement, measure); }, [isComposerResting, restingActionsElement]); const restingImagePreviewCounts = getRestingComposerImagePreviewCounts( standaloneComposerImages.length, @@ -7105,7 +7094,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) {!isComposerCollapsedMobile && !isComposerApprovalState && - pendingUserInputs.length === 0 && (uncommittedSnapShotIds.length > 0 || composerVideos.length > 0 || expandedComposerImages.length > 0) && ( diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index 3d398e31caae..c2b1a805011b 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -36,6 +36,7 @@ import { WorkspaceBreadcrumbSeparator, WorkspaceBreadcrumbText, } from "../WorkspaceBreadcrumb"; +import { observeResize } from "~/lib/observeResize"; import { cn } from "~/lib/utils"; import { useClientSettings } from "../../hooks/useSettings"; @@ -154,12 +155,11 @@ export const ChatHeader = memo(function ChatHeader({ measure(); const frame = requestAnimationFrame(measure); document.fonts.addEventListener("loadingdone", measure); - const observer = new ResizeObserver(measure); - observer.observe(list); + const stopObserving = observeResize(list, measure); return () => { cancelAnimationFrame(frame); document.fonts.removeEventListener("loadingdone", measure); - observer.disconnect(); + stopObserving(); }; }, [activeProjectName, activeThreadTitle, parentThreadLink, interfaceFont, isRenamingTitle]); const renameCommittedRef = useRef(false); diff --git a/apps/web/src/components/chat/ComposerBanner.tsx b/apps/web/src/components/chat/ComposerBanner.tsx index cbcb58897157..317c2136da87 100644 --- a/apps/web/src/components/chat/ComposerBanner.tsx +++ b/apps/web/src/components/chat/ComposerBanner.tsx @@ -183,17 +183,15 @@ function Row({ layout = "inline", ...props }: useRender.ComponentProps<"div"> & { - layout?: "inline" | "wrap-actions" | "wrap-actions-narrow" | "approval"; + layout?: "inline" | "wrap-actions" | "approval"; }) { const rowProps = { className: cn( "group/banner-row grid min-h-(--composer-banner-icon-column) w-full min-w-0 grid-cols-[var(--composer-banner-icon-column)_minmax(0,1fr)_auto] items-center gap-x-1 text-start", "not-has-[>[data-slot=composer-banner-actions]]:grid-cols-[var(--composer-banner-icon-column)_minmax(0,1fr)]", "[&:is(button)]:cursor-pointer [&:is(button)]:rounded-md [&:is(button)]:focus-visible:outline-2 [&:is(button)]:focus-visible:-outline-offset-2 [&:is(button)]:focus-visible:outline-ring", - layout === "wrap-actions" && - "@max-[400px]:*:data-[slot=composer-banner-content]:min-h-(--composer-banner-icon-column)", - layout === "wrap-actions-narrow" && - "@max-[320px]:*:data-[slot=composer-banner-content]:min-h-(--composer-banner-icon-column)", + // Actions share the title row while the title fits and move below it otherwise. + layout === "wrap-actions" && "flex flex-wrap *:data-[slot=composer-banner-content]:grow", layout === "approval" && "items-start gap-x-2 gap-y-3", className, ), @@ -257,8 +255,7 @@ function Actions({ className, ...props }: ComponentProps<"span">) { className={cn( "col-start-3 row-start-1 flex flex-wrap items-center justify-end gap-1", "group-data-[composer-banner-layout=approval]/banner-row:self-center group-data-[composer-banner-layout=approval]/banner-row:gap-1.5 @max-[560px]:group-data-[composer-banner-layout=approval]/banner-row:col-start-2 @max-[560px]:group-data-[composer-banner-layout=approval]/banner-row:col-end-4 @max-[560px]:group-data-[composer-banner-layout=approval]/banner-row:row-start-2", - "@max-[400px]:group-data-[composer-banner-layout=wrap-actions]/banner-row:has-[>:nth-child(2)]:col-start-2 @max-[400px]:group-data-[composer-banner-layout=wrap-actions]/banner-row:has-[>:nth-child(2)]:col-end-4 @max-[400px]:group-data-[composer-banner-layout=wrap-actions]/banner-row:has-[>:nth-child(2)]:row-start-2 @max-[400px]:group-data-[composer-banner-layout=wrap-actions]/banner-row:has-[>:nth-child(2)]:justify-end", - "@max-[320px]:group-data-[composer-banner-layout=wrap-actions-narrow]/banner-row:has-[>:nth-child(2)]:col-start-2 @max-[320px]:group-data-[composer-banner-layout=wrap-actions-narrow]/banner-row:has-[>:nth-child(2)]:col-end-4 @max-[320px]:group-data-[composer-banner-layout=wrap-actions-narrow]/banner-row:has-[>:nth-child(2)]:row-start-2 @max-[320px]:group-data-[composer-banner-layout=wrap-actions-narrow]/banner-row:has-[>:nth-child(2)]:-ms-2 @max-[320px]:group-data-[composer-banner-layout=wrap-actions-narrow]/banner-row:has-[>:nth-child(2)]:justify-start", + "group-data-[composer-banner-layout=wrap-actions]/banner-row:ms-auto", className, )} {...props} diff --git a/apps/web/src/components/chat/ComposerBannerStack.test.tsx b/apps/web/src/components/chat/ComposerBannerStack.test.tsx index d2e9a649df10..555097c568af 100644 --- a/apps/web/src/components/chat/ComposerBannerStack.test.tsx +++ b/apps/web/src/components/chat/ComposerBannerStack.test.tsx @@ -36,10 +36,10 @@ it("only offers notice details when the description cannot fit", async () => { vi.stubGlobal( "ResizeObserver", class { - constructor(callback: () => void) { - resize = callback; + constructor(private readonly callback: (entries: { target: unknown }[]) => void) {} + observe(target: unknown) { + resize = () => this.callback([{ target }]); } - observe() {} disconnect() {} }, ); diff --git a/apps/web/src/components/chat/ComposerBannerStack.tsx b/apps/web/src/components/chat/ComposerBannerStack.tsx index ff66869a9ec8..0cc952d73975 100644 --- a/apps/web/src/components/chat/ComposerBannerStack.tsx +++ b/apps/web/src/components/chat/ComposerBannerStack.tsx @@ -5,6 +5,7 @@ import { cn } from "~/lib/utils"; import { Button } from "../ui/button"; import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; import { ComposerBanner, type ComposerBannerVariant } from "./ComposerBanner"; +import { observeResize } from "~/lib/observeResize"; // Match the duration-220 exit transition before removing a dismissed notice. const DISMISS_TRANSITION_MS = 220; @@ -13,7 +14,6 @@ export interface ComposerBannerStackItem { readonly id: string; readonly variant: ComposerBannerVariant; readonly priority?: "urgent" | "activity" | "notice"; - readonly compact?: boolean; readonly icon: ReactNode; readonly title: ReactNode; readonly description?: ReactNode; @@ -250,7 +250,7 @@ export function ComposerBannerStack({ className, items, attachedAbove }: Compose } /** Keep full descriptions reachable only when their inline copy is clipped. */ -function NoticeDescription({ children, compact }: { children: ReactNode; compact?: boolean }) { +function NoticeDescription({ children }: { children: ReactNode }) { const descriptionRef = useRef(null); const detailsRef = useRef(null); const [showDetails, setShowDetails] = useState(false); @@ -271,25 +271,21 @@ function NoticeDescription({ children, compact }: { children: ReactNode; compact ); }; measure(); - const observer = new ResizeObserver(measure); - observer.observe(description); + const stopObserving = observeResize(description, measure); // A child can reveal new text without resizing its clipped box. const mutations = new MutationObserver(measure); mutations.observe(description, { childList: true, subtree: true, characterData: true }); return () => { - observer.disconnect(); + stopObserving(); mutations.disconnect(); }; }, []); return ( - + {children} @@ -354,17 +350,13 @@ function ComposerBannerStackAlert({ variant={item.variant} density="comfortable" > - + {item.icon} {item.title} - {item.description ? ( - - {item.description} - - ) : null} + {item.description ? {item.description} : null} {item.actions || item.onDismiss ? ( diff --git a/apps/web/src/components/chat/DraftHeroHeadline.tsx b/apps/web/src/components/chat/DraftHeroHeadline.tsx index 3a34d326e51b..e9bd7c99e045 100644 --- a/apps/web/src/components/chat/DraftHeroHeadline.tsx +++ b/apps/web/src/components/chat/DraftHeroHeadline.tsx @@ -1,16 +1,16 @@ import type { DraftId } from "~/composerDraftStore"; -import { useComposerDraftStore } from "~/composerDraftStore"; +import { composerDraftHasUserContent, useComposerDraftStore } from "~/composerDraftStore"; import { resolveEnvironmentMachineKind, type ScopedProjectRef } from "@t3tools/contracts"; import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime/environment"; import { isScratchProject } from "@t3tools/client-runtime/state/projects"; -import { FolderPlusIcon, MessageSquareDashedIcon } from "lucide-react"; +import { FolderPlusIcon } from "lucide-react"; import { useAtomValue } from "@effect/atom-react"; -import { useCallback, useEffect, useMemo, useRef } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { openCommandPalette } from "~/commandPaletteBus"; import { shortcutLabelForCommand } from "~/keybindings"; -import { projectIconColorClassName } from "~/projectIconColors"; import { primaryServerKeybindingsAtom } from "~/state/server"; +import { useNewThreadHandler } from "~/hooks/useHandleNewThread"; import { useScratchProject } from "~/hooks/useScratchProject"; import { useClientSettings } from "~/hooks/useSettings"; import { hasExplicitComposerModelSelection } from "~/lib/chatThreadActions"; @@ -29,20 +29,27 @@ import { ProjectEnvironmentBadge } from "../ProjectEnvironmentBadge"; import { ProjectFavicon } from "../ProjectFavicon"; import { sortLogicalProjectsForSidebar } from "../Sidebar.logic"; import { - Menu, - MenuItem, - MenuPopup, - MenuRadioGroup, - MenuRadioItem, - MenuSeparator, - MenuTrigger, -} from "../ui/menu"; + Combobox, + ComboboxEmpty, + ComboboxItem, + ComboboxList, + ComboboxPopup, + ComboboxSearchInput, + ComboboxTrigger, + useComboboxFilter, +} from "../ui/combobox"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { InlineButton } from "../ui/button"; import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; -// Menu value for "No project"; real entries are keyed by logical project key. -const NO_PROJECT_VALUE = "no-project"; +// Picker value for the "Add project" row; real entries are keyed by logical +// project key. +const ADD_PROJECT_VALUE = "add-project"; + +interface PickerItem { + readonly value: string; + readonly label: string; +} interface DraftHeroHeadlineProps { readonly draftId: DraftId | null; @@ -69,6 +76,7 @@ export function DraftHeroHeadline({ const setModelSelection = useComposerDraftStore((store) => store.setModelSelection); const openAddProject = useCallback(() => openCommandPalette({ open: "add-project" }), []); const { scratchEnvironmentId, scratchWorkspaceRootFor, openScratchProject } = useScratchProject(); + const openProjectDraft = useNewThreadHandler(); const keybindings = useAtomValue(primaryServerKeybindingsAtom); const environmentLabelById = useMemo( @@ -145,11 +153,15 @@ export function DraftHeroHeadline({ const hasResolvedProject = activeProjectTitle !== null; const canChooseProject = projectPickerEntries.length > 0; const shouldShowProjectMenu = canChooseProject; - // The project that hosts threads without a project appears once, as the - // "No project" item, not as a project row. - const menuEntries = projectPickerEntries.filter( - ({ targetProject }) => - !isScratchProject(targetProject, scratchWorkspaceRootFor(targetProject.environmentId)), + // The project that hosts threads without a project is not a row: the line + // under the headline is the way into it. + const menuEntries = useMemo( + () => + projectPickerEntries.filter( + ({ targetProject }) => + !isScratchProject(targetProject, scratchWorkspaceRootFor(targetProject.environmentId)), + ), + [projectPickerEntries, scratchWorkspaceRootFor], ); const activeProject = activeProjectRef === null @@ -166,15 +178,41 @@ export function DraftHeroHeadline({ const isScratchDraft = activeProject !== null && isScratchProject(activeProject, scratchWorkspaceRoot); + // {value, label} items let Base UI drive the combobox selection while the + // popup search filters the same collection. "Add project" is an action, not + // a project, so it only trails the unfiltered list. + const pickerItems = useMemo( + () => [ + ...menuEntries.map(({ group }) => ({ value: group.projectKey, label: group.displayName })), + { value: ADD_PROJECT_VALUE, label: "Add project" }, + ], + [menuEntries], + ); + const [pickerOpen, setPickerOpen] = useState(false); + const [pickerQuery, setPickerQuery] = useState(""); + const pickerFilter = useComboboxFilter(); + const filteredPickerItems = useMemo(() => { + const query = pickerQuery.trim(); + if (query.length === 0) return pickerItems; + return pickerItems.filter( + (item) => + item.value !== ADD_PROJECT_VALUE && + pickerFilter.contains(item, query, (candidate) => candidate.label), + ); + }, [pickerFilter, pickerItems, pickerQuery]); + const selectedPickerItem = pickerItems.find((item) => item.value === activeProjectKey) ?? null; + // The picker can change the draft's target while the no-project home is // still being opened; a stale continuation must not retarget it again. const latestTargetRef = useRef({ draftId, activeProjectKey, scratchTargetEnvironmentId }); useEffect(() => { latestTargetRef.current = { draftId, activeProjectKey, scratchTargetEnvironmentId }; }, [activeProjectKey, scratchTargetEnvironmentId, draftId]); - // Project selection changes the target of the open draft in place. The - // prompt stays in the same composer session, so the sidebar only gets a - // draft row if the user later navigates away. + // With a prompt typed, project selection changes the target of the open + // draft in place, so the prompt stays in the same composer session. An empty + // draft instead opens the chosen project's own draft, like starting a new + // thread there: moving it would replace that draft and strand whatever it + // holds, such as the browser tabs of the no-project draft. const selectProject = (project: (typeof projects)[number], logicalProjectKey: string) => { if (!draftId) { return; @@ -185,6 +223,10 @@ export function DraftHeroHeadline({ scratchTargetEnvironmentId: project.environmentId, }; const currentDraft = getComposerDraft(draftId); + if (!composerDraftHasUserContent(currentDraft)) { + void openProjectDraft(scopeProjectRef(project.environmentId, project.id)); + return; + } setLogicalProjectDraftThreadId( logicalProjectKey, scopeProjectRef(project.environmentId, project.id), @@ -226,7 +268,32 @@ export function DraftHeroHeadline({ }; const projectSelector = shouldShowProjectMenu ? ( - + item.label} + isItemEqualToValue={(a, b) => a.value === b.value} + open={pickerOpen} + onOpenChange={(open) => { + setPickerOpen(open); + setPickerQuery(""); + }} + value={selectedPickerItem} + onValueChange={(item) => { + if (!item) return; + setPickerOpen(false); + if (item.value === ADD_PROJECT_VALUE) { + openAddProject(); + return; + } + const entry = projectEntryByKey.get(item.value); + if (!entry || item.value === activeProjectKey) { + return; + } + selectProject(entry.targetProject, entry.group.projectKey); + }} + > } data-draft-project-trigger="" className="pointer-events-auto max-w-64 align-baseline" @@ -249,65 +316,43 @@ export function DraftHeroHeadline({ {activeProjectDisplayName} ) : null} - - { - if (value === NO_PROJECT_VALUE) { - void startScratch(); - return; - } - const entry = projectEntryByKey.get(value as string); - if (!entry || value === activeProjectKey) { - return; - } - selectProject(entry.targetProject, entry.group.projectKey); - }} - > - {scratchWorkspaceRoot === null ? null : ( - - - {/* Boxed like ProjectFavicon so the label lines up with project rows. */} - - No project - - - )} - {menuEntries.map(({ group }) => { + + setPickerQuery(event.target.value)} + /> + No matching projects. + + {(item: PickerItem) => { + const entry = projectEntryByKey.get(item.value); return ( - - - - - }> - {group.displayName} - - {group.displayName} - - {showProjectEnvironments ? ( - - ) : null} - - + + {item.value === ADD_PROJECT_VALUE ? ( + + ) : entry ? ( + + ) : null} + + }> + {item.label} + + {item.label} + + {entry && showProjectEnvironments ? ( + + ) : null} + ); - })} - - {projectPickerEntries.length > 0 ? : null} - - - Add project - - - + }} + + + ) : ( + } + /> + + {summaries.some(({ report }) => report !== null) && ( + + report + ? [ + `${connectedEnvironments.length > 1 ? `${environment.label}: ` : ""}${report.counts.removed} removed, ${report.counts.kept} kept${report.counts.failed > 0 ? `, ${report.counts.failed} failed` : ""}`, + ] + : [], + ) + .join(" · ")} + > + {summaries.map( + ({ environment, report }) => + report && ( + 1 ? environment.label : undefined + } + /> + ), + )} + + )} + + ); +} + export function StorageSettingsPanel() { const { scope, connectedEnvironments, targets, target } = useSettingsScope(); const scopedSettings = useScopedSettings(); @@ -312,6 +564,49 @@ export function StorageSettingsPanel() { /> } /> + {connectedEnvironments.every( + (environment) => + environment.serverConfig?.environment.capabilities.storageCleanupRun === true, + ) && ( + { + if ( + next === "any-local-files" || + next === "uncommitted-changes" || + next === "tracked-changes" + ) + updateWorktree({ worktreeKeepWhen: next }); + }} + > + + + {ruleStatus("worktreeKeepWhen") + ? "Mixed" + : KEEP_WHEN_LABELS[settings.worktreeKeepWhen]} + + + + Any local files + Uncommitted changes + Edited tracked files + + + } + /> + )} )} @@ -346,6 +641,7 @@ export function StorageSettingsPanel() { /> )} + {!isProjectScope && } ); } diff --git a/apps/web/src/components/settings/scopedSettings.test.ts b/apps/web/src/components/settings/scopedSettings.test.ts index 800ff48360a9..a47a4bf00241 100644 --- a/apps/web/src/components/settings/scopedSettings.test.ts +++ b/apps/web/src/components/settings/scopedSettings.test.ts @@ -160,6 +160,7 @@ describe("scoped settings writes", () => { worktreeOnDelete: true, worktreeOnMerge: true, worktreeUnchanged: false, + worktreeKeepWhen: "uncommitted-changes", }, }, }, @@ -183,12 +184,14 @@ describe("scoped settings writes", () => { worktreeOnDelete: false, worktreeOnMerge: true, worktreeUnchanged: false, + worktreeKeepWhen: "uncommitted-changes", }, { worktreeAfterDays: null, worktreeOnDelete: false, worktreeOnMerge: false, worktreeUnchanged: false, + worktreeKeepWhen: "uncommitted-changes", }, ]); }); @@ -295,6 +298,7 @@ describe("scoped settings writes", () => { worktreeOnDelete: false, worktreeOnMerge: true, worktreeUnchanged: false, + worktreeKeepWhen: "uncommitted-changes", }, }, }, @@ -315,6 +319,7 @@ describe("scoped settings writes", () => { worktreeOnDelete: true, worktreeOnMerge: true, worktreeUnchanged: false, + worktreeKeepWhen: "uncommitted-changes", }, }, }, @@ -328,6 +333,7 @@ describe("scoped settings writes", () => { worktreeOnDelete: true, worktreeOnMerge: false, worktreeUnchanged: false, + worktreeKeepWhen: "uncommitted-changes", }, }, }, diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 853886d81fcd..aff52adeb155 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -134,6 +134,23 @@ const KEYBINDING_SEARCH_ITEMS = STATIC_KEYBINDING_COMMANDS.toSorted((left, right * that may not be mounted point at their nearest stable section instead. */ export const SETTINGS_SEARCH_ITEMS = [ + { + id: "storage-worktree-keep-when", + targetId: "storage-worktrees", + title: "Keep worktrees with local changes", + to: "/settings/storage", + scope: "project-defaults", + searchTerms: [ + "any local files uncommitted changes edited tracked files ignored env build output cleanup", + ], + }, + { + id: "storage-delete-now", + title: "Delete now", + to: "/settings/storage", + scope: "environment-defaults", + searchTerms: ["storage cleanup run now results report removed kept failed"], + }, { id: "storage-worktrees", title: "Worktree cleanup", diff --git a/apps/web/src/components/sidebar/SidebarChrome.tsx b/apps/web/src/components/sidebar/SidebarChrome.tsx index 5aef68795ba7..89e735f99f81 100644 --- a/apps/web/src/components/sidebar/SidebarChrome.tsx +++ b/apps/web/src/components/sidebar/SidebarChrome.tsx @@ -29,6 +29,7 @@ import { SidebarThreadUndoNotice } from "./SidebarThreadUndoNotice"; import { SidebarProviderUpdatePill } from "./SidebarProviderUpdatePill"; import { SidebarUpdateArchitectureWarning, SidebarUpdatePill } from "./SidebarUpdatePill"; import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons"; +import { observeResize } from "~/lib/observeResize"; export const SidebarChromeHeader = memo(function SidebarChromeHeader({ isElectron, @@ -85,11 +86,9 @@ export function SidebarBrandWidthProbe({ }) { const observeWidth = useCallback( (probe: HTMLDivElement) => { - const observer = new ResizeObserver(([entry]) => { + return observeResize(probe, ([entry]) => { if (entry) onWidthChange(entry.borderBoxSize[0]?.inlineSize ?? probe.offsetWidth); }); - observer.observe(probe); - return () => observer.disconnect(); }, [onWidthChange], ); diff --git a/apps/web/src/components/ui/sidebar.tsx b/apps/web/src/components/ui/sidebar.tsx index 3f605bce0466..c1bd6b277c5d 100644 --- a/apps/web/src/components/ui/sidebar.tsx +++ b/apps/web/src/components/ui/sidebar.tsx @@ -369,9 +369,6 @@ function SidebarRail({ const suppressClickRef = React.useRef(false); const resolvedResizable = sidebarInstance?.resizable ?? null; const latestResizable = React.useRef(resolvedResizable); - React.useLayoutEffect(() => { - latestResizable.current = resolvedResizable; - }, [resolvedResizable]); const canResize = resolvedResizable !== null && open; const railLabel = canResize ? "Resize Sidebar" : "Toggle Sidebar"; const railTitle = canResize ? "Drag to resize sidebar" : "Toggle Sidebar"; @@ -390,14 +387,22 @@ function SidebarRail({ sidebarContainer.getBoundingClientRect().width, resolvedResizable, ); - const transitionTargets = [ + // Drag frames write width to the gap and container only. Changing the + // inherited --sidebar-width on the wrapper restyles the whole app, so it is + // written once when the drag commits. + const widthTargets = [ sidebarRoot.querySelector("[data-slot='sidebar-gap']"), sidebarContainer, ].filter((element): element is HTMLElement => element !== null); - transitionTargets.forEach((element) => { + const setTargetWidth = (value: number) => { + widthTargets.forEach((element) => { + element.style.setProperty("width", `${value}px`); + }); + }; + widthTargets.forEach((element) => { element.style.setProperty("transition-duration", "0ms"); }); - wrapper.style.setProperty("--sidebar-width", `${width}px`); + setTargetWidth(width); return { width, @@ -416,12 +421,13 @@ function SidebarRail({ wrapper, }) ?? true; if (accepted) { - wrapper.style.setProperty("--sidebar-width", `${nextWidth}px`); + setTargetWidth(nextWidth); width = nextWidth; } return width; }, finish(finalWidth, moved) { + wrapper.style.setProperty("--sidebar-width", `${finalWidth}px`); suppressClickRef.current = moved; const options = latestResizable.current; if (options?.storageKey) { @@ -434,12 +440,20 @@ function SidebarRail({ options?.onResize?.(finalWidth); }, cleanup() { - transitionTargets.forEach((element) => { + widthTargets.forEach((element) => { + element.style.removeProperty("width"); element.style.removeProperty("transition-duration"); }); }, }; - }); + // Toggling the sidebar cancels a drag so the inline width never pins a + // collapsed sidebar open. + }, String(open)); + React.useLayoutEffect(() => { + latestResizable.current = resolvedResizable; + // Bounds follow the window; keep an active drag's inline width inside them. + resize.refresh(); + }, [resolvedResizable]); const handleClick = React.useCallback( (event: React.MouseEvent) => { diff --git a/apps/web/src/components/ui/toast.tsx b/apps/web/src/components/ui/toast.tsx index 27ff2a2733a9..9448bfff99ea 100644 --- a/apps/web/src/components/ui/toast.tsx +++ b/apps/web/src/components/ui/toast.tsx @@ -340,6 +340,7 @@ function ToastBodyContent({ )} >
+ {leadingIcon ? (
) : null} -
observer.disconnect(); + return observeResize([plot, tooltip], positionTooltip); }, [hoverIndex, positionTooltip]); const handleMove = useCallback( diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index df7969a86188..8a234372b3dd 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -89,6 +89,7 @@ import { type TerminalContextDraft, } from "./lib/terminalContext"; import { createDeferredStorage } from "./lib/storage"; +import { applyPreviewServerSnapshot, resetPreviewStateForTests } from "./previewStateStore"; function makeImage(input: { id: string; @@ -1467,6 +1468,30 @@ describe("composerDraftStore project draft thread mapping", () => { expect(draftByKey(draftId)).toBeUndefined(); }); + it("keeps a draft with an open page when remapping a project to a new draft thread", () => { + const store = useComposerDraftStore.getState(); + store.setProjectDraftThreadId(projectRef, draftId, { threadId }); + // A link another app opened: the composer is empty, but the page is not. + applyPreviewServerSnapshot(scopeThreadRef(TEST_ENVIRONMENT_ID, threadId), { + threadId, + tabId: "tab-link", + navStatus: { _tag: "Success", url: "https://example.com/", title: "Example" }, + canGoBack: false, + canGoForward: false, + updatedAt: "2026-10-09T00:00:00.000Z", + }); + try { + store.setProjectDraftThreadId(projectRef, otherDraftId, { threadId: otherThreadId }); + + expect(useComposerDraftStore.getState().getDraftThread(draftId)?.threadId).toBe(threadId); + // It survives a reload too, so the page is still there when the app comes back. + const persisted = partializeComposerDraftStoreState(useComposerDraftStore.getState()); + expect(persisted.draftThreadsByThreadKey[draftId]?.threadId).toBe(threadId); + } finally { + resetPreviewStateForTests(); + } + }); + it("keeps invested composer drafts alive unmapped when remapping a project to a new draft thread", () => { const store = useComposerDraftStore.getState(); store.setProjectDraftThreadId(projectRef, draftId, { threadId }); diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 5e0e76730b9b..f5663094da62 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -40,6 +40,7 @@ import { DeepMutable } from "effect/Types"; import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model"; import { useMemo } from "react"; import { getLocalStorageItem } from "./hooks/useLocalStorage"; +import { readThreadPreviewState } from "./previewStateStore"; import { resolveAppModelSelection, resolveAppModelSelectionForInstance } from "./modelSelection"; import { DEFAULT_INTERACTION_MODE, @@ -1556,6 +1557,16 @@ function getComposerDraftState( return state.draftsByThreadKey[threadKey] ?? null; } +/** + * A draft holding an open page, such as one started for a link another app + * opened, is work in progress even with an empty composer. + */ +function draftThreadHasOpenPreview(draftThread: DraftThreadState | undefined): boolean { + if (draftThread === undefined) return false; + const ref = scopeThreadRef(draftThread.environmentId, draftThread.threadId); + return Object.keys(readThreadPreviewState(ref).sessions).length > 0; +} + function isComposerThreadKeyInUse(mappings: Record, threadKey: string): boolean { return Object.values(mappings).includes(threadKey); } @@ -2174,10 +2185,10 @@ export function partializeComposerDraftStoreState( state: ComposerDraftStoreState, ): PersistedComposerDraftStoreState { // Draft sessions worth persisting: mapped (a new-thread flow targets - // them), promoting (mid-send), or holding real user content (they back a - // sidebar row). Everything else is a zombie — and its composer blob must - // be dropped WITH it, or model/mode-only entries would persist forever - // keyed to a session that no longer exists. + // them), promoting (mid-send), holding real user content (they back a + // sidebar row), or holding an open page. Everything else is a zombie — and + // its composer blob must be dropped WITH it, or model/mode-only entries + // would persist forever keyed to a session that no longer exists. const mappedDraftKeys = new Set( Object.values(state.logicalProjectDraftThreadKeyByLogicalProjectKey), ); @@ -2187,7 +2198,8 @@ export function partializeComposerDraftStoreState( ([threadKey, draftThread]) => mappedDraftKeys.has(threadKey) || isDraftThreadPromoting(draftThread) || - composerDraftHasUserContent(state.draftsByThreadKey[threadKey]), + composerDraftHasUserContent(state.draftsByThreadKey[threadKey]) || + draftThreadHasOpenPreview(draftThread), ) .map(([threadKey]) => threadKey), ); @@ -2792,7 +2804,8 @@ const composerDraftStore = create()( !isDraftThreadPromoting(previousDraftThread) && !composerDraftHasUserContent( state.draftsByThreadKey[previousThreadKeyForLogicalProject], - ) + ) && + !draftThreadHasOpenPreview(previousDraftThread) ) { delete nextDraftThreadsByThreadKey[previousThreadKeyForLogicalProject]; if (state.draftsByThreadKey[previousThreadKeyForLogicalProject] !== undefined) { diff --git a/apps/web/src/hooks/useElementWidth.ts b/apps/web/src/hooks/useElementWidth.ts deleted file mode 100644 index d3f60eeea159..000000000000 --- a/apps/web/src/hooks/useElementWidth.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { useLayoutEffect, useState } from "react"; - -export function useElementWidth() { - const [element, setElement] = useState(null); - const [width, setWidth] = useState(null); - - useLayoutEffect(() => { - if (!element) return; - - const update = (nextWidth: number) => { - setWidth((currentWidth) => (currentWidth === nextWidth ? currentWidth : nextWidth)); - }; - - update(element.getBoundingClientRect().width); - if (typeof ResizeObserver === "undefined") return; - - const observer = new ResizeObserver(([entry]) => { - if (entry) update(entry.contentRect.width); - }); - observer.observe(element); - return () => observer.disconnect(); - }, [element]); - - return [setElement, width] as const; -} diff --git a/apps/web/src/hooks/usePreviewPanelInlineSize.ts b/apps/web/src/hooks/usePreviewPanelInlineSize.ts index f4621669417a..1f91c53ccb1f 100644 --- a/apps/web/src/hooks/usePreviewPanelInlineSize.ts +++ b/apps/web/src/hooks/usePreviewPanelInlineSize.ts @@ -1,6 +1,7 @@ -import { type RefObject, useEffect, useLayoutEffect, useState } from "react"; +import { type RefObject, useLayoutEffect, useState } from "react"; import { type ResizableWidthHandlers, useResizableWidth } from "./useResizableWidth"; +import { observeResize } from "../lib/observeResize"; export interface PreviewPanelInlineSize { readonly width: number; @@ -28,15 +29,11 @@ export function usePreviewPanelInlineSize( readonly enabled?: boolean | undefined; readonly widthStorageKey?: string | undefined; readonly defaultWidth?: number | undefined; - /** Use the caller's existing row measurement instead of observing the panel's parent. */ - readonly containerWidth?: number | undefined; + /** Measure this row instead of the panel's parent. */ + readonly container?: HTMLElement | null | undefined; } = {}, ): PreviewPanelInlineSize { - const maxWidth = useViewportClampedMaxWidth( - hostRef, - options.enabled ?? true, - options.containerWidth, - ); + const maxWidth = useViewportClampedMaxWidth(hostRef, options.enabled ?? true, options.container); return useResizableWidth({ storageKey: options.widthStorageKey ?? PREVIEW_PANEL_WIDTH_STORAGE_KEY, defaultWidth: options.defaultWidth ?? PREVIEW_PANEL_DEFAULT_WIDTH, @@ -54,46 +51,40 @@ export function usePreviewPanelInlineSize( function useViewportClampedMaxWidth( hostRef: RefObject | undefined, enabled: boolean, - containerWidth?: number, + container: HTMLElement | null | undefined, ): number { - const [vw, setVw] = useState(() => (typeof window === "undefined" ? 1280 : window.innerWidth)); - const [measuredContainerWidth, setContainerWidth] = useState(undefined); - useEffect(() => { + const [maxWidth, setMaxWidth] = useState(() => + getPreviewPanelMaxWidth(typeof window === "undefined" ? 1280 : window.innerWidth), + ); + useLayoutEffect(() => { if (typeof window === "undefined") return; + const row = container ?? (enabled ? hostRef?.current?.parentElement : null) ?? null; + // Measure before first paint: the persisted width must be clamped against + // the row on the initial render, not one observer tick later (the panel + // would flash over-wide on every mount). Only the derived cap is stored: the + // row resizes every frame of a sidebar drag, but the cap rarely moves, and an + // unchanged cap skips re-rendering the panel's owner. + const measure = () => { + setMaxWidth(getPreviewPanelMaxWidth(window.innerWidth, row?.clientWidth)); + }; + measure(); let frame = 0; const onResize = () => { if (frame !== 0) return; frame = window.requestAnimationFrame(() => { frame = 0; - setVw(window.innerWidth); + measure(); }); }; window.addEventListener("resize", onResize); + const stopObserving = row ? observeResize(row, measure) : undefined; return () => { window.removeEventListener("resize", onResize); if (frame !== 0) window.cancelAnimationFrame(frame); + stopObserving?.(); }; - }, []); - useLayoutEffect(() => { - if (!enabled) return; - const parent = hostRef?.current?.parentElement; - if (!parent) return; - // Measure before first paint: the persisted width must be clamped against - // the row on the initial render, not one observer tick later (the panel - // would flash over-wide on every mount). clientWidth is integral, so - // sub-pixel resize deltas bail out of re-rendering. - const measure = () => { - setContainerWidth(parent.clientWidth); - }; - measure(); - if (typeof ResizeObserver === "undefined") return; - const observer = new ResizeObserver(measure); - observer.observe(parent); - return () => { - observer.disconnect(); - }; - }, [hostRef, enabled]); - return getPreviewPanelMaxWidth(vw, containerWidth ?? measuredContainerWidth); + }, [container, hostRef, enabled]); + return maxWidth; } export function getPreviewPanelMaxWidth(viewportWidth: number, containerWidth?: number): number { const fractionCap = Math.floor(viewportWidth * PREVIEW_PANEL_MAX_WIDTH_FRACTION); diff --git a/apps/web/src/hooks/useResizableWidth.test.tsx b/apps/web/src/hooks/useResizableWidth.test.tsx index 11ae6265de55..2d727eb79dcf 100644 --- a/apps/web/src/hooks/useResizableWidth.test.tsx +++ b/apps/web/src/hooks/useResizableWidth.test.tsx @@ -2,12 +2,19 @@ import { act, useLayoutEffect, type PointerEvent } from "react"; import { create, type ReactTestRenderer } from "react-test-renderer"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; -import { useResizableWidth } from "./useResizableWidth"; +import { RESIZABLE_WIDTH_PROPERTY, useResizableWidth } from "./useResizableWidth"; let renderer: ReactTestRenderer; let result: ReturnType; let captured = false; +const hostStyle = new Map(); const target = { + parentElement: { + style: { + setProperty: (property: string, value: string) => hostStyle.set(property, value), + removeProperty: (property: string) => hostStyle.delete(property), + }, + }, setPointerCapture: () => { captured = true; }, @@ -65,6 +72,7 @@ function Panel({ beforeEach(async () => { savedWidths.clear(); + hostStyle.clear(); captured = false; frame = undefined; style.cursor = ""; @@ -102,7 +110,9 @@ describe("panel resize cleanup", () => { result.handlers.onPointerMove(pointer(50)); }); await act(() => frame?.(0)); - expect(result.width).toBe(450); + expect(hostStyle.get(RESIZABLE_WIDTH_PROPERTY)).toBe("450px"); + expect(hostStyle.get("transition-property")).toBe("none"); + expect(result.width).toBe(400); // Queue another move to check that interruption cancels pending work too. await act(() => result.handlers.onPointerMove(pointer(25))); expect(style.cursor).toBe("col-resize"); @@ -116,6 +126,7 @@ describe("panel resize cleanup", () => { expect(style.cursor).toBe(""); expect(style.userSelect).toBe(""); expect(captured).toBe(false); + expect(hostStyle.has("transition-property")).toBe(false); expect(cancelAnimationFrame).toHaveBeenCalledWith(42); if (reason === "unmount") { expect(setItem).not.toHaveBeenCalled(); @@ -151,11 +162,11 @@ describe("panel resize cleanup", () => { result.handlers.onPointerMove(pointer(-200)); }); await act(() => frame?.(0)); - expect(result.width).toBe(Math.min(700, initialMax)); + expect(hostStyle.get(RESIZABLE_WIDTH_PROPERTY)).toBe(`${Math.min(700, initialMax)}px`); await act(() => renderer.update()); await act(() => result.handlers.onPointerMove(pointer(-250))); await act(() => frame?.(0)); - expect(result.width).toBe(Math.min(750, nextMax)); + expect(hostStyle.get(RESIZABLE_WIDTH_PROPERTY)).toBe(`${Math.min(750, nextMax)}px`); await act(() => result.handlers.onPointerUp(pointer(-300))); expect(result.width).toBe(nextMax); expect(setItem).toHaveBeenCalledExactlyOnceWith("test-panel-width", String(nextMax)); @@ -225,10 +236,11 @@ describe("panel width storage changes", () => { result.handlers.onPointerMove(pointer(50)); }); await act(() => frame?.(0)); - expect(result.width).toBe(450); + expect(hostStyle.get(RESIZABLE_WIDTH_PROPERTY)).toBe("450px"); await act(() => result.handlers.onPointerMove(pointer(25))); await act(() => renderer.update()); expect(result.width).toBe(650); + expect(hostStyle.get(RESIZABLE_WIDTH_PROPERTY)).toBe("650px"); expect(captured).toBe(false); expect(style.cursor).toBe(""); await act(() => { diff --git a/apps/web/src/hooks/useResizableWidth.ts b/apps/web/src/hooks/useResizableWidth.ts index 22ab44bdead8..fc0d016c78b9 100644 --- a/apps/web/src/hooks/useResizableWidth.ts +++ b/apps/web/src/hooks/useResizableWidth.ts @@ -12,6 +12,12 @@ import { useResizeDrag } from "./useResizeDrag"; const WidthSchema = Schema.Finite; +/** + * Custom property the host element must size itself from. The drag handle sits + * directly inside the host, and a drag writes this property on it each frame. + */ +export const RESIZABLE_WIDTH_PROPERTY = "--resizable-width"; + export interface UseResizableWidthOptions { /** localStorage key the persisted width is stored under. */ readonly storageKey: string; @@ -39,9 +45,10 @@ export interface ResizableWidthHandlers { * specified edge. Width is read on mount or storage-key changes and persisted on * drag-end (not on every rAF tick — would otherwise be ~60 writes/sec). * - * The hook updates an internal `width` state during drag (so the panel - * follows the cursor live) and only commits to localStorage when the user - * lifts the pointer or the drag is interrupted. + * During a drag the hook writes `RESIZABLE_WIDTH_PROPERTY` on the handle's + * parent, so the panel follows the cursor in the same frame without + * re-rendering the owner. `width` state and localStorage commit once when the + * user lifts the pointer or the drag is interrupted. */ export function useResizableWidth(options: UseResizableWidthOptions): { readonly width: number; @@ -75,21 +82,26 @@ export function useResizableWidth(options: UseResizableWidthOptions): { } const clampedWidth = clamp(widthState.width); - const latestOptions = useRef({ clamp, storageKey }); + const latestOptions = useRef({ clamp, storageKey, width: clampedWidth }); useLayoutEffect(() => { - latestOptions.current = { clamp, storageKey }; - }, [clamp, storageKey]); + latestOptions.current = { clamp, storageKey, width: clampedWidth }; + }, [clamp, clampedWidth, storageKey]); - const handlers = useResizeDrag( - () => ({ + const { refresh, ...handlers } = useResizeDrag((event) => { + const host = event.currentTarget.parentElement; + // A collapsible host animates width; live drag writes must not. The host + // renders its own transition-duration, so override a property it leaves alone. + host?.style.setProperty("transition-property", "none"); + return { width: clampedWidth, edge, resize(value) { const nextWidth = latestOptions.current.clamp(value); - setWidthState({ storageKey, width: nextWidth }); + host?.style.setProperty(RESIZABLE_WIDTH_PROPERTY, `${nextWidth}px`); return nextWidth; }, finish(finalWidth) { + setWidthState({ storageKey, width: finalWidth }); // Commit once at drag-end to avoid 60Hz localStorage writes. try { setLocalStorageItem(latestOptions.current.storageKey, finalWidth, WidthSchema); @@ -97,9 +109,19 @@ export function useResizableWidth(options: UseResizableWidthOptions): { console.error("Could not persist panel width.", error); } }, - }), - storageKey, - ); + cleanup(committed) { + host?.style.removeProperty("transition-property"); + // React skips the write when the rendered width did not change. + if (!committed) { + host?.style.setProperty(RESIZABLE_WIDTH_PROPERTY, `${latestOptions.current.width}px`); + } + }, + }; + }, storageKey); + + // Bounds can change mid-drag (sidebar opens, window narrows) and the render + // rewrites the committed width, so re-apply the live pointer position. + useLayoutEffect(refresh, [clamp, clampedWidth, refresh]); return { width: clampedWidth, handlers }; } diff --git a/apps/web/src/hooks/useResizeDrag.ts b/apps/web/src/hooks/useResizeDrag.ts index 6509a61767d0..797a94ed1ab5 100644 --- a/apps/web/src/hooks/useResizeDrag.ts +++ b/apps/web/src/hooks/useResizeDrag.ts @@ -5,7 +5,8 @@ interface ResizeSession { edge: "left" | "right"; resize: (width: number) => number; finish: (width: number, moved: boolean) => void; - cleanup?: () => void; + /** Runs on every end; `committed` is false when the drag was abandoned without `finish`. */ + cleanup?: (committed: boolean) => void; } /** Shared pointer lifecycle for side panels, including interrupted and sub-frame drags. */ @@ -49,7 +50,7 @@ export function useResizeDrag( } document.body.style.removeProperty("cursor"); document.body.style.removeProperty("user-select"); - active.session.cleanup?.(); + active.session.cleanup?.(commit); if (commit) active.session.finish(active.width, active.moved); }, [flush], @@ -81,6 +82,8 @@ export function useResizeDrag( }; return { + /** Re-applies the pointer position to an active drag, e.g. after its bounds change. */ + refresh: flush, onPointerDown(event: PointerEvent) { if (event.button !== 0 || drag.current) return; const session = start(event); @@ -88,7 +91,7 @@ export function useResizeDrag( try { event.currentTarget.setPointerCapture(event.pointerId); } catch { - session.cleanup?.(); + session.cleanup?.(false); return; } event.preventDefault(); diff --git a/apps/web/src/lib/formatBytes.ts b/apps/web/src/lib/formatBytes.ts new file mode 100644 index 000000000000..306302db6761 --- /dev/null +++ b/apps/web/src/lib/formatBytes.ts @@ -0,0 +1,11 @@ +export function formatBytes(value: number): string { + if (value < 1_024) return `${Math.round(value)} B`; + const units = ["KB", "MB", "GB", "TB"] as const; + let next = value; + let unitIndex = -1; + do { + next /= 1_024; + unitIndex += 1; + } while (next >= 1_024 && unitIndex < units.length - 1); + return `${next.toFixed(next >= 100 ? 0 : next >= 10 ? 1 : 2)} ${units[unitIndex]}`; +} diff --git a/apps/web/src/lib/observeResize.ts b/apps/web/src/lib/observeResize.ts new file mode 100644 index 000000000000..a7d7b11dbaac --- /dev/null +++ b/apps/web/src/lib/observeResize.ts @@ -0,0 +1,107 @@ +import { flushSync } from "react-dom"; + +type ResizeCallback = (entries: readonly ResizeObserverEntry[]) => void; + +interface Subscription { + readonly callback: ResizeCallback; + active: boolean; +} + +interface Target { + readonly subscriptions: Set; + /** Subscribers that joined after the first delivery and still await their initial entry. */ + waiting: Set | null; + last: ResizeObserverEntry | null; +} + +let observer: ResizeObserver | null = null; +const targets = new Map(); + +function deliver(entries: readonly ResizeObserverEntry[]) { + const batches = new Map(); + for (const entry of entries) { + const target = targets.get(entry.target); + if (!target) continue; + let recipients = target.subscriptions; + if (target.waiting) { + // Re-observing for a new subscriber redelivers an unchanged size to everyone else too. + const previous = target.last?.contentRect; + if ( + previous?.width === entry.contentRect.width && + previous.height === entry.contentRect.height + ) { + recipients = target.waiting; + } + target.waiting = null; + } + target.last = entry; + for (const subscription of recipients) { + const batch = batches.get(subscription); + if (batch) batch.push(entry); + else batches.set(subscription, [entry]); + } + } + if (batches.size === 0) return; + flushSync(() => { + for (const [subscription, batch] of batches) { + if (!subscription.active) continue; + try { + subscription.callback(batch); + } catch (error) { + queueMicrotask(() => { + throw error; + }); + } + } + }); +} + +/** + * Observes element sizes on one shared ResizeObserver. Its callbacks run after + * layout and before paint, but React commits state set there after the paint, + * so layout derived from an observed size would land one frame late. Every + * resize delivered in a frame runs inside one flushSync, so all size-derived + * state commits in a single render before that paint. A callback gets one call + * with the entries for all of its targets, starting with an initial one each. + */ +export function observeResize( + elements: Element | readonly Element[], + callback: ResizeCallback, +): () => void { + if (typeof ResizeObserver === "undefined") return () => {}; + const shared = (observer ??= new ResizeObserver(deliver)); + const subscription: Subscription = { callback, active: true }; + const observed: readonly Element[] = Array.isArray(elements) ? elements : [elements as Element]; + for (const element of observed) { + const target = targets.get(element); + if (!target) { + targets.set(element, { subscriptions: new Set([subscription]), waiting: null, last: null }); + shared.observe(element); + continue; + } + target.subscriptions.add(subscription); + // An initial entry still pending reaches every subscriber; after it, only + // observing afresh makes the browser report the current size again. + if (!target.last) continue; + (target.waiting ??= new Set()).add(subscription); + shared.unobserve(element); + shared.observe(element); + } + return () => { + if (!subscription.active) return; + subscription.active = false; + const unobserved = observed.filter((element) => { + const target = targets.get(element); + if (!target?.subscriptions.delete(subscription)) return false; + target.waiting?.delete(subscription); + return target.subscriptions.size === 0 && targets.delete(element); + }); + if (targets.size > 0) { + for (const element of unobserved) shared.unobserve(element); + } else { + // A fresh observer next time also picks up a ResizeObserver a test stubbed since. + observer?.disconnect(); + observer = null; + } + }; +} diff --git a/apps/web/src/pierre-tree-theme.ts b/apps/web/src/pierre-tree-theme.ts index 906b34ba1c51..3f191d255789 100644 --- a/apps/web/src/pierre-tree-theme.ts +++ b/apps/web/src/pierre-tree-theme.ts @@ -8,7 +8,7 @@ export const PIERRE_TREE_UNSAFE_CSS = ` --trees-hover-bg-override: color-mix(in srgb, currentColor 7%, transparent); --trees-border-color-override: color-mix(in srgb, currentColor 14%, transparent); --trees-font-family-override: var(--font-sans); - --trees-font-size-override: 12px; + --trees-font-size-override: 0.75rem; } button[data-type='item'] { border-radius: 5px; } svg[data-icon-name='t3-tree-icon-loading'] { opacity: 0.6; } diff --git a/apps/web/src/rightPanelStore.ts b/apps/web/src/rightPanelStore.ts index 6124297ebef2..812de8804046 100644 --- a/apps/web/src/rightPanelStore.ts +++ b/apps/web/src/rightPanelStore.ts @@ -121,6 +121,13 @@ export interface ThreadPanelVisibility { interface RightPanelStoreState { byThreadKey: Record; + /** + * A thread whose right panel should open maximized the next time it shows, + * such as one started for a link the OS opened. Its view consumes it once. + */ + pendingMaximizeThreadKey: string | null; + requestMaximize: (ref: ScopedThreadRef) => void; + consumeMaximizeRequest: (ref: ScopedThreadRef) => boolean; threadPanelVisibilityByThreadKey: Record; /** Session-only count of user panel choices per thread. Automatic updates do not advance it. */ userActionRevisionByThreadKey: Record; @@ -590,6 +597,13 @@ export const useRightPanelStore = create()( persist( (set, get) => ({ byThreadKey: {}, + pendingMaximizeThreadKey: null, + requestMaximize: (ref) => set({ pendingMaximizeThreadKey: scopedThreadKey(ref) }), + consumeMaximizeRequest: (ref) => { + if (get().pendingMaximizeThreadKey !== scopedThreadKey(ref)) return false; + set({ pendingMaximizeThreadKey: null }); + return true; + }, threadPanelVisibilityByThreadKey: {}, userActionRevisionByThreadKey: {}, closeRevisionByThreadKey: {}, diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index d1c4ef730c86..69112d6de4e5 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -27,6 +27,7 @@ import { RelayClientInstallDialog } from "../components/cloud/RelayClientInstall import { SshPasswordPromptDialog } from "../components/desktop/SshPasswordPromptDialog"; import { SnapShotCoordinator } from "../components/desktop/SnapShotCoordinator"; import { DesktopAppActivationCoordinator } from "../components/desktop/DesktopAppActivationCoordinator"; +import { DesktopWebLinkCoordinator } from "../components/desktop/DesktopWebLinkCoordinator"; import { RunningThreadKeepAlive } from "../components/desktop/RunningThreadKeepAlive"; import { ProviderUpdateLaunchNotification } from "../components/ProviderUpdateLaunchNotification"; import { NightlyMobileBetaNotice } from "../components/NightlyMobileBeta"; @@ -228,6 +229,7 @@ function RootRouteView() { > {primaryEnvironmentAuthenticated ? : null} {primaryEnvironmentAuthenticated ? : null} + {primaryEnvironmentAuthenticated ? : null} {isElectron ? : null} diff --git a/apps/web/src/state/entities.ts b/apps/web/src/state/entities.ts index aa9b650fb5b5..ebf62f01be6f 100644 --- a/apps/web/src/state/entities.ts +++ b/apps/web/src/state/entities.ts @@ -117,6 +117,12 @@ export function useThreadShell(ref: ScopedThreadRef | null): EnvironmentThreadSh ); } +export function useChildThreadInputs(ref: ScopedThreadRef | null) { + return useAtomValue( + ref === null ? EMPTY_THREAD_SHELLS_ATOM : environmentThreadShells.childThreadInputsAtom(ref), + ); +} + export function useThreadProjection(ref: ScopedThreadRef | null): EnvironmentThread | null { return useAtomValue( ref === null ? EMPTY_THREAD_PROJECTION_ATOM : environmentThreadDetails.threadAtom(ref), diff --git a/apps/web/src/terminal/ghostty/surface.test.ts b/apps/web/src/terminal/ghostty/surface.test.ts index 530183cd238a..c203c5e3cc62 100644 --- a/apps/web/src/terminal/ghostty/surface.test.ts +++ b/apps/web/src/terminal/ghostty/surface.test.ts @@ -81,7 +81,7 @@ describe("GhosttyTerminalSurface visibility", () => { function createHarness() { vi.useFakeTimers(); const frames = new Map(); - const resizeCallbacks = new Set<() => void>(); + const resizeTargets = new Map<(entries: { target: Element }[]) => void, Set>(); const paint = vi.fn((_operation: string, _args: ReadonlyArray) => {}); let frameId = 0; const requestFrame = vi.fn((callback: FrameRequestCallback) => { @@ -169,12 +169,17 @@ describe("GhosttyTerminalSurface visibility", () => { vi.stubGlobal( "ResizeObserver", class { - constructor(private readonly callback: () => void) { - resizeCallbacks.add(callback); + constructor(private readonly callback: (entries: { target: Element }[]) => void) { + resizeTargets.set(callback, new Set()); + } + observe(target: Element) { + resizeTargets.get(this.callback)?.add(target); + } + unobserve(target: Element) { + resizeTargets.get(this.callback)?.delete(target); } - observe() {} disconnect() { - resizeCallbacks.delete(this.callback); + resizeTargets.delete(this.callback); } }, ); @@ -199,7 +204,9 @@ describe("GhosttyTerminalSurface visibility", () => { for (const callback of queued) callback(0); }, resize() { - for (const callback of resizeCallbacks) callback(); + for (const [callback, targets] of resizeTargets) { + callback([...targets].map((target) => ({ target }))); + } }, pointer( type: string, diff --git a/apps/web/src/terminal/ghostty/surface.ts b/apps/web/src/terminal/ghostty/surface.ts index 5ea27cea08c9..7d35733ed44a 100644 --- a/apps/web/src/terminal/ghostty/surface.ts +++ b/apps/web/src/terminal/ghostty/surface.ts @@ -16,6 +16,7 @@ import { } from "./renderer"; import symbolsFontUrl from "./fonts/SymbolsNerdFontMono-Regular.woff2?url"; import { isMonospaceFamily } from "../../appearanceFonts"; +import { observeResize } from "../../lib/observeResize"; export const DEFAULT_TERMINAL_FONT_SIZE = 12; const MIN_TERMINAL_FONT_SIZE = 6; @@ -575,7 +576,7 @@ export class GhosttyTerminalSurface { private fontSize: number; private fontEpoch = 0; private pendingFontEpoch: number | null = null; - private readonly resizeObserver: ResizeObserver; + private readonly stopObservingResize: () => void; private readonly scrollbarThumb: HTMLDivElement; private snapshot: GhosttySnapshot | null = null; private frame = 0; @@ -666,12 +667,11 @@ export class GhosttyTerminalSurface { this.fontFamily = fontFamily; this.requestedFontFamily = options.font?.family; this.fontSize = terminalFontSize(options.font?.size); - this.resizeObserver = new ResizeObserver(() => this.fit()); this.installEvents(); this.watchDevicePixelRatio(); this.reducedMotionMedia?.addEventListener("change", this.onReducedMotionChange); document.fonts.addEventListener("loadingdone", this.onFontsLoaded); - this.resizeObserver.observe(mount); + this.stopObservingResize = observeResize(mount, () => this.fit()); } static async create( @@ -1058,7 +1058,7 @@ export class GhosttyTerminalSurface { dispose(): void { if (this.disposed) return; this.disposed = true; - this.resizeObserver.disconnect(); + this.stopObservingResize(); document.fonts.removeEventListener("loadingdone", this.onFontsLoaded); this.dprMedia?.removeEventListener("change", this.onDevicePixelRatioChange); this.dprMedia = null; diff --git a/docs/README.md b/docs/README.md index a313434ba236..d7628960cb8b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -14,6 +14,7 @@ - [SnapShots](./user/snap-shot.md) - [Visual replies](./user/html-renders.md) - [Import browser sessions](./user/browser-import.md) +- [Use T3 Code as your default browser](./user/default-browser.md) - [Devices](./user/devices.md) - [Usage and limits](./user/usage.md) - [Product usage data](./user/telemetry.md) diff --git a/docs/user/composer.md b/docs/user/composer.md index ba8d26fd81c2..17c6ae4e6ba0 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -157,6 +157,7 @@ and use **Attach again** or remove the missing file before sending. On supported iPhones with iOS 26 or later, use the composer's microphone to record, then confirm to transcribe. Text is inserted where your selection was when recording started, ready for you to review and edit before sending. +To dictate in a language other than the app's, choose it in **Settings → Voice input**. The first use may download Apple's speech model and needs a network connection. Later transcription works offline for that language. Recordings can be up to five diff --git a/docs/user/default-browser.md b/docs/user/default-browser.md new file mode 100644 index 000000000000..23057d0661d3 --- /dev/null +++ b/docs/user/default-browser.md @@ -0,0 +1,13 @@ +# Use T3 Code as your default browser + +On macOS, the desktop app can be your default web browser. Links you open from other apps then +open in T3 Code. When the thread on screen shows its browser panel, the link opens there as a new +tab. Otherwise it starts a new thread without a project, with the browser panel maximized on the +page, so you can ask an agent about it right away. + +Choose **T3 Code** under **System Settings → Desktop & Dock → Default web browser**. To go back, +choose another browser there. + +HTML files you open from Finder open the same way. + +A link that launches T3 Code waits until the app has connected to its environment, then opens. diff --git a/docs/user/project-settings.md b/docs/user/project-settings.md index 598ef5cb5e2e..c92226a9ee4f 100644 --- a/docs/user/project-settings.md +++ b/docs/user/project-settings.md @@ -143,8 +143,11 @@ captures and log retention remain machine-wide. Worktrees can be removed after a chosen number of inactive days, after merging, or when they have no commits beyond the default branch. Only T3-managed worktrees are eligible. Active -sessions, shared worktrees, uncommitted changes, and ignored files other than `node_modules` -prevent removal. Branches and thread history stay; starting another turn recreates the checkout. +sessions and shared worktrees prevent removal. **Keep worktrees with local changes** defaults +to **Uncommitted changes**, which protects tracked edits and untracked files but deletes ignored +files such as `.env` and build output. **Any local files** also protects ignored files other than +`node_modules`. **Edited tracked files** protects only tracked edits and allows untracked and +ignored files to be deleted. Branches and thread history stay; starting another turn recreates the checkout. Merge cleanup requires a merged pull request whose commits are included in the remote default branch. A squash or rebase merge on GitHub also counts when the pull request targeted the default branch and the worktree is still at the pull request's last commit. @@ -154,6 +157,9 @@ thread is deleted, including archived threads and worktrees left by earlier dele server waits for sessions and terminals to stop and retries skipped worktrees after restart. Existing prompts for deleting a worktree manually remain available when this policy is off. +Choose **Delete now** to run the enabled rules immediately. The latest results show which +worktrees were removed or kept and why, plus any failures. + Browser captures and rotated logs have separate retention periods. Expired capture links stop working. Current logs, message attachments, and browser profiles are kept. diff --git a/docs/user/source-control.md b/docs/user/source-control.md index 2e6785db07b9..3730eb6a18d4 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -3,6 +3,13 @@ T3 Code integrates with GitHub, GitLab, Forgejo, Gitea, Bitbucket, and Azure DevOps to clone and publish repositories, create pull requests, and review changes. +## Review turn changes + +A turn's changed files and diff show only the turn's own work. When a turn pulls, merges, or +rebases, the files Git brought in are left out. A file stays in the list when the turn edited it, +committed it, or fixed a conflict in it. Use the branch comparison to review everything that changed +against your base branch. Restore still returns the complete saved workspace. + ## Connect an account Install Git and configure authentication on the machine running your T3 Code server. For a remote diff --git a/oxlint-plugin-t3code/index.ts b/oxlint-plugin-t3code/index.ts index 8497f5f61076..969769ac9b5e 100644 --- a/oxlint-plugin-t3code/index.ts +++ b/oxlint-plugin-t3code/index.ts @@ -9,6 +9,7 @@ import noMobileUniwindThemeEscapeHatches from "./rules/no-mobile-uniwind-theme-e import noNativeTitleTooltip from "./rules/no-native-title-tooltip.ts"; import noOutsetStateIndicators from "./rules/no-outset-state-indicators.ts"; import noRawMcpRegistration from "./rules/no-raw-mcp-registration.ts"; +import noRawResizeObserver from "./rules/no-raw-resize-observer.ts"; import noTestInLoop from "./rules/no-test-in-loop.ts"; import noRpcPermissionBypass from "./rules/no-rpc-permission-bypass.ts"; import noUnscopedHas from "./rules/no-unscoped-has.ts"; @@ -30,6 +31,7 @@ export default definePlugin({ "no-native-title-tooltip": noNativeTitleTooltip, "no-outset-state-indicators": noOutsetStateIndicators, "no-raw-mcp-registration": noRawMcpRegistration, + "no-raw-resize-observer": noRawResizeObserver, "no-test-in-loop": noTestInLoop, "no-rpc-permission-bypass": noRpcPermissionBypass, "no-unscoped-has": noUnscopedHas, diff --git a/oxlint-plugin-t3code/rules/no-raw-resize-observer.ts b/oxlint-plugin-t3code/rules/no-raw-resize-observer.ts new file mode 100644 index 000000000000..40c754ccfbee --- /dev/null +++ b/oxlint-plugin-t3code/rules/no-raw-resize-observer.ts @@ -0,0 +1,33 @@ +import { defineRule, type ESTree } from "@oxlint/plugins"; + +const GLOBAL_OBJECTS = new Set(["globalThis", "window", "self"]); + +function isResizeObserverConstructor(node: ESTree.Node): boolean { + if (node.type === "Identifier") return node.name === "ResizeObserver"; + if (node.type !== "MemberExpression" || node.object.type !== "Identifier") return false; + if (!GLOBAL_OBJECTS.has(node.object.name)) return false; + const { property } = node; + if (!node.computed && property.type === "Identifier") return property.name === "ResizeObserver"; + return property.type === "Literal" && property.value === "ResizeObserver"; +} + +export default defineRule({ + meta: { + type: "problem", + docs: { + description: "Disallow constructing ResizeObserver directly in web code; use observeResize.", + }, + }, + create(context) { + return { + NewExpression(node) { + if (!isResizeObserverConstructor(node.callee)) return; + context.report({ + node: node.callee, + message: + "Use observeResize from ~/lib/observeResize. React commits state set in a raw ResizeObserver callback after the paint, so layout derived from the observed size lands one frame late.", + }); + }, + }; + }, +}); diff --git a/packages/client-runtime/src/rpc/client.ts b/packages/client-runtime/src/rpc/client.ts index 35a957e9d0f2..c11a49897b88 100644 --- a/packages/client-runtime/src/rpc/client.ts +++ b/packages/client-runtime/src/rpc/client.ts @@ -58,6 +58,7 @@ export type EnvironmentSubscriptionRpcTag = | typeof WS_METHODS.subscribeServerConfig | typeof WS_METHODS.subscribeServerLifecycle | typeof WS_METHODS.scheduledTasksSubscribe + | typeof WS_METHODS.serverGetStorageCleanupReport | typeof WS_METHODS.subscribeTerminalEvents | typeof WS_METHODS.subscribeTerminalMetadata | typeof WS_METHODS.subscribePreviewEvents diff --git a/packages/client-runtime/src/state/commandPermissions.test.ts b/packages/client-runtime/src/state/commandPermissions.test.ts index b9d1ebf6b738..b2c6933fabb7 100644 --- a/packages/client-runtime/src/state/commandPermissions.test.ts +++ b/packages/client-runtime/src/state/commandPermissions.test.ts @@ -8,6 +8,7 @@ import { describe, expect, it } from "@effect/vitest"; import { vi } from "vite-plus/test"; import { AuthOrchestrationOperateScope, + AuthSettingsWriteScope, AuthSourceControlWriteScope, ThreadId, EnvironmentId, @@ -58,6 +59,32 @@ const setup = Effect.gen(function* () { }); describe("command permissions", () => { + it.effect("requires the destination settings grant to run storage cleanup", () => + Effect.scoped( + Effect.gen(function* () { + const registry = yield* setup; + const cleanup = createCommandPermissions(runtime, WS_METHODS.serverRunStorageCleanup); + registry.set(sessions(env), AsyncResult.success(grant(true))); + expect(registry.get(cleanup.permissionAtom(env))).toBe(false); + expect((yield* cleanup.authorize(registry, env).pipe(Effect.flip))._tag).toBe( + "EnvironmentAuthorizationError", + ); + registry.set( + sessions(env), + AsyncResult.success({ + ...grant(true), + scopes: [AuthSettingsWriteScope], + permissions: [AuthSettingsWriteScope], + }), + ); + expect(registry.get(cleanup.permissionAtom(env))).toBe(true); + yield* cleanup.authorize(registry, env); + registry.set(sessions(other), AsyncResult.success(grant(false))); + expect(registry.get(cleanup.permissionAtom(other))).toBe(false); + }), + ), + ); + it.effect("uses the target grant for both availability and execution", () => Effect.scoped( Effect.gen(function* () { diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index 91464b16a9b1..1d49fd71f70c 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -983,6 +983,16 @@ export function createServerEnvironmentAtoms( return { configValueAtom, + runStorageCleanup: createEnvironmentRpcCommand(runtime, { + label: "environment-data:server:run-storage-cleanup", + tag: WS_METHODS.serverRunStorageCleanup, + concurrency: { mode: "singleFlight", key: ({ environmentId }) => environmentId }, + }), + storageCleanupReport: createEnvironmentRpcSubscriptionAtomFamily(runtime, { + label: "environment-data:server:storage-cleanup-report", + tag: WS_METHODS.serverGetStorageCleanupReport, + idleTtlMs: 0, + }), updateStateAtom, settingsValueAtom, providersValueAtom, diff --git a/packages/client-runtime/src/state/threadShell.ts b/packages/client-runtime/src/state/threadShell.ts index bc6d444f471a..3cb54087ac14 100644 --- a/packages/client-runtime/src/state/threadShell.ts +++ b/packages/client-runtime/src/state/threadShell.ts @@ -7,6 +7,7 @@ import type { ScopedThreadRef, ThreadId, } from "@t3tools/contracts"; +import { isProviderNativeSubagentThread } from "@t3tools/contracts"; import { Atom } from "effect/reactivity"; import type { EnvironmentThreadShell } from "./models.ts"; @@ -138,6 +139,50 @@ export function createEnvironmentThreadShellAtoms(input: { }).pipe(Atom.withLabel(`environment-thread-shell:${key}`)); }); + // Child questions are hidden with their sidebar rows. Read their shell summaries, + // without subscribing to every child transcript. + const childThreadInputsAtomFamily = Atom.family((key: string) => { + const ref = parseThreadKey(key); + let previous: ReadonlyArray = []; + return Atom.make((get) => { + const children = new Map(); + for (const thread of get(environmentThreadsAtom(ref.environmentId))) { + const parent = thread.lineage.parentThreadId; + if (parent === null || thread.lineage.relationshipToParent !== "subagent") continue; + const siblings = children.get(parent); + if (siblings) siblings.push(thread); + else children.set(parent, [thread]); + } + const seen = new Set([ref.threadId]); + const pending = [ref.threadId]; + const next: EnvironmentThreadShell[] = []; + for (const parent of pending) { + for (const child of children.get(parent) ?? []) { + if (seen.has(child.id)) continue; + seen.add(child.id); + pending.push(child.id); + if ( + child.pendingRuntimeRequest?.kind === "user_input" && + !isProviderNativeSubagentThread(child) + ) { + next.push(scopedThread(ref.environmentId, child)); + } + } + } + // Indicators read only ids and titles, so other shell updates keep the old array. + if ( + previous.length === next.length && + previous.every( + (thread, index) => thread.id === next[index]?.id && thread.title === next[index]?.title, + ) + ) { + return previous; + } + previous = next; + return next; + }).pipe(Atom.withLabel(`environment-child-thread-inputs:${key}`)); + }); + const threadShellsForProjectRefsAtomFamily = Atom.family((key: string) => { const projectRefs = parseProjectRefCollectionKey(key); let previous: ReadonlyArray = []; @@ -225,5 +270,6 @@ export function createEnvironmentThreadShellAtoms(input: { threadShellsForProjectRefsAtom: (refs: ReadonlyArray) => threadShellsForProjectRefsAtomFamily(projectRefCollectionKey(refs)), threadShellAtom: (ref: ScopedThreadRef) => threadShellAtomFamily(threadKey(ref)), + childThreadInputsAtom: (ref: ScopedThreadRef) => childThreadInputsAtomFamily(threadKey(ref)), }; } diff --git a/packages/client-runtime/src/voice-input/controller.test.ts b/packages/client-runtime/src/voice-input/controller.test.ts index d73c8e7505ba..ae637b0d9748 100644 --- a/packages/client-runtime/src/voice-input/controller.test.ts +++ b/packages/client-runtime/src/voice-input/controller.test.ts @@ -134,6 +134,31 @@ describe("resolveTranscriptCommit", () => { }); }); + it("adds word spacing for other space-delimited languages", () => { + const atEnd = draft({ + text: "Revisá la caché.", + selection: { start: "Revisá la caché.".length, end: "Revisá la caché.".length }, + }); + expect(resolveTranscriptCommit(atEnd, atEnd, "Ésta también.", "es-ES")).toMatchObject({ + kind: "commit", + text: "Revisá la caché. Ésta también.", + }); + }); + + it("treats accented letters as word boundaries", () => { + const afterAccent = draft({ text: "café", selection: { start: 4, end: 4 } }); + expect(resolveTranscriptCommit(afterAccent, afterAccent, "también", "es-ES")).toMatchObject({ + kind: "commit", + text: "café también", + }); + + const beforeAccent = draft({ text: "él", selection: { start: 0, end: 0 } }); + expect(resolveTranscriptCommit(beforeAccent, beforeAccent, "Dijo", "es-ES")).toMatchObject({ + kind: "commit", + text: "Dijo él", + }); + }); + it("does not add English boundary spaces to CJK or selected inline text", () => { const cjk = draft({ text: "修正キャッシュ", selection: { start: 8, end: 8 } }); expect(resolveTranscriptCommit(cjk, cjk, "テストも", "ja-JP")).toMatchObject({ diff --git a/packages/client-runtime/src/voice-input/controller.ts b/packages/client-runtime/src/voice-input/controller.ts index 80a8a528f335..c2d07898968e 100644 --- a/packages/client-runtime/src/voice-input/controller.ts +++ b/packages/client-runtime/src/voice-input/controller.ts @@ -92,18 +92,18 @@ export function resolveTranscriptCommit( const isEmptySelection = captured.selection.start === captured.selection.end; const normalizedLocale = locale.replaceAll("_", "-").toLowerCase(); - const usesEnglishSpacing = normalizedLocale === "en" || normalizedLocale.startsWith("en-"); + const usesWordSpacing = !/^(ja|zh|yue|th|lo|km|my)(-|$)/.test(normalizedLocale); let insertion = replacement; - if (isEmptySelection && usesEnglishSpacing) { + if (isEmptySelection && usesWordSpacing) { const left = captured.text[captured.selection.start - 1]; const right = captured.text[captured.selection.start]; const leftNeedsBoundary = left !== undefined && - /[A-Za-z0-9.!?,:;)\]}'"]/.test(left) && + /[\p{L}\p{N}.!?,:;)\]}'"]/u.test(left) && (right === undefined || /\s/.test(right)); const rightNeedsBoundary = right !== undefined && - /[A-Za-z0-9([{'"]/.test(right) && + /[\p{L}\p{N}([{'"]/u.test(right) && (left === undefined || /\s/.test(left)); insertion = `${leftNeedsBoundary ? " " : ""}${replacement}${rightNeedsBoundary ? " " : ""}`; } diff --git a/packages/contracts/src/clientRpcPermissions.ts b/packages/contracts/src/clientRpcPermissions.ts index 9605829e47d7..c84169e6e33f 100644 --- a/packages/contracts/src/clientRpcPermissions.ts +++ b/packages/contracts/src/clientRpcPermissions.ts @@ -2,6 +2,7 @@ import * as Schema from "effect/Schema"; import { GitPreparePullRequestThreadInput } from "./git.ts"; import { AuthOrchestrationOperateScope, + AuthSettingsWriteScope, AuthSourceControlWriteScope, type AuthEnvironmentScope, } from "./auth.ts"; @@ -9,6 +10,7 @@ import { WS_METHODS } from "./rpc.ts"; /** Incremental client enforcement; the server still authorizes every request. */ export const CLIENT_GUARDED_RPC_SCOPES = { + [WS_METHODS.serverRunStorageCleanup]: AuthSettingsWriteScope, [WS_METHODS.pullRequestsRunAction]: AuthSourceControlWriteScope, [WS_METHODS.pullRequestsUpdate]: AuthSourceControlWriteScope, [WS_METHODS.pullRequestsComment]: AuthSourceControlWriteScope, diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index c9ac7482d4cc..2a61480a907b 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -125,6 +125,7 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ /** Server evaluates merge and inactivity settlement without a client. */ threadAutoSettlement: Schema.optionalKey(Schema.Boolean), storageCleanup: Schema.optionalKey(Schema.Boolean), + storageCleanupRun: Schema.optionalKey(Schema.Boolean), projectWorktreeCleanup: Schema.optionalKey(Schema.Boolean), /** Server honors the `worktreesDirectory` setting. */ worktreesDirectory: Schema.optionalKey(Schema.Boolean), diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 19e40442355f..e521c4c37271 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -1246,6 +1246,14 @@ export interface DesktopBridge { uninstall: () => Promise; }; /** Present when the desktop shell accepts `t3 app` activation requests. */ + /** + * Web links the OS opens with T3 Code as the default browser (macOS). The + * renderer reports when it listens; links queue in the main process until then. + */ + webLinks?: { + setReady: (ready: boolean) => Promise; + onOpen: (listener: (url: string) => void) => () => void; + }; appActivation?: { setReady: (ready: boolean) => Promise; complete: (response: DesktopAppActivationResponse) => Promise; diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index ed14e2e16b7c..36afee2f9efa 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -317,7 +317,12 @@ import { ProviderConsumeResetCreditResult, } from "./providerUsageLimits.ts"; import { UsagePricing, UsageReadError, UsageSummary, UsageSummaryInput } from "./usage.ts"; -import { ServerSettings, ServerSettingsError, ServerSettingsPatch } from "./settings.ts"; +import { + StorageCleanupReport, + ServerSettings, + ServerSettingsError, + ServerSettingsPatch, +} from "./settings.ts"; import { ScheduledTaskDeleteInput, ScheduledTaskDeleteResult, @@ -468,6 +473,8 @@ export const WS_METHODS = { serverCommitDesktopUpdate: "server.commitDesktopUpdate", serverUpsertKeybinding: "server.upsertKeybinding", serverRemoveKeybinding: "server.removeKeybinding", + serverRunStorageCleanup: "server.runStorageCleanup", + serverGetStorageCleanupReport: "server.getStorageCleanupReport", serverGetSettings: "server.getSettings", serverUpdateSettings: "server.updateSettings", serverDiscoverSourceControl: "server.discoverSourceControl", @@ -730,6 +737,18 @@ const WsServerCommitDesktopUpdateRpc = Rpc.make(WS_METHODS.serverCommitDesktopUp error: Schema.Union([ServerSelfUpdateError, EnvironmentAuthorizationError]), }); +const WsServerRunStorageCleanupRpc = Rpc.make(WS_METHODS.serverRunStorageCleanup, { + payload: Schema.Struct({}), + success: StorageCleanupReport, + error: Schema.Union([ServerSettingsError, EnvironmentAuthorizationError]), +}); +const WsServerGetStorageCleanupReportRpc = Rpc.make(WS_METHODS.serverGetStorageCleanupReport, { + payload: Schema.Struct({}), + success: Schema.NullOr(StorageCleanupReport), + stream: true, + error: EnvironmentAuthorizationError, +}); + const WsServerGetSettingsRpc = Rpc.make(WS_METHODS.serverGetSettings, { payload: Schema.Struct({}), success: ServerSettings, @@ -1833,6 +1852,8 @@ export const WsRpcGroup = RpcGroup.make( WsServerCommitDesktopUpdateRpc, WsServerUpsertKeybindingRpc, WsServerRemoveKeybindingRpc, + WsServerRunStorageCleanupRpc, + WsServerGetStorageCleanupReportRpc, WsServerGetSettingsRpc, WsServerUpdateSettingsRpc, WsServerDiscoverSourceControlRpc, diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 58e8fa5d9658..b5d39323a23d 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -57,6 +57,7 @@ describe("storage cleanup settings", () => { worktreeOnMerge: false, worktreeOnDelete: false, worktreeUnchanged: false, + worktreeKeepWhen: "uncommitted-changes", browserArtifactsAfterDays: null, logsAfterDays: null, }); @@ -86,6 +87,29 @@ describe("storage cleanup settings", () => { ).toThrow(); }); + it("decodes older custom rules with the default local-file policy", () => { + const settings = decodeServerSettings({ + worktreeCleanup: { + mode: "custom", + rules: { + worktreeAfterDays: 8, + worktreeOnMerge: false, + worktreeOnDelete: false, + worktreeUnchanged: false, + }, + }, + }); + expect(settings.worktreeCleanup).toMatchObject({ + rules: { worktreeKeepWhen: "uncommitted-changes" }, + }); + expect( + decodeServerSettingsPatch({ storageCleanup: { worktreeKeepWhen: "tracked-changes" } }), + ).toEqual({ storageCleanup: { worktreeKeepWhen: "tracked-changes" } }); + expect(() => + decodeServerSettingsPatch({ storageCleanup: { worktreeKeepWhen: "unknown" } }), + ).toThrow(); + }); + it.each([0, -1, 1.5, 3651])("rejects invalid retention %s", (days) => { expect(() => decodeServerSettingsPatch({ storageCleanup: { browserArtifactsAfterDays: days } }), diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 0e8027b9f5fd..0038ede86c2d 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -13,6 +13,7 @@ import { ForwardCompatibleOptional, OmittedWhenNull, ProjectId, + ThreadId, TrimmedNonEmptyString, TrimmedString, } from "./baseSchemas.ts"; @@ -941,7 +942,17 @@ const StorageRetentionDays = Schema.NullOr( Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 3650 })), ); +export const WorktreeKeepWhen = Schema.Literals([ + "any-local-files", + "uncommitted-changes", + "tracked-changes", +]); +export type WorktreeKeepWhen = typeof WorktreeKeepWhen.Type; + export const WorktreeCleanupRules = Schema.Struct({ + worktreeKeepWhen: WorktreeKeepWhen.pipe( + Schema.withDecodingDefault(Effect.succeed("uncommitted-changes")), + ), worktreeAfterDays: StorageRetentionDays, worktreeOnMerge: Schema.Boolean, worktreeOnDelete: Schema.Boolean, @@ -1034,7 +1045,32 @@ const NULLABLE_PROJECT_SETTINGS_OVERRIDES: ReadonlySet { worktreeOnDelete: false, worktreeOnMerge: false, worktreeUnchanged: false, + worktreeKeepWhen: "uncommitted-changes", }); expect(resolveWorktreeCleanup(off, otherProjectId)).toEqual(inherited); const custom = applyServerSettingsPatch(off, { @@ -370,6 +371,7 @@ describe("resolveWorktreeCleanup", () => { worktreeOnDelete: true, worktreeOnMerge: true, worktreeUnchanged: false, + worktreeKeepWhen: "uncommitted-changes", }); expect( resolveWorktreeCleanup(applyServerSettingsPatch(edited, { worktreeCleanup: null }), null) diff --git a/packages/shared/src/projectSettings.ts b/packages/shared/src/projectSettings.ts index f99dacbbb69d..af9a7112c3ea 100644 --- a/packages/shared/src/projectSettings.ts +++ b/packages/shared/src/projectSettings.ts @@ -220,12 +220,24 @@ export function resolveWorktreeCleanup( if (policy?.mode === "custom") return policy.rules; if (policy?.mode === "off") return { + worktreeKeepWhen: settings.storageCleanup.worktreeKeepWhen, worktreeAfterDays: null, worktreeOnMerge: false, worktreeOnDelete: false, worktreeUnchanged: false, }; - const { worktreeAfterDays, worktreeOnMerge, worktreeOnDelete, worktreeUnchanged } = - settings.storageCleanup; - return { worktreeAfterDays, worktreeOnMerge, worktreeOnDelete, worktreeUnchanged }; + const { + worktreeKeepWhen, + worktreeAfterDays, + worktreeOnMerge, + worktreeOnDelete, + worktreeUnchanged, + } = settings.storageCleanup; + return { + worktreeKeepWhen, + worktreeAfterDays, + worktreeOnMerge, + worktreeOnDelete, + worktreeUnchanged, + }; } diff --git a/packages/shared/src/serverSettings.test.ts b/packages/shared/src/serverSettings.test.ts index f7305812e74f..dbc2704171f9 100644 --- a/packages/shared/src/serverSettings.test.ts +++ b/packages/shared/src/serverSettings.test.ts @@ -37,6 +37,7 @@ describe("serverSettings helpers", () => { worktreeOnMerge: true, worktreeOnDelete: false, worktreeUnchanged: false, + worktreeKeepWhen: "uncommitted-changes", browserArtifactsAfterDays: null, logsAfterDays: 30, }); diff --git a/packages/shared/src/serverSettings.ts b/packages/shared/src/serverSettings.ts index 99b0f2c336c1..909eeb0a35ce 100644 --- a/packages/shared/src/serverSettings.ts +++ b/packages/shared/src/serverSettings.ts @@ -324,6 +324,7 @@ export function applyServerSettingsPatch( ? { mode: "custom" as const, rules: { + worktreeKeepWhen: next.storageCleanup.worktreeKeepWhen, worktreeAfterDays: next.storageCleanup.worktreeAfterDays, worktreeOnMerge: next.storageCleanup.worktreeOnMerge, worktreeOnDelete: next.storageCleanup.worktreeOnDelete, diff --git a/scripts/build-desktop-artifact.test.ts b/scripts/build-desktop-artifact.test.ts index 8d27a55266fb..e7c07225ab64 100644 --- a/scripts/build-desktop-artifact.test.ts +++ b/scripts/build-desktop-artifact.test.ts @@ -698,6 +698,21 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { assert.deepStrictEqual((linux.linux as Record).protocols, [ { name: "T3 Code", schemes: ["t3code", "t3code-dev"] }, ]); + // macOS also offers itself as a web browser, so it can be chosen as the default. + assert.deepStrictEqual((mac.mac as Record).protocols, [ + { name: "T3 Code", schemes: ["t3code", "t3code-dev"] }, + { name: "Web site URL", schemes: ["http", "https"], role: "Viewer" }, + ]); + // macOS lists a default browser only when it also opens web pages as documents. + const macInfo = (mac.mac as { extendInfo: Record }).extendInfo; + assert.deepStrictEqual(macInfo.CFBundleDocumentTypes, [ + { + CFBundleTypeName: "Web page", + CFBundleTypeRole: "Viewer", + LSHandlerRank: "Alternate", + LSItemContentTypes: ["public.html", "public.xhtml"], + }, + ]); assert.deepStrictEqual(linux.toolsets, { appimage: "1.0.3" }); assert.notProperty(mac, "toolsets"); assert.notProperty(win, "toolsets"); @@ -2082,6 +2097,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { assert.match(String(mac.sign), /[\\/]scripts[\\/]sign-macos\.ts$/); assert.deepStrictEqual(mac.protocols, [ { name: "T3 Code", schemes: ["t3code", "t3code-dev"] }, + { name: "Web site URL", schemes: ["http", "https"], role: "Viewer" }, ]); }).pipe(Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })))), ); diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index 374e37830662..8270b4138565 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -2813,12 +2813,29 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* ( extendInfo: { NSScreenCaptureUsageDescription: "T3 Code captures the active window when you use the window capture shortcut.", + // macOS lists an app under Default web browser only when it opens web + // pages as documents as well as http and https links (see protocols). + CFBundleDocumentTypes: [ + { + CFBundleTypeName: "Web page", + CFBundleTypeRole: "Viewer", + LSHandlerRank: "Alternate", + LSItemContentTypes: ["public.html", "public.xhtml"], + }, + ], }, protocols: [ { name: "T3 Code", schemes: ["t3code", "t3code-dev"], }, + // Lets people choose T3 Code as their default web browser, which opens + // each link in a new thread's browser panel. + { + name: "Web site URL", + schemes: ["http", "https"], + role: "Viewer", + }, ], ...(signed ? { sign: path.join(repoRoot, "scripts/sign-macos.ts") } : {}), ...(macPasskeySigning diff --git a/vite.config.ts b/vite.config.ts index 8a0fb8966339..2020589ec0a9 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -250,6 +250,16 @@ export default defineConfig({ files: ["apps/mobile/src/**"], rules: { "t3code/no-mobile-uniwind-theme-escape-hatches": "error" }, }, + { + // React commits state set in a raw ResizeObserver callback after the paint, so derived + // layout lands a frame late. observeResize flushes every observed resize in one render. + files: ["apps/web/src/**"], + excludeFiles: [ + "apps/web/src/lib/observeResize.ts", + "**/*.{test,spec}.{ts,tsx,js,jsx,mts,cts,mjs,cjs}", + ], + rules: { "t3code/no-raw-resize-observer": "error" }, + }, { // Every class in web code must be one Tailwind generates: a typo or a class nothing // declares ships silently unstyled. JS hooks use data attributes, not class names.