diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 000000000000..6fc9f6f1a1f7 --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,4 @@ +reviews: + review_status: false + auto_review: + enabled: false diff --git a/.macroscope/approvability.md b/.macroscope/approvability.md index 37be2dc60772..ce4f160ae602 100644 --- a/.macroscope/approvability.md +++ b/.macroscope/approvability.md @@ -1,3 +1,7 @@ Use Macroscope's default approvability criteria. Additionally, any pull request that changes product defaults is not auto-approvable and requires human review. + +Any pull request that adds or broadens a directive that disables or suppresses a lint, +type-checker, LSP, or other static-analysis diagnostic is not auto-approvable and requires +human review. This includes file-level, line-level, and configuration-level overrides. diff --git a/.macroscope/check-run-agents/effect-service-conventions.md b/.macroscope/check-run-agents/effect-service-conventions.md index b76d56d45dbc..57254a1f6eeb 100644 --- a/.macroscope/check-run-agents/effect-service-conventions.md +++ b/.macroscope/check-run-agents/effect-service-conventions.md @@ -82,6 +82,7 @@ Review changed TypeScript and directly affected call sites for the conventions b ## Change discipline - Preserve useful comments, invariants, and specification documentation while moving code. +- Require every new or broadened directive that disables or suppresses a lint, type-checker, LSP, or other static-analysis diagnostic to have an adjacent comment explaining why that diagnostic must be disabled there. The directive itself is not an explanation. Report a missing explanation as a concrete violation. - Do not add large tests solely to prove a mechanical refactor. Update existing tests and imports as needed. - If backend behavior changes, require focused tests. Use test implementations/layers for external services only; do not mock out core business logic. - Do not require `Layer.effect`, universal namespace imports, generic `make`/`layer` names for abstract-port implementations, separate error classes for diagnostic-only fields, or new tests for import-only changes. diff --git a/app.json b/app.json deleted file mode 100644 index 306ca48315c1..000000000000 --- a/app.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "expo": {} -} diff --git a/apps/desktop/package.json b/apps/desktop/package.json index eac3ae11cdfe..9c0349a02537 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/desktop", - "version": "0.0.45", + "version": "0.0.46", "private": true, "type": "module", "main": "dist-electron/main.cjs", @@ -21,7 +21,7 @@ "@t3tools/ssh": "workspace:*", "@t3tools/tailscale": "workspace:*", "effect": "catalog:", - "electron": "41.5.0", + "electron": "43.4.1", "electron-store": "^8.2.0", "electron-updater": "^6.6.2", "playwright-core": "1.60.0", diff --git a/apps/desktop/src/preview/BrowserSession.test.ts b/apps/desktop/src/preview/BrowserSession.test.ts index 743fd6a1fcec..50798de916e0 100644 --- a/apps/desktop/src/preview/BrowserSession.test.ts +++ b/apps/desktop/src/preview/BrowserSession.test.ts @@ -184,7 +184,7 @@ describe("BrowserSession", () => { assert.strictEqual(browserSession.clearStorageData.mock.calls.length, 1); assert.deepEqual(browserSession.clearStorageData.mock.calls[0], [ { - storages: ["cookies", "localstorage", "indexdb", "websql", "serviceworkers"], + storages: ["cookies", "localstorage", "indexdb", "serviceworkers"], }, ]); assert.strictEqual(browserSession.clearCache.mock.calls.length, 1); diff --git a/apps/desktop/src/preview/BrowserSession.ts b/apps/desktop/src/preview/BrowserSession.ts index e11d25bbed77..784afe019edf 100644 --- a/apps/desktop/src/preview/BrowserSession.ts +++ b/apps/desktop/src/preview/BrowserSession.ts @@ -168,7 +168,7 @@ export const make = Effect.gen(function* BrowserSessionMake() { Effect.tryPromise({ try: () => browserSession.clearStorageData({ - storages: ["cookies", "localstorage", "indexdb", "websql", "serviceworkers"], + storages: ["cookies", "localstorage", "indexdb", "serviceworkers"], }), catch: (cause) => new BrowserSessionStorageClearError({ diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 6819d0d91ac9..81ba55d01276 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -40,8 +40,6 @@ const clientSettings: ClientSettings = { planModeEnabled: false, showSkillsInSlashMenu: false, providerModelPreferences: {}, - sidebarAutoSettleAfterDays: 3, - sidebarAutoSettleOnMerge: true, sidebarProjectGroupingMode: "repository_path", sidebarProjectGroupingOverrides: { "environment-1:/tmp/project-a": "separate", diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index c299ddf81a49..9c5501b4ef0b 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -206,6 +206,7 @@ const config: ExpoConfig = { NSAllowsArbitraryLoads: true, }, NSLocalNetworkUsageDescription: `Allow ${MOBILE_PRODUCT_NAME} to connect to ${MOBILE_PRODUCT_NAME} servers on your local network or tailnet.`, + NSPhotoLibraryAddUsageDescription: `Allow ${MOBILE_PRODUCT_NAME} to save images to your photo library.`, ITSAppUsesNonExemptEncryption: false, // The App Store screenshot harness rotates the iPad interface from // inside the app (CI denies osascript the Accessibility access that diff --git a/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3KeyboardCommandsModule.kt b/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3KeyboardCommandsModule.kt new file mode 100644 index 000000000000..68608d9eb3f9 --- /dev/null +++ b/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3KeyboardCommandsModule.kt @@ -0,0 +1,46 @@ +package expo.modules.t3nativecontrols + +import android.content.Context +import android.view.KeyEvent +import expo.modules.kotlin.AppContext +import expo.modules.kotlin.modules.Module +import expo.modules.kotlin.modules.ModuleDefinition +import expo.modules.kotlin.viewevent.EventDispatcher +import expo.modules.kotlin.views.ExpoView + +class T3KeyboardCommandsModule : Module() { + override fun definition() = ModuleDefinition { + Name("T3KeyboardCommands") + + View(T3KeyboardCommandsView::class) { + Prop("enabledCommands") { view: T3KeyboardCommandsView, commands: List -> + view.enabledCommands = commands.toSet() + } + Events("onCommand") + } + } +} + +class T3KeyboardCommandsView( + context: Context, + appContext: AppContext +) : ExpoView(context, appContext) { + private val onCommand by EventDispatcher() + var enabledCommands = emptySet() + + override fun dispatchKeyEvent(event: KeyEvent): Boolean { + val copiesThreadReference = + event.action == KeyEvent.ACTION_DOWN && + event.repeatCount == 0 && + event.keyCode == KeyEvent.KEYCODE_C && + event.isCtrlPressed && + event.isShiftPressed && + !event.isAltPressed && + enabledCommands.contains("copyThreadReference") + if (copiesThreadReference) { + onCommand(mapOf("command" to "copyThreadReference")) + return true + } + return super.dispatchKeyEvent(event) + } +} diff --git a/apps/mobile/modules/t3-native-controls/expo-module.config.json b/apps/mobile/modules/t3-native-controls/expo-module.config.json index d9a77f14e254..8481d61cb5b6 100644 --- a/apps/mobile/modules/t3-native-controls/expo-module.config.json +++ b/apps/mobile/modules/t3-native-controls/expo-module.config.json @@ -4,6 +4,9 @@ "modules": ["T3NativeControlsModule", "T3KeyboardCommandsModule"] }, "android": { - "modules": ["expo.modules.t3nativecontrols.T3NativeControlsModule"] + "modules": [ + "expo.modules.t3nativecontrols.T3NativeControlsModule", + "expo.modules.t3nativecontrols.T3KeyboardCommandsModule" + ] } } diff --git a/apps/mobile/modules/t3-native-controls/ios/T3KeyboardCommandsModule.swift b/apps/mobile/modules/t3-native-controls/ios/T3KeyboardCommandsModule.swift index ea572cc7a018..f902579f4287 100644 --- a/apps/mobile/modules/t3-native-controls/ios/T3KeyboardCommandsModule.swift +++ b/apps/mobile/modules/t3-native-controls/ios/T3KeyboardCommandsModule.swift @@ -29,6 +29,13 @@ public final class T3KeyboardCommandsView: ExpoView { enabledCommand("files", input: "f", modifiers: [.command, .shift], action: #selector(openFiles), title: "Open Files"), enabledCommand("terminal", input: "t", modifiers: [.command, .shift], action: #selector(openTerminal), title: "Open Terminal"), enabledCommand("review", input: "r", modifiers: [.command, .shift], action: #selector(openReview), title: "Open Review"), + enabledCommand( + "copyThreadReference", + input: "c", + modifiers: [.command, .shift], + action: #selector(copyThreadReference), + title: "Copy PR Link or Thread ID" + ), enabledCommand("toggleSidebar", input: "\\", modifiers: .command, action: #selector(handleToggleSidebar), title: "Toggle Sidebar"), ].compactMap { $0 } } @@ -106,6 +113,7 @@ public final class T3KeyboardCommandsView: ExpoView { @objc private func openFiles() { emit("files") } @objc private func openTerminal() { emit("terminal") } @objc private func openReview() { emit("review") } + @objc private func copyThreadReference() { emit("copyThreadReference") } @objc private func handleToggleSidebar() { emit("toggleSidebar") } private func emit(_ command: String) { diff --git a/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift b/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift index 6aa8fa6bb159..ddc8a80270fa 100644 --- a/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift +++ b/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift @@ -3,9 +3,57 @@ import Security import UIKit public final class T3NativeControlsModule: Module { + private let presentationSources = T3PresentationSources() + private var videoPresentation: T3NativeVideoPresentation? + private var filePresentation: T3NativeFilePresentation? + public func definition() -> ModuleDefinition { Name("T3NativeControls") + AsyncFunction("presentVideo") { (url: URL, title: String, sourceIdentifier: String, identifier: String, promise: Promise) in + try self.presentVideo( + url: url, + title: title, + sourceIdentifier: sourceIdentifier, + identifier: identifier, + promise: promise + ) + }.runOnQueue(.main) + + AsyncFunction("dismissVideo") { (identifier: String) in + self.dismissVideo(identifier: identifier) + }.runOnQueue(.main) + + AsyncFunction("presentFile") { (url: URL, title: String, sourceIdentifier: String, identifier: String, promise: Promise) in + try self.presentFile(url: url, title: title, sourceIdentifier: sourceIdentifier, + identifier: identifier, promise: promise) + }.runOnQueue(.main) + + AsyncFunction("dismissFile") { (identifier: String) in + self.dismissFile(identifier: identifier) + }.runOnQueue(.main) + + OnDestroy { + let presentation = self.videoPresentation + let file = self.filePresentation + DispatchQueue.main.async { + presentation?.dismiss() + file?.dismiss() + } + } + + View(T3PresentationSourceView.self) { + ViewName("PresentationSource") + Prop("identifier") { (view: T3PresentationSourceView, identifier: String) in + view.sources = self.presentationSources + view.identifier = identifier + } + } + + AsyncFunction("shareFileFromSource") { (url: URL, title: String, identifier: String, promise: Promise) in + try self.shareFile(url: url, title: title, sourceIdentifier: identifier, promise: promise) + }.runOnQueue(.main) + Function("getShowcasePairingUrl") { let arguments = ProcessInfo.processInfo.arguments guard @@ -101,4 +149,65 @@ public final class T3NativeControlsModule: Module { try? scene.write(toFile: readyPath, atomically: true, encoding: .utf8) } } + + private func presentVideo(url: URL, title: String, sourceIdentifier: String, identifier: String, promise: Promise) throws { + let isPlayableURL = url.isFileURL + ? FileManager.default.isReadableFile(atPath: url.path) + : (["https", "http"].contains(url.scheme?.lowercased() ?? "") && url.host != nil) + guard videoPresentation == nil, filePresentation == nil, + let presenter = appContext?.utilities?.currentViewController(), + isPlayableURL + else { + throw NSError( + domain: "T3NativeVideo", + code: 2, + userInfo: [NSLocalizedDescriptionKey: "The video preview is no longer available."] + ) + } + let presentation = T3NativeVideoPresentation(identifier: identifier, url: url, title: title) { [weak self] error in + self?.videoPresentation = nil + if let error { promise.reject(error) } else { promise.resolve(nil) } + } + videoPresentation = presentation + presentation.present(from: presenter, sources: presentationSources, sourceIdentifier: sourceIdentifier) + } + + private func dismissVideo(identifier: String) { + if videoPresentation?.identifier == identifier { videoPresentation?.dismiss() } + } + + private func presentFile(url: URL, title: String, sourceIdentifier: String, + identifier: String, promise: Promise) throws { + guard filePresentation == nil, videoPresentation == nil, + let presenter = appContext?.utilities?.currentViewController() + else { throw URLError(.cannotLoadFromNetwork) } + let file = T3NativeFilePresentation(identifier: identifier, sources: presentationSources, + sourceIdentifier: sourceIdentifier) { [weak self] error in + self?.filePresentation = nil + if let error { promise.reject(error) } else { promise.resolve(nil) } + } + filePresentation = file + file.present(url: url, title: title, from: presenter) + } + + private func dismissFile(identifier: String) { + if filePresentation?.identifier == identifier { filePresentation?.dismiss() } + } + + private func shareFile(url: URL, title: String, sourceIdentifier: String, promise: Promise) throws { + guard let presenter = appContext?.utilities?.currentViewController() else { + throw NSError( + domain: "T3NativePresentation", + code: 2, + userInfo: [NSLocalizedDescriptionKey: "The presenting screen is no longer open."] + ) + } + try presentFileShare( + url: url, + title: title, + source: presentationSources.view(for: sourceIdentifier), + presenter: presenter, + promise: promise + ) + } } diff --git a/apps/mobile/modules/t3-native-controls/ios/T3NativeFilePresentation.swift b/apps/mobile/modules/t3-native-controls/ios/T3NativeFilePresentation.swift new file mode 100644 index 000000000000..1a7009c3821d --- /dev/null +++ b/apps/mobile/modules/t3-native-controls/ios/T3NativeFilePresentation.swift @@ -0,0 +1,165 @@ +import ImageIO +import QuickLook +import UIKit +import UniformTypeIdentifiers + +private final class FilePreviewItem: NSObject, QLPreviewItem { + var previewItemURL: URL? + var previewItemTitle: String? +} + +private final class FilePreviewController: QLPreviewController { + var onAppear: (() -> Void)? + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + onAppear?() + } +} + +/// Quick Look owns image and document controls, zooming, and source-view transitions. +final class T3NativeFilePresentation: NSObject, QLPreviewControllerDataSource, + QLPreviewControllerDelegate, UIAdaptivePresentationControllerDelegate { + let identifier: String + private var controller: UIViewController? + private let completion: (Error?) -> Void + private weak var sources: T3PresentationSources? + private let sourceIdentifier: String + private let item = FilePreviewItem() + private var loading: Task? + private var dismissRequested = false + private var finished = false + + init(identifier: String, sources: T3PresentationSources, sourceIdentifier: String, completion: @escaping (Error?) -> Void) { + self.identifier = identifier + self.sources = sources + self.sourceIdentifier = sourceIdentifier + self.completion = completion + super.init() + } + + func present(url: URL, title: String, from presenter: UIViewController) { + loading = Task { @MainActor [self] in + do { + let file = try await Self.prepareFile(url: url, title: title) + guard !finished, !Task.isCancelled else { + try? FileManager.default.removeItem(at: file.deletingLastPathComponent()) + return + } + item.previewItemURL = file + item.previewItemTitle = title + let preview = FilePreviewController() + preview.delegate = self + preview.dataSource = self + preview.onAppear = { [weak self] in self?.resumePendingDismissal() } + controller = preview + presenter.present(preview, animated: !UIAccessibility.isReduceMotionEnabled) { [self] in + resumePendingDismissal() + } + preview.presentationController?.delegate = self + } catch { + finish(error: error) + } + } + } + + func dismiss() { + dismissRequested = true + loading?.cancel() + guard !finished else { return } + guard let controller else { finish(); return } + // Drain Close from viewDidAppear after opening or cancelling an interactive dismissal. + // Starting a second modal transition while UIKit is settling the first can strand it. + guard !controller.isBeingPresented, !controller.isBeingDismissed else { return } + controller.dismiss(animated: !UIAccessibility.isReduceMotionEnabled) { [self] in finish() } + } + + private func resumePendingDismissal() { + // Appearance callbacks run before UIKit has cleared the current transition. + DispatchQueue.main.async { [weak self] in + if self?.dismissRequested == true { self?.dismiss() } + } + } + + func numberOfPreviewItems(in controller: QLPreviewController) -> Int { item.previewItemURL == nil ? 0 : 1 } + + func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> QLPreviewItem { + item + } + + func previewController(_ controller: QLPreviewController, transitionViewFor item: QLPreviewItem) -> UIView? { + guard !UIAccessibility.isReduceMotionEnabled else { return nil } + return sources?.view(for: sourceIdentifier) + } + + func previewController(_ controller: QLPreviewController, frameFor item: QLPreviewItem, + inSourceView view: AutoreleasingUnsafeMutablePointer) -> CGRect { + guard !UIAccessibility.isReduceMotionEnabled, let source = sources?.view(for: sourceIdentifier) else { return .zero } + view.pointee = source + return source.bounds + } + + func previewControllerDidDismiss(_ controller: QLPreviewController) { finish() } + + func presentationControllerDidDismiss(_ presentationController: UIPresentationController) { finish() } + + private func finish(error: Error? = nil) { + guard !finished else { return } + finished = true + loading?.cancel() + loading = nil + if let file = item.previewItemURL { + try? FileManager.default.removeItem(at: file.deletingLastPathComponent()) + } + item.previewItemURL = nil + DispatchQueue.main.async { [completion] in completion(error) } + } + + /// Copy original bytes so preview and sharing do not mutate a draft or workspace file. + nonisolated private static func prepareFile(url: URL, title: String) async throws -> URL { + try Task.checkCancellation() + let directory = FileManager.default.temporaryDirectory.appendingPathComponent("t3-preview-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + do { + let download = directory.appendingPathComponent("original") + if url.isFileURL { + try FileManager.default.copyItem(at: url, to: download) + } else if url.scheme == "data" { + try Data(contentsOf: url).write(to: download, options: .atomic) + } else { + guard ["https", "http"].contains(url.scheme?.lowercased() ?? "") else { + throw URLError(.unsupportedURL) + } + let (temporaryFile, response) = try await URLSession.shared.download(from: url) + guard let response = response as? HTTPURLResponse, (200..<300).contains(response.statusCode) else { + throw URLError(.badServerResponse) + } + try FileManager.default.moveItem(at: temporaryFile, to: download) + } + try Task.checkCancellation() + let type: UTType + if let image = CGImageSourceCreateWithURL(download as CFURL, nil), + CGImageSourceGetCount(image) > 0, let imageType = CGImageSourceGetType(image), + let detectedType = UTType(imageType as String) { + type = detectedType + } else if CGPDFDocument(download as CFURL) != nil { + type = .pdf + } else { + throw URLError(.cannotDecodeContentData) + } + let filename = URL(fileURLWithPath: title).lastPathComponent as NSString + let originalExtension = filename.pathExtension + let fileExtension = UTType(filenameExtension: originalExtension) == type + ? originalExtension : type.preferredFilenameExtension ?? "png" + let stem = filename.deletingPathExtension + var name = String(stem.prefix(60)).components(separatedBy: .controlCharacters).joined(separator: "_") + while name.utf8.count > 200 { name.removeLast() } + let file = directory.appendingPathComponent("\(name.isEmpty ? "Preview" : name).\(fileExtension)") + try FileManager.default.moveItem(at: download, to: file) + return file + } catch { + try? FileManager.default.removeItem(at: directory) + throw error + } + } +} diff --git a/apps/mobile/modules/t3-native-controls/ios/T3NativePresentation.swift b/apps/mobile/modules/t3-native-controls/ios/T3NativePresentation.swift new file mode 100644 index 000000000000..f537e8704dcb --- /dev/null +++ b/apps/mobile/modules/t3-native-controls/ios/T3NativePresentation.swift @@ -0,0 +1,75 @@ +import ExpoModulesCore +import UIKit + +final class T3PresentationSources { + private class Entry { + weak var view: UIView? + init(_ view: UIView) { self.view = view } + } + + private var entries: [String: Entry] = [:] + + func register(_ view: UIView, identifier: String) { + entries[identifier] = Entry(view) + } + + func remove(_ view: UIView, identifier: String) { + if entries[identifier]?.view == nil || entries[identifier]?.view === view { + entries.removeValue(forKey: identifier) + } + } + + func view(for identifier: String) -> UIView? { + // Use the child bounds, not the wrapper's potentially stretched layout bounds. + entries[identifier]?.view?.subviews.first + } +} + +final class T3PresentationSourceView: ExpoView { + weak var sources: T3PresentationSources? + var identifier = "" { + didSet { + sources?.remove(self, identifier: oldValue) + if !identifier.isEmpty { sources?.register(self, identifier: identifier) } + } + } + + deinit { + sources?.remove(self, identifier: identifier) + } +} + +func presentFileShare( + url: URL, + title: String, + source: UIView?, + presenter: UIViewController, + promise: Promise +) throws { + guard url.isFileURL, FileManager.default.isReadableFile(atPath: url.path) else { + throw NSError( + domain: "T3NativePresentation", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "The file is no longer available."] + ) + } + + guard let origin = source ?? presenter.view else { + throw NSError( + domain: "T3NativePresentation", + code: 2, + userInfo: [NSLocalizedDescriptionKey: "The presenting screen is no longer open."] + ) + } + + let activity = UIActivityViewController(activityItems: [url], applicationActivities: nil) + activity.title = title + activity.overrideUserInterfaceStyle = source?.traitCollection.userInterfaceStyle + ?? presenter.traitCollection.userInterfaceStyle + activity.completionWithItemsHandler = { _, _, _, _ in promise.resolve(nil) } + activity.modalPresentationStyle = .popover + activity.popoverPresentationController?.sourceView = origin + activity.popoverPresentationController?.sourceRect = source?.bounds + ?? CGRect(x: origin.bounds.midX, y: origin.bounds.maxY, width: 0, height: 0) + presenter.present(activity, animated: true) +} diff --git a/apps/mobile/modules/t3-native-controls/ios/T3NativeVideoPresentation.swift b/apps/mobile/modules/t3-native-controls/ios/T3NativeVideoPresentation.swift new file mode 100644 index 000000000000..74d2f1c7551d --- /dev/null +++ b/apps/mobile/modules/t3-native-controls/ios/T3NativeVideoPresentation.swift @@ -0,0 +1,167 @@ +import AVKit +import UIKit + +final class T3NativeVideoPresentation: NSObject, AVPlayerViewControllerDelegate, + UIAdaptivePresentationControllerDelegate { + let identifier: String + private let controller = AVPlayerViewController() + private let completion: (Error?) -> Void + private var itemObservation: NSKeyValueObservation? + private var backgroundObserver: NSObjectProtocol? + private var playbackError: Error? + private var presented = false + private var dismissRequested = false + private var finished = false + private struct AudioSessionConfiguration { + let category: AVAudioSession.Category + let mode: AVAudioSession.Mode + let options: AVAudioSession.CategoryOptions + + init(_ session: AVAudioSession) { + category = session.category + mode = session.mode + options = session.categoryOptions + } + } + private var previousAudioSession: AudioSessionConfiguration? + private weak var fullScreenController: UIViewController? + private var embedded = false + + init(identifier: String, url: URL, title: String, completion: @escaping (Error?) -> Void) { + self.identifier = identifier + self.completion = completion + super.init() + + let item = AVPlayerItem(url: url) + let metadata = AVMutableMetadataItem() + metadata.identifier = .commonIdentifierTitle + metadata.value = title as NSString + item.externalMetadata = [metadata] + controller.player = AVPlayer(playerItem: item) + controller.delegate = self + controller.overrideUserInterfaceStyle = .dark + controller.allowsPictureInPicturePlayback = false + + itemObservation = item.observe(\.status, options: [.initial, .new]) { [weak self] item, _ in + guard item.status == .failed else { return } + DispatchQueue.main.async { + guard let self else { return } + self.playbackError = item.error ?? NSError( + domain: "T3NativeVideo", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "This video couldn't be played on this device."] + ) + self.dismiss() + } + } + backgroundObserver = NotificationCenter.default.addObserver( + forName: UIApplication.didEnterBackgroundNotification, object: nil, queue: .main + ) { [weak self] _ in self?.controller.player?.pause() } + } + + func present(from presenter: UIViewController, sources: T3PresentationSources, sourceIdentifier: String) { + let audioSession = AVAudioSession.sharedInstance() + previousAudioSession = AudioSessionConfiguration(audioSession) + do { + try audioSession.setCategory(.playback, mode: .moviePlayback) + } catch { + NSLog("T3 video audio session: %@", error.localizedDescription) + } + // AVKit exposes programmatic inline-to-full-screen entry through this selector. + // This is the same guarded entry point used by expo-video's enterFullscreen(). + let enterFullScreen = NSSelectorFromString("enterFullScreenAnimated:completionHandler:") + if let source = sources.view(for: sourceIdentifier), source.window != nil, + controller.responds(to: enterFullScreen) { + // AVKit owns the transition from its inline view to full screen. Using a + // separate UIKit zoom transition prevents its native Close action from exiting. + var responder: UIResponder? = source + while let current = responder, !(current is UIViewController) { responder = current.next } + let parent = responder as? UIViewController ?? presenter + embedded = true + parent.addChild(controller) + controller.view.frame = source.bounds + controller.view.autoresizingMask = [.flexibleWidth, .flexibleHeight] + source.addSubview(controller.view) + controller.didMove(toParent: parent) + controller.view.layoutIfNeeded() + controller.perform(enterFullScreen, with: true, with: nil) + controller.player?.play() + } else { + presenter.present(controller, animated: true) { [self] in + presented = true + if dismissRequested { + dismiss() + } else if UIApplication.shared.applicationState == .active { + controller.player?.play() + } + } + controller.presentationController?.delegate = self + } + } + + func dismiss() { + dismissRequested = true + guard !finished else { return } + guard presented else { + if embedded && fullScreenController == nil { finish() } + return + } + (fullScreenController ?? controller).dismiss(animated: true) { [self] in finish() } + } + + func playerViewController( + _ playerViewController: AVPlayerViewController, + willBeginFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator + ) { + fullScreenController = coordinator.viewController(forKey: .to) + coordinator.animate(alongsideTransition: nil) { [weak self] context in + guard let self else { return } + if context.isCancelled { + finish() + } else { + presented = true + if dismissRequested { dismiss() } + } + } + } + + func playerViewController( + _ playerViewController: AVPlayerViewController, + willEndFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator + ) { + coordinator.animate(alongsideTransition: nil) { [weak self] context in + if !context.isCancelled { self?.finish() } + } + } + + func presentationControllerDidDismiss(_ presentationController: UIPresentationController) { + finish() + } + + private func finish() { + guard !finished else { return } + finished = true + controller.player?.pause() + if embedded { + controller.willMove(toParent: nil) + controller.view.removeFromSuperview() + controller.removeFromParent() + } + itemObservation = nil + controller.player = nil + if let backgroundObserver { NotificationCenter.default.removeObserver(backgroundObserver) } + backgroundObserver = nil + let audioSession = AVAudioSession.sharedInstance() + if let previousAudioSession, audioSession.category == .playback, + audioSession.mode == .moviePlayback, audioSession.categoryOptions.isEmpty { + // AVPlayer owns activation. Deactivating the shared session here could + // stop another player or recorder that was active before this preview. + try? audioSession.setCategory( + previousAudioSession.category, + mode: previousAudioSession.mode, + options: previousAudioSession.options + ) + } + completion(playbackError) + } +} diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 3fcb1f76f3d9..488ca3e16b24 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -85,6 +85,7 @@ "expo-crypto": "~57.0.2", "expo-dev-client": "~57.0.16", "expo-device": "~57.0.1", + "expo-document-picker": "~57.0.1", "expo-file-system": "~57.0.6", "expo-font": "~57.0.2", "expo-glass-effect": "~57.0.1", @@ -102,6 +103,7 @@ "expo-sqlite": "~57.0.2", "expo-symbols": "~57.0.2", "expo-updates": "~57.0.19", + "expo-video": "~57.0.3", "expo-web-browser": "~57.0.2", "expo-widgets": "~57.0.15", "punycode": "^2.3.1", diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index 7cffbf62b0d7..57303a1bb001 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -73,6 +73,7 @@ import { NATIVE_LIQUID_GLASS_SUPPORTED } from "./native/native-glass"; import { nativeHeaderScrollEdgeEffects } from "./native/StackHeader"; import { FORM_SHEET_PRESENTATION_OPTIONS } from "./native/sheet-surface"; import { useThreadOutboxDrain } from "./state/use-thread-outbox-drain"; +import { useComposerAttachmentUploadWorker } from "./state/composer-attachment-uploads"; const HEADER_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version); @@ -355,6 +356,7 @@ function workspacePathFromState(state: NavigationState): string { // each enqueue, shell change, or reconnect. function ThreadOutboxDrainWorker() { useThreadOutboxDrain(); + useComposerAttachmentUploadWorker(); return null; } diff --git a/apps/mobile/src/components/ComposerAttachmentStrip.tsx b/apps/mobile/src/components/ComposerAttachmentStrip.tsx index 3b8017fb1816..16f0d422af78 100644 --- a/apps/mobile/src/components/ComposerAttachmentStrip.tsx +++ b/apps/mobile/src/components/ComposerAttachmentStrip.tsx @@ -1,16 +1,33 @@ import { SymbolView } from "../components/AppSymbol"; -import { Image, Pressable, ScrollView, View } from "react-native"; +import { videoMimeType } from "@t3tools/shared/video"; +import { useEffect, useRef, useState } from "react"; +import { Alert, Image, Pressable, ScrollView, View } from "react-native"; import { AppText as Text } from "./AppText"; -import type { DraftComposerAttachment } from "../lib/composerImages"; +import type { DraftComposerAttachment, DraftComposerFileAttachment } from "../lib/composerImages"; +import { VideoAttachmentTile } from "./VideoAttachmentTile"; +import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview"; +import { PresentationSource } from "./NativePresentation"; +import type { FilePreviewSource } from "./FilePreviewModal"; +import { isPdfFile } from "../lib/filePreview"; +import type { EnvironmentId } from "@t3tools/contracts"; +import { + retryComposerAttachmentUpload, + useComposerAttachmentUploadState, +} from "../state/composer-attachment-uploads"; export interface ComposerAttachmentStripProps { + readonly environmentId?: EnvironmentId; /** Attachments to display. */ readonly attachments: ReadonlyArray; /** Called when the user removes an attachment. */ readonly onRemove: (imageId: string) => void; - /** Called when the user taps on an image thumbnail to preview it. */ - readonly onPressImage?: (previewUri: string) => void; + /** Called when the user taps an image or PDF to preview it. */ + readonly onPressPreview?: (source: FilePreviewSource) => void; + readonly onPressVideo?: ( + attachment: DraftComposerFileAttachment, + sourceIdentifier: string, + ) => void; /** Image thumbnail size in points. Defaults to 72. */ readonly imageSize?: number; /** Border radius of each image thumbnail. Defaults to 16. */ @@ -19,6 +36,199 @@ export interface ComposerAttachmentStripProps { readonly removeButtonPlacement?: "overlay" | "gutter"; } +type ComposerAttachmentThumbnailProps = { + readonly environmentId?: EnvironmentId; + readonly attachment: DraftComposerAttachment; + readonly size: number; + readonly borderRadius: number; + readonly compact?: boolean; + readonly onPressPreview?: (source: FilePreviewSource) => void; + readonly onPressVideo?: ( + attachment: DraftComposerFileAttachment, + sourceIdentifier: string, + ) => void; +}; + +export function ComposerAttachmentThumbnail(props: ComposerAttachmentThumbnailProps) { + const upload = useComposerAttachmentUploadState(props.environmentId, props.attachment.id); + return ( + + + {upload && upload.status !== "ready" ? ( + + props.environmentId && + retryComposerAttachmentUpload(props.environmentId, props.attachment.id) + } + className="absolute bottom-0.5 left-0.5 flex-row items-center gap-0.5 rounded-full bg-black/70 px-1 py-0.5" + > + + {!props.compact ? ( + + {upload.status === "failed" ? "Retry" : `${Math.floor(upload.progress * 100)}%`} + + ) : null} + + ) : null} + + ); +} + +function ComposerAttachmentContent(props: ComposerAttachmentThumbnailProps) { + const { attachment } = props; + const style = { width: props.size, height: props.size, borderRadius: props.borderRadius }; + if (attachment.type === "image") { + const sourceIdentifier = `draft-image:${attachment.id}`; + return ( + + + props.onPressPreview?.({ + kind: "image", + uri: attachment.dataUrl, + name: attachment.name, + sourceIdentifier, + }) + } + > + + + + ); + } + const onPressVideo = props.onPressVideo; + if (onPressVideo && videoMimeType(attachment) !== null) { + return ( + + ); + } + const canPreview = isPdfFile(attachment) && props.onPressPreview !== undefined; + const sourceIdentifier = `draft-file:${attachment.id}`; + return ( + + + props.onPressPreview?.({ + kind: "pdf", + name: attachment.name, + attachment, + sourceIdentifier, + }) + } + className={ + props.compact + ? "items-center justify-center bg-subtle" + : "items-center justify-center gap-1 bg-subtle px-2" + } + style={style} + > + + {!props.compact ? ( + + {attachment.name} + + ) : null} + + + ); +} + +function ComposerVideoAttachment(props: { + readonly attachment: DraftComposerFileAttachment; + readonly size: number; + readonly borderRadius: number; + readonly compact?: boolean; + readonly onPressVideo: ( + attachment: DraftComposerFileAttachment, + sourceIdentifier: string, + ) => void; +}) { + const { attachment } = props; + const sourceIdentifier = `draft:${attachment.id}`; + const style = { width: props.size, height: props.size, borderRadius: props.borderRadius }; + const shareRef = useRef(null); + const [sharing, setSharing] = useState(false); + useEffect( + () => () => { + shareRef.current?.abort(); + shareRef.current = null; + }, + [], + ); + + const onShare = () => { + if (shareRef.current) return; + const controller = new AbortController(); + shareRef.current = controller; + setSharing(true); + void (async () => { + const preview = await loadLocalAttachmentPreview(attachment, controller.signal); + if (!preview) return; + try { + await preview.share(controller.signal, sourceIdentifier); + } finally { + preview.dispose(); + } + })() + .catch((error: unknown) => { + if (!controller.signal.aborted) { + Alert.alert( + "Could not share video", + error instanceof Error ? error.message : "Try again.", + ); + } + }) + .finally(() => { + if (shareRef.current === controller) { + shareRef.current = null; + setSharing(false); + } + }); + }; + + return ( + props.onPressVideo(attachment, sourceIdentifier)} + onShare={onShare} + disabled={sharing} + style={style} + /> + ); +} + /** * Attachment thumbnails used by the thread composer and the new-task draft screen. */ @@ -49,38 +259,14 @@ export function ComposerAttachmentStrip(props: ComposerAttachmentStripProps) { paddingRight: removeButtonGutter, }} > - {attachment.type === "image" ? ( - props.onPressImage!(attachment.previewUri) : undefined - } - > - - - ) : ( - - - - {attachment.name} - - - )} + ; + dismissFile(identifier: string): Promise; +}>("T3NativeControls"); + +export function FilePreview(props: { + readonly source: ResolvedFilePreviewSource; + readonly onRequestClose: () => void; +}) { + const { uri, name, sourceIdentifier } = props.source; + const identifier = useId(); + const onRequestClose = useEffectEvent(props.onRequestClose); + + useEffect(() => { + let canceled = false; + void NativeControls.presentFile(uri, name ?? "Preview", sourceIdentifier ?? "", identifier) + .catch(() => { + if (!canceled) { + Alert.alert("Could not open preview", "The file could not be loaded. Please try again."); + } + }) + .finally(() => { + if (!canceled) onRequestClose(); + }); + return () => { + canceled = true; + void NativeControls.dismissFile(identifier).catch(() => undefined); + }; + }, [uri, name, sourceIdentifier, identifier]); + + return null; +} diff --git a/apps/mobile/src/components/FilePreview.tsx b/apps/mobile/src/components/FilePreview.tsx new file mode 100644 index 000000000000..f10bfb8b3e63 --- /dev/null +++ b/apps/mobile/src/components/FilePreview.tsx @@ -0,0 +1,52 @@ +import { useEffect, useEffectEvent } from "react"; +import { Alert } from "react-native"; +import ImageViewing from "react-native-image-viewing"; + +import { downloadAndShareAttachment, shareLocalAttachment } from "../lib/attachmentDownload"; +import type { ResolvedFilePreviewSource } from "./FilePreviewModal"; + +function PdfPreview(props: { + readonly source: ResolvedFilePreviewSource; + readonly onRequestClose: () => void; +}) { + const { uri, name } = props.source; + const onRequestClose = useEffectEvent(props.onRequestClose); + useEffect(() => { + const controller = new AbortController(); + const input = { + attachment: { name: name ?? "Document.pdf", mimeType: "application/pdf" }, + signal: controller.signal, + }; + // Android's system chooser supplies the installed PDF apps. + const opened = + uri.startsWith("file:") || uri.startsWith("content:") + ? shareLocalAttachment({ ...input, uri }) + : downloadAndShareAttachment({ ...input, url: uri }); + void opened + .catch(() => { + if (!controller.signal.aborted) Alert.alert("Could not open PDF", "Please try again."); + }) + .finally(() => { + if (!controller.signal.aborted) onRequestClose(); + }); + return () => controller.abort(); + }, [uri, name]); + return null; +} + +export function FilePreview(props: { + readonly source: ResolvedFilePreviewSource; + readonly onRequestClose: () => void; +}) { + if (props.source.kind === "pdf") return ; + return ( + + ); +} diff --git a/apps/mobile/src/components/FilePreviewModal.tsx b/apps/mobile/src/components/FilePreviewModal.tsx new file mode 100644 index 000000000000..c9df7e892c27 --- /dev/null +++ b/apps/mobile/src/components/FilePreviewModal.tsx @@ -0,0 +1,93 @@ +import { useIsFocused } from "@react-navigation/native"; +import type { AssetResource, EnvironmentId } from "@t3tools/contracts"; +import { useEffect, useEffectEvent, useState } from "react"; +import { Alert, Keyboard } from "react-native"; + +import type { DraftComposerFileAttachment } from "../lib/composerImages"; +import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview"; +import { useAssetUrlState } from "../state/assets"; +import { usePreparedConnection } from "../state/session"; +import { FilePreview } from "./FilePreview"; + +export interface ResolvedFilePreviewSource { + readonly kind: "image" | "pdf"; + readonly uri: string; + readonly name?: string; + readonly sourceIdentifier?: string; +} + +export type FilePreviewSource = Omit & + ( + | { readonly uri: string } + | { readonly attachment: DraftComposerFileAttachment } + | { readonly environmentId: EnvironmentId; readonly resource: AssetResource } + ); + +function ResolvedFilePreview(props: { + readonly source: FilePreviewSource; + readonly onRequestClose: () => void; +}) { + const { source } = props; + const environmentId = "environmentId" in source ? source.environmentId : null; + const connection = usePreparedConnection(environmentId); + const asset = useAssetUrlState(environmentId, "resource" in source ? source.resource : null); + // Keep the original URL through dismissal; a refreshed signature must not reopen the viewer. + const [uri, setUri] = useState("uri" in source ? source.uri : null); + const onRequestClose = useEffectEvent(props.onRequestClose); + const failed = + environmentId !== null && + uri === null && + (connection._tag === "None" || asset._tag === "Failure"); + useEffect(() => Keyboard.dismiss(), []); + useEffect(() => { + if (uri === null && asset._tag === "Success") setUri(asset.url); + }, [uri, asset]); + useEffect(() => { + if (!failed) return; + Alert.alert("Could not open preview", "Reconnect to this environment and try again."); + onRequestClose(); + }, [failed]); + useEffect(() => { + if (!("attachment" in source)) return; + const controller = new AbortController(); + let release: (() => void) | undefined; + void loadLocalAttachmentPreview(source.attachment, controller.signal) + .then((file) => { + if (!file) return; + if (controller.signal.aborted) { + file.dispose(); + return; + } + release = file.dispose; + setUri(file.uri); + }) + .catch(() => { + if (controller.signal.aborted) return; + Alert.alert("Could not open preview", "Attach the file again and retry."); + onRequestClose(); + }); + return () => { + controller.abort(); + release?.(); + }; + }, [source]); + + return uri === null ? null : ( + + ); +} + +export function FilePreviewModal(props: { + readonly source: FilePreviewSource | null; + readonly onRequestClose: () => void; +}) { + const isFocused = useIsFocused(); + const hasSource = props.source !== null; + const onRequestClose = useEffectEvent(props.onRequestClose); + useEffect(() => { + if (!isFocused && hasSource) onRequestClose(); + }, [isFocused, hasSource]); + + if (!props.source || !isFocused) return null; + return ; +} diff --git a/apps/mobile/src/components/NativePresentation.ios.tsx b/apps/mobile/src/components/NativePresentation.ios.tsx new file mode 100644 index 000000000000..b93578dde602 --- /dev/null +++ b/apps/mobile/src/components/NativePresentation.ios.tsx @@ -0,0 +1,12 @@ +import { requireNativeView } from "expo"; +import type { ComponentType } from "react"; +import type { PresentationSourceProps } from "./NativePresentation"; + +const NativeSource: ComponentType = requireNativeView( + "T3NativeControls", + "PresentationSource", +); + +export function PresentationSource(props: PresentationSourceProps) { + return ; +} diff --git a/apps/mobile/src/components/NativePresentation.tsx b/apps/mobile/src/components/NativePresentation.tsx new file mode 100644 index 000000000000..d48b8839540c --- /dev/null +++ b/apps/mobile/src/components/NativePresentation.tsx @@ -0,0 +1,13 @@ +import type { ReactElement } from "react"; +import { View, type ViewProps } from "react-native"; + +export interface PresentationSourceProps extends ViewProps { + readonly children: ReactElement; + /** Stable across remounts so dismissal can find a recycled attachment thumbnail. */ + readonly identifier: string; +} + +/** Registers the view as an iOS zoom or share-sheet origin. */ +export function PresentationSource({ identifier: _identifier, ...props }: PresentationSourceProps) { + return ; +} diff --git a/apps/mobile/src/components/VideoAttachmentMenu.tsx b/apps/mobile/src/components/VideoAttachmentMenu.tsx new file mode 100644 index 000000000000..301d6503a508 --- /dev/null +++ b/apps/mobile/src/components/VideoAttachmentMenu.tsx @@ -0,0 +1,53 @@ +import type { ReactElement } from "react"; +import { Platform, type PressableProps } from "react-native"; + +import { ControlPillMenu } from "./ControlPill"; +import { PresentationSource } from "./NativePresentation"; + +export function VideoAttachmentMenu(props: { + readonly sourceIdentifier: string; + readonly onOpen: () => void; + readonly onShare?: () => void; + readonly disabled?: boolean; + readonly children: ReactElement; +}) { + return ( + { + if (!props.disabled) props.onOpen(); + }} + accessibilityActions={props.onShare ? [{ name: "share", label: "Save or share video" }] : []} + onAccessibilityAction={({ nativeEvent }) => { + if (nativeEvent.actionName === "share" && !props.disabled) props.onShare?.(); + }} + > + {Platform.OS === "ios" && props.onShare ? ( + { + if (nativeEvent.event === "share") props.onShare?.(); + }} + > + {props.children} + + ) : ( + props.children + )} + + ); +} diff --git a/apps/mobile/src/components/VideoAttachmentTile.tsx b/apps/mobile/src/components/VideoAttachmentTile.tsx new file mode 100644 index 000000000000..6f582ac5f005 --- /dev/null +++ b/apps/mobile/src/components/VideoAttachmentTile.tsx @@ -0,0 +1,66 @@ +import { Platform, Pressable, View, type StyleProp, type ViewStyle } from "react-native"; + +import { cn } from "../lib/cn"; +import type { DraftComposerFileAttachment } from "../lib/composerImages"; +import { SymbolView } from "./AppSymbol"; +import { AppText } from "./AppText"; +import { VideoAttachmentMenu } from "./VideoAttachmentMenu"; +import { VideoThumbnailImage } from "./VideoThumbnailImage"; + +export function VideoAttachmentTile(props: { + readonly name: string; + readonly sourceIdentifier: string; + readonly thumbnailSource: string | DraftComposerFileAttachment | null; + readonly compact?: boolean; + readonly onPress: (sourceIdentifier: string) => void; + readonly onShare?: () => void; + readonly disabled?: boolean; + readonly className?: string; + readonly style?: StyleProp; +}) { + return ( + props.onPress(props.sourceIdentifier)} + onShare={props.onShare} + disabled={props.disabled} + > + props.onPress(props.sourceIdentifier)} + className={cn("items-center justify-center overflow-hidden bg-black/80", props.className)} + style={props.style} + > + + + + + {!props.compact ? ( + + + {props.name} + + + ) : null} + + + ); +} diff --git a/apps/mobile/src/components/VideoPreviewModal.ios.tsx b/apps/mobile/src/components/VideoPreviewModal.ios.tsx new file mode 100644 index 000000000000..a947d8d2e51c --- /dev/null +++ b/apps/mobile/src/components/VideoPreviewModal.ios.tsx @@ -0,0 +1,121 @@ +import { useIsFocused } from "@react-navigation/native"; +import { videoMimeType } from "@t3tools/shared/video"; +import { requireNativeModule } from "expo"; +import { useEffect, useEffectEvent, useId, useState } from "react"; +import { Alert, Keyboard } from "react-native"; + +import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview"; +import { useAssetUrlState } from "../state/assets"; +import { usePreparedConnection } from "../state/session"; +import type { VideoPreviewSource } from "./VideoPreviewModal"; + +export type { VideoPreviewSource } from "./VideoPreviewModal"; + +const NativeControls = requireNativeModule<{ + presentVideo( + uri: string, + title: string, + sourceIdentifier: string, + identifier: string, + ): Promise; + dismissVideo(identifier: string): Promise; +}>("T3NativeControls"); + +function NativeVideoPreview(props: { + readonly source: VideoPreviewSource; + readonly onRequestClose: () => void; +}) { + const { source } = props; + const { attachment } = source; + const identifier = useId(); + const onRequestClose = useEffectEvent(props.onRequestClose); + const environmentId = source.type === "remote" ? source.environmentId : null; + const preparedConnection = usePreparedConnection(environmentId); + const mimeType = videoMimeType(attachment) ?? attachment.mimeType; + const assetUrl = useAssetUrlState( + environmentId, + source.type === "remote" + ? { _tag: "attachment", attachmentId: attachment.id, fileName: attachment.name, mimeType } + : null, + ); + const [playbackUrl, setPlaybackUrl] = useState(() => + assetUrl._tag === "Success" ? assetUrl.url : null, + ); + const loadError = + source.type === "remote" && playbackUrl === null + ? preparedConnection._tag === "None" + ? "Reconnect to this environment and open the video again." + : assetUrl._tag === "Failure" + ? "Could not load this video. Check the connection and try again." + : null + : null; + + useEffect(() => Keyboard.dismiss(), []); + useEffect(() => { + if (playbackUrl === null && assetUrl._tag === "Success") setPlaybackUrl(assetUrl.url); + }, [playbackUrl, assetUrl]); + useEffect(() => { + if (!loadError) return; + Alert.alert("Could not open video", loadError); + onRequestClose(); + }, [loadError]); + + useEffect(() => { + if (source.type === "remote" && playbackUrl === null) return; + const controller = new AbortController(); + let ready = false; + void (async () => { + const file = + source.type === "local" + ? await loadLocalAttachmentPreview(source.attachment, controller.signal) + : null; + if (source.type === "local" && !file) return; + try { + if (controller.signal.aborted) return; + ready = true; + await NativeControls.presentVideo( + file?.uri ?? playbackUrl!, + attachment.name, + source.sourceIdentifier ?? "", + identifier, + ); + if (!controller.signal.aborted) onRequestClose(); + } finally { + // Native completion follows dismissal, so local playback keeps its file lease. + file?.dispose(); + } + })().catch((error: unknown) => { + if (controller.signal.aborted) return; + Alert.alert( + "Could not open video", + ready + ? "This video couldn't be loaded or played. Check the connection, or touch and hold the attachment to save or share the original." + : error instanceof Error + ? error.message + : "Could not load this video.", + ); + onRequestClose(); + }); + return () => { + controller.abort(); + void NativeControls.dismissVideo(identifier).catch(() => undefined); + }; + }, [source, attachment.name, playbackUrl, identifier]); + + return null; +} + +export function VideoPreviewModal(props: { + readonly source: VideoPreviewSource | null; + readonly onRequestClose: () => void; +}) { + const isFocused = useIsFocused(); + const hasSource = props.source !== null; + const onRequestClose = useEffectEvent(props.onRequestClose); + useEffect(() => { + if (!isFocused && hasSource) onRequestClose(); + }, [isFocused, hasSource]); + + if (!props.source || !isFocused) return null; + return ; +} diff --git a/apps/mobile/src/components/VideoPreviewModal.tsx b/apps/mobile/src/components/VideoPreviewModal.tsx new file mode 100644 index 000000000000..eaa01c5d1714 --- /dev/null +++ b/apps/mobile/src/components/VideoPreviewModal.tsx @@ -0,0 +1,258 @@ +import { useIsFocused } from "@react-navigation/native"; +import type { ChatFileAttachment, EnvironmentId } from "@t3tools/contracts"; +import { videoMimeType } from "@t3tools/shared/video"; +import { useEvent } from "expo"; +import { useVideoPlayer, VideoView } from "expo-video"; +import { useEffect, useRef, useState } from "react"; +import { + ActivityIndicator, + AppState, + Keyboard, + Modal, + Pressable, + StyleSheet, + View, +} from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +import { + downloadAttachmentForPreview, + type AttachmentPreviewFile, +} from "../lib/attachmentDownload"; +import type { DraftComposerFileAttachment } from "../lib/composerImages"; +import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview"; +import { useAssetUrlState } from "../state/assets"; +import { usePreparedConnection } from "../state/session"; +import { SymbolView } from "./AppSymbol"; +import { AppText } from "./AppText"; + +export type VideoPreviewSource = ( + | { readonly type: "local"; readonly attachment: DraftComposerFileAttachment } + | { + readonly type: "remote"; + readonly environmentId: EnvironmentId; + readonly attachment: ChatFileAttachment; + } +) & { readonly sourceIdentifier?: string }; + +function VideoPlayback(props: { readonly file: AttachmentPreviewFile }) { + const player = useVideoPlayer(props.file.uri, (player) => { + player.staysActiveInBackground = false; + if (AppState.currentState === "active") player.play(); + }); + const { status } = useEvent(player, "statusChange", { status: player.status }); + const shareControllerRef = useRef(null); + const [sharing, setSharing] = useState(false); + const [shareError, setShareError] = useState(null); + + useEffect( + () => () => { + shareControllerRef.current?.abort(); + shareControllerRef.current = null; + }, + [], + ); + + const onShare = () => { + if (shareControllerRef.current) return; + player.pause(); + const controller = new AbortController(); + shareControllerRef.current = controller; + setSharing(true); + setShareError(null); + void props.file + .share(controller.signal) + .catch((error: unknown) => { + if (!controller.signal.aborted) { + setShareError(error instanceof Error ? error.message : "Could not share this video."); + } + }) + .finally(() => { + if (shareControllerRef.current === controller) { + shareControllerRef.current = null; + setSharing(false); + } + }); + }; + + return ( + <> + + {status === "error" ? ( + + This video couldn't be played on this device. You can save or share the original file. + + ) : ( + <> + + {status === "loading" ? ( + + ) : null} + + )} + + + + {sharing ? "Opening share sheet..." : "Save or share video"} + + + {shareError ? ( + + {shareError} + + ) : null} + + ); +} + +function OpenVideoPreviewModal(props: { + readonly source: VideoPreviewSource; + readonly onRequestClose: () => void; +}) { + const { source } = props; + const { attachment } = source; + const insets = useSafeAreaInsets(); + const environmentId = source.type === "remote" ? source.environmentId : null; + const preparedConnection = usePreparedConnection(environmentId); + const fileUri = source.type === "local" ? source.attachment.fileUri : null; + const mimeType = videoMimeType(attachment) ?? attachment.mimeType; + const assetUrl = useAssetUrlState( + environmentId, + source.type === "remote" + ? { _tag: "attachment", attachmentId: attachment.id, fileName: attachment.name, mimeType } + : null, + ); + const [downloadUrl, setDownloadUrl] = useState(null); + const [file, setFile] = useState(null); + const [failure, setFailure] = useState(null); + + useEffect(() => Keyboard.dismiss(), []); + useEffect(() => { + if (environmentId !== null && downloadUrl === null && assetUrl._tag === "Success") { + setDownloadUrl(assetUrl.url); + } + }, [environmentId, downloadUrl, assetUrl]); + + useEffect(() => { + if (source.type === "remote" && downloadUrl === null) return; + const controller = new AbortController(); + let preview: AttachmentPreviewFile | null = null; + setFile(null); + setFailure(null); + const loading = + source.type === "local" + ? loadLocalAttachmentPreview(source.attachment, controller.signal) + : downloadAttachmentForPreview({ + url: downloadUrl!, + attachment: { name: attachment.name, mimeType }, + signal: controller.signal, + }); + void loading.then( + (loaded) => { + if (controller.signal.aborted) { + loaded?.dispose(); + return; + } + preview = loaded; + setFile(loaded); + }, + (error: unknown) => { + if (!controller.signal.aborted) { + setFailure(error instanceof Error ? error.message : "Could not load this video."); + } + }, + ); + return () => { + controller.abort(); + preview?.dispose(); + }; + }, [source.type, environmentId, attachment.id, attachment.name, mimeType, fileUri, downloadUrl]); + + const loadError = + failure ?? + (environmentId !== null && downloadUrl === null + ? preparedConnection._tag === "None" + ? "This environment is disconnected. Reconnect and open the video again." + : assetUrl._tag === "Failure" + ? "Could not load this video. Check the connection to this environment and try again." + : null + : null); + + return ( + + + + + {attachment.name} + + + + + + {file ? ( + + ) : ( + + {loadError ? ( + + {loadError} + + ) : ( + <> + + Loading video... + + )} + + )} + + + ); +} + +export function VideoPreviewModal(props: { + readonly source: VideoPreviewSource | null; + readonly onRequestClose: () => void; +}) { + const isFocused = useIsFocused(); + const hasSource = props.source !== null; + useEffect(() => { + if (!isFocused && hasSource) props.onRequestClose(); + }, [isFocused, hasSource, props.onRequestClose]); + const { source } = props; + if (source === null || !isFocused) return null; + const key = + source.type === "local" + ? `local:${source.attachment.id}:${source.attachment.fileUri}` + : `remote:${source.environmentId}:${source.attachment.id}`; + return ; +} diff --git a/apps/mobile/src/components/VideoThumbnailImage.tsx b/apps/mobile/src/components/VideoThumbnailImage.tsx new file mode 100644 index 000000000000..0be94c700ce6 --- /dev/null +++ b/apps/mobile/src/components/VideoThumbnailImage.tsx @@ -0,0 +1,45 @@ +import { Image } from "expo-image"; +import { useIsFocused } from "@react-navigation/native"; +import type { VideoThumbnail } from "expo-video"; +import { useEffect, useState } from "react"; +import { StyleSheet } from "react-native"; + +import type { DraftComposerFileAttachment } from "../lib/composerImages"; +import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview"; +import { cachedVideoThumbnail, loadVideoThumbnail } from "../lib/videoThumbnails"; + +export function VideoThumbnailImage(props: { + readonly cacheKey: string; + readonly source: string | DraftComposerFileAttachment | null; +}) { + const { cacheKey, source } = props; + const isFocused = useIsFocused(); + const [loaded, setLoaded] = useState<{ key: string; thumbnail: VideoThumbnail } | null>(null); + const thumbnail = loaded?.key === cacheKey ? loaded.thumbnail : cachedVideoThumbnail(cacheKey); + + useEffect(() => { + if (!source || !isFocused) return; + const controller = new AbortController(); + void loadVideoThumbnail( + cacheKey, + async (signal) => + typeof source === "string" + ? { uri: source, dispose: () => undefined } + : loadLocalAttachmentPreview(source, signal), + controller.signal, + ).then((thumbnail) => { + if (thumbnail && !controller.signal.aborted) setLoaded({ key: cacheKey, thumbnail }); + }); + return () => controller.abort(); + }, [cacheKey, source, isFocused]); + + return thumbnail ? ( + + ) : null; +} diff --git a/apps/mobile/src/features/cloud/CloudAuthProvider.test.ts b/apps/mobile/src/features/cloud/CloudAuthProvider.test.ts index 2bc62d2a34ee..5fe74f673141 100644 --- a/apps/mobile/src/features/cloud/CloudAuthProvider.test.ts +++ b/apps/mobile/src/features/cloud/CloudAuthProvider.test.ts @@ -26,6 +26,12 @@ vi.mock("../../connection/catalog", () => ({ }, })); +vi.mock("./cloud-drafts", () => ({ removeCloudEnvironments: {} })); +vi.mock("../../state/use-composer-drafts", () => ({ + getComposerCloudAccountId: vi.fn(async () => null), + restoreCloudComposerDrafts: vi.fn(async () => undefined), +})); + vi.mock("./publicConfig", () => ({ resolveCloudPublicConfig: vi.fn(() => ({ clerk: { publishableKey: null }, diff --git a/apps/mobile/src/features/cloud/CloudAuthProvider.tsx b/apps/mobile/src/features/cloud/CloudAuthProvider.tsx index f7ece97cbaa9..fffdd2343044 100644 --- a/apps/mobile/src/features/cloud/CloudAuthProvider.tsx +++ b/apps/mobile/src/features/cloud/CloudAuthProvider.tsx @@ -5,14 +5,18 @@ import { reportAtomCommandResult, settleAsyncResult, settlePromise, + squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; import * as Effect from "effect/Effect"; import { type ReactNode, useEffect, useRef } from "react"; -import { environmentCatalog } from "../../connection/catalog"; import { runtime } from "../../lib/runtime"; import { appAtomRegistry } from "../../state/atom-registry"; import { useAtomCommand } from "../../state/use-atom-command"; +import { + getComposerCloudAccountId, + restoreCloudComposerDrafts, +} from "../../state/use-composer-drafts"; import { releaseAgentAwarenessRelayTokenProvider, setAgentAwarenessRelayTokenProvider, @@ -20,6 +24,7 @@ import { } from "../agent-awareness/remoteRegistration"; import { clearConnectOnboardingRequest, requestConnectOnboarding } from "./connectOnboarding"; import { resolveCloudPublicConfig, resolveRelayClerkTokenOptions } from "./publicConfig"; +import { removeCloudEnvironments } from "./cloud-drafts"; function resetManagedRelayTokenCache() { return settleAsyncResult(() => @@ -47,7 +52,7 @@ export function activateCloudRelayAccount( function CloudAuthBridge(props: { readonly children: ReactNode }) { const { getToken, isLoaded, isSignedIn, userId } = useAuth({ treatPendingAsSignedOut: false }); - const removeRelayEnvironments = useAtomCommand(environmentCatalog.removeRelayEnvironments, { + const removeRelayEnvironments = useAtomCommand(removeCloudEnvironments, { reportFailure: false, reportDefect: false, }); @@ -81,32 +86,37 @@ function CloudAuthBridge(props: { readonly children: ReactNode }) { clearConnectOnboardingRequest(); } - const queueAccountCleanup = ( + const cleanUpAccount = async ( previous: { readonly userId: string; readonly provider: () => Promise; } | null, + accountId: string | null, ) => { - const previousTransition = accountTransitionRef.current ?? Promise.resolve(); - accountTransitionRef.current = previousTransition.then(async () => { - const cleanup = [ - resetManagedRelayTokenCache(), - removeRelayEnvironments(), - ...(previous - ? [ - settleAsyncResult(() => - runtime.runPromiseExit( - unregisterAgentAwarenessDeviceForCurrentUser(previous.provider), - ), + const removal = await removeRelayEnvironments(accountId); + if (removal._tag !== "Success") throw squashAtomCommandFailure(removal); + const cleanup = [ + resetManagedRelayTokenCache(), + ...(previous + ? [ + settleAsyncResult(() => + runtime.runPromiseExit( + unregisterAgentAwarenessDeviceForCurrentUser(previous.provider), ), - ] - : []), - ]; - const results = await Promise.all(cleanup); - for (const result of results) { - reportAtomCommandResult(result, { label: "cloud account cleanup" }); - } - }); + ), + ] + : []), + ]; + const results = await Promise.all(cleanup); + for (const result of results) { + reportAtomCommandResult(result, { label: "cloud account cleanup" }); + } + }; + const queueAccountCleanup = (previous: typeof previousTokenProviderRef.current) => { + const previousTransition = accountTransitionRef.current ?? Promise.resolve(); + accountTransitionRef.current = previousTransition + .catch(() => {}) + .then(() => cleanUpAccount(previous, previousObservedAccount ?? null)); return accountTransitionRef.current; }; @@ -115,7 +125,9 @@ function CloudAuthBridge(props: { readonly children: ReactNode }) { previousTokenProviderRef.current = null; deactivateCloudRelayAccount(); if (previousObservedAccount !== null) { - void queueAccountCleanup(previous); + void settlePromise(() => queueAccountCleanup(previous)).then((result) => { + reportAtomCommandResult(result, { label: "cloud account cleanup" }); + }); } return; } @@ -133,13 +145,21 @@ function CloudAuthBridge(props: { readonly children: ReactNode }) { } }; const activateAfterTransition = (transition: Promise) => { - void (async () => { - const result = await settlePromise(async () => { - await transition; - activateSession(); - }); - reportAtomCommandResult(result, { label: "cloud account activation" }); + const activation = (async () => { + await transition; + if (cancelled) return; + const storedAccount = await getComposerCloudAccountId(); + if (storedAccount !== null && storedAccount !== userId) { + await cleanUpAccount(null, storedAccount); + } + if (cancelled) return; + await restoreCloudComposerDrafts(userId); + activateSession(); })(); + accountTransitionRef.current = activation; + void settlePromise(() => activation).then((result) => { + reportAtomCommandResult(result, { label: "cloud account activation" }); + }); }; if ( previousObservedAccount !== undefined && @@ -150,7 +170,9 @@ function CloudAuthBridge(props: { readonly children: ReactNode }) { deactivateCloudRelayAccount(); activateAfterTransition(queueAccountCleanup(previous)); } else { - activateAfterTransition(accountTransitionRef.current ?? Promise.resolve()); + // A failed disk write can be retried. The persisted account check above + // still requires cleanup before activating a different account. + activateAfterTransition((accountTransitionRef.current ?? Promise.resolve()).catch(() => {})); } return () => { diff --git a/apps/mobile/src/features/cloud/cloud-drafts.ts b/apps/mobile/src/features/cloud/cloud-drafts.ts new file mode 100644 index 000000000000..bc41b2b41fe0 --- /dev/null +++ b/apps/mobile/src/features/cloud/cloud-drafts.ts @@ -0,0 +1,46 @@ +import { EnvironmentRegistry } from "@t3tools/client-runtime/connection"; +import { createRuntimeCommand } from "@t3tools/client-runtime/state/runtime"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import * as SubscriptionRef from "effect/SubscriptionRef"; + +import { connectionAtomRuntime } from "../../connection/runtime"; +import { archiveCloudComposerDrafts } from "../../state/use-composer-drafts"; + +export class CloudDraftArchiveError extends Schema.TaggedErrorClass()( + "CloudDraftArchiveError", + { + environmentCount: Schema.Number, + hasAccountId: Schema.Boolean, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Could not preserve local drafts for ${this.environmentCount} cloud environments before sign-out.`; + } +} + +export const removeCloudEnvironments = createRuntimeCommand(connectionAtomRuntime, { + label: "cloud:preserve-drafts-and-remove-environments", + execute: Effect.fn("removeCloudEnvironments")(function* (accountId: string | null) { + const registry = yield* EnvironmentRegistry; + const entries = yield* SubscriptionRef.get(registry.entries); + const environmentIds = new Set( + [...entries.values()] + .filter((entry) => entry.target._tag === "RelayConnectionTarget") + .map((entry) => entry.target.environmentId), + ); + // Credentials are already revoked. A failed backup must leave the local + // owners intact so a later sign-in can retry without losing their files. + yield* Effect.tryPromise({ + try: () => archiveCloudComposerDrafts(accountId, environmentIds), + catch: (cause) => + new CloudDraftArchiveError({ + environmentCount: environmentIds.size, + hasAccountId: accountId !== null, + cause, + }), + }); + yield* registry.removeRelayEnvironments(); + }), +}); diff --git a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx index 462d075324dd..5dddac1dd820 100644 --- a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx +++ b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx @@ -17,9 +17,11 @@ import { SymbolView } from "../../components/AppSymbol"; import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; import { ControlPillMenu } from "../../components/ControlPill"; import { EmptyState } from "../../components/EmptyState"; +import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal"; import { LoadingScreen } from "../../components/LoadingScreen"; import { resolveFileSelectionNavigationAction } from "../../lib/adaptive-navigation"; import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; +import { isPdfFile } from "../../lib/filePreview"; import { tryOpenExternalUrl } from "../../lib/openExternalUrl"; import { useUniwindTheme } from "../../lib/useUniwindTheme"; import { useThreadSelection } from "../../state/use-thread-selection"; @@ -487,6 +489,7 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { readonly mode: FileViewMode; } | null>(null); const [previewRevision, setPreviewRevision] = useState(0); + const [fullScreenPreview, setFullScreenPreview] = useState(null); const isBrowserFile = relativePath !== null && isBrowserPreviewFile(relativePath); const isImageFile = relativePath !== null && isImagePreviewFile(relativePath); const canPreview = @@ -586,6 +589,20 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { inline: false, onPress: () => copyTextWithHaptic(relativePath), } as const, + isPdfFile({ name: relativePath }) && previewUri !== null + ? ({ + id: "open-pdf", + title: "Open PDF", + icon: "arrow.up.left.and.arrow.down.right", + inline: false, + onPress: () => + setFullScreenPreview({ + kind: "pdf", + uri: previewUri, + name: basename(relativePath), + }), + } as const) + : null, isBrowserFile && typeof assetPreviewUri === "string" ? ({ id: "open-browser", @@ -605,7 +622,15 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { } as const) : null, ].filter((action) => action !== null); - }, [assetPreviewUri, canPreview, isBrowserFile, isImageFile, relativePath, resolvedActiveMode]); + }, [ + assetPreviewUri, + previewUri, + canPreview, + isBrowserFile, + isImageFile, + relativePath, + resolvedActiveMode, + ]); const androidFileMenuActions = useMemo( () => @@ -766,6 +791,10 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { truncated={fileData?.truncated ?? false} onRefresh={() => fileQuery.refresh()} /> + setFullScreenPreview(null)} + /> ); diff --git a/apps/mobile/src/features/files/WorkspaceFileImagePreview.tsx b/apps/mobile/src/features/files/WorkspaceFileImagePreview.tsx index 73eca66bf999..e725c4d133f6 100644 --- a/apps/mobile/src/features/files/WorkspaceFileImagePreview.tsx +++ b/apps/mobile/src/features/files/WorkspaceFileImagePreview.tsx @@ -1,24 +1,25 @@ import { useAtomValue } from "@effect/atom-react"; -import { useMemo, useState } from "react"; +import { useId, useMemo, useState } from "react"; import { ActivityIndicator, Image, Pressable, View } from "react-native"; -import ImageViewing from "react-native-image-viewing"; import { AsyncResult } from "effect/unstable/reactivity"; import { AppText as Text } from "../../components/AppText"; import { EmptyState } from "../../components/EmptyState"; import { workspaceFileImageAtom } from "./workspace-file-image-cache"; +import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal"; +import { PresentationSource } from "../../components/NativePresentation"; function ResolvedWorkspaceFileImagePreview(props: { readonly accessibilityLabel: string; readonly uri: string; }) { const [loadError, setLoadError] = useState(null); - const [fullScreenVisible, setFullScreenVisible] = useState(false); + const [preview, setPreview] = useState(null); + const sourceIdentifier = useId(); const imageSource = useMemo( () => ({ uri: props.uri, cache: "force-cache" as const }), [props.uri], ); - const fullScreenImages = useMemo(() => [imageSource], [imageSource]); return ( @@ -27,18 +28,27 @@ function ResolvedWorkspaceFileImagePreview(props: { accessibilityLabel={`Open full-screen preview of ${props.accessibilityLabel}`} disabled={loadError !== null} className="flex-1 p-4 active:bg-subtle-strong" - onPress={() => setFullScreenVisible(true)} + onPress={() => + setPreview({ + kind: "image", + uri: props.uri, + name: props.accessibilityLabel, + sourceIdentifier, + }) + } > - setLoadError(null)} - onError={(event) => { - setLoadError(event.nativeEvent.error || "The image could not be rendered."); - }} - /> + + setLoadError(null)} + onError={(event) => { + setLoadError(event.nativeEvent.error || "The image could not be rendered."); + }} + /> + {loadError !== null ? ( @@ -47,14 +57,7 @@ function ResolvedWorkspaceFileImagePreview(props: { ) : null} - setFullScreenVisible(false)} - swipeToCloseEnabled - doubleTapToZoomEnabled - /> + setPreview(null)} /> ); } diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index b738ff6dd12f..34f4f4057a5d 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -53,7 +53,6 @@ import { buildThreadListV2ListItems, THREAD_LIST_V2_SETTLED_INITIAL_COUNT, THREAD_LIST_V2_SETTLED_PAGE_COUNT, - type ThreadListV2ChangeRequestState, type ThreadListV2ListItem, } from "../threads/threadListV2"; import { useThreadListV2ShelfPreferences } from "../threads/use-thread-list-v2-shelf-preferences"; @@ -209,9 +208,6 @@ export function HomeScreen(props: HomeScreenProps) { >(() => new Map()); const preferencesResult = useAtomValue(mobilePreferencesAtom); const threadListV2Enabled = useThreadListV2Enabled(); - const autoSettleOnMerge = - !AsyncResult.isSuccess(preferencesResult) || - preferencesResult.value.autoSettleOnMerge !== false; const savePreferences = useAtomSet(updateMobilePreferencesAtom); const openSwipeableRef = useRef(null); const listRef = useRef(null); @@ -487,33 +483,6 @@ export function HomeScreen(props: HomeScreenProps) { // Settled threads stay in the live shell stream (settled ≠ archived), so // the partition works directly off live shells — no snapshot merging or // optimistic holds. - // PR states stream in per-row. The next partition applies the configured - // merge rule and the always-on close rule, matching web. - const [changeRequestByKey, setChangeRequestByKey] = useState< - ReadonlyMap - >(() => new Map()); - const handleChangeRequestState = useCallback( - (threadKey: string, changeRequest: ThreadListV2ChangeRequestState | null) => { - setChangeRequestByKey((current) => { - const existing = current.get(threadKey) ?? null; - if ( - (existing?.state ?? null) === (changeRequest?.state ?? null) && - (existing?.updatedAt ?? null) === (changeRequest?.updatedAt ?? null) && - (existing?.linkedPullRequestKey ?? null) === (changeRequest?.linkedPullRequestKey ?? null) - ) { - return current; - } - const next = new Map(current); - if (changeRequest === null) { - next.delete(threadKey); - } else { - next.set(threadKey, changeRequest); - } - return next; - }); - }, - [], - ); const handleSettleThread = useCallback( (thread: EnvironmentThreadShell) => { void props.onSettleThread(thread); @@ -580,9 +549,7 @@ export function HomeScreen(props: HomeScreenProps) { toggleSettledShelf, toggleSnoozedShelf, } = useThreadListV2ShelfPreferences(); - // now is quantized to the minute and ticks so the inactivity auto-settle - // boundary is actually crossed while the app stays open (mirrors web); - // without a clock dependency the partition memoizes a frozen "now". + // The queued-start and snooze helpers need a clock while the list stays open. const [nowMinute, setNowMinute] = useState(() => new Date().toISOString().slice(0, 16)); // Snooze wake times are second-precise; a counter bumped exactly at the // next wake boundary re-runs the partition with a fresh clock so a woken @@ -591,8 +558,7 @@ export function HomeScreen(props: HomeScreenProps) { useFocusEffect( useCallback(() => { if (!threadListV2Enabled) return; - // Refresh immediately on enable or focus: the previous value can be hours - // old and misclassify the inactivity auto-settle boundary until the first tick. + // Refresh immediately on enable or focus because the previous value can be hours old. setNowMinute(new Date().toISOString().slice(0, 16)); const id = setInterval(() => setNowMinute(new Date().toISOString().slice(0, 16)), 60_000); return () => clearInterval(id); @@ -679,20 +645,15 @@ export function HomeScreen(props: HomeScreenProps) { projectRefs: v2ScopedProjectGroup === null ? null : v2ScopedProjectGroup.projectRefs, searchQuery: props.searchQuery, matchedThreadKeys, - changeRequestByKey, - autoSettleOnMerge, settlementEnvironmentIds, snoozeEnvironmentIds, settledLimit: settledVisibleCount, - now: `${nowMinute}:00.000Z`, - snoozeNow: new Date().toISOString(), + now: new Date().toISOString(), snoozedShelfExpanded, settledShelfExpanded, selectedThreadKey: null, }); }, [ - changeRequestByKey, - autoSettleOnMerge, nowMinute, snoozeWakeTick, snoozedShelfExpanded, @@ -864,7 +825,6 @@ export function HomeScreen(props: HomeScreenProps) { onPinThread={handlePinThread} onUnpinThread={handleUnpinThread} onMovePinnedThread={handleMovePinnedThread} - onChangeRequestState={handleChangeRequestState} projectCwd={ projectCwdByKey.get(scopedProjectKey(thread.environmentId, thread.projectId)) ?? null } @@ -874,7 +834,6 @@ export function HomeScreen(props: HomeScreenProps) { ); }, [ - handleChangeRequestState, handleDeleteThread, arrangedPinnedKeys, handleMovePinnedThread, diff --git a/apps/mobile/src/features/home/useThreadListActions.ts b/apps/mobile/src/features/home/useThreadListActions.ts index 5c66944042ad..dae6c46a89dd 100644 --- a/apps/mobile/src/features/home/useThreadListActions.ts +++ b/apps/mobile/src/features/home/useThreadListActions.ts @@ -1,5 +1,5 @@ import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; -import { canSettle, canSnooze } from "@t3tools/client-runtime/state/thread-settled"; +import { canSnooze } from "@t3tools/client-runtime/state/thread-settled"; import * as Cause from "effect/Cause"; import * as Haptics from "expo-haptics"; import { useCallback, useRef } from "react"; @@ -118,16 +118,6 @@ function useThreadActionExecutor( ); return false; } - // Settle may only target what effectiveSettled could classify as - // settled: not starting/running sessions, not threads waiting on - // approvals or user input. Anything else would hide live work. - if (action === "settle" && !canSettle(thread, { now: new Date().toISOString() })) { - Alert.alert( - actionFailureTitle(action), - "This thread still needs attention. Resolve or interrupt it first, then try again.", - ); - return false; - } // Archive keeps its original, narrower guard: never interrupt a // thread mid-turn. if ( diff --git a/apps/mobile/src/features/keyboard/HardwareKeyboardCommandProvider.tsx b/apps/mobile/src/features/keyboard/HardwareKeyboardCommandProvider.tsx index 96a4a63e9018..909bbcf5a762 100644 --- a/apps/mobile/src/features/keyboard/HardwareKeyboardCommandProvider.tsx +++ b/apps/mobile/src/features/keyboard/HardwareKeyboardCommandProvider.tsx @@ -1,7 +1,22 @@ import { StackActions, useNavigation } from "@react-navigation/native"; -import { useCallback, useMemo, useSyncExternalStore, type PropsWithChildren } from "react"; +import { resolveThreadReferenceCopyTarget } from "@t3tools/shared/threadReference"; +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + useSyncExternalStore, + type PropsWithChildren, +} from "react"; +import { tryCopyTextWithHaptic } from "../../lib/copyTextWithHaptic"; import { T3KeyboardCommands } from "../../native/T3KeyboardCommands"; +import { useProject, useThreadShell } from "../../state/entities"; +import { useEnvironmentQuery } from "../../state/query"; +import type { GitActionProgress } from "../../state/use-vcs-action-state"; +import { vcsEnvironment } from "../../state/vcs"; +import { GitActionProgressOverlay } from "../threads/GitActionProgressOverlay"; import { dispatchHardwareKeyboardCommand, getHardwareKeyboardCommandRegistrationVersion, @@ -11,11 +26,86 @@ import { type HardwareKeyboardCommand, } from "./hardwareKeyboardCommands"; +const EMPTY_COPY_FEEDBACK: GitActionProgress = { + phase: "idle", + label: null, + description: null, +}; +const COPY_FEEDBACK_DISMISS_MS = 3_000; + export function HardwareKeyboardCommandProvider({ children, pathname, }: PropsWithChildren<{ readonly pathname: string }>) { const navigation = useNavigation(); + const activeThreadRef = useMemo(() => parseActiveThreadPath(pathname), [pathname]); + const activeThread = useThreadShell(activeThreadRef); + const activeProjectRef = useMemo( + () => + activeThread === null + ? null + : { + environmentId: activeThread.environmentId, + projectId: activeThread.projectId, + }, + [activeThread], + ); + const activeProject = useProject(activeProjectRef); + const activeThreadCwd = activeThread?.worktreePath ?? activeProject?.workspaceRoot ?? null; + const gitStatus = useEnvironmentQuery( + activeThread !== null && + activeThread.linkedPullRequest == null && + activeThread.branch !== null && + activeThreadCwd !== null + ? vcsEnvironment.status({ + environmentId: activeThread.environmentId, + input: { cwd: activeThreadCwd }, + }) + : null, + ).data; + const detectedPullRequestUrl = + activeThread?.branch != null && gitStatus?.refName === activeThread.branch + ? (gitStatus.pr?.url ?? null) + : null; + const copyTarget = useMemo( + () => + activeThreadRef === null + ? null + : resolveThreadReferenceCopyTarget({ + threadId: activeThread?.id ?? activeThreadRef.threadId, + linkedPullRequestUrl: activeThread?.linkedPullRequest?.url ?? null, + detectedPullRequestUrl, + }), + [activeThread, activeThreadRef, detectedPullRequestUrl], + ); + const [copyFeedback, setCopyFeedback] = useState(EMPTY_COPY_FEEDBACK); + const copyRequestIdRef = useRef(0); + const copyFeedbackTimerRef = useRef | null>(null); + const dismissCopyFeedback = useCallback(() => { + if (copyFeedbackTimerRef.current !== null) { + clearTimeout(copyFeedbackTimerRef.current); + copyFeedbackTimerRef.current = null; + } + setCopyFeedback(EMPTY_COPY_FEEDBACK); + }, []); + const showCopyFeedback = useCallback((feedback: GitActionProgress) => { + if (copyFeedbackTimerRef.current !== null) { + clearTimeout(copyFeedbackTimerRef.current); + } + setCopyFeedback(feedback); + copyFeedbackTimerRef.current = setTimeout(() => { + copyFeedbackTimerRef.current = null; + setCopyFeedback(EMPTY_COPY_FEEDBACK); + }, COPY_FEEDBACK_DISMISS_MS); + }, []); + useEffect( + () => () => { + if (copyFeedbackTimerRef.current !== null) { + clearTimeout(copyFeedbackTimerRef.current); + } + }, + [], + ); const registrationVersion = useSyncExternalStore( subscribeToHardwareKeyboardCommandRegistrations, getHardwareKeyboardCommandRegistrationVersion, @@ -25,10 +115,11 @@ export function HardwareKeyboardCommandProvider({ const commands = new Set(getRegisteredHardwareKeyboardCommands()); commands.add("newTask"); if (pathname !== "/" || navigation.canGoBack()) commands.add("back"); - if (parseActiveThreadPath(pathname)) { + if (activeThreadRef !== null) { commands.add("files"); commands.add("terminal"); commands.add("review"); + if (pathname.split("/")[4] !== "terminal") commands.add("copyThreadReference"); } return [...commands]; }, [pathname, registrationVersion, navigation]); @@ -37,6 +128,30 @@ export function HardwareKeyboardCommandProvider({ (command: HardwareKeyboardCommand) => { if (dispatchHardwareKeyboardCommand(command)) return; + if (command === "copyThreadReference") { + if (copyTarget === null) return; + const requestId = ++copyRequestIdRef.current; + void tryCopyTextWithHaptic(copyTarget.value, { + target: copyTarget.clipboardTarget, + }).then((didCopy) => { + if (requestId !== copyRequestIdRef.current) return; + showCopyFeedback( + didCopy + ? { + phase: "success", + label: copyTarget.successTitle, + description: copyTarget.value, + } + : { + phase: "error", + label: copyTarget.failureTitle, + description: "Try again.", + }, + ); + }); + return; + } + if (command === "newTask") { navigation.navigate("NewTaskSheet", { screen: "NewTask" }); return; @@ -62,12 +177,15 @@ export function HardwareKeyboardCommandProvider({ navigation.navigate("ThreadReview", thread); } }, - [pathname, navigation], + [copyTarget, navigation, pathname, showCopyFeedback], ); return ( - - {children} - + <> + + {children} + + + ); } diff --git a/apps/mobile/src/features/keyboard/hardwareKeyboardCommands.ts b/apps/mobile/src/features/keyboard/hardwareKeyboardCommands.ts index 300434eb736a..fa1c953849f9 100644 --- a/apps/mobile/src/features/keyboard/hardwareKeyboardCommands.ts +++ b/apps/mobile/src/features/keyboard/hardwareKeyboardCommands.ts @@ -8,6 +8,7 @@ export type HardwareKeyboardCommand = | "files" | "terminal" | "review" + | "copyThreadReference" | "toggleSidebar"; type CommandHandler = () => boolean | void; diff --git a/apps/mobile/src/features/review/ReviewCommentComposerSheet.tsx b/apps/mobile/src/features/review/ReviewCommentComposerSheet.tsx index a798e5ebe4ae..74ccc8cf0bcb 100644 --- a/apps/mobile/src/features/review/ReviewCommentComposerSheet.tsx +++ b/apps/mobile/src/features/review/ReviewCommentComposerSheet.tsx @@ -5,7 +5,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { Platform, Pressable, ScrollView, View, useWindowDimensions } from "react-native"; import { KeyboardAvoidingView, KeyboardStickyView } from "react-native-keyboard-controller"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import ImageViewing from "react-native-image-viewing"; +import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal"; import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; import { SymbolView } from "../../components/AppSymbol"; @@ -53,7 +53,7 @@ export function ReviewCommentComposerSheet(props: ReviewCommentComposerSheetProp Record> >({}); const [attachments, setAttachments] = useState>([]); - const [previewImageUri, setPreviewImageUri] = useState(null); + const [previewFile, setPreviewFile] = useState(null); const selectedLines = useMemo( () => (target ? getSelectedReviewCommentLines(target) : []), @@ -272,7 +272,7 @@ export function ReviewCommentComposerSheet(props: ReviewCommentComposerSheetProp attachments={attachments} imageBorderRadius={16} imageSize={60} - onPressImage={setPreviewImageUri} + onPressPreview={setPreviewFile} removeButtonPlacement="gutter" onRemove={(imageId) => { setAttachments((current) => @@ -332,14 +332,7 @@ export function ReviewCommentComposerSheet(props: ReviewCommentComposerSheetProp ) : null} - setPreviewImageUri(null)} - swipeToCloseEnabled - doubleTapToZoomEnabled - /> + setPreviewFile(null)} /> ); } diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index 3f0db4373623..781732734f02 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -35,6 +35,9 @@ import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar"; import { runtime } from "../../lib/runtime"; import { MOBILE_PRODUCT_NAME } from "../../lib/mobileBranding"; import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; +import { serverEnvironment } from "../../state/server"; +import { useAtomCommand } from "../../state/use-atom-command"; +import type { EnvironmentId } from "@t3tools/contracts"; import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled"; import { type AppUpdateCheckState, @@ -528,26 +531,54 @@ function ConfiguredSettingsRouteScreen() { } function GeneralSettingsSection() { - const preferencesResult = useAtomValue(mobilePreferencesAtom); - const savePreferences = useAtomSet(updateMobilePreferencesAtom); - const autoSettleOnMerge = - !AsyncResult.isSuccess(preferencesResult) || - preferencesResult.value.autoSettleOnMerge !== false; + const { savedConnectionsById } = useSavedRemoteConnections(); + const connections = Object.values(savedConnectionsById).sort((left, right) => + left.environmentLabel.localeCompare(right.environmentLabel), + ); return ( - savePreferences({ autoSettleOnMerge: value })} - /> + {connections.map((connection) => ( + + ))} ); } +function EnvironmentAutoSettleSwitch(props: { + readonly environmentId: EnvironmentId; + readonly environmentLabel: string; +}) { + const settings = useAtomValue(serverEnvironment.settingsValueAtom(props.environmentId)); + const config = useAtomValue(serverEnvironment.configValueAtom(props.environmentId)); + const updateSettings = useAtomCommand(serverEnvironment.updateSettings, { + label: "auto-settle settings update", + reportFailure: true, + }); + if (config?.environment.capabilities.threadAutoSettlement !== true || settings === null) { + return null; + } + return ( + { + void updateSettings({ + environmentId: props.environmentId, + input: { patch: { sidebarAutoSettleOnMerge: value } }, + }); + }} + /> + ); +} + /** * Device-local legacy toggles. Mobile has no client-settings sync, so this is * the counterpart of web's Settings → General → Legacy features backed by diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index ca44385cb05d..8f8cf485f6f8 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -1,9 +1,12 @@ +import { useAtomValue } from "@effect/atom-react"; import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; import { + CommonActions, StackActions, useFocusEffect, useNavigation, usePreventRemove, + type NavigationAction, } from "@react-navigation/native"; import { useCallback, useEffect, useRef, useState } from "react"; import { Alert, Platform, Pressable, ScrollView, View } from "react-native"; @@ -33,10 +36,17 @@ import { import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; import { ComposerAttachmentButton } from "../../components/ComposerAttachmentButton"; import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip"; +import { + composerAttachmentUploadBlockReason, + composerAttachmentUploadsAtom, +} from "../../state/composer-attachment-uploads"; +import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal"; +import { VideoPreviewModal, type VideoPreviewSource } from "../../components/VideoPreviewModal"; import { ProviderIcon } from "../../components/ProviderIcon"; import { SymbolView } from "../../components/AppSymbol"; import { AppText as Text } from "../../components/AppText"; import { COMPOSER_LAYOUT_TRANSITION, ComposerSurface } from "./ThreadComposer"; +import { ShimmeringWorkContent } from "./thread-work-log"; import { ComposerCommandPopover } from "./ComposerCommandPopover"; import { useComposerCommandMenu } from "./use-composer-command-menu"; import { @@ -57,6 +67,7 @@ import { convertPastedImagesToAttachments, pickComposerFiles, pickComposerMedia, + type DraftComposerFileAttachment, } from "../../lib/composerImages"; import { useScaledTextRole } from "../settings/appearance/useScaledTextRole"; import { @@ -158,9 +169,47 @@ export function NewTaskDraftScreen(props: { connectedEnvironments.find( (environment) => environment.environmentId === selectedProject.environmentId, )?.connectionState === "connected"; + const uploadStates = useAtomValue(composerAttachmentUploadsAtom); + const attachmentBlockReason = selectedProject + ? composerAttachmentUploadBlockReason({ + environmentId: selectedProject.environmentId, + attachments: flow.attachments, + connected: environmentConnected, + serverConfig: selectedEnvironmentServerConfig, + states: uploadStates, + }) + : null; const promptInputRef = useRef(null); const loadedBranchesProjectKeyRef = useRef(null); const [isComposerFocused, setIsComposerFocused] = useState(false); + const [previewVideo, setPreviewVideo] = useState(null); + const [previewFile, setPreviewFile] = useState(null); + const wasFocusedBeforePreviewRef = useRef(false); + const openVideoPreview = useCallback( + (attachment: DraftComposerFileAttachment, sourceIdentifier: string) => { + wasFocusedBeforePreviewRef.current = isComposerFocused; + setPreviewFile(null); + setPreviewVideo((current) => current ?? { type: "local", attachment, sourceIdentifier }); + }, + [isComposerFocused], + ); + const openFilePreview = useCallback( + (source: FilePreviewSource) => { + wasFocusedBeforePreviewRef.current = isComposerFocused; + setPreviewVideo(null); + setPreviewFile((current) => current ?? source); + }, + [isComposerFocused], + ); + const closeMediaPreview = useCallback(() => { + setPreviewVideo(null); + setPreviewFile(null); + if (wasFocusedBeforePreviewRef.current) { + setTimeout(() => { + if (navigation.isFocused()) promptInputRef.current?.focus(); + }, 100); + } + }, [navigation]); const settingsSheetPresentation = useThreadSettingsSheetPresentation({ editorRef: promptInputRef, isEditorFocused: isComposerFocused, @@ -212,6 +261,9 @@ export function NewTaskDraftScreen(props: { const [isCancellingShareImport, setIsCancellingShareImport] = useState(false); const [cancelledIncomingShareId, setCancelledIncomingShareId] = useState(null); const [isReturningToProjectPicker, setIsReturningToProjectPicker] = useState(false); + const [submitNavigationAction, setSubmitNavigationAction] = useState( + null, + ); const [shareImportAttempt, setShareImportAttempt] = useState(0); const startedShareImportKeyRef = useRef(null); const cancellingShareImportKeyRef = useRef(null); @@ -275,12 +327,23 @@ export function NewTaskDraftScreen(props: { voiceInput.elapsedSeconds, ); const isVoiceInputPresented = voicePresentation.statusLabel !== null; - usePreventRemove( + const preventRemove = (isIncomingShareTransferPending && !isProjectPickerReturnActive) || - isCancellingShareImport || - flow.submitting, - () => undefined, - ); + isCancellingShareImport || + flow.submitting; + usePreventRemove(preventRemove, () => undefined); + useEffect(() => { + if (preventRemove || submitNavigationAction === null) { + return; + } + // Give the guard update a frame to reach the parent sheet before navigating, + // just like the project-picker fallback below. + const frame = requestAnimationFrame(() => { + setSubmitNavigationAction(null); + (navigation.getParent() ?? navigation).dispatch(submitNavigationAction); + }); + return () => cancelAnimationFrame(frame); + }, [navigation, preventRemove, submitNavigationAction]); const hasImportedIncomingShare = Boolean( props.incomingShareId && flow.draftKey && @@ -364,7 +427,8 @@ export function NewTaskDraftScreen(props: { }; }, [props.pendingTaskId, cancelEditingPendingTask]); - const foregroundColor = useUniwindTheme()["--color-foreground"]; + const theme = useUniwindTheme(); + const foregroundColor = theme["--color-foreground"]; const regularFontFamily = useFontFamily("regular"); const bodyText = useScaledTextRole("body"); @@ -814,6 +878,7 @@ export function NewTaskDraftScreen(props: { const initialMessageText = draft.text.trim(); if ( + attachmentBlockReason !== null || !modelSelection || initialMessageText.length === 0 || flow.submitting || @@ -872,7 +937,7 @@ export function NewTaskDraftScreen(props: { clearWorkspaceSelection: true, }); } - navigation.getParent()?.goBack(); + setSubmitNavigationAction(CommonActions.goBack()); return; } @@ -943,7 +1008,7 @@ export function NewTaskDraftScreen(props: { clearWorkspaceSelection: true, }); } - navigation.dispatch( + setSubmitNavigationAction( StackActions.replace("Thread", { environmentId: String(result.value.environmentId), threadId: String(result.value.threadId), @@ -968,6 +1033,7 @@ export function NewTaskDraftScreen(props: { const isAndroid = Platform.OS === "android"; const canStart = + attachmentBlockReason === null && Boolean(flow.selectedProject) && Boolean(flow.selectedModel) && flow.prompt.trim().length > 0 && @@ -1094,31 +1160,50 @@ export function NewTaskDraftScreen(props: { const workspaceControls = ( - + + + ) : ( + <> + + } + label={workspaceLabel} + maxWidth={flow.workspaceMode === "local" ? 220 : 148} + onPress={() => + flow.setWorkspaceMode(flow.workspaceMode === "local" ? "worktree" : "local") + } + showChevron={false} /> - } - label={workspaceLabel} - maxWidth={flow.workspaceMode === "local" ? 220 : 148} - onPress={() => flow.setWorkspaceMode(flow.workspaceMode === "local" ? "worktree" : "local")} - showChevron={false} - /> - openContextPicker("NewTaskBranch")} - /> + openContextPicker("NewTaskBranch")} + /> + + )} ); @@ -1148,6 +1233,7 @@ export function NewTaskDraftScreen(props: { {flow.attachments.length > 0 ? ( undefined : flow.removeAttachment } + onPressPreview={ + isComposerInteractionLocked || voiceInput.isBusy ? undefined : openFilePreview + } + onPressVideo={ + isComposerInteractionLocked || voiceInput.isBusy ? undefined : openVideoPreview + } /> ) : null} @@ -1243,11 +1335,12 @@ export function NewTaskDraftScreen(props: { {voicePresentation.showsSend ? ( + + ); diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 9912679ab06c..facb046c397b 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -1,3 +1,4 @@ +import { useAtomValue } from "@effect/atom-react"; import type { EnvironmentId, ProviderUsageWindow, @@ -23,14 +24,16 @@ import { import { ActivityIndicator, AppState, - Image, Platform, Pressable, - StyleSheet, View, type ViewStyle, } from "react-native"; -import ImageViewing from "react-native-image-viewing"; +import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal"; +import { + composerAttachmentUploadBlockReason, + composerAttachmentUploadsAtom, +} from "../../state/composer-attachment-uploads"; import Animated, { FadeIn, FadeInDown, @@ -49,9 +52,12 @@ import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/re import { scopedThreadKey } from "../../lib/scopedEntities"; import { AppText as Text } from "../../components/AppText"; -import { SymbolView } from "../../components/AppSymbol"; import { ComposerAttachmentButton } from "../../components/ComposerAttachmentButton"; -import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip"; +import { + ComposerAttachmentStrip, + ComposerAttachmentThumbnail, +} from "../../components/ComposerAttachmentStrip"; +import { VideoPreviewModal, type VideoPreviewSource } from "../../components/VideoPreviewModal"; import { GlassSurface } from "../../components/GlassSurface"; import { ComposerEditor, type ComposerEditorHandle } from "../../components/ComposerEditor"; import { @@ -69,7 +75,10 @@ import { import { serverEnvironment } from "../../state/server"; import { useAtomCommand } from "../../state/use-atom-command"; import { ProviderIcon } from "../../components/ProviderIcon"; -import type { DraftComposerAttachment } from "../../lib/composerImages"; +import type { + DraftComposerAttachment, + DraftComposerFileAttachment, +} from "../../lib/composerImages"; import { buildModelOptions, groupByProvider } from "../../lib/modelOptions"; import { useScaledTextRole } from "../settings/appearance/useScaledTextRole"; import type { RemoteClientConnectionState } from "../../lib/connection"; @@ -330,7 +339,8 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer const inFlightThreadIdsRef = useRef(new Set()); const { onExpandedChange } = props; - const [previewImageUri, setPreviewImageUri] = useState(null); + const [previewFile, setPreviewFile] = useState(null); + const [previewVideo, setPreviewVideo] = useState(null); const hasContent = props.draftMessage.trim().length > 0 || props.draftAttachments.length > 0; const showStopAction = !hasContent && @@ -383,27 +393,48 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer const isExpanded = isFocused || settingsSheetPresentation.isActive; const showsCompactDictation = isVoiceInputPresented && !isExpanded; const isToolbarVisible = isExpanded || isVoiceInputPresented; - const canSend = hasContent && !voiceInput.blocksSubmission; + const uploadStates = useAtomValue(composerAttachmentUploadsAtom); + const attachmentBlockReason = composerAttachmentUploadBlockReason({ + environmentId: props.environmentId, + attachments: props.draftAttachments, + connected: props.connectionState === "connected", + serverConfig: props.serverConfig, + states: uploadStates, + }); + const canSend = hasContent && !voiceInput.blocksSubmission && attachmentBlockReason === null; // Keep the feed inset aligned with the card or compact dictation strip. useEffect(() => { onExpandedChange?.(isExpanded); }, [isExpanded, onExpandedChange]); - const onPressImage = useCallback( - (uri: string) => { + const onPressPreview = useCallback( + (source: FilePreviewSource) => { wasExpandedBeforePreviewRef.current = isFocused; - setPreviewImageUri(uri); + setPreviewVideo(null); + setPreviewFile((current) => current ?? source); }, [isFocused], ); const closePreview = useCallback(() => { - setPreviewImageUri(null); + setPreviewFile(null); + setPreviewVideo(null); if (wasExpandedBeforePreviewRef.current) { - setTimeout(() => inputRef.current?.focus(), 100); + setTimeout(() => { + if (navigation.isFocused()) inputRef.current?.focus(); + }, 100); } - }, [inputRef]); + }, [inputRef, navigation]); + + const onPressVideo = useCallback( + (attachment: DraftComposerFileAttachment, sourceIdentifier: string) => { + wasExpandedBeforePreviewRef.current = isFocused; + setPreviewFile(null); + setPreviewVideo((current) => current ?? { type: "local", attachment, sourceIdentifier }); + }, + [isFocused], + ); const onEditorFocusChange = props.onEditorFocusChange; const handleFocus = useCallback(() => { @@ -685,9 +716,11 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer exiting={FadeOut.duration(120)} > undefined : props.onRemoveDraftImage} - onPressImage={voiceInput.isBusy ? undefined : onPressImage} + onPressPreview={voiceInput.isBusy ? undefined : onPressPreview} + onPressVideo={voiceInput.isBusy ? undefined : onPressVideo} /> ) : null} @@ -733,27 +766,18 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer {!isExpanded && props.draftAttachments.length > 0 ? ( - {props.draftAttachments.slice(0, 3).map((attachment) => - attachment.type === "image" ? ( - onPressImage(attachment.previewUri)} - > - - - ) : ( - - - - ), - )} + {props.draftAttachments.slice(0, 3).map((attachment) => ( + + ))} {props.draftAttachments.length > 3 ? ( @@ -797,10 +821,10 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer /> ) : ( )} @@ -888,7 +912,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer /> ) : voicePresentation.showsSend ? ( - + + ); }); diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 41489bca952c..2736549b2b75 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -688,7 +688,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread onTouchCancel={handleFeedTouchCancel} > = new Set(); +// Let neighboring rows move out of the new rows' space before showing their text. +const THREAD_FEED_DISCLOSURE_ENTER_TRANSITION = FadeIn.delay( + THREAD_DISCLOSURE_TRANSITION_MS, +).duration(140); // Entering animations must only play for rows born just now — LegendList // remounts rows when they scroll back into view, and replaying an entrance for @@ -209,9 +220,11 @@ export interface ThreadFeedProps { function MessageAttachmentImage(props: { readonly environmentId: EnvironmentId; readonly attachmentId: string; + readonly name: string; readonly className: string; - readonly onPressImage: (uri: string, headers?: Record) => void; + readonly onPressPreview: (source: FilePreviewSource) => void; }) { + const sourceIdentifier = useId(); const uri = useAssetUrl(props.environmentId, { _tag: "attachment", attachmentId: props.attachmentId, @@ -226,9 +239,17 @@ function MessageAttachmentImage(props: { } return ( - props.onPressImage(uri)}> - - + + + props.onPressPreview({ kind: "image", uri, name: props.name, sourceIdentifier }) + } + > + + + ); } @@ -246,12 +267,32 @@ function isFileAttachment(attachment: ChatAttachment): attachment is ChatFileAtt function MessageAttachmentFile(props: { readonly environmentId: EnvironmentId; readonly attachment: ChatFileAttachment; + readonly onPressPreview: (source: FilePreviewSource) => void; + readonly onPressVideo: (attachment: ChatFileAttachment, sourceIdentifier: string) => void; }) { + const sourceIdentifier = useId(); const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { reportFailure: false, }); const preparedConnection = usePreparedConnection(props.environmentId); const { attachment } = props; + const videoType = videoMimeType(attachment); + const isPdf = isPdfFile(attachment); + const fileTypeLabel = isPdf + ? "PDF" + : (attachment.name.match(/\.([a-z0-9]{1,8})$/i)?.[1]?.toUpperCase() ?? "File"); + const sizeLabel = formatAttachmentSize(attachment.sizeBytes); + const thumbnailUrl = useAssetUrl( + props.environmentId, + videoType === null + ? null + : { + _tag: "attachment", + attachmentId: attachment.id, + fileName: attachment.name, + mimeType: videoType, + }, + ); const httpBaseUrl = Option.isSome(preparedConnection) ? preparedConnection.value.httpBaseUrl : null; @@ -268,68 +309,127 @@ function MessageAttachmentFile(props: { }, [props.environmentId, attachment.id, httpBaseUrl]), ); + const shareFile = (sourceIdentifier?: string) => { + if (httpBaseUrl === null || openingRef.current) return; + const controller = new AbortController(); + openingRef.current = controller; + setOpening(true); + void (async () => { + try { + const result = await createAssetUrl({ + environmentId: props.environmentId, + input: { + resource: { + _tag: "attachment", + attachmentId: attachment.id, + fileName: attachment.name, + mimeType: attachment.mimeType, + }, + }, + }); + if (controller.signal.aborted) return; + if (result._tag === "Failure") { + throw squashAtomCommandFailure(result); + } + const url = resolveAssetUrl(httpBaseUrl, result.value.relativeUrl); + if (url === null) { + throw new Error("The attachment could not be opened."); + } + await downloadAndShareAttachment({ + url, + attachment, + signal: controller.signal, + sourceIdentifier, + }); + } catch (error) { + if (!controller.signal.aborted) { + Alert.alert( + "Could not open attachment", + error instanceof Error ? error.message : "The attachment is unavailable.", + ); + } + } finally { + if (openingRef.current === controller) { + openingRef.current = null; + setOpening(false); + } + } + })(); + }; + + if (videoType !== null) { + return ( + props.onPressVideo(attachment, sourceIdentifier)} + onShare={() => shareFile(`attachment:${props.environmentId}:${attachment.id}`)} + className="my-1 rounded-2xl" + style={{ width: 224, maxWidth: "100%", aspectRatio: 16 / 9 }} + /> + ); + } + return ( - { - if (httpBaseUrl === null || openingRef.current) return; - const controller = new AbortController(); - openingRef.current = controller; - setOpening(true); - void (async () => { - try { - const result = await createAssetUrl({ - environmentId: props.environmentId, - input: { + + + isPdf + ? props.onPressPreview({ + kind: "pdf", + name: attachment.name, + environmentId: props.environmentId, resource: { _tag: "attachment", attachmentId: attachment.id, fileName: attachment.name, - mimeType: attachment.mimeType, + mimeType: "application/pdf", }, - }, - }); - if (controller.signal.aborted) return; - if (result._tag === "Failure") { - throw squashAtomCommandFailure(result); - } - const url = resolveAssetUrl(httpBaseUrl, result.value.relativeUrl); - if (url === null) { - throw new Error("The attachment could not be opened."); - } - await downloadAndShareAttachment({ url, attachment, signal: controller.signal }); - } catch (error) { - if (!controller.signal.aborted) { - Alert.alert( - "Could not open attachment", - error instanceof Error ? error.message : "The attachment is unavailable.", - ); - } - } finally { - if (openingRef.current === controller) { - openingRef.current = null; - setOpening(false); - } - } - })(); - }} - > - {opening ? ( - - ) : ( - - )} - - {attachment.name} - - - {formatAttachmentSize(attachment.sizeBytes)} - - + sourceIdentifier, + }) + : shareFile(sourceIdentifier) + } + > + + {opening ? ( + + ) : ( + + )} + + + + {attachment.name} + + + {fileTypeLabel} · {sizeLabel} + + + + + ); } @@ -353,8 +453,9 @@ function ThreadMarkdownImageView(props: { readonly sourceKey: string; readonly unavailable: boolean; readonly alt: string | null; - readonly onPressImage: (uri: string) => void; + readonly onPressPreview: (source: FilePreviewSource) => void; }) { + const sourceIdentifier = useId(); const [availableWidth, setAvailableWidth] = useState(0); const [sourceSize, setSourceSize] = useState<{ width: number; height: number } | null>(null); const [failedUri, setFailedUri] = useState(null); @@ -399,27 +500,35 @@ function ThreadMarkdownImageView(props: { )} ) : ( - props.onPressImage(props.uri!)} - style={{ alignSelf: "flex-start" }} - > - + + props.onPressPreview({ + kind: "image", + uri: props.uri!, + name: props.alt ?? "Image", + sourceIdentifier, + }) + } + style={{ alignSelf: "flex-start" }} > - setFailedUri(props.uri)} - /> - - + + setFailedUri(props.uri)} + /> + + + )} {props.alt ? ( @@ -462,27 +571,27 @@ function ThreadMarkdownImageRequest(props: { ); } -/** Markdown image whose src is a workspace file — loads through a signed asset URL. */ +/** Environment-hosted image that loads through a signed asset URL. */ function ThreadMarkdownImage(props: { readonly environmentId: EnvironmentId; - readonly threadId: ThreadId; - readonly path: string; + readonly resource: Extract; readonly alt: string | null; - readonly onPressImage: (uri: string) => void; + readonly srcFragment?: string; + readonly onPressPreview: (source: FilePreviewSource) => void; }) { - const assetUrl = useAssetUrlState(props.environmentId, { - _tag: "workspace-file", - threadId: props.threadId, - path: props.path, - }); + const assetUrl = useAssetUrlState(props.environmentId, props.resource); return ( ); } @@ -494,7 +603,7 @@ function ThreadMarkdownImageUnavailable(props: { readonly alt: string | null }) sourceKey="unavailable" unavailable alt={props.alt} - onPressImage={() => undefined} + onPressPreview={() => undefined} /> ); } @@ -1226,9 +1335,11 @@ function renderFeedEntry( readonly onToggleWorkGroup: (groupId: string) => void; readonly onToggleWorkRow: (rowId: string) => void; readonly onToggleTurnFold: (turnId: TurnId) => void; - readonly onPressImage: (uri: string, headers?: Record) => void; + readonly onPressPreview: (source: FilePreviewSource) => void; + readonly onPressVideo: (attachment: ChatFileAttachment, sourceIdentifier: string) => void; readonly onMarkdownLinkPress: (href: string) => void; readonly renderMarkdownImage: MarkdownImageRenderer; + readonly renderViewedImage: MarkdownImageRenderer; readonly iconSubtleColor: string | import("react-native").ColorValue; readonly userBubbleColor: string | import("react-native").ColorValue; readonly markdownStyles: MarkdownStyleSets; @@ -1247,16 +1358,16 @@ function renderFeedEntry( accessibilityState={{ expanded: entry.expanded }} onPress={() => props.onToggleTurnFold(entry.turnId)} hitSlop={4} - className="mb-3 min-h-11 flex-row items-center gap-2 border-b border-adaptive-neutral-200-a80-white-a8 px-2" + className="mb-1 min-h-11 flex-row items-center gap-2 border-b border-adaptive-neutral-200-a80-white-a8 px-2" > {entry.label} - ); @@ -1336,14 +1447,17 @@ function renderFeedEntry( key={attachment.id} environmentId={props.environmentId} attachmentId={attachment.id} + name={attachment.name} className="aspect-[1.3] w-full rounded-[14px] bg-white/15" - onPressImage={props.onPressImage} + onPressPreview={props.onPressPreview} /> ) : isFileAttachment(attachment) ? ( ) : ( @@ -1377,7 +1491,7 @@ function renderFeedEntry( const enterAnimated = isFreshTimestamp(message.createdAt); return ( {renderedText.trim().length > 0 ? ( @@ -1396,14 +1510,17 @@ function renderFeedEntry( key={attachment.id} environmentId={props.environmentId} attachmentId={attachment.id} + name={attachment.name} className="mt-1.5 aspect-[1.3] w-full rounded-[18px] bg-adaptive-neutral-200-800" - onPressImage={props.onPressImage} + onPressPreview={props.onPressPreview} /> ) : isFileAttachment(attachment) ? ( ) : ( @@ -1435,6 +1552,7 @@ function renderFeedEntry( iconSubtleColor={iconSubtleColor} onCopyRow={props.onCopyWorkRow} onToggleRow={props.onToggleWorkRow} + renderImage={props.renderViewedImage} /> ); } @@ -1715,7 +1833,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { const disclosureSettleFrameRef = useRef(null); const disclosureSettleSecondFrameRef = useRef(null); const disclosureAnchorKeyRef = useRef(null); - const previousPresentedFeedRef = useRef | null>(null); const headerMaterialVisibleRef = useRef(false); const previousLatestTurnRef = useRef(props.latestTurn); const userScrollSettleTimerRef = useRef | null>(null); @@ -1728,12 +1845,12 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { // Live-follow latch. LegendList's maintainScrollAtEnd alone re-pins the feed // whenever the viewport drifts back inside its geometric threshold, which // yanked users off history they were reading every time a stream chunk grew - // a row. Follow breaks when the user scrolls up and away, and re-arms only - // when the list actually returns to the end (or on send / thread switch). + // a row. Scrolling away or expanding a disclosure above the end breaks + // follow; reaching the end (or sending / switching threads) re-arms it. const [endFollowEnabled, setEndFollowEnabled] = useState(true); const endFollowEnabledRef = useRef(true); // A "user scroll session" spans from drag start through the end of its - // momentum; only motion inside a session can break follow, so MVCP + // momentum; scroll events only break follow inside that session, so MVCP // compensations and programmatic scrolls never strand a follower. const userScrollSessionRef = useRef(false); const setEndFollow = useCallback( @@ -1765,10 +1882,12 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { expandedTurnIds: new Set(), }); const { copiedRowId, expandedWorkGroups, expandedWorkRows, expandedTurnIds } = interactionState; - const [expandedImage, setExpandedImage] = useState<{ - uri: string; - headers?: Record; - } | null>(null); + const [expandedFile, setExpandedFile] = useState(null); + const [expandedVideo, setExpandedVideo] = useState(null); + useEffect(() => { + setExpandedVideo(null); + setExpandedFile(null); + }, [props.environmentId, props.threadId, props.contentPresentation.kind]); const horizontalPadding = props.layoutVariant === "split" ? 20 : 16; const contentHorizontalPadding = deriveCenteredContentHorizontalPadding({ viewportWidth, @@ -1813,6 +1932,22 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { ); if (relativePath) { void Haptics.selectionAsync(); + if (isPdfFile({ name: relativePath })) { + setExpandedFile( + (current) => + current ?? { + kind: "pdf", + name: relativePath.split("/").at(-1), + environmentId: props.environmentId, + resource: { + _tag: "workspace-file", + threadId: props.threadId, + path: relativePath, + }, + }, + ); + return; + } navigation.navigate("ThreadFile", { environmentId: String(props.environmentId), threadId: String(props.threadId), @@ -1824,6 +1959,12 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { } if (presentation.href) { + if (/^https?:\/\//i.test(presentation.href) && isPdfFile({ name: presentation.href })) { + setExpandedFile( + (current) => current ?? { kind: "pdf", uri: presentation.href!, name: "Document.pdf" }, + ); + return; + } void tryOpenExternalUrl(presentation.href, "markdown-link"); } }, @@ -1839,7 +1980,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { sourceKey={imageSource.uri} unavailable={false} alt={image.alt} - onPressImage={(uri) => setExpandedImage({ uri })} + onPressPreview={(source) => setExpandedFile((current) => current ?? source)} /> ); } @@ -1849,15 +1990,37 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { return ( setExpandedImage({ uri })} + srcFragment={markdownImageSourceFragment(image.href)} + onPressPreview={(source) => setExpandedFile((current) => current ?? source)} /> ); }, [props.environmentId, props.threadId, props.workspaceRoot], ); + const renderViewedImage = useCallback( + (image) => { + const viewedImage = resolveViewedImageAsset(image.href, { + threadId: props.threadId, + workspaceRoot: props.workspaceRoot, + }); + return viewedImage ? ( + setExpandedFile((current) => current ?? source)} + /> + ) : null; + }, + [props.environmentId, props.threadId, props.workspaceRoot], + ); const markdownStyles = useMarkdownStyles(onMarkdownLinkPress, renderMarkdownImage); const reviewCommentColors = useReviewCommentColors(); // LegendList does not invalidate visible rows when only the renderItem closure changes. @@ -2024,33 +2187,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { props.latestTurn, ], ); - const disclosureEnteringEntryIds = useMemo(() => { - const anchorKey = disclosureAnchorKeyRef.current; - const previousPresentedFeed = previousPresentedFeedRef.current; - if (!disclosureToggleSettling || anchorKey === null || previousPresentedFeed === null) { - return EMPTY_DISCLOSURE_ENTRY_IDS; - } - - const previousIds = new Set(previousPresentedFeed.map((entry) => entry.id)); - const anchorIndex = presentedFeed.findIndex((entry) => entry.id === anchorKey); - const enteringIds = new Set(); - if (anchorIndex < 0) { - return enteringIds; - } - for (let index = anchorIndex + 1; index < presentedFeed.length; index += 1) { - const entryId = presentedFeed[index]!.id; - if (previousIds.has(entryId)) { - break; - } - enteringIds.add(entryId); - } - return enteringIds; - }, [disclosureToggleSettling, presentedFeed]); - - useLayoutEffect(() => { - previousPresentedFeedRef.current = presentedFeed; - }, [presentedFeed]); - // The empty↔filled key below remounts the list and resets its imperative // content-inset override. Seed the fresh instance synchronously with the // current overlay height before the scroll integration's next reaction; @@ -2137,13 +2273,23 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { } disclosureSettleFrameRef.current = requestAnimationFrame(() => { disclosureSettleSecondFrameRef.current = requestAnimationFrame(() => { + // A disclosure can leave the reader above the end without a drag. + // Reconcile follow before a later layout or resume can re-pin it. + const listState = props.listRef.current?.getState(); + if (listState) { + transitionEndFollow({ + type: "disclosure-settled", + isAtEnd: listState.isAtEnd, + userScrollSessionActive: userScrollSessionRef.current, + }); + } disclosureAnchorKeyRef.current = null; setDisclosureToggleSettling(false); disclosureSettleFrameRef.current = null; disclosureSettleSecondFrameRef.current = null; }); }); - }, []); + }, [props.listRef, transitionEndFollow]); const suspendEndScrollMaintenanceForDisclosure = useCallback((anchorKey: string | null) => { disclosureAnchorKeyRef.current = anchorKey; @@ -2240,9 +2386,23 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { [suspendEndScrollMaintenanceForDisclosure], ); - const onPressImage = useCallback((uri: string, headers?: Record) => { - setExpandedImage({ uri, headers }); + const onPressPreview = useCallback((source: FilePreviewSource) => { + setExpandedFile((current) => current ?? source); }, []); + const onPressVideo = useCallback( + (attachment: ChatFileAttachment, sourceIdentifier: string) => { + setExpandedVideo( + (current) => + current ?? { + type: "remote", + environmentId: props.environmentId, + attachment, + sourceIdentifier, + }, + ); + }, + [props.environmentId], + ); // Rows whose height is known before they ever render. Without this, every // row above the viewport is assumed to be estimatedItemSize tall, and @@ -2270,16 +2430,13 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { [expandedWorkRows], ); + // Disclosures can mount existing offscreen rows as well as new work rows. + // Fade those in after movement; never retain removed rows over replacements. const renderItem = useCallback( (info: { item: ThreadFeedEntry; index: number }) => ( {renderFeedEntry(info, { environmentId: props.environmentId, @@ -2291,9 +2448,11 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { onToggleWorkGroup, onToggleWorkRow, onToggleTurnFold, - onPressImage, + onPressPreview, + onPressVideo, onMarkdownLinkPress, renderMarkdownImage, + renderViewedImage, iconSubtleColor, userBubbleColor, markdownStyles, @@ -2307,7 +2466,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { ), [ copiedRowId, - disclosureEnteringEntryIds, + disclosureToggleSettling, expandedWorkRows, terminalAssistantMessageIds, unsettledTurnId, @@ -2319,7 +2478,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { userBubbleMaxWidth, onCopyWorkRow, onMarkdownLinkPress, - onPressImage, + onPressPreview, + onPressVideo, onToggleTurnFold, onToggleWorkGroup, onToggleWorkRow, @@ -2327,6 +2487,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { props.onUseArtifactTemplate, props.skills, renderMarkdownImage, + renderViewedImage, ], ); @@ -2503,23 +2664,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { ) : null} - setExpandedImage(null)} - swipeToCloseEnabled - doubleTapToZoomEnabled - /> + setExpandedVideo(null)} /> + setExpandedFile(null)} /> ); }); diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index 512b9b78a4ca..4a4d36c7a211 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -9,7 +9,6 @@ import { import { LegendList } from "@legendapp/list/react-native"; import type { MenuAction } from "@react-native-menu/menu"; import { useAtomValue } from "@effect/atom-react"; -import { AsyncResult } from "effect/unstable/reactivity"; import type { EnvironmentId } from "@t3tools/contracts"; import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -28,7 +27,6 @@ import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; import { NativeStackScreenOptions } from "../../native/StackHeader"; import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities"; import { useProjects, useThreadShells } from "../../state/entities"; -import { mobilePreferencesAtom } from "../../state/preferences"; import { useThreadSearch } from "../../state/queries"; import { useThreadListV2Enabled } from "./use-thread-list-v2-enabled"; import { useThreadListV2ShelfPreferences } from "./use-thread-list-v2-shelf-preferences"; @@ -82,7 +80,6 @@ import { buildThreadListV2ListItems, THREAD_LIST_V2_SETTLED_INITIAL_COUNT, THREAD_LIST_V2_SETTLED_PAGE_COUNT, - type ThreadListV2ChangeRequestState, type ThreadListV2ListItem, } from "./threadListV2"; @@ -164,10 +161,6 @@ function ThreadNavigationSidebarPane( regenerateThreadTitle, } = useThreadListActions(); const threadListV2Enabled = useThreadListV2Enabled(); - const preferencesResult = useAtomValue(mobilePreferencesAtom); - const autoSettleOnMerge = - !AsyncResult.isSuccess(preferencesResult) || - preferencesResult.value.autoSettleOnMerge !== false; const pendingTasks = usePendingNewTasks(); const { openPendingTask, confirmDeletePendingTask } = usePendingTaskListActions(); const environments = useMemo( @@ -365,33 +358,6 @@ function ThreadNavigationSidebarPane( // Thread List v2 (beta) support — same model as the compact Home list // (HomeScreen.tsx): flat creation-order card block + settled recency tail. - // PR states stream in per-row. The next partition applies the configured - // merge rule and the always-on close rule. - const [changeRequestByKey, setChangeRequestByKey] = useState< - ReadonlyMap - >(() => new Map()); - const handleChangeRequestState = useCallback( - (threadKey: string, changeRequest: ThreadListV2ChangeRequestState | null) => { - setChangeRequestByKey((current) => { - const existing = current.get(threadKey) ?? null; - if ( - (existing?.state ?? null) === (changeRequest?.state ?? null) && - (existing?.updatedAt ?? null) === (changeRequest?.updatedAt ?? null) && - (existing?.linkedPullRequestKey ?? null) === (changeRequest?.linkedPullRequestKey ?? null) - ) { - return current; - } - const next = new Map(current); - if (changeRequest === null) { - next.delete(threadKey); - } else { - next.set(threadKey, changeRequest); - } - return next; - }); - }, - [], - ); // The settled tail renders in pages; expansion resets when the filter // context changes so environment/search flips never inherit a deep page. const [settledVisibleCount, setSettledVisibleCount] = useState( @@ -414,9 +380,7 @@ function ThreadNavigationSidebarPane( toggleSettledShelf, toggleSnoozedShelf, } = useThreadListV2ShelfPreferences(); - // now ticks per minute so the inactivity auto-settle boundary is actually - // crossed while the pane stays open; without a clock dependency the - // partition memoizes a frozen "now". + // The queued-start and snooze helpers need a clock while the pane stays open. const [nowMinute, setNowMinute] = useState(() => new Date().toISOString().slice(0, 16)); // Snooze wake times are second-precise; a counter bumped exactly at the // next wake boundary re-runs the partition with a fresh clock so a woken @@ -424,9 +388,7 @@ function ThreadNavigationSidebarPane( const [snoozeWakeTick, bumpSnoozeWakeTick] = useState(0); useEffect(() => { if (!threadListV2Enabled) return; - // Refresh immediately on enable: the mount-time value can be hours old - // by the time the beta is switched on, which would misclassify the - // inactivity auto-settle boundary until the first tick. + // Refresh immediately because the mount-time value can be hours old. setNowMinute(new Date().toISOString().slice(0, 16)); const id = setInterval(() => setNowMinute(new Date().toISOString().slice(0, 16)), 60_000); return () => clearInterval(id); @@ -509,20 +471,15 @@ function ThreadNavigationSidebarPane( projectRefs: selectedProjectScope === null ? null : selectedProjectScope.projectRefs, searchQuery: props.searchQuery, matchedThreadKeys, - changeRequestByKey, - autoSettleOnMerge, settlementEnvironmentIds, snoozeEnvironmentIds, settledLimit: settledVisibleCount, - now: `${nowMinute}:00.000Z`, - snoozeNow: new Date().toISOString(), + now: new Date().toISOString(), snoozedShelfExpanded, settledShelfExpanded, selectedThreadKey: props.selectedThreadKey ?? null, }); }, [ - changeRequestByKey, - autoSettleOnMerge, nowMinute, snoozeWakeTick, snoozedShelfExpanded, @@ -931,7 +888,6 @@ function ThreadNavigationSidebarPane( onPinThread={pinThread} onUnpinThread={unpinThread} onMovePinnedThread={movePinnedThread} - onChangeRequestState={handleChangeRequestState} projectCwd={projectCwdByKey.get(scopeKey) ?? null} onSwipeableClose={handleSwipeableClose} onSwipeableWillOpen={handleSwipeableWillOpen} @@ -1058,7 +1014,6 @@ function ThreadNavigationSidebarPane( arrangedPinnedKeys, confirmDeletePendingTask, confirmDeleteThread, - handleChangeRequestState, handleSelectThread, handleSwipeableClose, handleSwipeableWillOpen, diff --git a/apps/mobile/src/features/threads/thread-feed-live-follow.test.ts b/apps/mobile/src/features/threads/thread-feed-live-follow.test.ts index 2ea207923429..b77600805876 100644 --- a/apps/mobile/src/features/threads/thread-feed-live-follow.test.ts +++ b/apps/mobile/src/features/threads/thread-feed-live-follow.test.ts @@ -102,6 +102,17 @@ describe("resolveThreadFeedLiveFollow", () => { ).toBe(false); }); + it.each([ + { isAtEnd: false, userScrollSessionActive: false, expected: false }, + { isAtEnd: true, userScrollSessionActive: false, expected: true }, + { isAtEnd: false, userScrollSessionActive: true, expected: false }, + { isAtEnd: true, userScrollSessionActive: true, expected: false }, + ])("reconciles follow after a disclosure settles: %j", ({ expected, ...state }) => { + expect(resolveThreadFeedLiveFollow(!expected, { type: "disclosure-settled", ...state })).toBe( + expected, + ); + }); + it("re-arms at the actual end only after the user scroll session ends", () => { expect( resolveThreadFeedLiveFollow(false, { diff --git a/apps/mobile/src/features/threads/thread-feed-live-follow.ts b/apps/mobile/src/features/threads/thread-feed-live-follow.ts index 312fd67473e5..83d5cc22faed 100644 --- a/apps/mobile/src/features/threads/thread-feed-live-follow.ts +++ b/apps/mobile/src/features/threads/thread-feed-live-follow.ts @@ -7,7 +7,7 @@ export type ThreadFeedLiveFollowEvent = readonly userScrollSessionActive: boolean; } | { - readonly type: "scroll"; + readonly type: "scroll" | "disclosure-settled"; readonly isAtEnd: boolean; readonly userScrollSessionActive: boolean; }; @@ -41,6 +41,8 @@ export function resolveThreadFeedLiveFollow( return false; case "user-scroll-end": return event.userScrollSessionActive ? event.isAtEnd : current; + case "disclosure-settled": + return !event.userScrollSessionActive && event.isAtEnd; case "scroll": if (event.userScrollSessionActive) { return false; diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index 51b1ed7afcdb..5ea43000bf1b 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -23,12 +23,10 @@ import { ThreadSwipeable } from "../home/thread-swipe-actions"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { buildThreadTitleRegenerationMenuItems } from "./thread-title-regeneration-menu"; import { - resolveThreadListV2ChangeRequestState, resolveThreadListV2SnoozeMenuSelection, resolveThreadListV2SnoozeGateExpiryMs, resolveThreadListV2Status, resolveThreadListV2SwipeActions, - type ThreadListV2ChangeRequestState, type ThreadListV2Status, } from "./threadListV2"; import { ThreadSearchMatchExcerpt } from "./thread-search-match"; @@ -370,12 +368,6 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { readonly canMovePinnedDown?: boolean; readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void; readonly onSwipeableClose: (methods: SwipeableMethods) => void; - /** Reports this row's live PR (state + last activity) for the partition's - merge and close rules. Mirrors web's onChangeRequestState. */ - readonly onChangeRequestState?: ( - threadKey: string, - changeRequest: ThreadListV2ChangeRequestState | null, - ) => void; readonly projectCwd?: string | null; readonly searchMatch?: EnvironmentThreadSearchMatch; readonly searchQuery?: string; @@ -398,24 +390,11 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { onPinThread, onUnpinThread, onMovePinnedThread, - onChangeRequestState, } = props; const snoozedRow = props.snoozed === true; const pinnedRow = props.pinned === true; const pr = useThreadPr(thread, props.projectCwd ?? props.project?.workspaceRoot ?? null); - const prState = pr?.state ?? null; - const prUpdatedAt = pr?.updatedAt ?? null; - const threadKey = `${thread.environmentId}:${thread.id}`; - useEffect(() => { - const changeRequest = resolveThreadListV2ChangeRequestState({ - linkedPullRequest: thread.linkedPullRequest, - state: prState, - updatedAt: prUpdatedAt, - }); - if (changeRequest === undefined) return; - onChangeRequestState?.(threadKey, changeRequest); - }, [onChangeRequestState, prState, prUpdatedAt, thread.linkedPullRequest, threadKey]); const theme = useUniwindTheme(); const screenColor = theme["--color-screen"]; @@ -453,9 +432,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { ); const handleArchive = useCallback(() => onArchiveThread(thread), [onArchiveThread, thread]); - // Swipe: the v2 primary action is the lifecycle transition. Every settled - // row can un-settle — explicit settles clear the override, auto-settled - // rows get pinned active until real activity clears the pin. + // Swipe: the v2 primary action is the lifecycle transition. Un-settling a + // settled row keeps it active until new activity clears the user override. const canUnsettle = variant === "slim"; const [snoozeGateTick, bumpSnoozeGateTick] = useState(0); const snoozeGateExpiryMs = props.snoozeSupported diff --git a/apps/mobile/src/features/threads/thread-work-log.tsx b/apps/mobile/src/features/threads/thread-work-log.tsx index a316d9daee7c..d26f8976b9ca 100644 --- a/apps/mobile/src/features/threads/thread-work-log.tsx +++ b/apps/mobile/src/features/threads/thread-work-log.tsx @@ -2,13 +2,14 @@ import * as Haptics from "expo-haptics"; import { type AppSymbolName, SymbolView } from "../../components/AppSymbol"; import { MaskedView } from "@expo/ui/community/masked-view"; import { useIsFocused } from "@react-navigation/native"; -import { useEffect, useId, useState, type ComponentProps } from "react"; +import { useEffect, useId, useLayoutEffect, useState, type ComponentProps } from "react"; import { AccessibilityInfo, AppState, type ColorValue, Pressable, ScrollView, + StyleSheet, View, } from "react-native"; import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg"; @@ -16,7 +17,11 @@ import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg"; import { AppText as Text } from "../../components/AppText"; import { cn } from "../../lib/cn"; import type { ThreadFeedActivity } from "../../lib/threadActivity"; -import type { ToolGroupSummaryKind } from "@t3tools/client-runtime/work-log/presentation"; +import { + type ToolGroupSummaryKind, + workEntryViewedImagePath, +} from "@t3tools/client-runtime/work-log/presentation"; +import type { MarkdownImageRenderer } from "../../native/SelectableMarkdownText"; import Animated, { cancelAnimation, Easing, @@ -41,6 +46,44 @@ const WORK_LOG_LAYOUT_TRANSITION = LinearTransition.duration(THREAD_DISCLOSURE_T const WORK_LOG_DETAIL_ENTER_TRANSITION = FadeIn.duration(140); const WORK_LOG_DETAIL_EXIT_TRANSITION = FadeOut.duration(120); +export function ThreadDisclosureChevron(props: { + readonly expanded: boolean; + readonly collapsedDirection: "right" | "down"; + readonly size: number; + readonly tintColor: ColorValue; +}) { + const expandedAngle = props.collapsedDirection === "right" ? 90 : 180; + const rotation = useSharedValue(props.expanded ? expandedAngle : 0); + + useLayoutEffect(() => { + rotation.value = withTiming(props.expanded ? expandedAngle : 0, { + duration: THREAD_DISCLOSURE_TRANSITION_MS, + reduceMotion: ReduceMotion.System, + }); + }, [expandedAngle, props.expanded, rotation]); + + const rotationStyle = useAnimatedStyle(() => ({ + transform: [{ rotate: `${rotation.value}deg` }], + })); + + return ( + + + + ); +} + function ShimmerWorkContent(props: { readonly highlighted: boolean; readonly icon: AppSymbolName; @@ -78,7 +121,7 @@ function ShimmerWorkContent(props: { ); } -function ShimmeringWorkContent(props: { +export function ShimmeringWorkContent(props: { readonly icon: AppSymbolName; readonly iconSubtleColor: ColorValue; readonly label: string; @@ -143,7 +186,7 @@ function ShimmeringWorkContent(props: { return ( setAvailableWidth(event.nativeEvent.layout.width)} > @@ -259,7 +302,7 @@ const WORK_ROW_HEIGHT = 32; // min-h-8 const WORK_ROW_GAP = 1; // gap-px const WORK_LOG_BOTTOM_MARGIN = 4; // mb-1 -export const WORK_GROUP_TOGGLE_HEIGHT = 36; // min-h-8 (32) + mb-1 (4) +export const WORK_GROUP_TOGGLE_HEIGHT = 32; // min-h-8 export function collapsedWorkLogHeight(activities: ReadonlyArray): number { const rows = activities; @@ -276,6 +319,7 @@ export function ThreadWorkLog(props: { readonly iconSubtleColor: import("react-native").ColorValue; readonly onCopyRow: (rowId: string, value: string) => void; readonly onToggleRow: (rowId: string) => void; + readonly renderImage: MarkdownImageRenderer; }) { const rows = props.activities.map((activity) => ({ ...activity, @@ -293,6 +337,7 @@ export function ThreadWorkLog(props: { const expanded = props.expandedRows[row.id] ?? false; const canExpand = row.canExpand; const fullDetail = expanded ? row.getFullDetail() : null; + const viewedImagePath = workEntryViewedImagePath(row.workEntry); const displayText = row.detail ?? row.summary; const iconIsDestructive = row.icon === "alert" || row.icon === "warning"; const failed = row.status === "failure"; @@ -369,15 +414,11 @@ export function ThreadWorkLog(props: { ) : null} {canExpand ? ( - ) : null} @@ -392,6 +433,11 @@ export function ThreadWorkLog(props: { layout={WORK_LOG_LAYOUT_TRANSITION} className="ml-7 border-l border-adaptive-neutral-300-a60-white-a12 pb-1 pl-3 pt-0.5" > + {viewedImagePath ? ( + + {props.renderImage({ href: viewedImagePath, alt: null, title: null })} + + ) : null} + )} - diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts index 24c07eae6da1..48edf3906002 100644 --- a/apps/mobile/src/features/threads/threadListV2.test.ts +++ b/apps/mobile/src/features/threads/threadListV2.test.ts @@ -16,7 +16,6 @@ import type { PendingNewTask } from "../../state/use-pending-new-tasks"; import { buildThreadListV2Items, buildThreadListV2ListItems, - resolveThreadListV2ChangeRequestState, resolveThreadListV2Enabled, resolveThreadListV2SnoozeMenuSelection, resolveThreadListV2SnoozeGateExpiryMs, @@ -61,42 +60,6 @@ const linkedPullRequest = { url: "https://github.com/pingdotgg/t3code/pull/42", }; -describe("resolveThreadListV2ChangeRequestState", () => { - it("preserves the previous state while a linked pull request reloads", () => { - expect( - resolveThreadListV2ChangeRequestState({ - linkedPullRequest, - state: null, - updatedAt: null, - }), - ).toBeUndefined(); - }); - - it("clears the previous state after a pull request is unlinked", () => { - expect( - resolveThreadListV2ChangeRequestState({ - linkedPullRequest: null, - state: null, - updatedAt: null, - }), - ).toBeNull(); - }); - - it("reports a loaded linked pull request", () => { - expect( - resolveThreadListV2ChangeRequestState({ - linkedPullRequest, - state: "merged", - updatedAt: "2026-06-02T00:00:00.000Z", - }), - ).toEqual({ - state: "merged", - updatedAt: "2026-06-02T00:00:00.000Z", - linkedPullRequestKey: '["project-1","pingdotgg/t3code",42]', - }); - }); -}); - describe("resolveThreadListV2SnoozeMenuSelection", () => { it("accepts a displayed evening preset while its wake time is still future", () => { const menuOpenedAt = new Date(2026, 4, 8, 16, 59, 30); @@ -319,51 +282,18 @@ describe("sortThreadsForListV2", () => { }); describe("buildThreadListV2Items", () => { - it("ignores the previous pull request state after a different pull request is linked", () => { - const thread = makeThread({ - id: ThreadId.make("linked"), - title: "Linked pull request", - linkedPullRequest, - }); - const layout = buildThreadListV2Items({ - threads: [thread], - environmentId: null, - searchQuery: "", - changeRequestByKey: new Map([ - [ - `${environmentId}:${thread.id}`, - { - state: "merged" as const, - linkedPullRequestKey: '["project-1","pingdotgg/t3code",41]', - }, - ], - ]), - now: NOW, - }); - - expect(layout.settledCount).toBe(0); - expect(layout.items[0]?.variant).toBe("card"); - }); - - it("settles a thread only when the cached pull request identity matches", () => { + it("places a persisted settled thread in the settled shelf", () => { const thread = makeThread({ id: ThreadId.make("linked-merged"), title: "Linked merged pull request", linkedPullRequest, + settledOverride: "settled", + settledAt: NOW, }); const layout = buildThreadListV2Items({ threads: [thread], environmentId: null, searchQuery: "", - changeRequestByKey: new Map([ - [ - `${environmentId}:${thread.id}`, - { - state: "merged" as const, - linkedPullRequestKey: '["project-1","pingdotgg/t3code",42]', - }, - ], - ]), now: NOW, }); @@ -371,23 +301,6 @@ describe("buildThreadListV2Items", () => { expect(layout.items[0]?.variant).toBe("slim"); }); - it("keeps a merged thread active when auto-settle on merge is off", () => { - const merged = makeThread({ id: ThreadId.make("merged"), title: "Merged" }); - const layout = buildThreadListV2Items({ - threads: [merged], - environmentId: null, - searchQuery: "", - changeRequestByKey: new Map([ - [`${environmentId}:${merged.id}`, { state: "merged" as const }], - ]), - autoSettleOnMerge: false, - now: NOW, - }); - - expect(layout.items.map((item) => item.thread.id)).toEqual(["merged"]); - expect(layout.settledCount).toBe(0); - }); - it("hides snoozed threads and counts them — visibility parity with web", () => { const layout = buildThreadListV2Items({ threads: [ @@ -439,73 +352,21 @@ describe("buildThreadListV2Items", () => { expect(layout.settledCount).toBe(1); }); - it("moves pinned threads to the settled shelf when their pull request merges", () => { - const merged = makeThread({ - id: ThreadId.make("pinned-merged"), - title: "Pinned merged pull request", - pinnedAt: "2026-06-01T12:00:00.000Z", - }); - const layout = buildThreadListV2Items({ - threads: [makeThread({ id: ThreadId.make("active"), title: "Active" }), merged], - environmentId: null, - searchQuery: "", - changeRequestByKey: new Map([[`${environmentId}:${merged.id}`, { state: "merged" }]]), - now: NOW, - }); - - expect(layout.items.map((item) => item.thread.id)).toEqual(["active", "pinned-merged"]); - expect(layout.items.map((item) => item.variant)).toEqual(["card", "slim"]); - expect(layout.items[1]?.thread.pinnedAt).toBe("2026-06-01T12:00:00.000Z"); - expect(layout.settledCount).toBe(1); - }); - - it("moves inactive pinned threads to the settled shelf", () => { - const inactive = makeThread({ - id: ThreadId.make("pinned-inactive"), - title: "Pinned inactive thread", - createdAt: "2026-05-20T00:00:00.000Z", - pinnedAt: "2026-05-21T00:00:00.000Z", - latestTurn: { - turnId: TurnId.make("turn-inactive"), - state: "completed", - requestedAt: "2026-05-21T00:00:00.000Z", - startedAt: "2026-05-21T00:00:01.000Z", - completedAt: "2026-05-21T00:00:02.000Z", - assistantMessageId: null, - }, - }); - const layout = buildThreadListV2Items({ - threads: [inactive], - environmentId: null, - searchQuery: "", - now: NOW, - }); - - expect(layout.items[0]).toMatchObject({ - thread: { id: "pinned-inactive" }, - variant: "slim", - pinned: false, - }); - expect(layout.settledCount).toBe(1); - }); - - it("keeps pinned merged threads pinned when auto-settle on merge is off", () => { - const merged = makeThread({ - id: ThreadId.make("pinned-merged"), - title: "Pinned merged pull request", + it("keeps active pinned threads in the pinned block", () => { + const pinned = makeThread({ + id: ThreadId.make("pinned"), + title: "Pinned thread", pinnedAt: "2026-06-01T12:00:00.000Z", }); const layout = buildThreadListV2Items({ - threads: [merged], + threads: [pinned], environmentId: null, searchQuery: "", - changeRequestByKey: new Map([[`${environmentId}:${merged.id}`, { state: "merged" }]]), - autoSettleOnMerge: false, now: NOW, }); expect(layout.items[0]).toMatchObject({ - thread: { id: "pinned-merged" }, + thread: { id: "pinned" }, variant: "card", pinned: true, }); @@ -560,9 +421,7 @@ describe("buildThreadListV2Items", () => { ], environmentId: null, searchQuery: "", - // Minute-floored partition clock vs precise snooze clock. - now: "2026-06-02T00:01:00.000Z", - snoozeNow: "2026-06-02T00:01:07.500Z", + now: "2026-06-02T00:01:07.500Z", }); expect(layout.items.map((item) => item.thread.id)).toEqual(["just-woke"]); diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index be3343a21bad..cf284b41605a 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -1,22 +1,18 @@ import { - effectiveSettled, effectiveSnoozed, hasQueuedTurnStart, QUEUED_TURN_START_GRACE_MS, resolveSnoozePresets, snoozeWakeLabel, } from "@t3tools/client-runtime/state/thread-settled"; -import type { - ChangeRequestSettleSource, - SnoozePreset, -} from "@t3tools/client-runtime/state/thread-settled"; +import type { SnoozePreset } from "@t3tools/client-runtime/state/thread-settled"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; import { activeThreadAnchorTimestampMs, sortPinnedThreadsByOrderKey, } from "@t3tools/client-runtime/state/thread-sort"; -import type { EnvironmentId, ProjectId, ThreadLinkedPullRequest } from "@t3tools/contracts"; +import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; @@ -33,35 +29,6 @@ export { snoozeWakeLabel }; export type ThreadListV2Status = "approval" | "input" | "working" | "failed" | "ready"; export type ThreadListV2SwipeAction = "archive" | "settle" | "unsettle" | "snooze" | "unsnooze"; -export interface ThreadListV2ChangeRequestState extends ChangeRequestSettleSource { - readonly linkedPullRequestKey?: string | null; -} - -function linkedPullRequestKey( - linkedPullRequest: ThreadLinkedPullRequest | null | undefined, -): string | null { - if (linkedPullRequest == null) return null; - return JSON.stringify([ - linkedPullRequest.projectId, - linkedPullRequest.repository.toLowerCase(), - linkedPullRequest.number, - ]); -} - -/** Keep the previous linked PR state while its detail query reloads. */ -export function resolveThreadListV2ChangeRequestState(input: { - readonly linkedPullRequest: ThreadLinkedPullRequest | null | undefined; - readonly state: ChangeRequestSettleSource["state"] | null; - readonly updatedAt: string | null; -}): ThreadListV2ChangeRequestState | null | undefined { - if (input.state === null) return input.linkedPullRequest == null ? null : undefined; - return { - state: input.state, - updatedAt: input.updatedAt, - linkedPullRequestKey: linkedPullRequestKey(input.linkedPullRequest), - }; -} - export function resolveThreadListV2SnoozeMenuSelection(input: { readonly event: string; readonly displayedPresets: ReadonlyArray; @@ -347,8 +314,7 @@ export function buildThreadListV2ListItems(input: { /** * Partitions visible threads into the active card block (creation order) and - * the settled recency tail, matching the web v2 list. Mobile stores these - * auto-settle preferences per device. + * the settled recency tail, matching the web v2 list. */ export function buildThreadListV2Items(input: { readonly threads: ReadonlyArray; @@ -359,8 +325,6 @@ export function buildThreadListV2Items(input: { }> | null; readonly searchQuery: string; readonly matchedThreadKeys?: ReadonlySet; - /** Per-row PR reported up by visible rows ("env:threadId" keys). */ - readonly changeRequestByKey?: ReadonlyMap; /** Environments whose server supports thread.settle/unsettle. Threads on other environments never classify as settled — the user could neither un-settle nor pin them. Absent = no gating (tests). */ @@ -368,17 +332,10 @@ export function buildThreadListV2Items(input: { /** Environments whose server supports thread.snooze/unsnooze. Same contract as settlementEnvironmentIds. */ readonly snoozeEnvironmentIds?: ReadonlySet; - readonly autoSettleAfterDays?: number; - readonly autoSettleOnMerge?: boolean; /** Max settled rows to render; the rest are counted, not built. */ readonly settledLimit?: number; - /** Injectable for tests; defaults to now. */ - readonly now?: string; - /** Second-precise clock for snooze classification. Callers pass a - minute-quantized `now` for memoization; snooze wake times are - second-precise, so classifying with the floored minute would hold a - woken thread hidden for up to a minute. Defaults to `now`. */ - readonly snoozeNow?: string; + /** Second-precise clock used for time-based classification. */ + readonly now: string; /** Expands the snoozed shelf into rows. Collapsed is the default. */ readonly snoozedShelfExpanded?: boolean; /** Expands the settled shelf into rows. Expanded is the default. */ @@ -387,10 +344,7 @@ export function buildThreadListV2Items(input: { a split-view detail can never lose its navigation row. */ readonly selectedThreadKey?: string | null; }): ThreadListV2Layout { - const now = input.now ?? new Date().toISOString(); - const snoozeNow = input.snoozeNow ?? now; - const autoSettleAfterDays = input.autoSettleAfterDays ?? 3; - const autoSettleOnMerge = input.autoSettleOnMerge ?? true; + const now = input.now; const query = input.searchQuery.trim().toLocaleLowerCase(); const projectKeys = input.projectRefs ? new Set(input.projectRefs.map((ref) => `${ref.environmentId}:${ref.projectId}`)) @@ -402,8 +356,7 @@ export function buildThreadListV2Items(input: { const snoozed: EnvironmentThreadShell[] = []; let nextSnoozeWakeAt: string | null = null; for (const thread of input.threads) { - // Callers pass live (unarchived) shells; settled threads are among them - // and partition into the tail via effectiveSettled. + // Callers pass live shells. The server stamps settledOverride for the tail. if (input.environmentId !== null && thread.environmentId !== input.environmentId) continue; if (projectKeys !== null && !projectKeys.has(`${thread.environmentId}:${thread.projectId}`)) { continue; @@ -422,16 +375,8 @@ export function buildThreadListV2Items(input: { } const supportsSettlement = input.settlementEnvironmentIds?.has(thread.environmentId) ?? true; const supportsSnooze = input.snoozeEnvironmentIds?.has(thread.environmentId) ?? true; - const cachedChangeRequest = - input.changeRequestByKey?.get(`${thread.environmentId}:${thread.id}`) ?? null; - const changeRequest = - cachedChangeRequest !== null && - (cachedChangeRequest.linkedPullRequestKey ?? null) === - linkedPullRequestKey(thread.linkedPullRequest) - ? cachedChangeRequest - : null; // Snooze outranks settlement and pinning until the thread wakes. - if (supportsSnooze && effectiveSnoozed(thread, { now: snoozeNow })) { + if (supportsSnooze && effectiveSnoozed(thread, { now })) { snoozed.push(thread); if ( thread.snoozedUntil != null && @@ -442,15 +387,7 @@ export function buildThreadListV2Items(input: { } continue; } - if ( - supportsSettlement && - effectiveSettled(thread, { - now, - autoSettleAfterDays, - autoSettleOnMerge, - changeRequest, - }) - ) { + if (supportsSettlement && thread.settledOverride === "settled") { settled.push(thread); } else if (thread.pinnedAt != null) { pinned.push(thread); diff --git a/apps/mobile/src/features/threads/use-composer-command-menu.ts b/apps/mobile/src/features/threads/use-composer-command-menu.ts index 1043e5e39aad..ac703a547fde 100644 --- a/apps/mobile/src/features/threads/use-composer-command-menu.ts +++ b/apps/mobile/src/features/threads/use-composer-command-menu.ts @@ -9,6 +9,10 @@ import { normalizeSearchQuery, scoreQueryMatch, } from "@t3tools/shared/searchRanking"; +import { + dedupeProviderSkillsByName, + getProviderSkillsForSlashMenu, +} from "@t3tools/client-runtime/providerSkills"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { ComposerEditorSelection } from "../../components/ComposerEditor"; @@ -130,7 +134,7 @@ export function useComposerCommandMenu({ }); } - const skillItems = (selectedProviderStatus?.skills ?? []) + const skillItems = getProviderSkillsForSlashMenu(selectedProviderStatus?.skills ?? [], true) .filter((skill) => matchesSlashSkillQuery(skill, q)) .map((skill) => ({ id: `skill:${skill.name}`, @@ -144,7 +148,9 @@ export function useComposerCommandMenu({ } if (trigger.kind === "skill") { - const enabledSkills = (selectedProviderStatus?.skills ?? []).filter((skill) => skill.enabled); + const enabledSkills = dedupeProviderSkillsByName( + (selectedProviderStatus?.skills ?? []).filter((skill) => skill.enabled), + ); const normalizedQuery = normalizeSearchQuery(trigger.query, { trimLeadingPattern: /^\$+/, }); diff --git a/apps/mobile/src/features/threads/use-project-actions.ts b/apps/mobile/src/features/threads/use-project-actions.ts index f144baae048c..e9722e7db49c 100644 --- a/apps/mobile/src/features/threads/use-project-actions.ts +++ b/apps/mobile/src/features/threads/use-project-actions.ts @@ -85,6 +85,9 @@ export function useCreateProjectThread() { prepared = await prepareTurnAttachments({ environmentId: input.project.environmentId, attachments: input.initialAttachments, + supportsImageUploads: + appAtomRegistry.get(serverEnvironment.configValueAtom(input.project.environmentId)) + ?.environment.capabilities.attachmentUploads === true, persistUploadedReferences: async (draftAttachments) => { await input.onAttachmentsUploaded(draftAttachments); return "persisted"; diff --git a/apps/mobile/src/lib/attachmentDownload.test.ts b/apps/mobile/src/lib/attachmentDownload.test.ts index b0dd934bd4fa..78e182e74f66 100644 --- a/apps/mobile/src/lib/attachmentDownload.test.ts +++ b/apps/mobile/src/lib/attachmentDownload.test.ts @@ -4,7 +4,9 @@ const mocks = vi.hoisted(() => ({ directories: new Set(), deleted: vi.fn(), download: vi.fn(), + copy: vi.fn(), share: vi.fn(), + shareFromSource: vi.fn(), available: vi.fn(), uuid: vi.fn(), })); @@ -46,8 +48,12 @@ vi.mock("expo-file-system", () => { static downloadFileAsync = mocks.download; readonly uri: string; - constructor(directory: Directory, name: string) { - this.uri = `${directory.uri}/${encodeURIComponent(name)}`; + constructor(source: Directory | string, name?: string) { + this.uri = typeof source === "string" ? source : `${source.uri}/${encodeURIComponent(name!)}`; + } + + async copy(destination: File): Promise { + await mocks.copy(this.uri, destination.uri); } } @@ -60,8 +66,13 @@ vi.mock("expo-sharing", () => ({ })); vi.mock("./uuid", () => ({ uuidv4: mocks.uuid })); +vi.mock("./shareFileFromSource", () => ({ shareFileFromSource: mocks.shareFromSource })); -import { downloadAndShareAttachment } from "./attachmentDownload"; +import { + downloadAndShareAttachment, + downloadAttachmentForPreview, + shareLocalAttachment, +} from "./attachmentDownload"; import { isForegroundHandoffActive } from "./foreground-handoff"; const NOW = 1_787_990_400_000; @@ -76,11 +87,15 @@ beforeEach(() => { mocks.directories.clear(); mocks.deleted.mockReset(); mocks.download.mockReset(); + mocks.copy.mockReset(); mocks.share.mockReset(); + mocks.shareFromSource.mockReset(); mocks.available.mockReset(); mocks.uuid.mockReset(); mocks.download.mockImplementation(async (_url: string, file: { uri: string }) => file); + mocks.copy.mockResolvedValue(undefined); mocks.share.mockResolvedValue(undefined); + mocks.shareFromSource.mockResolvedValue(undefined); mocks.available.mockResolvedValue(true); let sequence = 0; mocks.uuid.mockImplementation( @@ -268,3 +283,118 @@ describe("downloadAndShareAttachment", () => { await first; }); }); + +describe("attachment preview files", () => { + it("does not start a native request after cancellation during setup", async () => { + const controller = new AbortController(); + const loading = downloadAttachmentForPreview({ ...input, signal: controller.signal }); + controller.abort(); + await expect(loading).resolves.toBeNull(); + expect(mocks.download).not.toHaveBeenCalled(); + expect(mocks.share).not.toHaveBeenCalled(); + }); + + it("downloads for playback without requiring a share sheet and removes the file on close", async () => { + mocks.available.mockResolvedValue(false); + const file = await downloadAttachmentForPreview({ + ...input, + signal: new AbortController().signal, + }); + expect(file?.uri.endsWith("/report.pdf")).toBe(true); + expect(mocks.available).not.toHaveBeenCalled(); + expect(mocks.deleted).not.toHaveBeenCalled(); + file?.dispose(); + file?.dispose(); + expect(mocks.deleted).toHaveBeenCalledTimes(1); + }); + + it.each([undefined, "share-button"])( + "keeps a shared preview after its owner closes (source: %s)", + async (sourceIdentifier) => { + const opened = Promise.withResolvers(); + const sharing = Promise.withResolvers(); + const nativeShare = sourceIdentifier ? mocks.shareFromSource : mocks.share; + nativeShare.mockImplementationOnce(() => { + opened.resolve(); + return sharing.promise; + }); + const file = await downloadAttachmentForPreview({ + ...input, + signal: new AbortController().signal, + }); + const share = file!.share(new AbortController().signal, sourceIdentifier); + await opened.promise; + file!.dispose(); + expect(mocks.deleted).not.toHaveBeenCalled(); + expect(isForegroundHandoffActive()).toBe(true); + sharing.resolve(); + await share; + expect(isForegroundHandoffActive()).toBe(false); + expect(mocks.deleted).not.toHaveBeenCalled(); + expect(mocks.download).toHaveBeenCalledTimes(1); + expect(mocks.copy).not.toHaveBeenCalled(); + }, + ); + + it.each([undefined, "share-button"])( + "does not share a disposed preview after availability checking (source: %s)", + async (sourceIdentifier) => { + const checking = Promise.withResolvers(); + const available = Promise.withResolvers(); + mocks.available.mockImplementation(() => { + checking.resolve(); + return available.promise; + }); + const file = await downloadAttachmentForPreview({ + ...input, + signal: new AbortController().signal, + }); + const share = file!.share(new AbortController().signal, sourceIdentifier); + await checking.promise; + file!.dispose(); + available.resolve(true); + await share; + expect(mocks.share).not.toHaveBeenCalled(); + expect(mocks.shareFromSource).not.toHaveBeenCalled(); + expect(mocks.deleted).toHaveBeenCalledTimes(1); + }, + ); + + it("copies a local original before sharing without downloading or deleting the source", async () => { + const uri = "file:///documents/draft/report.pdf"; + await shareLocalAttachment({ + uri, + attachment: input.attachment, + signal: new AbortController().signal, + }); + expect(mocks.copy).toHaveBeenCalledWith( + uri, + expect.stringMatching(/^file:\/\/\/cache\/.+\/report\.pdf$/), + ); + expect(mocks.share).toHaveBeenCalledWith(mocks.copy.mock.calls[0]![1], expect.any(Object)); + expect(mocks.download).not.toHaveBeenCalled(); + expect(mocks.deleted).not.toHaveBeenCalled(); + }); + + it("waits for a local copy to finish before cleaning up a canceled share", async () => { + const copying = Promise.withResolvers(); + const copied = Promise.withResolvers(); + mocks.copy.mockImplementation(() => { + copying.resolve(); + return copied.promise; + }); + const controller = new AbortController(); + const task = shareLocalAttachment({ + uri: "file:///documents/draft/report.pdf", + attachment: input.attachment, + signal: controller.signal, + }); + await copying.promise; + controller.abort(); + expect(mocks.deleted).not.toHaveBeenCalled(); + copied.resolve(); + await task; + expect(mocks.share).not.toHaveBeenCalled(); + expect(mocks.deleted).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/mobile/src/lib/attachmentDownload.ts b/apps/mobile/src/lib/attachmentDownload.ts index 8c9da9bc0073..2ae0c729c190 100644 --- a/apps/mobile/src/lib/attachmentDownload.ts +++ b/apps/mobile/src/lib/attachmentDownload.ts @@ -1,5 +1,6 @@ import type { ChatFileAttachment } from "@t3tools/contracts"; import type { Directory } from "expo-file-system"; +import type { SharingOptions } from "expo-sharing"; import { beginForegroundHandoff } from "./foreground-handoff"; import { uuidv4 } from "./uuid"; @@ -52,23 +53,27 @@ function removeDownloadDirectory(directory: Directory): void { } } -/** Downloads original bytes for the native save/share sheet, including inline video responses. */ -export async function downloadAndShareAttachment(input: { - readonly url: string; - readonly attachment: Pick; - readonly signal: AbortSignal; -}): Promise { - const [{ Directory, File, Paths }, Sharing] = await Promise.all([ - import("expo-file-system"), - import("expo-sharing"), - ]); - if (input.signal.aborted) return; +type AttachmentFileMetadata = Pick; + +export interface AttachmentPreviewFile { + readonly uri: string; + readonly share: (signal: AbortSignal, sourceIdentifier?: string) => Promise; + readonly dispose: () => void; +} + +async function availableSharing(signal: AbortSignal) { + if (signal.aborted) return null; + const Sharing = await import("expo-sharing"); const canShare = await Sharing.isAvailableAsync(); - if (input.signal.aborted) return; + if (signal.aborted) return null; if (!canShare) { throw new Error("Saving and sharing files is unavailable on this device."); } + return Sharing; +} +async function createCachedAttachmentFile(attachment: AttachmentFileMetadata) { + const { Directory, File, Paths } = await import("expo-file-system"); const cache = new Directory(Paths.cache, ATTACHMENT_DOWNLOAD_DIRECTORY); cache.create({ idempotent: true, intermediates: true }); const now = Date.now(); @@ -89,40 +94,135 @@ export async function downloadAndShareAttachment(input: { } const directory = new Directory(cache, `${now}-${uuidv4()}`); + directory.create(); + let file: InstanceType; + try { + file = new File(directory, downloadFileName(attachment.name)); + } catch (error) { + removeDownloadDirectory(directory); + throw error; + } activeDirectories.add(directory.uri); + let disposed = false; let shared = false; - let openingShareSheet = false; - try { - directory.create(); - const destination = new File(directory, downloadFileName(input.attachment.name)); - const file = await File.downloadFileAsync(input.url, destination, { signal: input.signal }); - if (input.signal.aborted) return; + let sharing = false; + const release = () => { + if (!disposed || sharing) return; + activeDirectories.delete(directory.uri); + // A receiver can still be reading after Android's chooser returns. + if (!shared) removeDownloadDirectory(directory); + }; + const preview: AttachmentPreviewFile = { + uri: file.uri, + dispose: () => { + disposed = true; + release(); + }, + share: async (signal, sourceIdentifier) => { + if (disposed || sharing || signal.aborted) return; + sharing = true; + try { + const Sharing = await availableSharing(signal); + if (Sharing === null || disposed) return; + const endHandoff = beginForegroundHandoff(); + try { + const options: SharingOptions = { + mimeType: attachment.mimeType.split(";", 1)[0]?.trim() || "application/octet-stream", + dialogTitle: attachment.name, + }; + if (sourceIdentifier) { + const { shareFileFromSource } = await import("./shareFileFromSource"); + if (signal.aborted || disposed) return; + await shareFileFromSource(file.uri, options, sourceIdentifier); + } else { + await Sharing.shareAsync(file.uri, options); + } + shared = true; + } catch (cause) { + if (!signal.aborted) { + throw new Error("Could not open the share sheet. Try again.", { cause }); + } + } finally { + endHandoff(); + } + } finally { + sharing = false; + release(); + } + }, + }; + return { file, preview }; +} - openingShareSheet = true; - const endHandoff = beginForegroundHandoff(); - try { - await Sharing.shareAsync(file.uri, { - mimeType: input.attachment.mimeType.split(";", 1)[0]?.trim() || "application/octet-stream", - dialogTitle: input.attachment.name, - }); - shared = true; - } finally { - endHandoff(); +/** The caller owns this cached file until disposal, unless it has been shared with another app. */ +export async function downloadAttachmentForPreview(input: { + readonly url: string; + readonly attachment: AttachmentFileMetadata; + readonly signal: AbortSignal; +}): Promise { + if (input.signal.aborted) return null; + const { File } = await import("expo-file-system"); + const cached = await createCachedAttachmentFile(input.attachment); + try { + if (input.signal.aborted) { + cached.preview.dispose(); + return null; + } + await File.downloadFileAsync(input.url, cached.file, { signal: input.signal }); + if (input.signal.aborted) { + cached.preview.dispose(); + return null; } + return cached.preview; } catch (cause) { - if (input.signal.aborted) return; - throw new Error( - openingShareSheet - ? "Could not open the share sheet. Try again." - : "Could not download the attachment. Check the connection and try again.", - { cause }, - ); + // Android may leave a partial file after a failed or interrupted request. + cached.preview.dispose(); + if (input.signal.aborted) return null; + throw new Error("Could not download the attachment. Check the connection and try again.", { + cause, + }); + } +} + +/** Downloads original bytes for the native save/share sheet, including inline video responses. */ +export async function downloadAndShareAttachment(input: { + readonly url: string; + readonly attachment: AttachmentFileMetadata; + readonly signal: AbortSignal; + readonly sourceIdentifier?: string; +}): Promise { + if ((await availableSharing(input.signal)) === null) return; + const file = await downloadAttachmentForPreview(input); + if (file === null) return; + try { + await file.share(input.signal, input.sourceIdentifier); } finally { - activeDirectories.delete(directory.uri); - // A receiver can still be reading after Android's chooser returns. - // Successful exports expire on a later open; partial downloads do not. - if (!shared) { - removeDownloadDirectory(directory); + file.dispose(); + } +} + +/** Shares a cache copy so another app never relies on the lifetime of a composer draft. */ +export async function shareLocalAttachment(input: { + readonly uri: string; + readonly attachment: AttachmentFileMetadata; + readonly signal: AbortSignal; + readonly sourceIdentifier?: string; +}): Promise { + if ((await availableSharing(input.signal)) === null) return; + const { File } = await import("expo-file-system"); + const cached = await createCachedAttachmentFile(input.attachment); + try { + if (input.signal.aborted) return; + try { + await new File(input.uri).copy(cached.file); + } catch (cause) { + if (input.signal.aborted) return; + throw new Error("Could not prepare the attachment for sharing.", { cause }); } + if (!input.signal.aborted) { + await cached.preview.share(input.signal, input.sourceIdentifier); + } + } finally { + cached.preview.dispose(); } } diff --git a/apps/mobile/src/lib/attachmentUpload.test.ts b/apps/mobile/src/lib/attachmentUpload.test.ts index 488c8375a3e4..5e8a34dd1cdb 100644 --- a/apps/mobile/src/lib/attachmentUpload.test.ts +++ b/apps/mobile/src/lib/attachmentUpload.test.ts @@ -12,6 +12,8 @@ const mocks = vi.hoisted(() => ({ runAtomCommand: vi.fn(), readAtom: vi.fn(), upload: vi.fn(), + writeFile: vi.fn(), + deleteFile: vi.fn(), })); vi.mock("@t3tools/client-runtime/state/runtime", () => ({ @@ -53,13 +55,25 @@ vi.mock("./uuid", () => ({ vi.mock("expo-file-system", () => ({ File: class { - constructor(readonly uri: string) {} + readonly uri: string; + exists = true; + constructor(uri: string, name?: string) { + this.uri = name ? `${uri}/${name}` : uri; + } + create() {} + write(bytes: string, options: unknown) { + mocks.writeFile(this.uri, bytes, options); + } + delete() { + mocks.deleteFile(this.uri); + } upload(url: string, options: unknown) { return mocks.upload(this.uri, url, options); } }, Paths: { + cache: "file:///cache", get document() { return { uri: mocks.documentUri }; }, @@ -158,6 +172,8 @@ describe("prepareTurnAttachments", () => { mocks.runAtomCommand.mockReset(); mocks.readAtom.mockReset(); mocks.upload.mockReset(); + mocks.writeFile.mockReset(); + mocks.deleteFile.mockReset(); mocks.readAtom.mockReturnValue(Option.some({ httpBaseUrl: "https://environment.example/" })); mocks.runAtomCommand.mockImplementation(async (_registry: unknown, command: unknown) => command === mocks.createUploadUrl @@ -198,11 +214,11 @@ describe("prepareTurnAttachments", () => { expect(mocks.upload).toHaveBeenCalledWith( "file:///documents/report.pdf", "https://environment.example/api/attachments/upload/signed", - { + expect.objectContaining({ httpMethod: "POST", uploadType: 0, headers: { "Content-Type": "application/pdf" }, - }, + }), ); expect(prepared.status).toBe("ready"); if (prepared.status !== "ready") return; @@ -354,6 +370,123 @@ describe("prepareTurnAttachments", () => { expect(prepared.pendingAttachmentIds).toEqual([MINTED_ID]); }); + it("uploads image bytes over HTTP while retaining the durable offline image", async () => { + const persisted = vi.fn(async () => "persisted" as const); + const prepared = await prepareTurnAttachments({ + environmentId, + attachments: [image], + supportsImageUploads: true, + persistUploadedReferences: persisted, + }); + expect(mocks.writeFile).toHaveBeenCalledWith("file:///cache/t3-upload-uuid", "YWJj", { + encoding: "base64", + }); + expect(mocks.upload).toHaveBeenCalledWith( + "file:///cache/t3-upload-uuid", + "https://environment.example/api/attachments/upload/signed", + expect.objectContaining({ headers: { "Content-Type": "image/png" } }), + ); + expect(mocks.deleteFile).toHaveBeenCalledExactlyOnceWith("file:///cache/t3-upload-uuid"); + expect(prepared.status).toBe("ready"); + if (prepared.status !== "ready") return; + expect(prepared.attachments).toEqual([ + { + type: "image", + id: MINTED_ID, + name: image.name, + mimeType: image.mimeType, + sizeBytes: image.sizeBytes, + }, + ]); + expect(prepared.draftAttachments).toEqual([ + { ...image, uploadedAttachmentId: MINTED_ID, uploadEnvironmentId: environmentId }, + ]); + expect(persisted).toHaveBeenCalledWith(prepared.draftAttachments); + }); + + it("reuses an uploaded image and reuploads its local bytes after server expiry", async () => { + const saved = { + ...image, + uploadedAttachmentId: "saved-image", + uploadEnvironmentId: environmentId, + }; + const reused = await prepareTurnAttachments({ + environmentId, + attachments: [saved], + supportsImageUploads: true, + }); + expect(reused.status === "ready" && reused.attachments[0]).toEqual({ + type: "image", + id: "saved-image", + name: image.name, + mimeType: image.mimeType, + sizeBytes: image.sizeBytes, + }); + expect(mocks.upload).not.toHaveBeenCalled(); + mocks.executeAtomQuery.mockResolvedValueOnce({ + _tag: "Failure", + error: { _tag: "AssetAttachmentNotFoundError" }, + }); + const restored = await prepareTurnAttachments({ + environmentId, + attachments: [saved], + supportsImageUploads: true, + }); + expect(restored.status === "ready" && restored.draftAttachments[0]).toEqual({ + ...saved, + uploadedAttachmentId: MINTED_ID, + }); + expect(mocks.writeFile).toHaveBeenCalledWith("file:///cache/t3-upload-uuid", "YWJj", { + encoding: "base64", + }); + }); + + it("does not reuse an image upload from another environment", async () => { + const prepared = await prepareTurnAttachments({ + environmentId, + attachments: [ + { + ...image, + uploadedAttachmentId: "other-image", + uploadEnvironmentId: EnvironmentId.make("other"), + }, + ], + supportsImageUploads: true, + }); + expect(mocks.executeAtomQuery).not.toHaveBeenCalled(); + expect(mocks.upload).toHaveBeenCalledOnce(); + expect(prepared.status === "ready" && prepared.draftAttachments[0]?.uploadEnvironmentId).toBe( + environmentId, + ); + }); + + it("aborts an active transfer without dropping local bytes or stamping a partial upload", async () => { + const started = Promise.withResolvers(); + const controller = new AbortController(); + const persist = vi.fn(async () => "persisted" as const); + mocks.upload.mockImplementation( + (_uri: string, _url: string, options: { signal: AbortSignal }) => + new Promise((_, reject) => { + options.signal.addEventListener("abort", () => reject(new Error("cancelled")), { + once: true, + }); + started.resolve(); + }), + ); + const preparing = prepareTurnAttachments({ + environmentId, + attachments: [file], + signal: controller.signal, + persistUploadedReferences: persist, + }); + await started.promise; + controller.abort(); + expect(await preparing).toEqual({ status: "abandoned" }); + expect(persist).not.toHaveBeenCalled(); + expect(mocks.deleteFile).not.toHaveBeenCalled(); + expect(removeCallsFor(MINTED_ID)).toBe(1); + }); + it("removes pending uploads when the native HTTP request fails", async () => { mocks.upload.mockResolvedValue({ status: 500, body: "failed", headers: {} }); diff --git a/apps/mobile/src/lib/attachmentUpload.ts b/apps/mobile/src/lib/attachmentUpload.ts index afe669d6fe64..f39329373dd7 100644 --- a/apps/mobile/src/lib/attachmentUpload.ts +++ b/apps/mobile/src/lib/attachmentUpload.ts @@ -9,9 +9,11 @@ import { import { runAtomCommand, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import type { ChatFileAttachment, + ChatImageAttachment, EnvironmentId, UploadChatImageAttachment, } from "@t3tools/contracts"; +import { PROVIDER_SEND_TURN_SUPPORTED_IMAGE_MIME_TYPES } from "@t3tools/contracts"; import * as Option from "effect/Option"; import { appAtomRegistry } from "../state/atom-registry"; @@ -20,6 +22,7 @@ import { attachmentEnvironment } from "../state/attachments"; import { environmentSession } from "../state/session"; import { resolveOwnedComposerAttachmentFileUri } from "./composerAttachmentFiles"; import { toUploadChatImageAttachments, type DraftComposerAttachment } from "./composerImages"; +import { uuidv4 } from "./uuid"; /** * This module owns the server side of a composer attachment's lifecycle. @@ -31,7 +34,10 @@ import { toUploadChatImageAttachments, type DraftComposerAttachment } from "./co * owned by `removeThreadOutboxMessage` / the composer draft mutators, which * release files through `releaseUnusedComposerAttachmentFiles`. */ -export type UploadedMobileAttachment = UploadChatImageAttachment | ChatFileAttachment; +export type UploadedMobileAttachment = + | UploadChatImageAttachment + | ChatImageAttachment + | ChatFileAttachment; export function validateDraftFileAttachments(input: { readonly attachments: ReadonlyArray; @@ -56,7 +62,7 @@ export function validateDraftFileAttachments(input: { return oversized ? fileAttachmentTooLargeMessage(oversized.name, maxBytes) : null; } -/** Keep uploaded file ids on durable drafts so a later send can reuse their bytes. */ +/** Keep uploaded ids alongside the local bytes so a later send can reuse them. */ export function withUploadedMobileAttachmentReferences(input: { readonly environmentId: EnvironmentId; readonly attachments: ReadonlyArray; @@ -65,8 +71,9 @@ export function withUploadedMobileAttachmentReferences(input: { return input.attachments.map((attachment, index) => { const uploaded = input.uploadedAttachments[index]; if ( - attachment.type !== "file" || - uploaded?.type !== "file" || + !uploaded || + !("id" in uploaded) || + attachment.type !== uploaded.type || (attachment.uploadedAttachmentId === uploaded.id && attachment.uploadEnvironmentId === input.environmentId) ) { @@ -145,21 +152,73 @@ export type PrepareTurnAttachmentsResult = | PreparedTurnAttachments | { readonly status: "abandoned" }; +function uploadedReference( + attachment: DraftComposerAttachment, + id: string, +): ChatImageAttachment | ChatFileAttachment { + const fields = { + id, + name: attachment.name, + mimeType: attachment.mimeType, + sizeBytes: attachment.sizeBytes, + }; + return attachment.type === "image" ? { type: "image", ...fields } : { type: "file", ...fields }; +} + +function attachmentUploadInput(attachment: DraftComposerAttachment) { + const fields = { + name: attachment.name, + mimeType: attachment.mimeType, + sizeBytes: attachment.sizeBytes, + }; + if (attachment.type === "file") return { type: "file" as const, ...fields }; + const mimeType = PROVIDER_SEND_TURN_SUPPORTED_IMAGE_MIME_TYPES.find( + (type) => type === attachment.mimeType.toLowerCase(), + ); + if (!mimeType) throw new Error(`Unsupported image type for '${attachment.name}'.`); + return { ...fields, mimeType }; +} + async function uploadFileBytes( - attachment: Extract, + attachment: DraftComposerAttachment, url: string, + signal: AbortSignal, + onProgress?: (progress: number) => void, ): Promise { const { File, Paths, UploadType } = await import("expo-file-system"); - const fileUri = - resolveOwnedComposerAttachmentFileUri(attachment.fileUri, Paths.document.uri) ?? - attachment.fileUri; - const result = await new File(fileUri).upload(url, { - httpMethod: "POST", - uploadType: UploadType.BINARY_CONTENT, - headers: { "Content-Type": attachment.mimeType }, - }); - if (result.status < 200 || result.status >= 300) { - throw new Error(`Upload failed for '${attachment.name}' (${result.status}).`); + if (signal.aborted) throw new Error("Upload cancelled."); + const file = + attachment.type === "image" + ? new File(Paths.cache, `t3-upload-${uuidv4()}`) + : new File( + resolveOwnedComposerAttachmentFileUri(attachment.fileUri, Paths.document.uri) ?? + attachment.fileUri, + ); + try { + if (attachment.type === "image") { + file.create(); + file.write(attachment.dataUrl.slice(attachment.dataUrl.indexOf(",") + 1), { + encoding: "base64", + }); + } + const result = await file.upload(url, { + httpMethod: "POST", + uploadType: UploadType.BINARY_CONTENT, + headers: { "Content-Type": attachment.mimeType }, + signal, + ...(onProgress + ? { + onProgress: ({ bytesSent, totalBytes }) => { + if (totalBytes > 0) onProgress(bytesSent / totalBytes); + }, + } + : {}), + }); + if (result.status < 200 || result.status >= 300) { + throw new Error(`Upload failed for '${attachment.name}' (${result.status}).`); + } + } finally { + if (attachment.type === "image" && file.exists) file.delete(); } } @@ -176,11 +235,16 @@ async function uploadFileBytes( export async function prepareTurnAttachments(input: { readonly environmentId: EnvironmentId; readonly attachments: ReadonlyArray; + /** Older environments continue to receive inline images. */ + readonly supportsImageUploads?: boolean; + readonly signal?: AbortSignal; + readonly onUploadProgress?: (attachmentId: string, progress: number) => void; readonly persistUploadedReferences?: ( draftAttachments: ReadonlyArray, ) => Promise<"persisted" | "abandon">; }): Promise { const { environmentId } = input; + if (input.signal?.aborted) return { status: "abandoned" }; const files = input.attachments.filter((attachment) => attachment.type === "file"); const ready = ( attachments: ReadonlyArray, @@ -194,7 +258,7 @@ export async function prepareTurnAttachments(input: { releaseUploads: () => releasePendingAttachmentUploads(environmentId, pendingAttachmentIds), }); - if (files.length === 0) { + if (input.attachments.length === 0 || (files.length === 0 && !input.supportsImageUploads)) { return ready( toUploadChatImageAttachments( input.attachments.filter((attachment) => attachment.type === "image"), @@ -214,9 +278,13 @@ export async function prepareTurnAttachments(input: { const uploadedAttachments: UploadedMobileAttachment[] = []; const pendingAttachmentIds: string[] = []; const createdAttachmentIds: string[] = []; + const controller = new AbortController(); + const abort = () => controller.abort(); + input.signal?.addEventListener("abort", abort, { once: true }); try { for (const attachment of input.attachments) { - if (attachment.type === "image") { + if (controller.signal.aborted) throw new Error("Upload cancelled."); + if (attachment.type === "image" && !input.supportsImageUploads) { uploadedAttachments.push(...toUploadChatImageAttachments([attachment])); continue; } @@ -238,13 +306,7 @@ export async function prepareTurnAttachments(input: { } if (verification.status === "verified") { pendingAttachmentIds.push(attachment.uploadedAttachmentId); - uploadedAttachments.push({ - type: "file", - id: attachment.uploadedAttachmentId, - name: attachment.name, - mimeType: attachment.mimeType, - sizeBytes: attachment.sizeBytes, - }); + uploadedAttachments.push(uploadedReference(attachment, attachment.uploadedAttachmentId)); continue; } // "missing": the pending upload expired, upload the bytes again. @@ -255,12 +317,7 @@ export async function prepareTurnAttachments(input: { createUploadUrl: attachmentEnvironment.createUploadUrl, remove: attachmentEnvironment.remove, environmentId, - upload: { - type: "file", - name: attachment.name, - mimeType: attachment.mimeType, - sizeBytes: attachment.sizeBytes, - }, + upload: attachmentUploadInput(attachment), // Read the connection at transfer time: the environment may have // reconnected on a new base URL since this cycle started. resolveUploadUrl: (relativeUrl) => { @@ -272,11 +329,18 @@ export async function prepareTurnAttachments(input: { : resolveAssetUrl(currentConnection.value.httpBaseUrl, relativeUrl); }, transport: (url) => ({ - done: uploadFileBytes(attachment, url), - // expo-file-system uploads cannot abort mid-flight. - abort: () => {}, + done: uploadFileBytes( + attachment, + url, + controller.signal, + input.onUploadProgress + ? (progress) => input.onUploadProgress?.(attachment.id, progress) + : undefined, + ), + abort, }), onMinted: (attachmentId) => { + if (controller.signal.aborted) return "cancel"; pendingAttachmentIds.push(attachmentId); createdAttachmentIds.push(attachmentId); return "continue"; @@ -287,15 +351,11 @@ export async function prepareTurnAttachments(input: { ? result.error : new Error(`Upload failed for '${attachment.name}'.`); } - uploadedAttachments.push({ - type: "file", - id: result.attachmentId, - name: attachment.name, - mimeType: attachment.mimeType, - sizeBytes: attachment.sizeBytes, - }); + uploadedAttachments.push(uploadedReference(attachment, result.attachmentId)); } + if (controller.signal.aborted) throw new Error("Upload cancelled."); + const draftAttachments = withUploadedMobileAttachmentReferences({ environmentId, attachments: input.attachments, @@ -313,6 +373,9 @@ export async function prepareTurnAttachments(input: { return ready(uploadedAttachments, pendingAttachmentIds, draftAttachments); } catch (error) { await releaseCreatedUploadsQuietly(environmentId, createdAttachmentIds); + if (controller.signal.aborted) return { status: "abandoned" }; throw error; + } finally { + input.signal?.removeEventListener("abort", abort); } } diff --git a/apps/mobile/src/lib/composer-image-schema.ts b/apps/mobile/src/lib/composer-image-schema.ts index 401a5fd512c3..3303dad36b0c 100644 --- a/apps/mobile/src/lib/composer-image-schema.ts +++ b/apps/mobile/src/lib/composer-image-schema.ts @@ -9,6 +9,8 @@ export const DraftComposerImageAttachmentSchema = Schema.Struct({ mimeType: Schema.String, sizeBytes: Schema.Number, dataUrl: Schema.String, + uploadedAttachmentId: Schema.optional(Schema.String), + uploadEnvironmentId: Schema.optional(EnvironmentId), }); export const DraftComposerFileAttachmentSchema = Schema.Struct({ diff --git a/apps/mobile/src/lib/composerAttachmentFiles.ts b/apps/mobile/src/lib/composerAttachmentFiles.ts index a50daa30b627..963566b6ad88 100644 --- a/apps/mobile/src/lib/composerAttachmentFiles.ts +++ b/apps/mobile/src/lib/composerAttachmentFiles.ts @@ -6,6 +6,7 @@ const IOS_DOCUMENTS_PATH = new RegExp( `^(.*/Containers/Data/Application/)${UUID_PATTERN}/Documents$`, "i", ); +const retainedFiles = new Map(); function fileUriPath(uri: string): string | null { try { @@ -52,6 +53,30 @@ export function composerAttachmentFileReferenceKey(uri: string): string { return `file://${documentPath}/${COMPOSER_ATTACHMENT_DIRECTORY}/${encodeURIComponent(location.name)}`; } +/** Holds a local copy until its last player or share-copy operation releases it. */ +export function retainComposerAttachmentFile(uri: string, onLastRelease: () => void): () => void { + const key = composerAttachmentFileReferenceKey(uri); + retainedFiles.set(key, (retainedFiles.get(key) ?? 0) + 1); + let released = false; + return () => { + if (released) { + return; + } + released = true; + const remaining = (retainedFiles.get(key) ?? 1) - 1; + if (remaining > 0) { + retainedFiles.set(key, remaining); + return; + } + retainedFiles.delete(key); + onLastRelease(); + }; +} + +export function isComposerAttachmentFileRetained(uri: string): boolean { + return retainedFiles.has(composerAttachmentFileReferenceKey(uri)); +} + /** * Resolves only our saved attachment copies. iOS preserves Documents on updates * but can change its container UUID. Picker and open-in-place source URIs must diff --git a/apps/mobile/src/lib/composerAttachmentUploadQueue.test.ts b/apps/mobile/src/lib/composerAttachmentUploadQueue.test.ts new file mode 100644 index 000000000000..6b040b698e3d --- /dev/null +++ b/apps/mobile/src/lib/composerAttachmentUploadQueue.test.ts @@ -0,0 +1,258 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { + composerAttachmentUploadBlockReason, + composerAttachmentUploadKey, + composerDraftEnvironmentId, + createComposerAttachmentUploadQueue, + type ComposerAttachmentUploadRequest, + type ComposerAttachmentUploadState, +} from "./composerAttachmentUploadQueue"; + +const environmentId = EnvironmentId.make("environment-1"); +function request(id: string, environment = environmentId): ComposerAttachmentUploadRequest { + return { + environmentId: environment, + attachment: { + id, + type: "file", + name: `${id}.pdf`, + mimeType: "application/pdf", + sizeBytes: 42, + fileUri: `file:///documents/${id}.pdf`, + }, + }; +} + +describe("composer attachment upload queue", () => { + it("bounds concurrency, deduplicates updates, and drains all attachments", async () => { + const gates = new Map>>(); + const fourthStarted = Promise.withResolvers(); + const firstThreeStarted = Promise.withResolvers(); + let active = 0; + let maximum = 0; + const upload = vi.fn(async (input: ComposerAttachmentUploadRequest) => { + active += 1; + maximum = Math.max(maximum, active); + const gate = Promise.withResolvers(); + gates.set(input.attachment.id, gate); + if (gates.size === 3) firstThreeStarted.resolve(); + if (gates.size === 4) fourthStarted.resolve(); + try { + return await gate.promise; + } finally { + active -= 1; + } + }); + const queue = createComposerAttachmentUploadQueue({ upload, onChange: () => {} }); + const requests = [request("one"), request("two"), request("three"), request("four")]; + queue.sync(requests); + queue.sync(requests); + await firstThreeStarted.promise; + expect(upload).toHaveBeenCalledTimes(3); + gates.get("one")!.resolve(true); + await fourthStarted.promise; + for (const gate of gates.values()) gate.resolve(true); + await queue.settled(); + queue.sync(requests); + await queue.settled(); + expect(maximum).toBe(3); + expect(upload).toHaveBeenCalledTimes(4); + queue.dispose(); + }); + + it("cancels on disconnect and resumes from the same local draft on reconnect", async () => { + const started = Promise.withResolvers(); + let states: Readonly> = {}; + let signal: AbortSignal | undefined; + const upload = vi.fn( + async (_request: ComposerAttachmentUploadRequest, currentSignal: AbortSignal) => { + signal = currentSignal; + started.resolve(); + return new Promise((resolve) => + currentSignal.addEventListener("abort", () => resolve(false), { once: true }), + ); + }, + ); + const queue = createComposerAttachmentUploadQueue({ + upload, + onChange: (next) => { + states = next; + }, + }); + const local = request("offline-draft"); + queue.sync([local]); + await started.promise; + queue.sync([]); + await queue.settled(); + expect(signal?.aborted).toBe(true); + expect(states).toEqual({}); + upload.mockResolvedValueOnce(true); + queue.sync([local]); + await queue.settled(); + expect(upload.mock.calls[1]?.[0]).toBe(local); + expect(states[composerAttachmentUploadKey(environmentId, local.attachment.id)]).toEqual({ + status: "ready", + }); + expect(local.attachment).toMatchObject({ fileUri: "file:///documents/offline-draft.pdf" }); + queue.dispose(); + }); + + it("ignores a late completion after removal or environment switch", async () => { + const gate = Promise.withResolvers(); + const started = Promise.withResolvers(); + let states: Readonly> = {}; + const upload = vi.fn(async () => { + started.resolve(); + return gate.promise; + }); + const queue = createComposerAttachmentUploadQueue({ + upload, + onChange: (next) => { + states = next; + }, + }); + queue.sync([request("photo")]); + await started.promise; + upload.mockResolvedValueOnce(true); + const other = EnvironmentId.make("environment-2"); + queue.sync([request("photo", other)]); + gate.resolve(true); + await queue.settled(); + expect(states).toEqual({ [composerAttachmentUploadKey(other, "photo")]: { status: "ready" } }); + queue.sync([]); + expect(states).toEqual({}); + queue.dispose(); + }); + + it("restarts a re-added attachment after its aborted transfer finishes settling", async () => { + const firstStarted = Promise.withResolvers(); + const firstSettled = Promise.withResolvers(); + const secondStarted = Promise.withResolvers(); + const secondSettled = Promise.withResolvers(); + let states: Readonly> = {}; + let firstSignal: AbortSignal | undefined; + const upload = vi.fn(async (_request: ComposerAttachmentUploadRequest, signal: AbortSignal) => { + if (!firstSignal) { + firstSignal = signal; + firstStarted.resolve(); + return firstSettled.promise; + } + secondStarted.resolve(); + return secondSettled.promise; + }); + const queue = createComposerAttachmentUploadQueue({ + upload, + onChange: (next) => { + states = next; + }, + }); + const local = request("re-added"); + queue.sync([local]); + await firstStarted.promise; + queue.sync([]); + queue.sync([local]); + expect(firstSignal?.aborted).toBe(true); + expect(upload).toHaveBeenCalledOnce(); + firstSettled.resolve(false); + await secondStarted.promise; + expect(upload).toHaveBeenCalledTimes(2); + secondSettled.resolve(true); + await queue.settled(); + expect(states[composerAttachmentUploadKey(environmentId, local.attachment.id)]).toEqual({ + status: "ready", + }); + queue.dispose(); + }); + + it("keeps failures stable until retry and reports bounded progress", async () => { + let states: Readonly> = {}; + const progress: number[] = []; + const upload = vi.fn( + async ( + _request: ComposerAttachmentUploadRequest, + _signal: AbortSignal, + report: (value: number) => void, + ): Promise => { + report(0.12); + report(0.13); + report(1.1); + throw new Error("Server unavailable"); + }, + ); + const queue = createComposerAttachmentUploadQueue({ + upload, + onChange: (next) => { + states = next; + const state = next[composerAttachmentUploadKey(environmentId, "file")]; + if (state?.status === "uploading") progress.push(state.progress); + }, + }); + queue.sync([request("file")]); + await queue.settled(); + queue.sync([request("file")]); + expect(upload).toHaveBeenCalledOnce(); + expect(states[composerAttachmentUploadKey(environmentId, "file")]).toEqual({ + status: "failed", + reason: "Server unavailable", + }); + expect(progress).toEqual([0, 0.1, 1]); + upload.mockImplementationOnce(async () => true); + queue.retry(environmentId, "file"); + await queue.settled(); + expect(states[composerAttachmentUploadKey(environmentId, "file")]).toEqual({ status: "ready" }); + queue.dispose(); + }); + + it("does not spin when an upload's draft was abandoned before persistence", async () => { + const upload = vi.fn(async () => false); + const queue = createComposerAttachmentUploadQueue({ upload, onChange: () => {} }); + queue.sync([request("discarded")]); + await queue.settled(); + expect(upload).toHaveBeenCalledOnce(); + queue.dispose(); + }); +}); + +describe("draft upload scope and offline submission", () => { + it("resolves thread, new-task, and queued-task drafts without crossing environments", () => { + expect(composerDraftEnvironmentId("environment-1:thread", [])).toBe(environmentId); + expect(composerDraftEnvironmentId("new-task:environment-1:project", [])).toBe(environmentId); + expect( + composerDraftEnvironmentId("pending-task:message", [{ messageId: "message", environmentId }]), + ).toBe(environmentId); + expect(composerDraftEnvironmentId("pending-task:missing", [])).toBeNull(); + const colonEnvironment = EnvironmentId.make("a:vcs-status:b"); + expect(composerDraftEnvironmentId(`${colonEnvironment}:thread`, [])).toBe(colonEnvironment); + expect(composerDraftEnvironmentId(`new-task:${colonEnvironment}:project`, [])).toBe( + colonEnvironment, + ); + }); + + it("allows offline queuing while a connected composer waits for upload or retry", () => { + const key = composerAttachmentUploadKey(environmentId, "file"); + const input = { + environmentId, + attachments: [request("file").attachment], + connected: true, + serverConfig: { + environment: { + capabilities: { attachmentUploads: true, fileAttachments: { maxUploadBytes: 1024 } }, + }, + }, + states: {}, + }; + expect(composerAttachmentUploadBlockReason(input)).toBe("Attachment still uploading"); + expect(composerAttachmentUploadBlockReason({ ...input, connected: false })).toBeNull(); + expect( + composerAttachmentUploadBlockReason({ + ...input, + states: { [key]: { status: "failed", reason: "Offline" } }, + }), + ).toBe("Retry or remove the failed attachment"); + expect( + composerAttachmentUploadBlockReason({ ...input, states: { [key]: { status: "ready" } } }), + ).toBeNull(); + }); +}); diff --git a/apps/mobile/src/lib/composerAttachmentUploadQueue.ts b/apps/mobile/src/lib/composerAttachmentUploadQueue.ts new file mode 100644 index 000000000000..071afefa4c7d --- /dev/null +++ b/apps/mobile/src/lib/composerAttachmentUploadQueue.ts @@ -0,0 +1,193 @@ +import { EnvironmentId, type ServerConfig } from "@t3tools/contracts"; +import { clampFileAttachmentUploadBytes } from "@t3tools/client-runtime/state/attachments"; + +import type { DraftComposerAttachment } from "./composerImages"; + +export interface ComposerAttachmentUploadRequest { + readonly environmentId: EnvironmentId; + readonly attachment: DraftComposerAttachment; +} + +export type ComposerAttachmentUploadState = + | { readonly status: "uploading"; readonly progress: number } + | { readonly status: "ready" } + | { readonly status: "failed"; readonly reason: string }; + +export function composerAttachmentUploadKey( + environmentId: EnvironmentId, + attachmentId: string, +): string { + return `${environmentId}:${attachmentId}`; +} + +export function composerDraftEnvironmentId( + draftKey: string, + queuedMessages: ReadonlyArray<{ + readonly messageId: string; + readonly environmentId: EnvironmentId; + }>, +): EnvironmentId | null { + if (draftKey.startsWith("pending-task:")) { + return ( + queuedMessages.find((message) => `pending-task:${message.messageId}` === draftKey) + ?.environmentId ?? null + ); + } + const scope = draftKey.startsWith("new-task:") ? draftKey.slice("new-task:".length) : draftKey; + const separator = scope.lastIndexOf(":"); + return separator > 0 ? EnvironmentId.make(scope.slice(0, separator)) : null; +} + +type UploadServerConfig = { + readonly environment: { + readonly capabilities: Pick< + ServerConfig["environment"]["capabilities"], + "attachmentUploads" | "fileAttachments" + >; + }; +}; + +export function canUploadComposerAttachment( + attachment: DraftComposerAttachment, + config: UploadServerConfig | null | undefined, +): boolean { + const capabilities = config?.environment.capabilities; + return ( + capabilities?.attachmentUploads === true && + (attachment.type === "image" || + (capabilities.fileAttachments !== undefined && + attachment.sizeBytes <= + clampFileAttachmentUploadBytes(capabilities.fileAttachments.maxUploadBytes))) + ); +} + +export function composerAttachmentUploadBlockReason(input: { + readonly environmentId: EnvironmentId; + readonly attachments: ReadonlyArray; + readonly connected: boolean; + readonly serverConfig: UploadServerConfig | null; + readonly states: Readonly>; +}): string | null { + if (!input.connected) return null; + for (const attachment of input.attachments) { + if (!canUploadComposerAttachment(attachment, input.serverConfig)) continue; + const state = input.states[composerAttachmentUploadKey(input.environmentId, attachment.id)]; + if (state?.status === "failed") return "Retry or remove the failed attachment"; + if (state?.status !== "ready") return "Attachment still uploading"; + } + return null; +} + +/** Bounds transfers across environments; disconnected or discarded drafts keep their local bytes. */ +export function createComposerAttachmentUploadQueue(options: { + readonly upload: ( + request: ComposerAttachmentUploadRequest, + signal: AbortSignal, + onProgress: (progress: number) => void, + ) => Promise; + readonly onChange: (states: Readonly>) => void; +}) { + const jobs = new Map< + string, + { readonly controller: AbortController; readonly done: Promise } + >(); + let desired = new Map(); + let states: Readonly> = {}; + let disposed = false; + + function setState(key: string, state: ComposerAttachmentUploadState | undefined) { + const previous = states[key]; + if ( + previous === state || + (previous?.status === "uploading" && + state?.status === "uploading" && + previous.progress === state.progress) + ) + return; + const next = { ...states }; + if (state) next[key] = state; + else delete next[key]; + states = next; + options.onChange(states); + } + + function pump() { + if (disposed) return; + for (const [key, request] of desired) { + if (jobs.size >= 3) break; + if (jobs.has(key) || states[key]?.status === "ready" || states[key]?.status === "failed") + continue; + const controller = new AbortController(); + setState(key, { status: "uploading", progress: 0 }); + // Publish the job before starting async work, including synchronous test transports. + const done = Promise.resolve() + .then(() => + options.upload(request, controller.signal, (progress) => { + if (controller.signal.aborted) return; + setState(key, { + status: "uploading", + progress: Math.floor(Math.max(0, Math.min(1, progress)) * 20) / 20, + }); + }), + ) + .then((persisted) => { + if (!controller.signal.aborted && desired.has(key)) { + if (!persisted) desired.delete(key); + setState(key, persisted ? { status: "ready" } : undefined); + } + }) + .catch((error: unknown) => { + if (!controller.signal.aborted && desired.has(key)) { + setState(key, { + status: "failed", + reason: error instanceof Error ? error.message : "Upload failed. Tap to retry.", + }); + } + }) + .finally(() => { + jobs.delete(key); + pump(); + }); + jobs.set(key, { controller, done }); + } + } + + return { + sync(requests: ReadonlyArray) { + if (disposed) return; + desired = new Map( + requests.map((request) => [ + composerAttachmentUploadKey(request.environmentId, request.attachment.id), + request, + ]), + ); + for (const [key, job] of jobs) { + if (!desired.has(key)) job.controller.abort(); + } + for (const key of Object.keys(states)) { + if (!desired.has(key)) setState(key, undefined); + } + for (const key of desired.keys()) { + if (!states[key]) setState(key, { status: "uploading", progress: 0 }); + } + pump(); + }, + retry(environmentId: EnvironmentId, attachmentId: string) { + const key = composerAttachmentUploadKey(environmentId, attachmentId); + if (states[key]?.status !== "failed") return; + setState(key, undefined); + pump(); + }, + /** Waits for the current transfers, useful for shutdown and focused verification. */ + async settled() { + while (jobs.size > 0) await Promise.all([...jobs.values()].map((job) => job.done)); + }, + dispose() { + disposed = true; + desired.clear(); + for (const job of jobs.values()) job.controller.abort(); + states = {}; + options.onChange(states); + }, + }; +} diff --git a/apps/mobile/src/lib/composerFiles.test.ts b/apps/mobile/src/lib/composerFiles.test.ts index f52bd9276cff..b38c0813c6a1 100644 --- a/apps/mobile/src/lib/composerFiles.test.ts +++ b/apps/mobile/src/lib/composerFiles.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { PROVIDER_SEND_TURN_MAX_IMAGE_BYTES } from "@t3tools/contracts"; import type { ImagePickerAsset } from "expo-image-picker"; const mocks = vi.hoisted(() => ({ @@ -9,6 +10,7 @@ const mocks = vi.hoisted(() => ({ delete: vi.fn(), open: vi.fn(), size: vi.fn(), + readBase64: vi.fn(), })); vi.mock("expo-file-system", () => { @@ -23,8 +25,6 @@ vi.mock("expo-file-system", () => { } class File { - static pickFileAsync = mocks.pickFile; - readonly uri: string; constructor(source: string | Directory, name?: string) { @@ -57,6 +57,10 @@ vi.mock("expo-file-system", () => { mocks.copy(this.uri, destination.uri); } + async base64(): Promise { + return mocks.readBase64(this.uri); + } + delete(): void { mocks.delete(this.uri); } @@ -75,6 +79,7 @@ vi.mock("expo-file-system", () => { }); vi.mock("expo-image-picker", () => ({ launchImageLibraryAsync: mocks.pickMedia })); +vi.mock("expo-document-picker", () => ({ getDocumentAsync: mocks.pickFile })); vi.mock("./uuid", () => ({ uuidv4: () => "attachment-id" })); import { @@ -85,6 +90,7 @@ import { removePersistedComposerAttachmentFile, } from "./composerImages"; import { isForegroundHandoffActive } from "./foreground-handoff"; +import { retainComposerAttachmentFile } from "./composerAttachmentFiles"; describe("composer file attachments", () => { beforeEach(() => { @@ -95,9 +101,115 @@ describe("composer file attachments", () => { mocks.delete.mockReset(); mocks.open.mockReset(); mocks.size.mockReset(); + mocks.readBase64.mockReset(); mocks.size.mockImplementation((uri: string) => (uri.startsWith("content:") ? null : 42)); }); + describe("photo library image conversion", () => { + const jpeg = "/9j/2Q=="; + const photo: ImagePickerAsset = { + uri: "file:///picker/photo.heic", + type: "image", + fileName: "photo.HEIC", + mimeType: "image/heic", + fileSize: 20 * 1024 * 1024, + base64: jpeg, + width: 1, + height: 1, + }; + + it.each(["image/heic", "image/heif", undefined])( + "attaches the native JPEG conversion with matching metadata when the source MIME is %s", + async (mimeType) => { + mocks.pickMedia.mockResolvedValue({ + canceled: false, + assets: [{ ...photo, mimeType }], + }); + + const result = await pickComposerImages({ existingCount: 0 }); + + expect(result).toEqual({ + images: [ + { + id: "attachment-id", + type: "image", + name: "photo.jpg", + mimeType: "image/jpeg", + sizeBytes: 4, + dataUrl: `data:image/jpeg;base64,${jpeg}`, + previewUri: `data:image/jpeg;base64,${jpeg}`, + }, + ], + error: null, + }); + }, + ); + + it.each([ + { extension: "png", mimeType: "image/png", base64: "iVBORw0KGgo=" }, + { extension: "gif", mimeType: "image/gif", base64: "R0lGODlh" }, + { extension: "webp", mimeType: "image/webp", base64: "UklGRgQAAABXRUJQ" }, + ])("preserves original $extension bytes instead of the picker's JPEG", async (original) => { + const name = `photo.${original.extension}`; + mocks.pickMedia.mockResolvedValue({ + canceled: false, + assets: [{ ...photo, fileName: name, mimeType: original.mimeType }], + }); + mocks.readBase64.mockResolvedValue(original.base64); + + const result = await pickComposerImages({ existingCount: 0 }); + + expect(result.error).toBeNull(); + expect(result.images).toEqual([ + expect.objectContaining({ + name, + mimeType: original.mimeType, + dataUrl: `data:${original.mimeType};base64,${original.base64}`, + sizeBytes: Buffer.from(original.base64, "base64").byteLength, + }), + ]); + }); + + it("checks the converted JPEG size even when the HEIC source was smaller", async () => { + const oversized = + jpeg.slice(0, 4) + "A".repeat(Math.ceil(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / 3) * 4); + mocks.pickMedia.mockResolvedValue({ + canceled: false, + assets: [{ ...photo, fileSize: 42, base64: oversized }], + }); + + await expect(pickComposerImages({ existingCount: 0 })).resolves.toEqual({ + images: [], + error: "'photo.HEIC' exceeds the 10 MB attachment limit.", + }); + }); + + it("does not relabel unconverted HEIC bytes as JPEG", async () => { + mocks.pickMedia.mockResolvedValue({ + canceled: false, + assets: [{ ...photo, base64: "AAAAGGZ0eXBoZWlj" }], + }); + + const result = await pickComposerImages({ existingCount: 0 }); + + expect(result.images).toEqual([]); + expect(result.error).toContain("not a supported image type"); + }); + + it("retains a converted photo when another original cannot be read", async () => { + mocks.pickMedia.mockResolvedValue({ + canceled: false, + assets: [{ ...photo, fileName: "missing.gif", mimeType: "image/gif" }, photo], + }); + mocks.readBase64.mockRejectedValue(new Error("missing file")); + + const result = await pickComposerImages({ existingCount: 0 }); + + expect(result.images).toEqual([expect.objectContaining({ name: "photo.jpg" })]); + expect(result.error).toBe("Failed to read 'missing.gif'."); + }); + }); + describe("photo library videos", () => { const image: ImagePickerAsset = { uri: "file:///picker/photo.png", @@ -274,11 +386,11 @@ describe("composer file attachments", () => { it("copies picked files into app-owned storage without loading their contents", async () => { mocks.pickFile.mockResolvedValue({ canceled: false, - result: [ + assets: [ { uri: "file:///downloads/report.pdf", name: "report.pdf", - type: "application/pdf", + mimeType: "application/pdf", size: 42, }, ], @@ -303,14 +415,120 @@ describe("composer file attachments", () => { ); }); + it("preserves Android picker metadata instead of using the content URI document id", async () => { + const uri = "content://com.android.providers.media.documents/document/video%3A18"; + mocks.pickFile.mockResolvedValue({ + canceled: false, + assets: [ + { + uri, + name: "preview-h264.mp4", + mimeType: "video/mp4", + size: 620_992, + lastModified: 0, + }, + ], + }); + mocks.size.mockReturnValue(620_992); + + await expect(pickComposerFiles({ existingCount: 0 })).resolves.toEqual({ + files: [ + { + id: "attachment-id", + type: "file", + name: "preview-h264.mp4", + mimeType: "video/mp4", + sizeBytes: 620_992, + fileUri: "file:///documents/t3-composer-attachments/attachment-id-preview-h264.mp4", + }, + ], + error: null, + }); + expect(mocks.pickFile).toHaveBeenCalledWith({ multiple: true, copyToCacheDirectory: true }); + expect(mocks.copy).toHaveBeenCalledWith( + uri, + "file:///documents/t3-composer-attachments/attachment-id-preview-h264.mp4", + ); + expect(mocks.delete).not.toHaveBeenCalled(); + }); + + it("persists provider selections that require a readable cache copy", async () => { + const providerUri = "content://cloud-provider/documents/clip"; + const cachedUri = "file:///cache/DocumentPicker/clip.mp4"; + mocks.pickFile.mockImplementation(async (options) => ({ + canceled: false, + assets: [ + { + uri: options.copyToCacheDirectory ? cachedUri : providerUri, + name: "Cloud recording.mp4", + mimeType: "video/mp4", + size: 42, + lastModified: 0, + }, + ], + })); + mocks.copy.mockImplementation((uri: string) => { + if (uri === providerUri) throw new Error("The provider URI is not directly readable."); + }); + + const result = await pickComposerFiles({ existingCount: 0 }); + + expect(result.error).toBeNull(); + expect(result.files).toEqual([ + expect.objectContaining({ + name: "Cloud recording.mp4", + fileUri: "file:///documents/t3-composer-attachments/attachment-id-Cloud recording.mp4", + }), + ]); + expect(mocks.copy).toHaveBeenCalledWith(cachedUri, result.files[0]!.fileUri); + }); + + it("ends the foreground handoff when the picker is canceled without copying files", async () => { + mocks.pickFile.mockImplementation(async () => { + expect(isForegroundHandoffActive()).toBe(true); + return { canceled: true, assets: null }; + }); + + await expect(pickComposerFiles({ existingCount: 0 })).resolves.toEqual({ + files: [], + error: null, + }); + + expect(isForegroundHandoffActive()).toBe(false); + expect(mocks.copy).not.toHaveBeenCalled(); + expect(mocks.open).not.toHaveBeenCalled(); + }); + + it("reports picker failures and releases the foreground handoff", async () => { + mocks.pickFile.mockRejectedValue(new Error("The document provider is unavailable.")); + + await expect(pickComposerFiles({ existingCount: 0 })).resolves.toEqual({ + files: [], + error: "The document provider is unavailable.", + }); + + expect(isForegroundHandoffActive()).toBe(false); + expect(mocks.copy).not.toHaveBeenCalled(); + }); + + it("does not open the picker when the draft has no remaining attachment slots", async () => { + await expect(pickComposerFiles({ existingCount: 8 })).resolves.toEqual({ + files: [], + error: "You can attach up to 8 files per message.", + }); + + expect(mocks.pickFile).not.toHaveBeenCalled(); + expect(isForegroundHandoffActive()).toBe(false); + }); + it("falls back to a usable name when the picker reports a blank one", async () => { mocks.pickFile.mockResolvedValue({ canceled: false, - result: [ + assets: [ { uri: "file:///downloads/unnamed", name: " ", - type: "application/pdf", + mimeType: "application/pdf", size: 42, }, ], @@ -325,11 +543,11 @@ describe("composer file attachments", () => { it("rejects files that exceed the environment's advertised upload limit", async () => { mocks.pickFile.mockResolvedValue({ canceled: false, - result: [ + assets: [ { uri: "file:///downloads/archive.zip", name: "archive.zip", - type: "application/zip", + mimeType: "application/zip", size: 2 * 1024 * 1024, }, ], @@ -345,11 +563,11 @@ describe("composer file attachments", () => { it("never accepts files above the 50 MB contract limit", async () => { mocks.pickFile.mockResolvedValue({ canceled: false, - result: [ + assets: [ { uri: "file:///downloads/archive.zip", name: "archive.zip", - type: "application/zip", + mimeType: "application/zip", size: 51 * 1024 * 1024, }, ], @@ -366,11 +584,11 @@ describe("composer file attachments", () => { it("rejects a file that grew after the picker reported its size", async () => { mocks.pickFile.mockResolvedValue({ canceled: false, - result: [ + assets: [ { uri: "file:///downloads/archive.zip", name: "archive.zip", - type: "application/zip", + mimeType: "application/zip", size: 42, }, ], @@ -434,11 +652,11 @@ describe("composer file attachments", () => { mocks.size.mockReturnValue(0); mocks.pickFile.mockResolvedValue({ canceled: false, - result: [ + assets: [ { uri: "file:///downloads/empty.txt", name: "empty.txt", - type: "text/plain", + mimeType: "text/plain", size: 0, }, ], @@ -450,7 +668,7 @@ describe("composer file attachments", () => { }); }); - it("copies an Android SAF file when the picker reports an unknown zero size", async () => { + it.each([0, undefined])("copies an Android SAF file when the picker size is %s", async (size) => { const reader = { readBytes: vi .fn() @@ -463,12 +681,12 @@ describe("composer file attachments", () => { mocks.open.mockImplementation((uri: string) => (uri.startsWith("content:") ? reader : writer)); mocks.pickFile.mockResolvedValue({ canceled: false, - result: [ + assets: [ { uri: "content://shared/report", name: "report.pdf", - type: "application/pdf", - size: 0, + mimeType: "application/pdf", + size, }, ], }); @@ -491,17 +709,17 @@ describe("composer file attachments", () => { it("uses the remaining slot for the first valid file after an oversized selection", async () => { mocks.pickFile.mockResolvedValue({ canceled: false, - result: [ + assets: [ { uri: "file:///downloads/huge.zip", name: "huge.zip", - type: "application/zip", + mimeType: "application/zip", size: 2 * 1024 * 1024, }, { uri: "file:///downloads/report.pdf", name: "report.pdf", - type: "application/pdf", + mimeType: "application/pdf", size: 42, }, ], @@ -557,6 +775,26 @@ describe("composer file attachments", () => { ]); }); + it("rechecks preview ownership after loading the native filesystem", async () => { + const fileName = "33333333-3333-4333-8333-333333333333-recording.mp4"; + const oldUri = `file:///private/var/mobile/Containers/Data/Application/11111111-1111-4111-8111-111111111111/Documents/t3-composer-attachments/${fileName}`; + mocks.documentUri = + "file:///var/mobile/Containers/Data/Application/22222222-2222-4222-8222-222222222222/Documents"; + const currentUri = `${mocks.documentUri}/t3-composer-attachments/${fileName}`; + + const deleting = removePersistedComposerAttachmentFile(oldUri); + const release = retainComposerAttachmentFile(currentUri, () => {}); + try { + await deleting; + expect(mocks.delete).not.toHaveBeenCalled(); + } finally { + release(); + } + + await removePersistedComposerAttachmentFile(oldUri); + expect(mocks.delete.mock.calls).toEqual([[currentUri]]); + }); + it("copies an open-in-place source from its actual container without rebasing it", async () => { const sourceUri = "file:///var/mobile/Containers/Data/Application/11111111-1111-4111-8111-111111111111/Documents/t3-composer-attachments/33333333-3333-4333-8333-333333333333-report.pdf"; diff --git a/apps/mobile/src/lib/composerImages.ts b/apps/mobile/src/lib/composerImages.ts index c19193150213..77c2ec225564 100644 --- a/apps/mobile/src/lib/composerImages.ts +++ b/apps/mobile/src/lib/composerImages.ts @@ -10,10 +10,11 @@ import { type EnvironmentId, type UploadChatImageAttachment, } from "@t3tools/contracts"; -import type { PickMultipleFilesResult } from "expo-file-system"; +import type { DocumentPickerResult } from "expo-document-picker"; import { estimateBase64ByteSize } from "./base64"; import { COMPOSER_ATTACHMENT_DIRECTORY, + isComposerAttachmentFileRetained, resolveOwnedComposerAttachmentFileUri, } from "./composerAttachmentFiles"; import { beginForegroundHandoff } from "./foreground-handoff"; @@ -22,6 +23,8 @@ import { uuidv4 } from "./uuid"; export interface DraftComposerImageAttachment extends UploadChatImageAttachment { readonly id: string; readonly previewUri: string; + readonly uploadedAttachmentId?: string; + readonly uploadEnvironmentId?: EnvironmentId; } export interface DraftComposerFileAttachment { @@ -145,7 +148,7 @@ export async function removePersistedComposerAttachmentFile(uri: string): Promis try { const { File, Paths } = await import("expo-file-system"); const ownedUri = resolveOwnedComposerAttachmentFileUri(uri, Paths.document.uri); - if (ownedUri === null) { + if (ownedUri === null || isComposerAttachmentFileRetained(ownedUri)) { return; } const file = new File(ownedUri); @@ -206,11 +209,18 @@ export async function pickComposerFiles(input: { }; } - const { File } = await import("expo-file-system"); + const { getDocumentAsync } = await import("expo-document-picker"); const endHandoff = beginForegroundHandoff(); - let result: PickMultipleFilesResult; + let result: DocumentPickerResult; try { - result = await File.pickFileAsync({ multipleFiles: true }); + // File providers may expose a URI that FileSystem cannot read directly. + // Import a readable cache copy before persisting the draft's owned file. + result = await getDocumentAsync({ multiple: true, copyToCacheDirectory: true }); + } catch (cause) { + return { + files: [], + error: cause instanceof Error ? cause.message : "Could not open the file picker.", + }; } finally { endHandoff(); } @@ -224,7 +234,7 @@ export async function pickComposerFiles(input: { const attachments: DraftComposerFileAttachment[] = []; let error: string | null = null; let exceededAttachmentLimit = false; - for (const file of result.result) { + for (const file of result.assets) { if (attachments.length >= remainingSlots) { exceededAttachmentLimit = true; break; @@ -238,7 +248,7 @@ export async function pickComposerFiles(input: { await createComposerFileAttachment({ uri: file.uri, name, - mimeType: file.type || "application/octet-stream", + mimeType: file.mimeType || "application/octet-stream", sizeBytes: file.size ?? null, maxBytes, }), @@ -343,7 +353,7 @@ export async function pickComposerMedia(input: { error = `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} attachments per message.`; break; } - const mimeType = asset.mimeType?.toLowerCase(); + let mimeType = asset.mimeType?.toLowerCase(); if (asset.type === "video" || mimeType?.startsWith("video/")) { if (input.maxVideoBytes === undefined) { error = "Video attachments are unavailable here."; @@ -367,35 +377,61 @@ export async function pickComposerMedia(input: { } continue; } - if (!mimeType?.startsWith("image/")) { + if (asset.type !== "image" && !mimeType?.startsWith("image/")) { error = `Unsupported file type for '${asset.fileName ?? "image"}'.`; continue; } - if (!isProviderSendTurnSupportedImageMimeType(mimeType)) { - error = `'${asset.fileName ?? "image"}' is not a supported image type. Attach GIF, JPEG, PNG, or WebP images.`; - continue; - } - const base64 = asset.base64; + let base64 = asset.base64; if (!base64) { error = `Failed to read '${asset.fileName ?? "image"}'.`; continue; } - const sizeBytes = asset.fileSize ?? estimateBase64ByteSize(base64); + let name = asset.fileName?.trim() || "image"; + // The iOS picker returns JPEG base64 even when its metadata describes HEIC, + // PNG, or GIF. Keep supported originals so transparency and animation survive; + // use the native JPEG conversion for formats providers cannot accept. + if (base64.startsWith("/9j/")) { + if ( + mimeType && + mimeType !== "image/jpeg" && + isProviderSendTurnSupportedImageMimeType(mimeType) + ) { + try { + const { File } = await import("expo-file-system"); + base64 = await new File(asset.uri).base64(); + } catch { + error = `Failed to read '${name}'.`; + continue; + } + } else { + mimeType = "image/jpeg"; + if (!/\.jpe?g$/i.test(name)) { + name = `${name.replace(/\.[^.]+$/, "")}.jpg`; + } + } + } + if (!mimeType || !isProviderSendTurnSupportedImageMimeType(mimeType)) { + error = `'${name}' is not a supported image type. Attach GIF, JPEG, PNG, or WebP images.`; + continue; + } + + const sizeBytes = estimateBase64ByteSize(base64); if (sizeBytes <= 0 || sizeBytes > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES) { error = `'${asset.fileName ?? "image"}' exceeds the 10 MB attachment limit.`; continue; } + const dataUrl = `data:${mimeType};base64,${base64}`; attachments.push({ id: uuidv4(), type: "image", - name: asset.fileName ?? "image", + name, mimeType, sizeBytes, - dataUrl: `data:${mimeType};base64,${base64}`, - previewUri: asset.uri, + dataUrl, + previewUri: mimeType === asset.mimeType?.toLowerCase() ? asset.uri : dataUrl, }); } diff --git a/apps/mobile/src/lib/copyTextWithHaptic.test.ts b/apps/mobile/src/lib/copyTextWithHaptic.test.ts index 236fb44cd6b0..a9e8cb049fdb 100644 --- a/apps/mobile/src/lib/copyTextWithHaptic.test.ts +++ b/apps/mobile/src/lib/copyTextWithHaptic.test.ts @@ -22,6 +22,7 @@ import { CopyTextClipboardWriteError, CopyTextHapticFeedbackError, copyTextWithHaptic, + tryCopyTextWithHaptic, } from "./copyTextWithHaptic"; describe("copyTextWithHaptic", () => { @@ -54,6 +55,19 @@ describe("copyTextWithHaptic", () => { expect(mocks.impactAsync).not.toHaveBeenCalled(); }); + it("reports whether the clipboard write succeeded", async () => { + mocks.setStringAsync.mockResolvedValueOnce(undefined); + + await expect(tryCopyTextWithHaptic("thread-123")).resolves.toBe(true); + }); + + it("returns false when the clipboard write fails", async () => { + vi.spyOn(console, "error").mockImplementation(() => undefined); + mocks.setStringAsync.mockRejectedValueOnce(new Error("native clipboard failure")); + + await expect(tryCopyTextWithHaptic("thread-123")).resolves.toBe(false); + }); + it("reports structured failures without including clipboard contents", async () => { const clipboardCause = new Error("native clipboard failure"); const hapticCause = new Error("native haptic failure"); diff --git a/apps/mobile/src/lib/copyTextWithHaptic.ts b/apps/mobile/src/lib/copyTextWithHaptic.ts index 1cc8c94eef7a..3a7da03b5ea3 100644 --- a/apps/mobile/src/lib/copyTextWithHaptic.ts +++ b/apps/mobile/src/lib/copyTextWithHaptic.ts @@ -27,19 +27,22 @@ export class CopyTextHapticFeedbackError extends Schema.TaggedErrorClass { const target = options.target ?? "text"; const feedback = options.feedback ?? "light-impact"; - void (async () => { + const clipboardWrite = (async () => { try { await Clipboard.setStringAsync(value); + return true; } catch (cause) { console.error( new CopyTextClipboardWriteError({ @@ -47,6 +50,7 @@ export function copyTextWithHaptic( cause, }), ); + return false; } })(); @@ -67,4 +71,10 @@ export function copyTextWithHaptic( ); } })(); + + return await clipboardWrite; +} + +export function copyTextWithHaptic(value: string, options: CopyTextWithHapticOptions = {}): void { + void tryCopyTextWithHaptic(value, options); } diff --git a/apps/mobile/src/lib/filePreview.test.ts b/apps/mobile/src/lib/filePreview.test.ts new file mode 100644 index 000000000000..50be5369c7d3 --- /dev/null +++ b/apps/mobile/src/lib/filePreview.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { isPdfFile } from "./filePreview"; + +describe("PDF preview detection", () => { + it.each([ + [{ name: "download", mimeType: "application/pdf" }, true], + [{ name: "download", mimeType: "APPLICATION/PDF; charset=binary" }, true], + [{ name: "Report.PDF", mimeType: "application/octet-stream" }, true], + [{ name: "https://example.com/report.pdf?signature=abc#page=2" }, true], + [{ name: "report.pdf", mimeType: "text/plain" }, false], + [{ name: "report.pdf.exe" }, false], + [{ name: "https://example.com/page?download=report.pdf" }, false], + ])("classifies %j as %s", (file, expected) => { + expect(isPdfFile(file)).toBe(expected); + }); +}); diff --git a/apps/mobile/src/lib/filePreview.ts b/apps/mobile/src/lib/filePreview.ts new file mode 100644 index 000000000000..7ee96476d720 --- /dev/null +++ b/apps/mobile/src/lib/filePreview.ts @@ -0,0 +1,6 @@ +/** MIME metadata wins; use the extension for files reported without a specific type. */ +export function isPdfFile(file: { readonly name: string; readonly mimeType?: string }): boolean { + const mimeType = file.mimeType?.split(";", 1)[0]?.trim().toLowerCase(); + if (mimeType && mimeType !== "application/octet-stream") return mimeType === "application/pdf"; + return /\.pdf$/i.test(file.name.split(/[?#]/, 1)[0] ?? ""); +} diff --git a/apps/mobile/src/lib/localAttachmentPreview.test.ts b/apps/mobile/src/lib/localAttachmentPreview.test.ts new file mode 100644 index 000000000000..2ed83b26f640 --- /dev/null +++ b/apps/mobile/src/lib/localAttachmentPreview.test.ts @@ -0,0 +1,127 @@ +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const mocks = vi.hoisted(() => ({ + retain: vi.fn(), + share: vi.fn(), + exists: vi.fn(), +})); + +vi.mock("../state/use-composer-drafts", () => ({ + retainComposerAttachmentFileForPreview: mocks.retain, +})); +vi.mock("./attachmentDownload", () => ({ shareLocalAttachment: mocks.share })); +vi.mock("expo-file-system", () => ({ + File: class { + constructor(readonly uri: string) {} + get exists(): boolean { + return mocks.exists(this.uri); + } + }, + Paths: { + document: { + uri: "file:///var/mobile/Containers/Data/Application/22222222-2222-4222-8222-222222222222/Documents/", + }, + }, +})); + +import { loadLocalAttachmentPreview } from "./localAttachmentPreview"; + +const attachment = { + type: "file" as const, + id: "draft-video", + name: "clip.mov", + mimeType: "video/quicktime", + sizeBytes: 12, + fileUri: + "file:///var/mobile/Containers/Data/Application/11111111-1111-4111-8111-111111111111/Documents/t3-composer-attachments/33333333-3333-4333-8333-333333333333-clip.mov", +}; + +beforeEach(() => { + mocks.retain.mockReset(); + mocks.share.mockReset(); + mocks.exists.mockReset(); + mocks.retain.mockImplementation(() => vi.fn()); + mocks.exists.mockReturnValue(true); + mocks.share.mockResolvedValue(undefined); +}); + +describe("loadLocalAttachmentPreview", () => { + it("retains and shares a PDF with its original filename and type", async () => { + const pdf = { ...attachment, name: "report.pdf", mimeType: "application/pdf" }; + const preview = await loadLocalAttachmentPreview(pdf, new AbortController().signal); + await preview!.share(new AbortController().signal); + expect(mocks.share).toHaveBeenCalledWith( + expect.objectContaining({ + attachment: { name: "report.pdf", mimeType: "application/pdf" }, + }), + ); + expect(mocks.retain.mock.results[0]!.value).not.toHaveBeenCalled(); + preview!.dispose(); + expect(mocks.retain.mock.results[0]!.value).toHaveBeenCalledTimes(1); + }); + it("resolves the current iOS container and releases its playback lease once", async () => { + const preview = await loadLocalAttachmentPreview(attachment, new AbortController().signal); + expect(preview?.uri).toContain("/22222222-2222-4222-8222-222222222222/Documents/"); + expect(mocks.retain).toHaveBeenCalledWith(attachment); + const release = mocks.retain.mock.results[0]!.value; + expect(release).not.toHaveBeenCalled(); + preview?.dispose(); + preview?.dispose(); + expect(release).toHaveBeenCalledTimes(1); + }); + + it.each([undefined, "share-button"])( + "keeps a separate share lease after playback closes (source: %s)", + async (sourceIdentifier) => { + const shared = Promise.withResolvers(); + mocks.share.mockReturnValue(shared.promise); + const preview = await loadLocalAttachmentPreview(attachment, new AbortController().signal); + const share = preview!.share(new AbortController().signal, sourceIdentifier); + expect(mocks.retain).toHaveBeenCalledTimes(2); + const releasePlayback = mocks.retain.mock.results[0]!.value; + const releaseShare = mocks.retain.mock.results[1]!.value; + preview!.dispose(); + expect(releasePlayback).toHaveBeenCalledTimes(1); + expect(releaseShare).not.toHaveBeenCalled(); + shared.resolve(); + await share; + expect(releaseShare).toHaveBeenCalledTimes(1); + }, + ); + + it("releases a failed share while keeping playback retained", async () => { + mocks.share.mockRejectedValue(new Error("Sharing unavailable")); + const preview = await loadLocalAttachmentPreview(attachment, new AbortController().signal); + await expect(preview!.share(new AbortController().signal)).rejects.toThrow( + "Sharing unavailable", + ); + expect(mocks.retain.mock.results[1]!.value).toHaveBeenCalledTimes(1); + expect(mocks.retain.mock.results[0]!.value).not.toHaveBeenCalled(); + preview!.dispose(); + }); + + it("releases a load canceled during native module loading", async () => { + const controller = new AbortController(); + const loading = loadLocalAttachmentPreview(attachment, controller.signal); + controller.abort(); + await expect(loading).resolves.toBeNull(); + expect(mocks.retain.mock.results[0]!.value).toHaveBeenCalledTimes(1); + expect(mocks.exists).not.toHaveBeenCalled(); + }); + + it("reports missing files and releases their lease", async () => { + mocks.exists.mockReturnValue(false); + await expect( + loadLocalAttachmentPreview(attachment, new AbortController().signal), + ).rejects.toThrow("This attachment is no longer available. Attach the file again."); + expect(mocks.retain.mock.results[0]!.value).toHaveBeenCalledTimes(1); + }); + + it("does not start sharing a disposed preview", async () => { + const preview = await loadLocalAttachmentPreview(attachment, new AbortController().signal); + preview!.dispose(); + await preview!.share(new AbortController().signal); + expect(mocks.share).not.toHaveBeenCalled(); + expect(mocks.retain).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/mobile/src/lib/localAttachmentPreview.ts b/apps/mobile/src/lib/localAttachmentPreview.ts new file mode 100644 index 000000000000..bdd20e2e63d5 --- /dev/null +++ b/apps/mobile/src/lib/localAttachmentPreview.ts @@ -0,0 +1,59 @@ +import { videoMimeType } from "@t3tools/shared/video"; + +import type { DraftComposerFileAttachment } from "./composerImages"; +import { resolveOwnedComposerAttachmentFileUri } from "./composerAttachmentFiles"; +import { shareLocalAttachment, type AttachmentPreviewFile } from "./attachmentDownload"; +import { retainComposerAttachmentFileForPreview } from "../state/use-composer-drafts"; + +/** Retains the draft original for preview and gives each outgoing share its own lease. */ +export async function loadLocalAttachmentPreview( + attachment: DraftComposerFileAttachment, + signal: AbortSignal, +): Promise { + if (signal.aborted) return null; + const release = retainComposerAttachmentFileForPreview(attachment); + try { + const { File, Paths } = await import("expo-file-system"); + if (signal.aborted) { + release(); + return null; + } + const uri = + resolveOwnedComposerAttachmentFileUri(attachment.fileUri, Paths.document.uri) ?? + attachment.fileUri; + const file = new File(uri); + if (!file.exists) { + throw new Error("The local attachment file is missing."); + } + let disposed = false; + return { + uri: file.uri, + dispose: () => { + if (disposed) return; + disposed = true; + release(); + }, + share: async (shareSignal, sourceIdentifier) => { + if (disposed || shareSignal.aborted) return; + const releaseShare = retainComposerAttachmentFileForPreview(attachment); + try { + await shareLocalAttachment({ + uri: file.uri, + attachment: { + name: attachment.name, + mimeType: videoMimeType(attachment) ?? attachment.mimeType, + }, + signal: shareSignal, + sourceIdentifier, + }); + } finally { + releaseShare(); + } + }, + }; + } catch (cause) { + release(); + if (signal.aborted) return null; + throw new Error("This attachment is no longer available. Attach the file again.", { cause }); + } +} diff --git a/apps/mobile/src/lib/projectThreadStartTurn.ts b/apps/mobile/src/lib/projectThreadStartTurn.ts index 75a84a906ee1..aac1abc4b81e 100644 --- a/apps/mobile/src/lib/projectThreadStartTurn.ts +++ b/apps/mobile/src/lib/projectThreadStartTurn.ts @@ -2,15 +2,14 @@ import { CommandId, MessageId, ThreadId, - type ChatFileAttachment, type ModelSelection, type ProjectId, type ProviderInteractionMode, type RuntimeMode, - type UploadChatImageAttachment, } from "@t3tools/contracts"; import { toUploadChatImageAttachments, type DraftComposerAttachment } from "./composerImages"; +import type { UploadedMobileAttachment } from "./attachmentUpload"; export function deriveThreadTitleFromPrompt(value: string): string { const trimmed = value.trim(); @@ -31,7 +30,7 @@ export interface ProjectThreadStartTurnSpec { readonly createdAt: string; readonly text: string; readonly attachments: ReadonlyArray; - readonly uploadedAttachments?: ReadonlyArray; + readonly uploadedAttachments?: ReadonlyArray; readonly modelSelection: ModelSelection; readonly runtimeMode: RuntimeMode; readonly interactionMode: ProviderInteractionMode; diff --git a/apps/mobile/src/lib/shareFileFromSource.ios.ts b/apps/mobile/src/lib/shareFileFromSource.ios.ts new file mode 100644 index 000000000000..5de0e0cbd423 --- /dev/null +++ b/apps/mobile/src/lib/shareFileFromSource.ios.ts @@ -0,0 +1,14 @@ +import { requireNativeModule } from "expo"; +import type { SharingOptions } from "expo-sharing"; + +const NativeControls = requireNativeModule<{ + shareFileFromSource(uri: string, title: string, sourceIdentifier: string): Promise; +}>("T3NativeControls"); + +export function shareFileFromSource( + uri: string, + options: SharingOptions, + sourceIdentifier: string, +) { + return NativeControls.shareFileFromSource(uri, options.dialogTitle ?? "", sourceIdentifier); +} diff --git a/apps/mobile/src/lib/shareFileFromSource.ts b/apps/mobile/src/lib/shareFileFromSource.ts new file mode 100644 index 000000000000..5e806612a046 --- /dev/null +++ b/apps/mobile/src/lib/shareFileFromSource.ts @@ -0,0 +1,9 @@ +import { shareAsync, type SharingOptions } from "expo-sharing"; + +export function shareFileFromSource( + uri: string, + options: SharingOptions, + _sourceIdentifier: string, +) { + return shareAsync(uri, options); +} diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index 73080b3dfcc6..136b01190e31 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -234,6 +234,83 @@ function makeThread( } describe("buildThreadFeed", () => { + it("keeps setup failures visible without routine setup notices before or after a turn", () => { + const thread = makeThread({ + id: ThreadId.make("thread-worktree-setup"), + projectId: ProjectId.make("project-1"), + title: "Worktree setup", + activities: [ + makeActivity({ + id: EventId.make("setup-requested"), + kind: "setup-script.requested", + summary: "Starting setup script", + createdAt: "2026-08-30T00:00:00.000Z", + }), + makeActivity({ + id: EventId.make("setup-started"), + kind: "setup-script.started", + summary: "Setup script started", + createdAt: "2026-08-30T00:00:01.000Z", + }), + makeActivity({ + id: EventId.make("setup-failed"), + kind: "setup-script.failed", + summary: "Setup script failed to start", + createdAt: "2026-08-30T00:00:02.000Z", + tone: "error", + payload: { detail: "Setup command was not found" }, + }), + ], + }); + const latestTurn = { + turnId: TurnId.make("turn-after-setup"), + state: "running" as const, + requestedAt: "2026-08-30T00:00:03.000Z", + startedAt: "2026-08-30T00:00:04.000Z", + completedAt: null, + assistantMessageId: null, + }; + + for (const currentTurn of [null, latestTurn]) { + const feed = buildThreadFeed({ ...thread, latestTurn: currentTurn }); + expect(feed).toMatchObject([ + { + type: "activity-group", + activities: [{ id: "setup-failed", status: "failure" }], + }, + ]); + const group = feed[0]; + if (group?.type !== "activity-group") throw new Error("Expected the setup failure group"); + expect(group.activities[0]?.getCopyText()).toContain("Setup command was not found"); + } + }); + + it.each(["setup-script.requested", "setup-script.started"])( + "keeps error-toned %s notices visible", + (kind) => { + const feed = buildThreadFeed( + makeThread({ + id: ThreadId.make("thread-setup-error"), + projectId: ProjectId.make("project-1"), + title: "Setup error", + activities: [ + makeActivity({ + id: EventId.make("setup-error"), + kind, + summary: "Setup failed", + createdAt: "2026-08-30T00:00:00.000Z", + tone: "error", + }), + ], + }), + ); + + expect(feed).toMatchObject([ + { type: "activity-group", activities: [{ id: "setup-error", status: "failure" }] }, + ]); + }, + ); + it("keeps older local feedback before newer messages returned by the server", () => { const submission = { id: MessageId.make("feedback-command-ordering"), @@ -942,8 +1019,27 @@ describe("buildThreadFeed", () => { summary: "Running pnpm", summaryKind: "command", live: true, - shimmer: false, + shimmer: true, }); + expect(rows[0]).toMatchObject({ live: false, shimmer: false }); + + const stoppedRows = deriveThreadFeedPresentation(feed, latestTurn, new Set()); + expect(stoppedRows.filter((entry) => entry.type === "work-toggle")).toMatchObject([ + { live: false, shimmer: false }, + { live: false, shimmer: false }, + ]); + + const completedRows = deriveThreadFeedPresentation( + feed, + { ...latestTurn, state: "completed", completedAt: "2026-04-01T00:00:04.000Z" }, + new Set([turnId]), + new Set(), + latestTurn.startedAt, + ); + expect(completedRows.filter((entry) => entry.type === "work-toggle")).toMatchObject([ + { live: false, shimmer: false }, + { live: false, shimmer: false }, + ]); }); it("does not revive cached in-progress tools after work stops", () => { diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index ce07fa96c59c..167f0e7298bb 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -15,6 +15,7 @@ import type { import { formatDuration } from "@t3tools/shared/orchestrationTiming"; import { sortThreadActivities } from "@t3tools/client-runtime/state/thread-activity-order"; import { + isWorktreeSetupActivity, normalizeCompactToolLabel, omitSupersededLifecycleMarkers, summarizeToolGroup, @@ -344,6 +345,7 @@ function deriveWorkLogEntries( const ordered = sortThreadActivities(activities); const entries: DerivedWorkLogEntry[] = []; for (const activity of ordered) { + if (activity.tone !== "error" && isWorktreeSetupActivity(activity.kind)) continue; if (activity.kind === "tool.started") continue; if (activity.kind === "task.started") continue; // Terminal bypassed updates pass: Codex children's only terminal signal. @@ -1493,10 +1495,8 @@ function appendToolGroupRows( ), hasFailure: activities.findLast((activity) => activity.toolLike)?.status === "failure", live, - shimmer: - isWorking && - latestActivity.lifecycleStatus === "inProgress" && - latestActivity.turnId === unsettledTurnId, + // Match the live label until the turn or contiguous tool run settles. + shimmer: live, }); if (!expanded) { return; diff --git a/apps/mobile/src/lib/typography.test.ts b/apps/mobile/src/lib/typography.test.ts deleted file mode 100644 index 5b62e9bd3127..000000000000 --- a/apps/mobile/src/lib/typography.test.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { MOBILE_CODE_SURFACE, MOBILE_TYPOGRAPHY } from "./typography"; - -describe("mobile typography", () => { - it("uses the intentional mobile font scale anchored at a 16pt body", () => { - expect(Object.values(MOBILE_TYPOGRAPHY).map(({ fontSize }) => fontSize)).toEqual([ - 11, 12, 13, 14, 16, 18, 21, 26, 30, - ]); - expect(MOBILE_TYPOGRAPHY.body).toEqual({ fontSize: 16, lineHeight: 23 }); - }); - - it("uses caption-sized code with a compact readable row height", () => { - expect(MOBILE_CODE_SURFACE).toMatchObject({ - fontSize: MOBILE_TYPOGRAPHY.caption.fontSize, - lineNumberFontSize: MOBILE_TYPOGRAPHY.micro.fontSize, - rowHeight: 22, - }); - }); -}); diff --git a/apps/mobile/src/lib/videoThumbnails.test.ts b/apps/mobile/src/lib/videoThumbnails.test.ts new file mode 100644 index 000000000000..e577e448ea2a --- /dev/null +++ b/apps/mobile/src/lib/videoThumbnails.test.ts @@ -0,0 +1,168 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const mocks = vi.hoisted(() => ({ createPlayer: vi.fn() })); +vi.mock("expo-video", () => ({ createVideoPlayer: mocks.createPlayer })); + +let thumbnails: typeof import("./videoThumbnails"); +const frame = { width: 480, height: 270 }; +const player = () => ({ + replaceAsync: vi.fn(async (): Promise => {}), + generateThumbnailsAsync: vi.fn(async () => [frame]), + release: vi.fn(), +}); +const source = () => ({ uri: "file:///clip.mp4", dispose: vi.fn() }); + +beforeEach(async () => { + vi.resetModules(); + mocks.createPlayer.mockReset().mockImplementation(player); + thumbnails = await import("./videoThumbnails"); +}); + +afterEach(() => vi.useRealTimers()); + +describe("video thumbnails", () => { + it("reuses a frame for duplicate requests and refreshed signed URLs", async () => { + const file = source(); + const resolveSource = vi.fn(async () => file); + const signal = new AbortController().signal; + const results = await Promise.all([ + thumbnails.loadVideoThumbnail("env:clip", resolveSource, signal), + thumbnails.loadVideoThumbnail("env:clip", resolveSource, signal), + ]); + expect(results).toEqual([frame, frame]); + expect(resolveSource).toHaveBeenCalledTimes(1); + expect(mocks.createPlayer).toHaveBeenCalledTimes(1); + expect(file.dispose).toHaveBeenCalledTimes(1); + const refreshed = vi.fn(async () => ({ ...source(), uri: "https://host/new-token/clip.mp4" })); + expect(await thumbnails.loadVideoThumbnail("env:clip", refreshed, signal)).toBe(frame); + expect(refreshed).not.toHaveBeenCalled(); + }); + + it("serializes decoding and skips queued requests that scroll out of view", async () => { + const started = Promise.withResolvers(); + const generated = Promise.withResolvers<(typeof frame)[]>(); + const first = player(); + first.generateThumbnailsAsync.mockImplementation(() => { + started.resolve(); + return generated.promise; + }); + mocks.createPlayer.mockReturnValueOnce(first); + const firstRequest = thumbnails.loadVideoThumbnail( + "first", + async () => source(), + new AbortController().signal, + ); + await started.promise; + const removed = new AbortController(); + const skipped = vi.fn(async () => source()); + const queued = thumbnails.loadVideoThumbnail("removed", skipped, removed.signal); + const next = vi.fn(async () => source()); + const nextRequest = thumbnails.loadVideoThumbnail("next", next, new AbortController().signal); + expect(next).not.toHaveBeenCalled(); + removed.abort(); + generated.resolve([frame]); + expect(await firstRequest).toBe(frame); + expect(await queued).toBeNull(); + expect(await nextRequest).toBe(frame); + expect(skipped).not.toHaveBeenCalled(); + expect(first.release).toHaveBeenCalledTimes(1); + }); + + it("releases an active canceled player and ignores late source loading", async () => { + const started = Promise.withResolvers(); + const replaced = Promise.withResolvers(); + const first = player(); + first.replaceAsync.mockImplementation(() => { + started.resolve(); + return replaced.promise; + }); + mocks.createPlayer.mockReturnValueOnce(first); + const file = source(); + const controller = new AbortController(); + const request = thumbnails.loadVideoThumbnail("canceled", async () => file, controller.signal); + await started.promise; + controller.abort(); + expect(await request).toBeNull(); + expect(first.release).toHaveBeenCalledTimes(1); + expect(file.dispose).toHaveBeenCalledTimes(1); + replaced.resolve(); + expect( + await thumbnails.loadVideoThumbnail( + "next", + async () => source(), + new AbortController().signal, + ), + ).toBe(frame); + expect(first.generateThumbnailsAsync).not.toHaveBeenCalled(); + expect(thumbnails.cachedVideoThumbnail("canceled")).toBeNull(); + }); + + it("releases failed extractions and permits a later retry", async () => { + const broken = player(); + broken.generateThumbnailsAsync.mockRejectedValue(new Error("Invalid video")); + mocks.createPlayer.mockReturnValueOnce(broken); + const file = source(); + expect( + await thumbnails.loadVideoThumbnail("retry", async () => file, new AbortController().signal), + ).toBeNull(); + expect(broken.release).toHaveBeenCalledTimes(1); + expect(file.dispose).toHaveBeenCalledTimes(1); + expect( + await thumbnails.loadVideoThumbnail( + "retry", + async () => source(), + new AbortController().signal, + ), + ).toBe(frame); + }); + + it("does not let an unreachable source block the queue indefinitely", async () => { + vi.useFakeTimers(); + const started = Promise.withResolvers(); + const first = player(); + first.replaceAsync.mockImplementation(() => { + started.resolve(); + return new Promise(() => {}); + }); + mocks.createPlayer.mockReturnValueOnce(first); + const file = source(); + const request = thumbnails.loadVideoThumbnail( + "unreachable", + async () => file, + new AbortController().signal, + ); + await started.promise; + await vi.advanceTimersByTimeAsync(15_000); + expect(await request).toBeNull(); + expect(first.release).toHaveBeenCalledTimes(1); + expect(file.dispose).toHaveBeenCalledTimes(1); + expect( + await thumbnails.loadVideoThumbnail( + "reachable", + async () => source(), + new AbortController().signal, + ), + ).toBe(frame); + }); + + it("bounds the retained native images without invalidating frames still displayed", async () => { + for (let i = 0; i < 33; i++) { + await thumbnails.loadVideoThumbnail( + `clip:${i}`, + async () => source(), + new AbortController().signal, + ); + } + expect(thumbnails.cachedVideoThumbnail("clip:0")).toBeNull(); + expect(thumbnails.cachedVideoThumbnail("clip:32")).toBe(frame); + expect(mocks.createPlayer).toHaveBeenCalledTimes(33); + expect( + await thumbnails.loadVideoThumbnail( + "clip:0", + async () => source(), + new AbortController().signal, + ), + ).toBe(frame); + expect(mocks.createPlayer).toHaveBeenCalledTimes(34); + }); +}); diff --git a/apps/mobile/src/lib/videoThumbnails.ts b/apps/mobile/src/lib/videoThumbnails.ts new file mode 100644 index 000000000000..927e1174a7f2 --- /dev/null +++ b/apps/mobile/src/lib/videoThumbnails.ts @@ -0,0 +1,81 @@ +import type { VideoThumbnail } from "expo-video"; + +import type { AttachmentPreviewFile } from "./attachmentDownload"; + +const thumbnails = new Map(); +const MAX_CACHED_THUMBNAILS = 32; +let pending: Promise = Promise.resolve(); + +export function cachedVideoThumbnail(key: string): VideoThumbnail | null { + return thumbnails.get(key) ?? null; +} + +async function extractFrame(uri: string, signal: AbortSignal) { + const { createVideoPlayer } = await import("expo-video"); + if (signal.aborted) return null; + const player = createVideoPlayer(null); + let disposed = false; + let cancel = () => {}; + let timeout: ReturnType | undefined; + try { + // Never play or change audio settings: thumbnails must leave the shared audio session alone. + player.bufferOptions = { preferredForwardBufferDuration: 1 }; + const canceled = new Promise((resolve) => { + cancel = () => resolve(null); + }); + signal.addEventListener("abort", cancel, { once: true }); + // An unreachable environment must not hold up thumbnails for other environments. + timeout = setTimeout(cancel, 15_000); + const frame = (async () => { + await player.replaceAsync({ uri, contentType: "progressive" }); + if (disposed || signal.aborted) return null; + const [thumbnail] = await player.generateThumbnailsAsync([0], { + maxWidth: 480, + maxHeight: 480, + }); + return thumbnail ?? null; + })(); + return await Promise.race([frame, canceled]); + } finally { + disposed = true; + clearTimeout(timeout); + signal.removeEventListener("abort", cancel); + player.release(); + } +} + +/** Serializes frame extraction and releases each temporary player and local-file lease. */ +export function loadVideoThumbnail( + key: string, + resolveSource: ( + signal: AbortSignal, + ) => Promise | null>, + signal: AbortSignal, +): Promise { + if (signal.aborted) return Promise.resolve(null); + const cached = cachedVideoThumbnail(key); + if (cached) return Promise.resolve(cached); + const load = pending + .then(async () => { + if (signal.aborted) return null; + const cached = cachedVideoThumbnail(key); + if (cached) return cached; + + const source = await resolveSource(signal); + if (!source) return null; + try { + const thumbnail = await extractFrame(source.uri, signal); + if (!thumbnail || signal.aborted) return null; + thumbnails.set(key, thumbnail); + if (thumbnails.size > MAX_CACHED_THUMBNAILS) { + thumbnails.delete(thumbnails.keys().next().value!); + } + return thumbnail; + } finally { + source.dispose(); + } + }) + .catch(() => null); + pending = load; + return load; +} diff --git a/apps/mobile/src/native/T3KeyboardCommands.android.tsx b/apps/mobile/src/native/T3KeyboardCommands.android.tsx new file mode 100644 index 000000000000..ff4e817c2002 --- /dev/null +++ b/apps/mobile/src/native/T3KeyboardCommands.android.tsx @@ -0,0 +1,31 @@ +import { requireNativeView } from "expo"; +import type { PropsWithChildren } from "react"; +import type { NativeSyntheticEvent, ViewProps } from "react-native"; + +import type { HardwareKeyboardCommand } from "../features/keyboard/hardwareKeyboardCommands"; + +interface NativeKeyboardCommandsProps extends ViewProps, PropsWithChildren { + readonly enabledCommands: ReadonlyArray; + readonly onCommand: ( + event: NativeSyntheticEvent<{ readonly command: HardwareKeyboardCommand }>, + ) => void; +} + +const NativeKeyboardCommands = requireNativeView("T3KeyboardCommands"); + +export function T3KeyboardCommands( + props: PropsWithChildren<{ + readonly enabledCommands: ReadonlyArray; + readonly onCommand: (command: HardwareKeyboardCommand) => void; + }>, +) { + return ( + props.onCommand(event.nativeEvent.command)} + enabledCommands={props.enabledCommands} + style={{ flex: 1 }} + > + {props.children} + + ); +} diff --git a/apps/mobile/src/persistence/mobile-preferences.ts b/apps/mobile/src/persistence/mobile-preferences.ts index 5d0bd8a3c9dc..cf4c29c6041c 100644 --- a/apps/mobile/src/persistence/mobile-preferences.ts +++ b/apps/mobile/src/persistence/mobile-preferences.ts @@ -31,7 +31,6 @@ export interface Preferences { /** @deprecated Kept temporarily so older OTA bundles retain the selected mode. */ readonly projectGroupingEnabled?: boolean; readonly projectGroupingMode?: SidebarProjectGroupingMode; - readonly autoSettleOnMerge?: boolean; /** * Device-local mirror of the web `legacySidebarEnabled` setting. Mobile has * no client-settings sync, so the legacy grouped thread list is opted into @@ -101,7 +100,6 @@ function sanitizePreferences(parsed: Preferences): Preferences { collapsedProjectGroups?: readonly string[]; projectGroupingEnabled?: boolean; projectGroupingMode?: SidebarProjectGroupingMode; - autoSettleOnMerge?: boolean; legacyThreadListEnabled?: boolean; planModeEnabled?: boolean; threadListV2SettledShelfExpanded?: boolean; @@ -167,9 +165,6 @@ function sanitizePreferences(parsed: Preferences): Preferences { ) { preferences.projectGroupingMode = parsed.projectGroupingMode; } - if (typeof parsed.autoSettleOnMerge === "boolean") { - preferences.autoSettleOnMerge = parsed.autoSettleOnMerge; - } if (typeof parsed.legacyThreadListEnabled === "boolean") { preferences.legacyThreadListEnabled = parsed.legacyThreadListEnabled; } diff --git a/apps/mobile/src/state/composer-attachment-uploads.ts b/apps/mobile/src/state/composer-attachment-uploads.ts new file mode 100644 index 000000000000..efc3fe1c39e5 --- /dev/null +++ b/apps/mobile/src/state/composer-attachment-uploads.ts @@ -0,0 +1,126 @@ +import { useAtomValue } from "@effect/atom-react"; +import type { EnvironmentId } from "@t3tools/contracts"; +import { Atom } from "effect/unstable/reactivity"; +import { useEffect, useRef } from "react"; + +import { prepareTurnAttachments } from "../lib/attachmentUpload"; +import { + composerAttachmentUploadKey, + composerDraftEnvironmentId, + canUploadComposerAttachment, + createComposerAttachmentUploadQueue, + type ComposerAttachmentUploadState, +} from "../lib/composerAttachmentUploadQueue"; +import { appAtomRegistry } from "./atom-registry"; +import { useServerConfigs } from "./entities"; +import { flattenQueuedThreadMessages, threadOutboxManager } from "./thread-outbox"; +import { useThreadOutboxMessages } from "./use-thread-outbox"; +import { + composerDraftsAtom, + ensureComposerDraftsLoaded, + flushComposerDrafts, + retainComposerAttachmentFileForPreview, + setComposerDraftAttachmentUpload, +} from "./use-composer-drafts"; +import { useRemoteConnectionStatus } from "./use-remote-environment-registry"; + +export { composerAttachmentUploadBlockReason } from "../lib/composerAttachmentUploadQueue"; + +export const composerAttachmentUploadsAtom = Atom.make< + Readonly> +>({}).pipe(Atom.keepAlive); +const uploadStateAtom = Atom.family((key: string) => + Atom.map(composerAttachmentUploadsAtom, (states) => states[key]), +); +let uploadQueue: ReturnType | null = null; + +export function useComposerAttachmentUploadState( + environmentId: EnvironmentId | undefined, + attachmentId: string, +) { + return useAtomValue( + uploadStateAtom(environmentId ? composerAttachmentUploadKey(environmentId, attachmentId) : ""), + ); +} + +export function retryComposerAttachmentUpload(environmentId: EnvironmentId, attachmentId: string) { + uploadQueue?.retry(environmentId, attachmentId); +} + +/** Runs outside mounted composers so a transfer can finish after navigation. */ +export function useComposerAttachmentUploadWorker() { + const drafts = useAtomValue(composerDraftsAtom); + const queuedMessages = useThreadOutboxMessages(); + const serverConfigs = useServerConfigs(); + const { connectedEnvironments } = useRemoteConnectionStatus(); + const queueRef = useRef | null>(null); + + useEffect(() => { + ensureComposerDraftsLoaded(); + const queue = createComposerAttachmentUploadQueue({ + onChange: (states) => appAtomRegistry.set(composerAttachmentUploadsAtom, states), + upload: async ({ environmentId, attachment }, signal, onProgress) => { + const release = + attachment.type === "file" + ? retainComposerAttachmentFileForPreview(attachment) + : undefined; + try { + const result = await prepareTurnAttachments({ + environmentId, + attachments: [attachment], + supportsImageUploads: true, + signal, + onUploadProgress: (_, progress) => onProgress(progress), + persistUploadedReferences: async ([uploaded]) => { + if (signal.aborted || !uploaded) return "abandon"; + const queued = flattenQueuedThreadMessages( + appAtomRegistry.get(threadOutboxManager.queuedMessagesByThreadKeyAtom), + ); + let retained = false; + for (const [key, draft] of Object.entries(appAtomRegistry.get(composerDraftsAtom))) { + if ( + composerDraftEnvironmentId(key, queued) === environmentId && + draft.attachments.some((candidate) => candidate.id === attachment.id) + ) { + retained = setComposerDraftAttachmentUpload(key, uploaded) || retained; + } + } + if (!retained) return "abandon"; + await flushComposerDrafts(); + return "persisted"; + }, + }); + return result.status === "ready"; + } finally { + release?.(); + } + }, + }); + queueRef.current = queue; + uploadQueue = queue; + return () => { + queue.dispose(); + if (uploadQueue === queue) uploadQueue = null; + queueRef.current = null; + }; + }, []); + + useEffect(() => { + const queued = flattenQueuedThreadMessages(queuedMessages); + const connected = new Set( + connectedEnvironments + .filter((environment) => environment.connectionState === "connected") + .map((environment) => environment.environmentId), + ); + const requests = Object.entries(drafts).flatMap(([key, draft]) => { + const environmentId = composerDraftEnvironmentId(key, queued); + if (environmentId === null || !connected.has(environmentId)) return []; + return draft.attachments + .filter((attachment) => + canUploadComposerAttachment(attachment, serverConfigs.get(environmentId)), + ) + .map((attachment) => ({ environmentId, attachment })); + }); + queueRef.current?.sync(requests); + }, [connectedEnvironments, drafts, queuedMessages, serverConfigs]); +} diff --git a/apps/mobile/src/state/use-composer-drafts.test.ts b/apps/mobile/src/state/use-composer-drafts.test.ts index 3ab0baa39046..c5c6ca69f3c0 100644 --- a/apps/mobile/src/state/use-composer-drafts.test.ts +++ b/apps/mobile/src/state/use-composer-drafts.test.ts @@ -118,10 +118,12 @@ import { appAtomRegistry } from "./atom-registry"; import { threadOutboxManager } from "./thread-outbox"; import { appendComposerDraftAttachments, + archiveCloudComposerDrafts, clearComposerDraftContentState, clearComposerDraftsEnvironment, ComposerDraftPersistenceError, composerDraftsAtom, + composerCloudDraftsAtom, copyComposerDraftContentIfEmpty, copyComposerDraftContentState, decodePersistedComposerState, @@ -134,8 +136,12 @@ import { releaseUnusedComposerAttachmentFiles, removeComposerDraftsForEnvironment, resetComposerDraftsLoadState, + retainComposerAttachmentFileForPreview, restoreComposerDraftSnapshotState, + restoreCloudComposerDrafts, setComposerDraftText, + setComposerDraftAttachmentUpload, + waitForComposerDraftsLoaded, setStickyComposerModelSelection, stickyComposerModelSelectionAtom, undoComposerDraftMerge, @@ -156,6 +162,7 @@ afterEach(() => { composerDraftFileMocks.setOnWrite(null); composerDraftFileMocks.resetWrites(); appAtomRegistry.set(composerDraftsAtom, {}); + appAtomRegistry.set(composerCloudDraftsAtom, { accountId: null, signedOut: {} }); appAtomRegistry.set(stickyComposerModelSelectionAtom, null); appAtomRegistry.set(threadOutboxManager.queuedMessagesByThreadKeyAtom, {}); composerAttachmentCleanupMocks.remove.mockClear(); @@ -319,6 +326,226 @@ describe("mobile composer drafts", () => { expect(composerAttachmentCleanupMocks.releaseUploads).not.toHaveBeenCalled(); }); + it("retains offline image bytes and newer edits when an early upload finishes", async () => { + const key = "environment-1:thread-1"; + const image = { + id: "photo", + type: "image" as const, + name: "photo.png", + mimeType: "image/png", + sizeBytes: 3, + dataUrl: "data:image/png;base64,YWJj", + previewUri: "file:///photo.png", + }; + const second = { ...image, id: "second", name: "second.png" }; + const uploaded = { + ...image, + uploadedAttachmentId: "pending-photo", + uploadEnvironmentId: EnvironmentId.make("environment-1"), + }; + composerDraftFileMocks.setDocument({ schemaVersion: 1, drafts: {} }); + appendComposerDraftAttachments(key, [image]); + setComposerDraftText(key, "Edited while uploading"); + appendComposerDraftAttachments(key, [second]); + expect(setComposerDraftAttachmentUpload(key, uploaded)).toBe(true); + await flushComposerDrafts(); + + appAtomRegistry.set(composerDraftsAtom, {}); + resetComposerDraftsLoadState(); + await waitForComposerDraftsLoaded(); + expect(getComposerDraftSnapshot(key)).toMatchObject({ + text: "Edited while uploading", + attachments: [uploaded, second], + }); + expect(setComposerDraftAttachmentUpload(key, { ...uploaded, id: "removed-photo" })).toBe(false); + expect(getComposerDraftSnapshot(key).attachments).toHaveLength(2); + }); + + it("cleans up an unreferenced image upload even when there is no local file URI", async () => { + const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true); + onTestFinished(() => outboxLoad.mockRestore()); + const environmentId = EnvironmentId.make("environment-1"); + await releaseUnusedComposerAttachmentFiles([ + { + id: "photo", + type: "image", + name: "photo.png", + mimeType: "image/png", + sizeBytes: 3, + dataUrl: "data:image/png;base64,YWJj", + previewUri: "file:///photo.png", + uploadedAttachmentId: "pending-photo", + uploadEnvironmentId: environmentId, + }, + ]); + expect(composerAttachmentCleanupMocks.releaseUploads).toHaveBeenCalledWith(environmentId, [ + "pending-photo", + ]); + expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled(); + }); + + it("keeps signed-out files through cleanup and restart, and restores only the owning account", async () => { + const load = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true); + onTestFinished(() => load.mockRestore()); + await waitForComposerDraftsLoaded(); + const environmentId = EnvironmentId.make("cloud-environment"); + const key = `${environmentId}:thread-1`; + const file = { + id: "local-pdf", + type: "file" as const, + name: "notes.pdf", + mimeType: "application/pdf", + sizeBytes: 42, + fileUri: "file:///documents/t3-composer-attachments/notes.pdf", + uploadEnvironmentId: environmentId, + uploadedAttachmentId: "pending-pdf", + }; + const queued = { + environmentId, + threadId: ThreadId.make("thread-2"), + messageId: MessageId.make("queued-1"), + commandId: CommandId.make("command-1"), + text: "Send later", + attachments: [file], + createdAt: "2026-08-31T12:00:00.000Z", + }; + appAtomRegistry.set(composerDraftsAtom, { + [key]: { text: "Unsent notes", attachments: [file] }, + "direct-environment:thread-1": DRAFT, + "pending-task:queued-1": { text: "Edited queued task", attachments: [file] }, + }); + appAtomRegistry.set(threadOutboxManager.queuedMessagesByThreadKeyAtom, { queued: [queued] }); + await archiveCloudComposerDrafts("account-a", new Set([environmentId])); + expect(appAtomRegistry.get(composerDraftsAtom)).toEqual({ + "direct-environment:thread-1": DRAFT, + }); + // The registry can remove the active outbox and drafts after the backup lands. + appAtomRegistry.set(threadOutboxManager.queuedMessagesByThreadKeyAtom, {}); + await clearComposerDraftsEnvironment(environmentId); + await releaseUnusedComposerAttachmentFiles([file]); + expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled(); + expect(composerAttachmentCleanupMocks.releaseUploads).not.toHaveBeenCalled(); + + appAtomRegistry.set(composerDraftsAtom, {}); + appAtomRegistry.set(composerCloudDraftsAtom, { accountId: null, signedOut: {} }); + resetComposerDraftsLoadState(); + await waitForComposerDraftsLoaded(); + await restoreCloudComposerDrafts("account-b"); + expect(getComposerDraftSnapshot(key).attachments).toEqual([]); + expect(appAtomRegistry.get(threadOutboxManager.queuedMessagesByThreadKeyAtom)).toEqual({}); + const enqueue = vi.spyOn(threadOutboxManager, "enqueue").mockResolvedValue(); + onTestFinished(() => enqueue.mockRestore()); + await restoreCloudComposerDrafts("account-a"); + expect(getComposerDraftSnapshot(key)).toEqual({ text: "Unsent notes", attachments: [file] }); + expect(getComposerDraftSnapshot("pending-task:queued-1").text).toBe("Edited queued task"); + expect(enqueue).toHaveBeenCalledExactlyOnceWith(queued); + expect(appAtomRegistry.get(composerCloudDraftsAtom).signedOut).toEqual({}); + const persisted = decodePersistedComposerState( + JSON.parse(composerDraftFileMocks.getDocument()), + ); + expect(persisted.drafts[key]?.attachments).toEqual([file]); + expect(persisted.cloudDrafts.accountId).toBe("account-a"); + }); + + it("fails sign-out preservation before cleanup if a durable backup cannot be written", async () => { + const load = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true); + onTestFinished(() => load.mockRestore()); + await waitForComposerDraftsLoaded(); + appAtomRegistry.set(composerDraftsAtom, { "environment-1:thread-1": DRAFT }); + composerDraftFileMocks.setWriteError(new Error("Storage is full")); + await expect( + archiveCloudComposerDrafts("account-a", new Set([EnvironmentId.make("environment-1")])), + ).rejects.toThrow(); + expect( + appAtomRegistry.get(composerCloudDraftsAtom).signedOut["account-a"]?.drafts[ + "environment-1:thread-1" + ], + ).toEqual(DRAFT); + expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled(); + composerDraftFileMocks.setWriteError(null); + await archiveCloudComposerDrafts(null, new Set([EnvironmentId.make("environment-1")])); + expect( + decodePersistedComposerState(JSON.parse(composerDraftFileMocks.getDocument())).cloudDrafts + .signedOut["account-a"]?.drafts["environment-1:thread-1"], + ).toEqual(DRAFT); + }); + + it("keeps a removed file until both playback and a share copy finish", async () => { + const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true); + onTestFinished(() => outboxLoad.mockRestore()); + const fileName = "33333333-3333-4333-8333-333333333333-recording.mp4"; + const file = { + id: "file-preview", + type: "file" as const, + name: "recording.mp4", + mimeType: "video/mp4", + sizeBytes: 42, + fileUri: `file:///private/var/mobile/Containers/Data/Application/11111111-1111-4111-8111-111111111111/Documents/t3-composer-attachments/${fileName}`, + }; + const currentFile = { + ...file, + fileUri: `file:///var/mobile/Containers/Data/Application/22222222-2222-4222-8222-222222222222/Documents/t3-composer-attachments/${fileName}`, + }; + const releasePlayback = retainComposerAttachmentFileForPreview(file); + const releaseShareCopy = retainComposerAttachmentFileForPreview(currentFile); + onTestFinished(releasePlayback); + onTestFinished(releaseShareCopy); + + await releaseUnusedComposerAttachmentFiles([currentFile]); + expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled(); + + releasePlayback(); + releasePlayback(); + await releaseUnusedComposerAttachmentFiles([file]); + expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled(); + + const deleted = Promise.withResolvers(); + composerAttachmentCleanupMocks.remove.mockImplementationOnce(async () => { + deleted.resolve(); + return undefined; + }); + releaseShareCopy(); + await deleted.promise; + + expect(composerAttachmentCleanupMocks.remove.mock.calls).toEqual([[currentFile.fileUri]]); + }); + + it("preserves a preview opened while cleanup is checking the incoming inbox", async () => { + const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true); + onTestFinished(() => outboxLoad.mockRestore()); + const file = { + id: "file-opening-preview", + type: "file" as const, + name: "recording.mp4", + mimeType: "video/mp4", + sizeBytes: 42, + fileUri: "file:///documents/t3-composer-attachments/recording.mp4", + }; + const ownershipReadStarted = Promise.withResolvers(); + const ownershipRead = Promise.withResolvers<[]>(); + incomingShareStorageMocks.load.mockImplementationOnce(() => { + ownershipReadStarted.resolve(); + return ownershipRead.promise; + }); + + const cleanup = releaseUnusedComposerAttachmentFiles([file]); + await ownershipReadStarted.promise; + const release = retainComposerAttachmentFileForPreview(file); + onTestFinished(release); + ownershipRead.resolve([]); + await cleanup; + expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled(); + + const deleted = Promise.withResolvers(); + composerAttachmentCleanupMocks.remove.mockImplementationOnce(async () => { + deleted.resolve(); + return undefined; + }); + release(); + await deleted.promise; + expect(composerAttachmentCleanupMocks.remove.mock.calls).toEqual([[file.fileUri]]); + }); + it("removes an unreferenced local file and its pending upload", async () => { const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true); onTestFinished(() => outboxLoad.mockRestore()); diff --git a/apps/mobile/src/state/use-composer-drafts.ts b/apps/mobile/src/state/use-composer-drafts.ts index 7d243360f044..2a613b4914da 100644 --- a/apps/mobile/src/state/use-composer-drafts.ts +++ b/apps/mobile/src/state/use-composer-drafts.ts @@ -15,11 +15,22 @@ import { Atom } from "effect/unstable/reactivity"; import { writeFileAtomically } from "../lib/atomic-file"; import { DraftComposerAttachmentSchema } from "../lib/composer-image-schema"; -import { composerAttachmentFileReferenceKey } from "../lib/composerAttachmentFiles"; -import type { DraftComposerAttachment } from "../lib/composerImages"; +import { + composerAttachmentFileReferenceKey, + isComposerAttachmentFileRetained, + retainComposerAttachmentFile, +} from "../lib/composerAttachmentFiles"; +import type { DraftComposerAttachment, DraftComposerFileAttachment } from "../lib/composerImages"; import { SerializedAsyncQueue } from "../lib/serialized-async-queue"; import { appAtomRegistry } from "./atom-registry"; +import { + decodeQueuedThreadMessage, + encodeQueuedThreadMessage, + QueuedThreadMessageSchema, + type QueuedThreadMessage, +} from "./thread-outbox-model"; import { flushThreadOutbox, threadOutboxManager } from "./thread-outbox"; +import { composerDraftEnvironmentId } from "../lib/composerAttachmentUploadQueue"; const COMPOSER_DRAFTS_SCHEMA_VERSION = 1; const COMPOSER_DRAFTS_DIRECTORY = "composer-drafts"; @@ -89,6 +100,16 @@ const PersistedComposerDraftsSchema = Schema.Struct({ schemaVersion: Schema.Literal(COMPOSER_DRAFTS_SCHEMA_VERSION), drafts: Schema.Record(Schema.String, ComposerDraftSchema), stickyModelSelection: Schema.optional(ModelSelectionSchema), + cloudAccountId: Schema.optional(Schema.String), + signedOutDrafts: Schema.optional( + Schema.Record( + Schema.String, + Schema.Struct({ + drafts: Schema.Record(Schema.String, ComposerDraftSchema), + queuedMessages: Schema.Array(QueuedThreadMessageSchema), + }), + ), + ), }); const decodePersistedComposerDraftsDocument = Schema.decodeUnknownSync( @@ -110,6 +131,21 @@ export const stickyComposerModelSelectionAtom = Atom.make Atom.withLabel("mobile:sticky-composer-model-selection"), ); +interface SignedOutDrafts { + readonly drafts: Record; + readonly queuedMessages: ReadonlyArray; +} + +interface ComposerCloudDraftState { + readonly accountId: string | null; + readonly signedOut: Record; +} + +export const composerCloudDraftsAtom = Atom.make({ + accountId: null, + signedOut: {}, +}).pipe(Atom.keepAlive); + let loadPromise: Promise | null = null; let persistTimer: ReturnType | null = null; const persistenceQueue = new SerializedAsyncQueue(); @@ -152,6 +188,7 @@ function isEmptyDraft(draft: ComposerDraft): boolean { export function decodePersistedComposerState(value: unknown): { readonly drafts: Record; readonly stickyModelSelection: ModelSelection | null; + readonly cloudDrafts: ComposerCloudDraftState; } { const parsed = decodePersistedComposerDraftsDocument(value); return { @@ -184,6 +221,18 @@ export function decodePersistedComposerState(value: unknown): { .filter(([, draft]) => !isEmptyDraft(draft) || (draft.importedShareIds?.length ?? 0) > 0), ), stickyModelSelection: parsed.stickyModelSelection ?? null, + cloudDrafts: { + accountId: parsed.cloudAccountId ?? null, + signedOut: Object.fromEntries( + Object.entries(parsed.signedOutDrafts ?? {}).map(([id, saved]) => [ + id, + { + drafts: saved.drafts, + queuedMessages: saved.queuedMessages.map(decodeQueuedThreadMessage), + }, + ]), + ), + }, }; } @@ -198,15 +247,18 @@ async function getComposerDraftsFile() { return new File(directory, COMPOSER_DRAFTS_FILE); } -async function loadPersistedComposerState(): Promise<{ - readonly drafts: Record; - readonly stickyModelSelection: ModelSelection | null; -}> { +async function loadPersistedComposerState(): Promise< + ReturnType +> { let operation: ComposerDraftPersistenceError["operation"] = "open"; try { const file = await getComposerDraftsFile(); if (!file.exists) { - return { drafts: {}, stickyModelSelection: null }; + return { + drafts: {}, + stickyModelSelection: null, + cloudDrafts: { accountId: null, signedOut: {} }, + }; } operation = "read"; const raw = await file.text(); @@ -222,13 +274,18 @@ async function loadPersistedComposerState(): Promise<{ cause, }), ); - return { drafts: {}, stickyModelSelection: null }; + return { + drafts: {}, + stickyModelSelection: null, + cloudDrafts: { accountId: null, signedOut: {} }, + }; } } async function writePersistedComposerState( drafts: Record, stickyModelSelection: ModelSelection | null, + cloudDrafts = appAtomRegistry.get(composerCloudDraftsAtom), ): Promise { let operation: ComposerDraftPersistenceError["operation"] = "open"; try { @@ -241,6 +298,20 @@ async function writePersistedComposerState( schemaVersion: COMPOSER_DRAFTS_SCHEMA_VERSION, drafts: nonEmptyDrafts, ...(stickyModelSelection ? { stickyModelSelection } : {}), + ...(cloudDrafts.accountId ? { cloudAccountId: cloudDrafts.accountId } : {}), + ...(Object.keys(cloudDrafts.signedOut).length > 0 + ? { + signedOutDrafts: Object.fromEntries( + Object.entries(cloudDrafts.signedOut).map(([id, saved]) => [ + id, + { + drafts: saved.drafts, + queuedMessages: saved.queuedMessages.map(encodeQueuedThreadMessage), + }, + ]), + ), + } + : {}), } as const; const encoded = JSON.stringify(document); operation = "write"; @@ -286,13 +357,23 @@ export async function flushComposerDrafts(): Promise { } while (persistTimer !== null); } +function signedOutAttachmentOwners() { + return Object.values(appAtomRegistry.get(composerCloudDraftsAtom).signedOut).flatMap((saved) => [ + ...Object.values(saved.drafts), + ...saved.queuedMessages, + ]); +} + function isComposerAttachmentFileReferenced(fileUri: string): boolean { + if (isComposerAttachmentFileRetained(fileUri)) { + return true; + } const referenceKey = composerAttachmentFileReferenceKey(fileUri); const drafts = Object.values(appAtomRegistry.get(composerDraftsAtom)); const queuedMessages = Object.values( appAtomRegistry.get(threadOutboxManager.queuedMessagesByThreadKeyAtom), ).flat(); - return [...drafts, ...queuedMessages].some((owner) => + return [...drafts, ...queuedMessages, ...signedOutAttachmentOwners()].some((owner) => owner.attachments.some( (attachment) => attachment.type === "file" && @@ -309,10 +390,9 @@ function isComposerAttachmentUploadReferenced( const queuedMessages = Object.values( appAtomRegistry.get(threadOutboxManager.queuedMessagesByThreadKeyAtom), ).flat(); - return [...drafts, ...queuedMessages].some((owner) => + return [...drafts, ...queuedMessages, ...signedOutAttachmentOwners()].some((owner) => owner.attachments.some( (attachment) => - attachment.type === "file" && attachment.uploadEnvironmentId === environmentId && attachment.uploadedAttachmentId === attachmentId, ), @@ -330,7 +410,6 @@ export async function releaseUnusedComposerAttachmentFiles( const uploadCandidates = new Map>(); for (const attachment of attachments) { if ( - attachment.type !== "file" || attachment.uploadEnvironmentId === undefined || attachment.uploadedAttachmentId === undefined ) { @@ -340,7 +419,7 @@ export async function releaseUnusedComposerAttachmentFiles( ids.add(attachment.uploadedAttachmentId); uploadCandidates.set(attachment.uploadEnvironmentId, ids); } - if (candidates.size === 0) { + if (candidates.size === 0 && uploadCandidates.size === 0) { return; } @@ -428,7 +507,11 @@ export async function releaseUnusedComposerAttachmentFiles( export function scheduleUnusedComposerAttachmentCleanup( attachments: ReadonlyArray, ): void { - if (!attachments.some((attachment) => attachment.type === "file")) { + if ( + !attachments.some( + (attachment) => attachment.type === "file" || attachment.uploadedAttachmentId !== undefined, + ) + ) { return; } void releaseUnusedComposerAttachmentFiles(attachments).catch((error) => { @@ -436,6 +519,15 @@ export function scheduleUnusedComposerAttachmentCleanup( }); } +/** Keeps a native preview or upload readable until it finishes, then retries ownership cleanup. */ +export function retainComposerAttachmentFileForPreview( + attachment: DraftComposerFileAttachment, +): () => void { + return retainComposerAttachmentFile(attachment.fileUri, () => { + scheduleUnusedComposerAttachmentCleanup([attachment]); + }); +} + function schedulePersistComposerState(): void { if (persistTimer !== null) { clearTimeout(persistTimer); @@ -468,6 +560,7 @@ export function ensureComposerDraftsLoaded(): void { } loadPromise = loadPersistedComposerState() .then((persisted) => { + appAtomRegistry.set(composerCloudDraftsAtom, persisted.cloudDrafts); if (Object.keys(persisted.drafts).length > 0) { const current = appAtomRegistry.get(composerDraftsAtom); appAtomRegistry.set(composerDraftsAtom, { @@ -504,6 +597,192 @@ export async function waitForComposerDraftsLoaded(): Promise { } } +export async function getComposerCloudAccountId(): Promise { + await waitForComposerDraftsLoaded(); + return appAtomRegistry.get(composerCloudDraftsAtom).accountId; +} + +/** Save an account's local work before its relay environments are removed. */ +export async function archiveCloudComposerDrafts( + accountId: string | null, + environmentIds: ReadonlySet, +): Promise { + await waitForComposerDraftsLoaded(); + if (!(await threadOutboxManager.load())) throw new Error("Could not preserve queued messages."); + await flushThreadOutbox(); + const cloud = appAtomRegistry.get(composerCloudDraftsAtom); + const owner = accountId ?? cloud.accountId; + if (owner === null) return; + const queued = Object.values( + appAtomRegistry.get(threadOutboxManager.queuedMessagesByThreadKeyAtom), + ).flat(); + const current = appAtomRegistry.get(composerDraftsAtom); + const remaining = { ...current }; + const savedDrafts = { ...cloud.signedOut[owner]?.drafts }; + for (const [key, draft] of Object.entries(current)) { + const environmentId = composerDraftEnvironmentId(key, queued); + if (environmentId !== null && environmentIds.has(environmentId)) { + savedDrafts[key] = draft; + delete remaining[key]; + } + } + const savedMessages = new Map( + (cloud.signedOut[owner]?.queuedMessages ?? []).map((message) => [message.messageId, message]), + ); + for (const message of queued) { + if (environmentIds.has(message.environmentId)) savedMessages.set(message.messageId, message); + } + appAtomRegistry.set(composerDraftsAtom, remaining); + appAtomRegistry.set(composerCloudDraftsAtom, { + // Keep the owner through removal. A crash or failed cleanup can retry it + // on cold start before a different account activates. + accountId: owner, + signedOut: { + ...cloud.signedOut, + [owner]: { drafts: savedDrafts, queuedMessages: [...savedMessages.values()] }, + }, + }); + schedulePersistComposerState(); + await flushComposerDrafts(); +} + +function sameDraftAttachmentIds( + left: ReadonlyArray, + right: ReadonlyArray, +): boolean { + return ( + left.length === right.length && + left.every((attachment, index) => attachment.id === right[index]?.id) + ); +} + +/** An in-flight delivery can finish after sign-out took its snapshot. */ +export async function removeDeliveredCloudQueuedMessage( + message: QueuedThreadMessage, +): Promise { + await waitForComposerDraftsLoaded(); + const cloud = appAtomRegistry.get(composerCloudDraftsAtom); + const signedOut = { ...cloud.signedOut }; + let changed = false; + for (const [accountId, saved] of Object.entries(signedOut)) { + const archived = saved.queuedMessages.find( + (candidate) => + candidate.environmentId === message.environmentId && + candidate.messageId === message.messageId, + ); + if ( + !archived || + archived.commandId !== message.commandId || + archived.threadId !== message.threadId || + archived.text !== message.text || + !sameDraftAttachmentIds(archived.attachments, message.attachments) + ) + continue; + // Upload ids may change during preparation; user edits must remain recoverable. + if ( + JSON.stringify([ + archived.modelSelection, + archived.runtimeMode, + archived.interactionMode, + archived.creation, + ]) !== + JSON.stringify([ + message.modelSelection, + message.runtimeMode, + message.interactionMode, + message.creation, + ]) + ) + continue; + const editorKey = `pending-task:${message.messageId}`; + const editor = saved.drafts[editorKey]; + if ( + editor && + (editor.text !== message.text || + !sameDraftAttachmentIds(editor.attachments, message.attachments) || + (editor.modelSelection !== undefined && + JSON.stringify(editor.modelSelection) !== JSON.stringify(message.modelSelection)) || + (editor.runtimeMode !== undefined && editor.runtimeMode !== message.runtimeMode) || + (editor.interactionMode !== undefined && + editor.interactionMode !== message.interactionMode) || + (editor.workspaceSelection !== undefined && + (editor.workspaceSelection.mode !== message.creation?.workspaceMode || + editor.workspaceSelection.branch !== message.creation?.branch || + editor.workspaceSelection.worktreePath !== message.creation?.worktreePath || + (editor.workspaceSelection.startFromOrigin ?? false) !== + (message.creation?.startFromOrigin ?? false)))) + ) + continue; + const drafts = { ...saved.drafts }; + delete drafts[editorKey]; + signedOut[accountId] = { + drafts, + queuedMessages: saved.queuedMessages.filter((candidate) => candidate !== archived), + }; + changed = true; + } + if (!changed) return; + appAtomRegistry.set(composerCloudDraftsAtom, { ...cloud, signedOut }); + schedulePersistComposerState(); + try { + await flushComposerDrafts(); + } catch (error) { + // The live outbox can still remove this acknowledged message. Keep the + // archive update pending so a later successful flush lands it too. + schedulePersistComposerState(); + throw error; + } +} + +/** Restores only this account, before its connections can deliver queued turns. */ +export async function restoreCloudComposerDrafts(accountId: string): Promise { + await waitForComposerDraftsLoaded(); + const cloud = appAtomRegistry.get(composerCloudDraftsAtom); + const saved = cloud.signedOut[accountId]; + if (saved) { + if (!(await threadOutboxManager.load())) throw new Error("Could not restore queued messages."); + for (const message of saved.queuedMessages) { + const alreadyQueued = Object.values( + appAtomRegistry.get(threadOutboxManager.queuedMessagesByThreadKeyAtom), + ) + .flat() + .some((current) => current.messageId === message.messageId); + if (!alreadyQueued) await threadOutboxManager.enqueue(message); + } + updateComposerDrafts((current) => { + const restored = { ...current }; + for (const [key, draft] of Object.entries(saved.drafts)) { + const existing = current[key]; + const attachmentIds = new Set(existing?.attachments.map((attachment) => attachment.id)); + restored[key] = existing + ? { + ...draft, + ...existing, + text: mergeComposerDraftText(existing.text, draft.text), + // A concurrent import must not lose files, even above the send limit. + attachments: [ + ...existing.attachments, + ...draft.attachments.filter((attachment) => !attachmentIds.has(attachment.id)), + ], + importedShareIds: [ + ...new Set([ + ...(existing.importedShareIds ?? []), + ...(draft.importedShareIds ?? []), + ]), + ], + } + : draft; + } + return restored; + }); + } + const signedOut = { ...cloud.signedOut }; + delete signedOut[accountId]; + appAtomRegistry.set(composerCloudDraftsAtom, { accountId, signedOut }); + schedulePersistComposerState(); + await flushComposerDrafts(); +} + function updateComposerDrafts( update: (current: Record) => Record, ): void { @@ -639,6 +918,41 @@ export function removeComposerDraftAttachment(draftKey: string, imageId: string) ); } +/** Stamps a finished upload without overwriting text, removals, or newer attachments. */ +export function setComposerDraftAttachmentUpload( + draftKey: string, + attachment: DraftComposerAttachment, +): boolean { + let previous: DraftComposerAttachment | undefined; + updateComposerDrafts((current) => { + const draft = current[draftKey]; + previous = draft?.attachments.find((candidate) => candidate.id === attachment.id); + if (!draft || !previous) return current; + if ( + previous.uploadedAttachmentId === attachment.uploadedAttachmentId && + previous.uploadEnvironmentId === attachment.uploadEnvironmentId + ) + return current; + return { + ...current, + [draftKey]: { + ...draft, + attachments: draft.attachments.map((candidate) => + candidate.id === attachment.id + ? { + ...candidate, + uploadedAttachmentId: attachment.uploadedAttachmentId, + uploadEnvironmentId: attachment.uploadEnvironmentId, + } + : candidate, + ), + }, + }; + }); + if (previous) scheduleUnusedComposerAttachmentCleanup([previous]); + return previous !== undefined; +} + export function updateComposerDraftSettings( draftKey: string, settings: Partial, diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index 603a38ecc81a..66e57802d1a6 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -56,6 +56,10 @@ import { enqueueThreadOutboxMessage } from "./thread-outbox"; import { useThreadOutboxMessages } from "./use-thread-outbox"; import { threadEnvironment } from "./threads"; import { useAtomCommand } from "./use-atom-command"; +import { + composerAttachmentUploadBlockReason, + composerAttachmentUploadsAtom, +} from "./composer-attachment-uploads"; export function appendReviewCommentToDraft(input: { readonly environmentId: EnvironmentId; @@ -178,6 +182,16 @@ export function useThreadComposerState() { const thread = selectedThreadDetail ?? selectedThreadShell; const text = draft.text.trim(); const attachments = draft.attachments; + if ( + composerAttachmentUploadBlockReason({ + environmentId: selectedThreadShell.environmentId, + attachments, + connected: selectedEnvironmentRuntime?.connectionState === "connected", + serverConfig: selectedEnvironmentRuntime?.serverConfig ?? null, + states: appAtomRegistry.get(composerAttachmentUploadsAtom), + }) !== null + ) + return null; if (text.length === 0 && attachments.length === 0) { return null; } @@ -298,7 +312,8 @@ export function useThreadComposerState() { ); return messageId; }, [ - selectedEnvironmentRuntime?.serverConfig?.providers, + selectedEnvironmentRuntime?.connectionState, + selectedEnvironmentRuntime?.serverConfig, selectedThreadDetail, selectedThreadShell, uploadThreadFeedback, diff --git a/apps/mobile/src/state/use-thread-outbox-drain.test.ts b/apps/mobile/src/state/use-thread-outbox-drain.test.ts index b991fa4ee791..d27f07962d60 100644 --- a/apps/mobile/src/state/use-thread-outbox-drain.test.ts +++ b/apps/mobile/src/state/use-thread-outbox-drain.test.ts @@ -193,6 +193,7 @@ beforeEach(() => { afterEach(() => { appAtomRegistry.set(harness.manager.queuedMessagesByThreadKeyAtom, {}); appAtomRegistry.set(composerDrafts.composerDraftsAtom, {}); + appAtomRegistry.set(composerDrafts.composerCloudDraftsAtom, { accountId: null, signedOut: {} }); appAtomRegistry.set(editingQueuedMessageIdsAtom, {}); harness.draftFile.setWriteError(null); harness.removePersistedFile.mockClear(); @@ -321,6 +322,72 @@ describe("thread outbox attachment preparation", () => { }); describe("thread outbox drain delivery cleanup", () => { + it("removes an acknowledged outbox item even when the sign-out archive write fails", async () => { + const message = queuedMessage({ messageId: "archive-write-failure", text: "Delivered" }); + await harness.manager.enqueue(message); + await composerDrafts.archiveCloudComposerDrafts("account-a", new Set([message.environmentId])); + harness.draftFile.setWriteError(new Error("Draft storage unavailable")); + + await expect( + completeQueuedMessageDelivery(message, harness.manager.revisionOf(message.messageId)), + ).resolves.toBe("removed"); + expect(remainingMessages()).toEqual([]); + + harness.draftFile.setWriteError(null); + await composerDrafts.flushComposerDrafts(); + appAtomRegistry.set(composerDrafts.composerCloudDraftsAtom, { accountId: null, signedOut: {} }); + composerDrafts.resetComposerDraftsLoadState(); + await composerDrafts.restoreCloudComposerDrafts("account-a"); + expect(remainingMessages()).toEqual([]); + }); + + it.each([false, true])( + "does not restore a message delivered after the sign-out snapshot (outbox already cleared: %s)", + async (cleared) => { + const message = queuedMessage({ + messageId: "delivered-during-sign-out", + text: "Already delivered", + }); + await harness.manager.enqueue(message); + const deliveryRevision = harness.manager.revisionOf(message.messageId); + await composerDrafts.archiveCloudComposerDrafts( + "account-a", + new Set([message.environmentId]), + ); + expect( + appAtomRegistry.get(composerDrafts.composerCloudDraftsAtom).signedOut["account-a"] + ?.queuedMessages, + ).toEqual([message]); + + if (cleared) await harness.manager.clearEnvironment(message.environmentId); + await expect(completeQueuedMessageDelivery(message, deliveryRevision)).resolves.toBe( + cleared ? "edited" : "removed", + ); + + // Restart before signing back in: the archived copy must be removed on disk too. + appAtomRegistry.set(composerDrafts.composerCloudDraftsAtom, { + accountId: null, + signedOut: {}, + }); + composerDrafts.resetComposerDraftsLoadState(); + await composerDrafts.restoreCloudComposerDrafts("account-a"); + expect(remainingMessages()).toEqual([]); + }, + ); + + it("preserves an archived edit when an older payload finishes delivery", async () => { + const message = queuedMessage({ messageId: "edited-during-sign-out", text: "Original" }); + await harness.manager.enqueue(message); + const deliveryRevision = harness.manager.revisionOf(message.messageId); + const edited = { ...message, text: "Keep this edit" }; + await harness.manager.update(edited); + await composerDrafts.archiveCloudComposerDrafts("account-a", new Set([message.environmentId])); + await harness.manager.clearEnvironment(message.environmentId); + await expect(completeQueuedMessageDelivery(message, deliveryRevision)).resolves.toBe("edited"); + await composerDrafts.restoreCloudComposerDrafts("account-a"); + expect(remainingMessages()).toEqual([edited]); + }); + it("retries only cleanup after an acknowledged send removal fails", async () => { const message = queuedMessage({ messageId: "message-acknowledged", text: "delivered" }); const acknowledged = new Set([message.messageId]); diff --git a/apps/mobile/src/state/use-thread-outbox-drain.ts b/apps/mobile/src/state/use-thread-outbox-drain.ts index c81cb87a893a..de6a538b52ef 100644 --- a/apps/mobile/src/state/use-thread-outbox-drain.ts +++ b/apps/mobile/src/state/use-thread-outbox-drain.ts @@ -52,6 +52,7 @@ import { getComposerDraftSnapshot, mergeComposerDraftContent, replaceComposerDraftAttachments, + removeDeliveredCloudQueuedMessage, undoComposerDraftMerge, updateComposerDraftSettings, waitForComposerDraftsLoaded, @@ -114,7 +115,10 @@ function settingsCommandId(message: QueuedThreadMessage, setting: string): Comma * `deliveryRevision` is the revision of the payload this attempt will send, * used for the delivery removal's compare-and-set. */ -export async function prepareQueuedMessageAttachments(queuedMessage: QueuedThreadMessage): Promise< +export async function prepareQueuedMessageAttachments( + queuedMessage: QueuedThreadMessage, + supportsImageUploads = false, +): Promise< | { readonly status: "ready"; readonly prepared: PreparedTurnAttachments; @@ -135,6 +139,7 @@ export async function prepareQueuedMessageAttachments(queuedMessage: QueuedThrea const result = await prepareTurnAttachments({ environmentId: queuedMessage.environmentId, attachments: queuedMessage.attachments, + supportsImageUploads, persistUploadedReferences: async (draftAttachments) => { if (appAtomRegistry.get(editingQueuedMessageIdsAtom)[queuedMessage.messageId]) { return "abandon"; @@ -179,13 +184,19 @@ export async function completeQueuedMessageDelivery( queuedMessage: QueuedThreadMessage, deliveryRevision: number, ): Promise<"removed" | "edited" | "failed"> { - // The editor may have taken the entry while startTurn was in flight; its - // unsaved edits have not bumped the revision yet, so the CAS alone would - // let removal win and the editor would lose them once it saves. - if (appAtomRegistry.get(editingQueuedMessageIdsAtom)[queuedMessage.messageId]) { - return "edited"; - } try { + await removeDeliveredCloudQueuedMessage(queuedMessage).catch((error) => { + console.warn("[thread-outbox] could not update sign-out snapshot after delivery", { + messageId: queuedMessage.messageId, + error, + }); + }); + // The editor may have taken the entry while startTurn was in flight; its + // unsaved edits have not bumped the revision yet, so the CAS alone would + // let removal win and the editor would lose them once it saves. + if (appAtomRegistry.get(editingQueuedMessageIdsAtom)[queuedMessage.messageId]) { + return "edited"; + } // Removal also releases the message's local attachment files. const removed = await removeThreadOutboxMessage( queuedMessage, @@ -221,6 +232,12 @@ export async function removeAcknowledgedExistingThreadMessage( acknowledgedMessageIds: Set, ): Promise { try { + await removeDeliveredCloudQueuedMessage(queuedMessage).catch((error) => { + console.warn("[thread-outbox] could not update sign-out snapshot after delivery", { + messageId: queuedMessage.messageId, + error, + }); + }); const removed = await removeThreadOutboxMessage(queuedMessage); if (removed) { acknowledgedMessageIds.delete(queuedMessage.messageId); @@ -453,15 +470,10 @@ async function preserveUploadedAttachmentsForEditor( const draftKey = `pending-task:${originalMessage.messageId}`; const draft = getComposerDraftSnapshot(draftKey); const uploadedById = new Map( - uploadedMessage.attachments - .filter((attachment) => attachment.type === "file") - .map((attachment) => [attachment.id, attachment] as const), + uploadedMessage.attachments.map((attachment) => [attachment.id, attachment] as const), ); let changed = false; const nextAttachments = draft.attachments.map((attachment) => { - if (attachment.type !== "file") { - return attachment; - } const uploaded = uploadedById.get(attachment.id); if ( !uploaded?.uploadedAttachmentId || @@ -672,7 +684,11 @@ export function useThreadOutboxDrain(): void { let persistedMessage: QueuedThreadMessage; let deliveryRevision: number; try { - const preparedResult = await prepareQueuedMessageAttachments(queuedMessage); + const preparedResult = await prepareQueuedMessageAttachments( + queuedMessage, + serverConfigs.get(queuedMessage.environmentId)?.environment.capabilities + .attachmentUploads === true, + ); if (preparedResult.status === "abandoned") { return true; } @@ -744,6 +760,7 @@ export function useThreadOutboxDrain(): void { startTurn, updateThreadMetadata, restoreQueuedMessage, + serverConfigs, ], ); @@ -761,7 +778,11 @@ export function useThreadOutboxDrain(): void { let persistedMessage: QueuedThreadMessage; let deliveryRevision: number; try { - const preparedResult = await prepareQueuedMessageAttachments(queuedMessage); + const preparedResult = await prepareQueuedMessageAttachments( + queuedMessage, + serverConfigs.get(queuedMessage.environmentId)?.environment.capabilities + .attachmentUploads === true, + ); if (preparedResult.status === "abandoned") { return true; } @@ -838,7 +859,7 @@ export function useThreadOutboxDrain(): void { } return false; }, - [makeDeliveryHelpers, restoreQueuedMessage, startTurn], + [makeDeliveryHelpers, restoreQueuedMessage, serverConfigs, startTurn], ); useEffect(() => { diff --git a/apps/mobile/src/state/use-thread-selection.ts b/apps/mobile/src/state/use-thread-selection.ts index 8e340ef33bd4..e0e87d609d5f 100644 --- a/apps/mobile/src/state/use-thread-selection.ts +++ b/apps/mobile/src/state/use-thread-selection.ts @@ -54,6 +54,7 @@ function threadDetailToShell( interactionMode: thread.interactionMode, branch: thread.branch, worktreePath: thread.worktreePath, + linkedPullRequest: thread.linkedPullRequest ?? null, latestTurn: thread.latestTurn, createdAt: thread.createdAt, updatedAt: thread.updatedAt, diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index 6ade6025bcbc..c43486623c4b 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -64,6 +64,7 @@ import { type OrchestrationEngineShape, } from "../src/orchestration/Services/OrchestrationEngine.ts"; import { ThreadDeletionReactor } from "../src/orchestration/Services/ThreadDeletionReactor.ts"; +import * as ThreadSettlementReactor from "../src/orchestration/ThreadSettlementReactor.ts"; import { OrchestrationReactor } from "../src/orchestration/Services/OrchestrationReactor.ts"; import { ProjectionSnapshotQuery } from "../src/orchestration/Services/ProjectionSnapshotQuery.ts"; import { @@ -376,6 +377,12 @@ export const makeOrchestrationIntegrationHarness = ( drainThrough: () => Effect.void, }), ), + Layer.provideMerge( + Layer.succeed(ThreadSettlementReactor.ThreadSettlementReactor, { + start: () => Effect.void, + drain: Effect.void, + }), + ), Layer.provideMerge( Layer.succeed(AgentAwarenessRelay.AgentAwarenessRelay, { publishThread: () => Effect.void, diff --git a/apps/server/package.json b/apps/server/package.json index 01beed1c6553..ea50dd7b2eee 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -1,6 +1,6 @@ { "name": "t3", - "version": "0.0.45", + "version": "0.0.46", "license": "MIT", "repository": { "type": "git", diff --git a/apps/server/src/auth/EnvironmentAuth.test.ts b/apps/server/src/auth/EnvironmentAuth.test.ts index 440efcee51ee..6e5f22fa3af6 100644 --- a/apps/server/src/auth/EnvironmentAuth.test.ts +++ b/apps/server/src/auth/EnvironmentAuth.test.ts @@ -5,6 +5,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as ServerConfig from "../config.ts"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; import * as PairingGrantStore from "./PairingGrantStore.ts"; import * as EnvironmentAuth from "./EnvironmentAuth.ts"; @@ -34,6 +35,7 @@ const makeEnvironmentAuthLayer = (overrides?: Partial { }).pipe(Effect.provide(makeEnvironmentAuthLayer())), ); + it.effect("prefers a bearer token over a stale legacy cookie", () => + Effect.gen(function* () { + const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; + const sessions = yield* SessionStore.SessionStore; + const bearer = yield* serverAuth.issueSession(); + const verified = yield* serverAuth.authenticateHttpRequest({ + cookies: { [sessions.legacyCookieName ?? "t3_session"]: "stale" }, + headers: { authorization: `Bearer ${bearer.token}` }, + } as never); + + expect(verified.sessionId).toBe(bearer.sessionId); + }).pipe(Effect.provide(makeEnvironmentAuthLayer({ mode: "web", host: "192.168.1.50" }))), + ); + it.effect("does not exchange ordinary pairing grants for administrative access tokens", () => Effect.gen(function* () { const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; diff --git a/apps/server/src/auth/EnvironmentAuth.ts b/apps/server/src/auth/EnvironmentAuth.ts index f5d244dd9667..08838cb7b780 100644 --- a/apps/server/src/auth/EnvironmentAuth.ts +++ b/apps/server/src/auth/EnvironmentAuth.ts @@ -30,6 +30,7 @@ import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import * as EnvironmentAuthPolicy from "./EnvironmentAuthPolicy.ts"; import * as PairingGrantStore from "./PairingGrantStore.ts"; import * as ServerSecretStore from "./ServerSecretStore.ts"; @@ -562,6 +563,34 @@ function parseDpopToken(request: HttpServerRequest.HttpServerRequest): string | return token.length > 0 ? token : null; } +export function selectRequestCredential( + request: HttpServerRequest.HttpServerRequest, + cookieName: string, + legacyCookieName: string | undefined, +) { + const cookieToken = request.cookies[cookieName]; + if (cookieToken !== undefined) { + return { token: cookieToken, source: "cookie" } as const; + } + + const bearerToken = parseBearerToken(request); + if (bearerToken !== null) { + return { token: bearerToken, source: "bearer" } as const; + } + + const dpopToken = parseDpopToken(request); + if (dpopToken !== null) { + return { token: dpopToken, source: "dpop" } as const; + } + + const legacyToken = legacyCookieName ? request.cookies[legacyCookieName] : undefined; + if (legacyToken !== undefined) { + return { token: legacyToken, source: "legacy-cookie" } as const; + } + + return undefined; +} + export const make = Effect.gen(function* () { const policy = yield* EnvironmentAuthPolicy.EnvironmentAuthPolicy; const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore; @@ -600,17 +629,19 @@ export const make = Effect.gen(function* () { const authenticateRequest = ( request: HttpServerRequest.HttpServerRequest, ): Effect.Effect => { - const cookieToken = request.cookies[sessions.cookieName]; - const bearerToken = parseBearerToken(request); - const dpopToken = parseDpopToken(request); - const credential = cookieToken ?? bearerToken ?? dpopToken; - if (!credential) { + const credential = selectRequestCredential( + request, + sessions.cookieName, + sessions.legacyCookieName, + ); + if (!credential?.token) { return Effect.fail(new ServerAuthMissingCredentialError({})); } - return authenticateToken(credential).pipe( + const dpopToken = parseDpopToken(request); + return authenticateToken(credential.token).pipe( Effect.flatMap((session) => { if (session.proofKeyThumbprint) { - if (!dpopToken || dpopToken !== credential) { + if (!dpopToken || dpopToken !== credential.token) { return Effect.fail( new ServerAuthInvalidCredentialError({ diagnostic: "DPoP-bound access token requires DPoP authorization.", @@ -1003,4 +1034,7 @@ export const layer = Layer.effect(EnvironmentAuth, make).pipe( export const storageLayer = Layer.mergeAll(ServerSecretStore.layer, SqlitePersistenceLayer); -export const runtimeLayer = layer.pipe(Layer.provideMerge(storageLayer)); +export const runtimeLayer = layer.pipe( + Layer.provideMerge(storageLayer), + Layer.provideMerge(ServerEnvironment.identityLayer), +); diff --git a/apps/server/src/auth/EnvironmentAuthAdmin.test.ts b/apps/server/src/auth/EnvironmentAuthAdmin.test.ts index 03009270e15c..331a722534b4 100644 --- a/apps/server/src/auth/EnvironmentAuthAdmin.test.ts +++ b/apps/server/src/auth/EnvironmentAuthAdmin.test.ts @@ -4,6 +4,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as ServerConfig from "../config.ts"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; import * as EnvironmentAuth from "./EnvironmentAuth.ts"; import * as ServerSecretStore from "./ServerSecretStore.ts"; @@ -35,6 +36,7 @@ const makeEnvironmentAuthLayer = ( EnvironmentAuth.layer.pipe( Layer.provideMerge(ServerSecretStore.layer), Layer.provideMerge(SqlitePersistenceMemory), + Layer.provide(ServerEnvironment.identityLayer), Layer.provide(makeServerConfigLayer(overrides)), ); diff --git a/apps/server/src/auth/EnvironmentAuthPolicy.test.ts b/apps/server/src/auth/EnvironmentAuthPolicy.test.ts index 8e4c21710880..982ff397db40 100644 --- a/apps/server/src/auth/EnvironmentAuthPolicy.test.ts +++ b/apps/server/src/auth/EnvironmentAuthPolicy.test.ts @@ -4,12 +4,14 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as ServerConfig from "../config.ts"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import * as EnvironmentAuthPolicy from "./EnvironmentAuthPolicy.ts"; const makeEnvironmentAuthPolicyLayer = ( overrides?: Partial, ) => EnvironmentAuthPolicy.layer.pipe( + Layer.provide(ServerEnvironment.identityLayer), Layer.provide( Layer.effect( ServerConfig.ServerConfig, @@ -107,7 +109,7 @@ it.layer(NodeServices.layer)("EnvironmentAuthPolicy.layer", (it) => { expect(descriptor.policy).toBe("remote-reachable"); expect(descriptor.bootstrapMethods).toEqual(["one-time-token"]); - expect(descriptor.sessionCookieName).toBe("t3_session"); + expect(descriptor.sessionCookieName).toMatch(/^t3_session_[a-f0-9]{12}$/); }).pipe( Effect.provide( makeEnvironmentAuthPolicyLayer({ @@ -143,7 +145,7 @@ it.layer(NodeServices.layer)("EnvironmentAuthPolicy.layer", (it) => { const descriptor = yield* policy.getDescriptor(); expect(descriptor.policy).toBe("remote-reachable"); - expect(descriptor.sessionCookieName).toBe("t3_session"); + expect(descriptor.sessionCookieName).toMatch(/^t3_session_[a-f0-9]{12}$/); }).pipe( Effect.provide( makeEnvironmentAuthPolicyLayer({ diff --git a/apps/server/src/auth/EnvironmentAuthPolicy.ts b/apps/server/src/auth/EnvironmentAuthPolicy.ts index 9945c69067d7..446b8a8bba95 100644 --- a/apps/server/src/auth/EnvironmentAuthPolicy.ts +++ b/apps/server/src/auth/EnvironmentAuthPolicy.ts @@ -4,6 +4,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as ServerConfig from "../config.ts"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import { isRemoteReachableHost, resolveSessionCookieName } from "./utils.ts"; export class EnvironmentAuthPolicy extends Context.Service< @@ -15,6 +16,7 @@ export class EnvironmentAuthPolicy extends Context.Service< export const make = Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; + const serverEnvironment = yield* ServerEnvironment.ServerEnvironmentIdentity; const isRemoteReachable = isRemoteReachableHost(config.host); const policy = @@ -42,6 +44,7 @@ export const make = Effect.gen(function* () { port: config.port, host: config.host, instanceKey: config.stateDir, + environmentId: yield* serverEnvironment.getEnvironmentId, development: config.devUrl !== undefined, }), }; diff --git a/apps/server/src/auth/SessionStore.test.ts b/apps/server/src/auth/SessionStore.test.ts index 1fb01c1f0002..aa3b2d199148 100644 --- a/apps/server/src/auth/SessionStore.test.ts +++ b/apps/server/src/auth/SessionStore.test.ts @@ -1,4 +1,5 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; +import { EnvironmentId } from "@t3tools/contracts"; import { expect, it } from "@effect/vitest"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -7,15 +8,14 @@ import * as TestClock from "effect/testing/TestClock"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import * as ServerConfig from "../config.ts"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import { PersistenceSqlError } from "../persistence/Errors.ts"; import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; import * as AuthSessions from "../persistence/AuthSessions.ts"; import * as SessionStore from "./SessionStore.ts"; import * as ServerSecretStore from "./ServerSecretStore.ts"; -const makeServerConfigLayer = ( - overrides?: Partial>, -) => +const makeServerConfigLayer = (overrides?: Partial) => Layer.effect( ServerConfig.ServerConfig, Effect.gen(function* () { @@ -27,12 +27,19 @@ const makeServerConfigLayer = ( }), ).pipe(Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-auth-session-test-" }))); +const makeServerEnvironmentLayer = (environmentId: EnvironmentId) => + Layer.succeed(ServerEnvironment.ServerEnvironmentIdentity, { + getEnvironmentId: Effect.succeed(environmentId), + }); + const makeSessionStoreLayer = ( - overrides?: Partial>, + overrides?: Partial, + environmentId = EnvironmentId.make("test-environment"), ) => SessionStore.layer.pipe( Layer.provide(SqlitePersistenceMemory), Layer.provide(ServerSecretStore.layer), + Layer.provide(makeServerEnvironmentLayer(environmentId)), Layer.provide(makeServerConfigLayer(overrides)), ); @@ -58,10 +65,32 @@ const failingSessionLookupCredentialLayer = Layer.effect( Layer.provide(failingSessionLookupRepositoryLayer), Layer.provide(ServerSecretStore.layer), Layer.provide(SqlitePersistenceMemory), + Layer.provide(makeServerEnvironmentLayer(EnvironmentId.make("test-environment"))), Layer.provide(makeServerConfigLayer()), ); it.layer(NodeServices.layer)("SessionStore.layer", (it) => { + it.effect("keys remote cookies by environment identity instead of state directory", () => + Effect.gen(function* () { + const cookieName = (stateDir: string, environmentId: EnvironmentId) => + Effect.gen(function* () { + const sessions = yield* SessionStore.SessionStore; + return sessions.cookieName; + }).pipe( + Effect.provide( + makeSessionStoreLayer({ mode: "web", host: "192.168.1.50", stateDir }, environmentId), + ), + ); + + const original = yield* cookieName("/srv/t3-one", EnvironmentId.make("environment-one")); + const moved = yield* cookieName("/srv/t3-moved", EnvironmentId.make("environment-one")); + const other = yield* cookieName("/srv/t3-one", EnvironmentId.make("environment-two")); + + expect(moved).toBe(original); + expect(other).not.toBe(original); + }), + ); + it.effect("issues and verifies signed browser session tokens", () => Effect.gen(function* () { const sessions = yield* SessionStore.SessionStore; diff --git a/apps/server/src/auth/SessionStore.ts b/apps/server/src/auth/SessionStore.ts index cdcd4a1ac198..d4fbe445edf6 100644 --- a/apps/server/src/auth/SessionStore.ts +++ b/apps/server/src/auth/SessionStore.ts @@ -21,11 +21,13 @@ import * as Stream from "effect/Stream"; import * as Option from "effect/Option"; import * as ServerConfig from "../config.ts"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import * as AuthSessions from "../persistence/AuthSessions.ts"; import * as ServerSecretStore from "./ServerSecretStore.ts"; import { base64UrlDecodeUtf8, base64UrlEncode, + resolveLegacySessionCookieName, resolveSessionCookieName, signPayload, timingSafeEqualBase64Url, @@ -360,6 +362,7 @@ export class SessionStore extends Context.Service< SessionStore, { readonly cookieName: string; + readonly legacyCookieName: string | undefined; readonly issue: (input?: { readonly ttl?: Duration.Duration; readonly subject?: string; @@ -470,18 +473,22 @@ function toAuthClientSession(input: Omit): AuthCli export const make = Effect.gen(function* () { const crypto = yield* Crypto.Crypto; const serverConfig = yield* ServerConfig.ServerConfig; + const serverEnvironment = yield* ServerEnvironment.ServerEnvironmentIdentity; const secretStore = yield* ServerSecretStore.ServerSecretStore; const authSessions = yield* AuthSessions.AuthSessionRepository; const signingSecret = yield* secretStore.getOrCreateRandom(SIGNING_SECRET_NAME, 32); const connectedSessionsRef = yield* Ref.make(new Map()); const changesPubSub = yield* PubSub.unbounded(); - const cookieName = resolveSessionCookieName({ + const cookieInput = { mode: serverConfig.mode, port: serverConfig.port, host: serverConfig.host, instanceKey: serverConfig.stateDir, + environmentId: yield* serverEnvironment.getEnvironmentId, development: serverConfig.devUrl !== undefined, - }); + } as const; + const cookieName = resolveSessionCookieName(cookieInput); + const legacyCookieName = resolveLegacySessionCookieName(cookieInput); const emitUpsert = (clientSession: AuthClientSession) => PubSub.publish(changesPubSub, { @@ -930,6 +937,7 @@ export const make = Effect.gen(function* () { return SessionStore.of({ cookieName, + legacyCookieName, issue, verify, issueWebSocketToken, diff --git a/apps/server/src/auth/http.ts b/apps/server/src/auth/http.ts index 58277141a946..cc74966c41e2 100644 --- a/apps/server/src/auth/http.ts +++ b/apps/server/src/auth/http.ts @@ -171,6 +171,23 @@ export function failEnvironmentInternal(reason: EnvironmentInternalErrorReason, }); } +const appendSessionCookie = (cookieName: string, token: string, expiresAt: DateTime.DateTime) => + Effect.fromResult( + Cookies.set(Cookies.empty, cookieName, token, { + expires: DateTime.toDate(expiresAt), + httpOnly: true, + path: "/", + sameSite: "lax", + }), + ).pipe( + Effect.catch(() => failEnvironmentInternal("browser_session_cookie_failed")), + Effect.flatMap((cookies) => + HttpEffect.appendPreResponseHandler((_request, response) => + Effect.succeed(HttpServerResponse.mergeCookies(response, cookies)), + ), + ), + ); + export const requireEnvironmentScope = Effect.fn("environment.auth.requireScope")(function* ( scope: AuthEnvironmentScope, ) { @@ -224,7 +241,22 @@ export const authHttpApiLayer = HttpApiBuilder.group( function* (args) { yield* annotateEnvironmentRequest(args.endpoint.name); const request = yield* HttpServerRequest.HttpServerRequest; - return yield* serverAuth.getSessionState(request); + const result = yield* serverAuth.getSessionState(request); + const credential = EnvironmentAuth.selectRequestCredential( + request, + sessions.cookieName, + sessions.legacyCookieName, + ); + if ( + credential?.source === "legacy-cookie" && + result.authenticated && + result.sessionMethod === "browser-session-cookie" && + result.expiresAt + ) { + yield* appendSessionCookie(sessions.cookieName, credential.token, result.expiresAt); + yield* appendCredentialResponseHeaders; + } + return result; }, Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => failEnvironmentInternal("internal_error", error), @@ -241,17 +273,10 @@ export const authHttpApiLayer = HttpApiBuilder.group( args.payload.credential, deriveAuthClientMetadata({ request }), ); - const sessionCookies = yield* Effect.fromResult( - Cookies.set(Cookies.empty, sessions.cookieName, result.sessionToken, { - expires: DateTime.toDate(result.response.expiresAt), - httpOnly: true, - path: "/", - sameSite: "lax", - }), - ).pipe(Effect.catch(() => failEnvironmentInternal("browser_session_cookie_failed"))); - - yield* HttpEffect.appendPreResponseHandler((_request, response) => - Effect.succeed(HttpServerResponse.mergeCookies(response, sessionCookies)), + yield* appendSessionCookie( + sessions.cookieName, + result.sessionToken, + result.response.expiresAt, ); yield* appendCredentialResponseHeaders; return result.response; diff --git a/apps/server/src/auth/utils.test.ts b/apps/server/src/auth/utils.test.ts index 7f328a26f8b9..3259f44a3474 100644 --- a/apps/server/src/auth/utils.test.ts +++ b/apps/server/src/auth/utils.test.ts @@ -64,6 +64,7 @@ describe("session cookie isolation", () => { port: 5775, host: "127.0.0.1", instanceKey: "/tmp/t3-agent-one", + environmentId: "environment-one", development: true, }); const second = resolveSessionCookieName({ @@ -71,6 +72,7 @@ describe("session cookie isolation", () => { port: 5775, host: "127.0.0.1", instanceKey: "/tmp/t3-agent-two", + environmentId: "environment-two", development: true, }); @@ -79,25 +81,48 @@ describe("session cookie isolation", () => { expect(first).not.toBe(second); }); - it("keeps the hosted web cookie stable across server instances", () => { - expect( - resolveSessionCookieName({ - mode: "web", - port: 8080, - host: "0.0.0.0", - instanceKey: "/srv/release-a", - development: false, - }), - ).toBe("t3_session"); - expect( - resolveSessionCookieName({ - mode: "web", - port: 9090, - host: "app.example.com", - instanceKey: "/srv/release-b", - development: false, - }), - ).toBe("t3_session"); + it("isolates remote web servers by server state", () => { + const first = resolveSessionCookieName({ + mode: "web", + port: 3773, + host: "192.168.1.50", + instanceKey: "/srv/t3-one", + environmentId: "environment-one", + development: false, + }); + const second = resolveSessionCookieName({ + mode: "web", + port: 5775, + host: "192.168.1.50", + instanceKey: "/srv/t3-two", + environmentId: "environment-two", + development: false, + }); + + expect(first).toMatch(/^t3_session_[a-f0-9]{12}$/); + expect(second).toMatch(/^t3_session_[a-f0-9]{12}$/); + expect(first).not.toBe(second); + }); + + it("keeps a remote web server cookie stable across port changes", () => { + const first = resolveSessionCookieName({ + mode: "web", + port: 8080, + host: "0.0.0.0", + instanceKey: "/srv/t3", + environmentId: "environment-one", + development: false, + }); + const second = resolveSessionCookieName({ + mode: "web", + port: 9090, + host: "app.example.com", + instanceKey: "/srv/t3", + environmentId: "environment-one", + development: false, + }); + + expect(first).toBe(second); }); it("retains desktop port scoping", () => { @@ -107,6 +132,7 @@ describe("session cookie isolation", () => { port: 3773, host: "127.0.0.1", instanceKey: "/tmp/desktop", + environmentId: "environment-one", development: true, }), ).toBe("t3_session_3773"); @@ -119,6 +145,7 @@ describe("session cookie isolation", () => { port: 5775, host: "0.0.0.0", instanceKey: "/tmp/t3-wildcard-dev", + environmentId: "environment-one", development: true, }), ).toMatch(/^t3_session_5775_[a-f0-9]{12}$/); diff --git a/apps/server/src/auth/utils.ts b/apps/server/src/auth/utils.ts index 32a6799b01f4..30d59d654010 100644 --- a/apps/server/src/auth/utils.ts +++ b/apps/server/src/auth/utils.ts @@ -16,40 +16,53 @@ const SESSION_COOKIE_NAME = "t3_session"; * clobbers the first's session and both sides see "Invalid session token * signature" until someone clears cookies by hand. * - * Two populations qualify, for the same reason but from different causes: + * Remote web servers use their persisted environment identity and omit the + * port, so the name survives state-directory moves and public port changes. * - * - **Dev servers** (`devUrl` set), which run several at a time across worktrees. - * - **Desktop**, which scans upward from 3773 for a free port and binds + * Desktop scans upward from 3773 for a free port and binds * 127.0.0.1, so a second instance lands on a different port and the same host. - * - * Hosted deployments keep the stable production name: their public port can - * change between releases, and scoping it would log every user out. */ export function resolveSessionCookieName(input: { readonly mode: "web" | "desktop"; readonly port: number; readonly host: string | undefined; readonly instanceKey: string; + readonly environmentId: string; readonly development: boolean; }): string { if (input.mode === "desktop") { return `${SESSION_COOKIE_NAME}_${input.port}`; } + const instanceHash = NodeCrypto.createHash("sha256") + .update( + !input.development && isRemoteReachableHost(input.host) + ? input.environmentId + : input.instanceKey, + ) + .digest("hex") + .slice(0, 12); + if (!input.development && isRemoteReachableHost(input.host)) { - return SESSION_COOKIE_NAME; + return `${SESSION_COOKIE_NAME}_${instanceHash}`; } // Cookies are scoped by host, not port. Loopback development servers need an // instance-specific name or parallel agents overwrite each other's session, // and a server that later reuses the port receives a token signed elsewhere. - const instanceHash = NodeCrypto.createHash("sha256") - .update(input.instanceKey) - .digest("hex") - .slice(0, 12); return `${SESSION_COOKIE_NAME}_${input.port}_${instanceHash}`; } +export function resolveLegacySessionCookieName(input: { + readonly mode: "web" | "desktop"; + readonly host: string | undefined; + readonly development: boolean; +}): string | undefined { + return input.mode === "web" && !input.development && isRemoteReachableHost(input.host) + ? SESSION_COOKIE_NAME + : undefined; +} + export function isRemoteReachableHost(host: string | undefined): boolean { if (host === "0.0.0.0" || host === "::" || host === "[::]") { return true; diff --git a/apps/server/src/bin.test.ts b/apps/server/src/bin.test.ts index 42e8ee716bce..8e6f1b9f4783 100644 --- a/apps/server/src/bin.test.ts +++ b/apps/server/src/bin.test.ts @@ -13,6 +13,7 @@ import { ThreadId, } from "@t3tools/contracts"; import * as NetService from "@t3tools/shared/Net"; +import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as DateTime from "effect/DateTime"; @@ -26,7 +27,13 @@ import * as TestConsole from "effect/testing/TestConsole"; import { Command } from "effect/unstable/cli"; import { cli, makeCli } from "./bin.ts"; +import * as ServiceLauncherClient from "./cloud/serviceLauncherClient.ts"; +import { + SERVICE_LAUNCHER_CONTEXT_ENV, + SERVICE_LAUNCHER_PROTOCOL, +} from "./cloud/serviceProtocol.ts"; import * as ServerConfig from "./config.ts"; +import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts"; import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts"; import { OrchestrationLayerLive } from "./orchestration/runtimeLayer.ts"; @@ -42,7 +49,24 @@ import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; import { environmentAuthenticatedAuthLayer } from "./auth/http.ts"; +import packageJson from "../package.json" with { type: "json" }; + const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer); +const DisconnectedLauncherChildLayer = Layer.mergeAll( + Layer.succeed(HostProcessEnvironment, { + ...process.env, + [SERVICE_LAUNCHER_CONTEXT_ENV]: JSON.stringify({ + protocol: SERVICE_LAUNCHER_PROTOCOL, + childVersion: packageJson.version, + }), + }), + Layer.succeed(ServiceLauncherClient.ServiceLauncherHostProcess, { + connected: false, + send: () => false, + on: () => undefined, + off: () => undefined, + }), +); class ProjectCliHttpApi extends HttpApi.make("environment").add(EnvironmentOrchestrationHttpApi) {} const connectCli = makeCli({ cloudEnabled: true }); @@ -127,6 +151,7 @@ const withLiveProjectCliServer = (baseDir: string, run: () => Effect.Ef Layer.provideMerge( EnvironmentAuth.layer.pipe( Layer.provideMerge(SqlitePersistenceLayerLive), + Layer.provide(ServerEnvironment.identityLayer), Layer.provide(ServerSecretStore.layer), ), ), @@ -251,7 +276,7 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => { assert.equal(status.linked, false); assert.equal(status.cloudUserId, null); assert.equal(status.relayUrl, null); - }), + }).pipe(Effect.provide(DisconnectedLauncherChildLayer)), ); it.effect("reports actionable human-readable headless connect state", () => @@ -422,7 +447,7 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => { "relay:write", ]); assert.equal("token" in (listed[0] ?? {}), false); - }), + }).pipe(Effect.provide(DisconnectedLauncherChildLayer)), ); it.effect("rejects invalid ttl values before running auth commands", () => diff --git a/apps/server/src/cli/connect.ts b/apps/server/src/cli/connect.ts index d62d6343b2d6..f22b4764ef2a 100644 --- a/apps/server/src/cli/connect.ts +++ b/apps/server/src/cli/connect.ts @@ -337,7 +337,7 @@ const unlinkRelayEnvironment = Effect.fn("cloud.cli.unlink_relay_environment")(f return { status: "not-authenticated" } satisfies RelayUnlinkResult; } - const environment = yield* ServerEnvironment.ServerEnvironment; + const environment = yield* ServerEnvironment.ServerEnvironmentIdentity; const environmentId = yield* environment.getEnvironmentId; const relayUrl = yield* relayUrlConfig; const httpClient = yield* HttpClient.HttpClient; @@ -432,7 +432,7 @@ const runCloudCommand = Effect.fn("cloud.cli.run_cloud_command")(function* , options?: { readonly quietLogs?: boolean; @@ -449,7 +449,6 @@ const runCloudCommand = Effect.fn("cloud.cli.run_cloud_command")(function* { assert.equal(credentials.length, 1); assert.equal(credentials[0]?.label, "t3 pair"); }), - ).pipe(Effect.provide(NodeServices.layer)), + ).pipe( + Effect.provide(NodeServices.layer), + Effect.provideService(HostProcessEnvironment, { + ...process.env, + [SERVICE_LAUNCHER_CONTEXT_ENV]: JSON.stringify({ + protocol: SERVICE_LAUNCHER_PROTOCOL, + childVersion: packageJson.version, + }), + }), + Effect.provideService(ServiceLauncherClient.ServiceLauncherHostProcess, { + connected: false, + send: () => false, + on: () => undefined, + off: () => undefined, + }), + ), ); it.effect("pairs through the recorded dev web URL for dev servers", () => diff --git a/apps/server/src/environment/ServerEnvironment.test.ts b/apps/server/src/environment/ServerEnvironment.test.ts index 9dc1a8eb5b0c..32006f691ed7 100644 --- a/apps/server/src/environment/ServerEnvironment.test.ts +++ b/apps/server/src/environment/ServerEnvironment.test.ts @@ -1,5 +1,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { expect, it } from "@effect/vitest"; +import * as Crypto from "effect/Crypto"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -71,6 +73,77 @@ const makeServerConfig = Effect.fn(function* (baseDir: string) { }); it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { + it.effect.each([ + { name: "missing", content: undefined }, + { name: "empty", content: "" }, + { name: "whitespace-only", content: " \t\n" }, + ])("concurrent initializers recover a $name environment id file", ({ content }) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const crypto = yield* Crypto.Crypto; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-server-environment-concurrent-test-", + }); + const serverConfig = yield* makeServerConfig(baseDir); + yield* fileSystem.makeDirectory(serverConfig.stateDir, { recursive: true }); + if (content !== undefined) { + yield* fileSystem.writeFileString(serverConfig.environmentIdPath, content); + } + const bothGenerated = yield* Deferred.make(); + const bothReadEmpty = yield* Deferred.make(); + const firstInitialized = yield* Deferred.make(); + let remaining = 2; + let emptyReads = 0; + const readIdentity = Effect.gen(function* () { + const identity = yield* ServerEnvironment.ServerEnvironmentIdentity; + return yield* identity.getEnvironmentId; + }).pipe( + Effect.tap(() => Deferred.succeed(firstInitialized, undefined)), + Effect.provide(Layer.fresh(ServerEnvironment.identityLayer)), + Effect.provideService(ServerConfig.ServerConfig, serverConfig), + Effect.provideService(FileSystem.FileSystem, { + ...fileSystem, + readFileString: (path) => + fileSystem.readFileString(path).pipe( + Effect.tap( + Effect.fn(function* (value) { + if (path !== serverConfig.environmentIdPath || remaining > 0 || value.trim()) { + return; + } + // Both observe the empty file, but one repairs it after the other has finished. + if (++emptyReads === 2) { + yield* Deferred.succeed(bothReadEmpty, undefined); + yield* Deferred.await(firstInitialized); + } else { + yield* Deferred.await(bothReadEmpty); + } + }), + ), + ), + }), + Effect.provideService(Crypto.Crypto, { + ...crypto, + randomUUIDv4: Effect.gen(function* () { + const id = yield* crypto.randomUUIDv4; + if (--remaining === 0) { + yield* Deferred.succeed(bothGenerated, undefined); + } + yield* Deferred.await(bothGenerated); + return id; + }), + }), + ); + + const [first, second] = yield* Effect.all([readIdentity, readIdentity], { + concurrency: "unbounded", + }); + const persisted = yield* fileSystem.readFileString(serverConfig.environmentIdPath); + + expect(first).toBe(second); + expect(persisted.trim()).toBe(first); + }), + ); + it.effect("persists the environment id across service restarts", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -153,6 +226,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { }); const serverConfig = yield* makeServerConfig(baseDir); const environmentIdPath = serverConfig.environmentIdPath; + const tempPath = `${environmentIdPath}.tmp`; const methodByOperation = { check: "exists", read: "readFileString", @@ -172,6 +246,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { exists: () => operation === "check" ? Effect.fail(cause) : Effect.succeed(operation === "read"), readFileString: () => Effect.fail(cause), + makeTempFileScoped: () => Effect.succeed(tempPath), writeFileString: (path) => { writeAttempts.push(path); return Effect.fail(cause); @@ -201,7 +276,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { expect(error.message).toBe( `Server environment ID ${operation} failed at '${environmentIdPath}'.`, ); - expect(writeAttempts).toEqual(operation === "write" ? [environmentIdPath] : []); + expect(writeAttempts).toEqual(operation === "write" ? [tempPath] : []); } }), ); diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 9c3a0d2a9637..0de59db78160 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -24,12 +24,15 @@ import { resolveServerEnvironmentLabel } from "./ServerEnvironmentLabel.ts"; export class ServerEnvironmentIdPersistenceError extends Schema.TaggedErrorClass()( "ServerEnvironmentIdPersistenceError", { - operation: Schema.Literals(["check", "read", "write"]), + operation: Schema.Literals(["check", "read", "write", "initialize"]), environmentIdPath: Schema.String, - cause: Schema.Defect(), + cause: Schema.optional(Schema.Defect()), }, ) { override get message(): string { + if (this.operation === "initialize") { + return `Server environment ID file is missing or empty after initialization at '${this.environmentIdPath}'.`; + } return `Server environment ID ${this.operation} failed at '${this.environmentIdPath}'.`; } } @@ -42,6 +45,13 @@ export class ServerEnvironment extends Context.Service< } >()("t3/environment/ServerEnvironment") {} +export class ServerEnvironmentIdentity extends Context.Service< + ServerEnvironmentIdentity, + { + readonly getEnvironmentId: Effect.Effect; + } +>()("t3/environment/ServerEnvironment/ServerEnvironmentIdentity") {} + function platformOs(platform: NodeJS.Platform): ExecutionEnvironmentDescriptor["platform"]["os"] { switch (platform) { case "darwin": @@ -68,14 +78,10 @@ function platformArch( } } -export const make = Effect.gen(function* () { +const makeIdentity = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; const serverConfig = yield* ServerConfig.ServerConfig; - const secrets = yield* ServerSecretStore.ServerSecretStore; const crypto = yield* Crypto.Crypto; - const hostPlatform = yield* HostProcessPlatform; - const hostArchitecture = yield* HostProcessArchitecture; const readPersistedEnvironmentId = Effect.gen(function* () { const exists = yield* fileSystem.exists(serverConfig.environmentIdPath).pipe( @@ -107,17 +113,42 @@ export const make = Effect.gen(function* () { return raw.length > 0 ? raw : null; }); - const persistEnvironmentId = (value: string) => - fileSystem.writeFileString(serverConfig.environmentIdPath, `${value}\n`).pipe( - Effect.mapError( - (cause) => - new ServerEnvironmentIdPersistenceError({ - operation: "write", - environmentIdPath: serverConfig.environmentIdPath, - cause, - }), - ), - ); + const persistEnvironmentId = Effect.fn("ServerEnvironmentIdentity.persistEnvironmentId")( + function* (value: string, mode: "create" | "recover") { + const destinationPath = + mode === "recover" + ? `${serverConfig.environmentIdPath}.recovery` + : serverConfig.environmentIdPath; + const tempPath = yield* fileSystem.makeTempFileScoped({ + directory: serverConfig.stateDir, + prefix: ".environment-id-", + }); + yield* fileSystem.writeFileString(tempPath, `${value}\n`); + // Publish the completed file without replacing an ID created by another process. + yield* fileSystem + .link(tempPath, destinationPath) + .pipe( + Effect.catch((cause) => + cause.reason._tag === "AlreadyExists" ? Effect.void : Effect.fail(cause), + ), + ); + if (mode === "recover") { + // Keep the recovery ID so delayed initializers also publish the same winner. + yield* fileSystem.remove(tempPath); + yield* fileSystem.copyFile(destinationPath, tempPath); + yield* fileSystem.rename(tempPath, serverConfig.environmentIdPath); + } + }, + Effect.scoped, + Effect.mapError( + (cause) => + new ServerEnvironmentIdPersistenceError({ + operation: "write", + environmentIdPath: serverConfig.environmentIdPath, + cause, + }), + ), + ); const environmentIdRaw = yield* Effect.gen(function* () { const persisted = yield* readPersistedEnvironmentId; @@ -126,11 +157,35 @@ export const make = Effect.gen(function* () { } const generated = yield* crypto.randomUUIDv4; - yield* persistEnvironmentId(generated); - return generated; + yield* persistEnvironmentId(generated, "create"); + let winner = yield* readPersistedEnvironmentId; + if (winner === null) { + yield* persistEnvironmentId(generated, "recover"); + winner = yield* readPersistedEnvironmentId; + } + if (winner === null) { + return yield* new ServerEnvironmentIdPersistenceError({ + operation: "initialize", + environmentIdPath: serverConfig.environmentIdPath, + }); + } + return winner; }); const environmentId = EnvironmentId.make(environmentIdRaw); + return ServerEnvironmentIdentity.of({ + getEnvironmentId: Effect.succeed(environmentId), + }); +}); + +export const make = Effect.gen(function* () { + const path = yield* Path.Path; + const serverConfig = yield* ServerConfig.ServerConfig; + const secrets = yield* ServerSecretStore.ServerSecretStore; + const identity = yield* ServerEnvironmentIdentity; + const hostPlatform = yield* HostProcessPlatform; + const hostArchitecture = yield* HostProcessArchitecture; + const environmentId = yield* identity.getEnvironmentId; const cwdBaseName = path.basename(serverConfig.cwd).trim(); const label = yield* resolveServerEnvironmentLabel({ cwdBaseName }); const launcher = yield* resolveServiceLauncherMode(); @@ -154,6 +209,7 @@ export const make = Effect.gen(function* () { fileAttachments: { maxUploadBytes: PROVIDER_SEND_TURN_MAX_FILE_BYTES }, pullRequests: true, threadSettlement: true, + threadAutoSettlement: true, threadSnooze: true, environmentThemes: true, threadPinning: true, @@ -179,10 +235,15 @@ export const make = Effect.gen(function* () { }); }); +export const identityLayer = Layer.effect(ServerEnvironmentIdentity, makeIdentity); + /** * ServerEnvironment is acquired from persisted filesystem and host-process * state. It intentionally has no fallback Layer.succeed value: callers must * provide the external platform services, a ServerConfig, and the * ServerSecretStore backing the descriptor's publishing capability. */ -export const layer = Layer.effect(ServerEnvironment, make).pipe(Layer.provide(ProcessRunner.layer)); +export const layer = Layer.effect(ServerEnvironment, make).pipe( + Layer.provideMerge(identityLayer), + Layer.provide(ProcessRunner.layer), +); diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 969183f5c561..298f8e0bcc94 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -620,6 +620,7 @@ function makeManager(input?: { textGeneration?: Partial; serverSettings?: Parameters[0]; setupScriptRunner?: ProjectSetupScriptRunner.ProjectSetupScriptRunner["Service"]; + gitConfigReads?: string[]; }) { const { service: gitHubCli, ghCalls } = createGitHubCliWithFakeGh(input?.ghScenario); const textGeneration = createTextGeneration(input?.textGeneration); @@ -629,11 +630,30 @@ function makeManager(input?: { const serverSettingsLayer = ServerSettings.ServerSettingsService.layerTest(input?.serverSettings); - const vcsDriverLayer = GitVcsDriver.layer.pipe( - Layer.provideMerge(VcsProcess.layer), - Layer.provideMerge(NodeServices.layer), - Layer.provideMerge(serverConfigLayer), - ); + const vcsDriverLayer = input?.gitConfigReads + ? Layer.effect( + GitVcsDriver.GitVcsDriver, + GitVcsDriver.make.pipe( + Effect.map((service) => + GitVcsDriver.GitVcsDriver.of({ + ...service, + readConfigValue: (cwd, key) => + Effect.sync(() => input.gitConfigReads?.push(key)).pipe( + Effect.andThen(service.readConfigValue(cwd, key)), + ), + }), + ), + ), + ).pipe( + Layer.provideMerge(VcsProcess.layer), + Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(serverConfigLayer), + ) + : GitVcsDriver.layer.pipe( + Layer.provideMerge(VcsProcess.layer), + Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(serverConfigLayer), + ); const sourceControlRegistryLayer = Layer.effect( SourceControlProviderRegistry.SourceControlProviderRegistry, GitHubSourceControlProvider.make.pipe( @@ -993,6 +1013,30 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); + it.effect("a warm PR cache does not reread repository identity for status", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/status-identity-cache"]); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/status-identity-cache"]); + + const gitConfigReads: string[] = []; + const { manager } = yield* makeManager({ gitConfigReads }); + + yield* manager.remoteStatus({ cwd: repoDir }, { refreshUpstream: false }); + gitConfigReads.length = 0; + yield* manager.remoteStatus({ cwd: repoDir }, { refreshUpstream: false }); + + const identityReads = gitConfigReads.filter( + (key) => + key === "branch.feature/status-identity-cache.remote" || key === "remote.origin.url", + ); + expect(identityReads).toHaveLength(0); + }), + ); + it.effect("status skips the provider lookup for a branch that was never pushed", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); @@ -1012,6 +1056,377 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); + it.effect("branch PR lookup returns null when the repository has no remotes", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const { manager, ghCalls } = yield* makeManager(); + + const pullRequest = yield* manager.branchPullRequest({ cwd: repoDir, branch: "main" }); + + expect(pullRequest).toBeNull(); + expect(ghCalls).toHaveLength(0); + }), + ); + + it.effect("branch PR lookup uses a saved tracked branch without changing checkout", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["checkout", "-b", "feature/saved-branch"]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/saved-branch"]); + yield* runGit(repoDir, ["checkout", "main"]); + + const { manager } = yield* makeManager({ + ghScenario: { + prListSequence: [ + // Fake gh returns raw JSON stdout, matching the CLI boundary under test. + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([ + { + number: 216, + title: "Saved branch PR", + url: "https://github.com/pingdotgg/t3code/pull/216", + baseRefName: "main", + headRefName: "feature/saved-branch", + state: "OPEN", + updatedAt: "2026-04-03T15:00:00Z", + }, + ]), + ], + }, + }); + + const pullRequest = yield* manager.branchPullRequest({ + cwd: repoDir, + branch: "feature/saved-branch", + }); + + expect(pullRequest).toEqual({ + state: "open", + updatedAt: "2026-04-03T15:00:00.000Z", + }); + expect((yield* runGit(repoDir, ["branch", "--show-current"])).stdout.trim()).toBe("main"); + }), + ); + + it.effect("branch PR lookup uses the default branch from a non-origin remote", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "upstream", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "upstream", "main"]); + yield* runGit(repoDir, ["checkout", "-b", "develop"]); + yield* runGit(repoDir, ["push", "-u", "upstream", "develop"]); + yield* runGit(remoteDir, ["symbolic-ref", "HEAD", "refs/heads/develop"]); + yield* runGit(repoDir, ["remote", "set-head", "upstream", "develop"]); + + const { manager } = yield* makeManager({ + ghScenario: { + // Fake gh returns raw JSON stdout, matching the CLI boundary under test. + prListSequence: [ + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([ + { + number: 221, + title: "Merged main PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/221", + baseRefName: "develop", + headRefName: "main", + state: "MERGED", + updatedAt: "2026-04-08T15:00:00Z", + }, + ]), + ], + }, + }); + + const pullRequest = yield* manager.branchPullRequest({ cwd: repoDir, branch: "main" }); + + expect(pullRequest).toEqual({ + state: "merged", + updatedAt: "2026-04-08T15:00:00.000Z", + }); + }), + ); + + it.effect("branch PR lookup uses the saved name after the local branch is deleted", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["checkout", "-b", "feature/deleted-local-branch"]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/deleted-local-branch"]); + yield* runGit(repoDir, ["checkout", "main"]); + yield* runGit(repoDir, ["branch", "-D", "feature/deleted-local-branch"]); + yield* runGit(repoDir, ["branch", "feature/deleted-local-branch/child"]); + yield* runGit(repoDir, [ + "branch", + "--set-upstream-to", + "origin/main", + "feature/deleted-local-branch/child", + ]); + + const { manager, ghCalls } = yield* makeManager({ + ghScenario: { + prListSequence: [ + // Fake gh returns raw JSON stdout, matching the CLI boundary under test. + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([ + { + number: 217, + title: "Deleted local branch PR", + url: "https://github.com/pingdotgg/t3code/pull/217", + baseRefName: "main", + headRefName: "feature/deleted-local-branch", + state: "MERGED", + updatedAt: "2026-04-04T15:00:00Z", + }, + ]), + ], + }, + }); + + const pullRequest = yield* manager.branchPullRequest({ + cwd: repoDir, + branch: "feature/deleted-local-branch", + }); + + expect(pullRequest).toEqual({ + state: "merged", + updatedAt: "2026-04-04T15:00:00.000Z", + }); + expect(ghCalls.some((call) => call.includes("--head feature/deleted-local-branch"))).toBe( + true, + ); + }), + ); + + it.effect("branch PR lookup recovers a deleted fork branch from its remote-tracking ref", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const originDir = yield* createBareRemote(); + const forkDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", originDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* configureRemote(repoDir, "team/fork", forkDir, "team/fork"); + yield* runGit(repoDir, ["checkout", "-b", "feature/deleted-fork-branch"]); + yield* runGit(repoDir, ["push", "-u", "team/fork", "feature/deleted-fork-branch"]); + yield* runGit(repoDir, ["checkout", "main"]); + yield* runGit(repoDir, ["branch", "-D", "feature/deleted-fork-branch"]); + yield* configureVisibleRemoteUrlWithLocalRewrite( + repoDir, + "origin", + "git@github.com:pingdotgg/codething-mvp.git", + originDir, + ); + yield* configureVisibleRemoteUrlWithLocalRewrite( + repoDir, + "team/fork", + "git@github.com:contributor/codething-mvp.git", + forkDir, + ); + + const { manager, ghCalls } = yield* makeManager({ + ghScenario: { + prListByHeadSelector: { + // Fake gh returns raw JSON stdout, matching the CLI boundary under test. + // @effect-diagnostics-next-line preferSchemaOverJson:off + "contributor:feature/deleted-fork-branch": JSON.stringify([ + { + number: 218, + title: "Deleted fork branch PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/218", + baseRefName: "main", + headRefName: "feature/deleted-fork-branch", + state: "MERGED", + updatedAt: "2026-04-05T15:00:00Z", + isCrossRepository: true, + headRepository: { nameWithOwner: "contributor/codething-mvp" }, + headRepositoryOwner: { login: "contributor" }, + }, + ]), + }, + }, + }); + + const pullRequest = yield* manager.branchPullRequest({ + cwd: repoDir, + branch: "feature/deleted-fork-branch", + }); + + expect(pullRequest).toEqual({ + state: "merged", + updatedAt: "2026-04-05T15:00:00.000Z", + }); + expect( + ghCalls.some((call) => call.includes("--head contributor:feature/deleted-fork-branch")), + ).toBe(true); + }), + ); + + it.effect("branch PR lookup rejects ambiguous deleted-branch remote refs", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const originDir = yield* createBareRemote(); + const forkDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", originDir]); + yield* runGit(repoDir, ["remote", "add", "fork", forkDir]); + yield* runGit(repoDir, ["checkout", "-b", "feature/ambiguous-remote"]); + yield* runGit(repoDir, ["push", "origin", "feature/ambiguous-remote"]); + yield* runGit(repoDir, ["push", "fork", "feature/ambiguous-remote"]); + yield* runGit(repoDir, ["checkout", "main"]); + yield* runGit(repoDir, ["branch", "-D", "feature/ambiguous-remote"]); + const { manager, ghCalls } = yield* makeManager(); + + const error = yield* manager + .branchPullRequest({ cwd: repoDir, branch: "feature/ambiguous-remote" }) + .pipe(Effect.flip); + + expect(error).toMatchObject({ + _tag: "GitManagerError", + detail: "Multiple remotes track feature/ambiguous-remote. Its pull request is ambiguous.", + }); + expect(ghCalls).toHaveLength(0); + }), + ); + + it.effect("branch PR lookup does not reuse a cached PR after the remote is repointed", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const originalRemoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", originalRemoteDir]); + yield* runGit(repoDir, ["checkout", "-b", "feature/repointed-lookup"]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/repointed-lookup"]); + yield* configureVisibleRemoteUrlWithLocalRewrite( + repoDir, + "origin", + "git@github.com:old-owner/old-repository.git", + originalRemoteDir, + ); + const { manager, ghCalls } = yield* makeManager({ + ghScenario: { + prListSequence: [ + // Fake gh returns raw JSON stdout, matching the CLI boundary under test. + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([ + { + number: 219, + title: "Old repository PR", + url: "https://github.com/old-owner/old-repository/pull/219", + baseRefName: "main", + headRefName: "feature/repointed-lookup", + state: "MERGED", + updatedAt: "2026-04-06T15:00:00Z", + }, + ]), + "[]", + ], + }, + }); + + const first = yield* manager.branchPullRequest({ + cwd: repoDir, + branch: "feature/repointed-lookup", + }); + expect(first?.state).toBe("merged"); + + const replacementRemoteDir = yield* createBareRemote(); + yield* configureVisibleRemoteUrlWithLocalRewrite( + repoDir, + "origin", + "git@github.com:new-owner/new-repository.git", + replacementRemoteDir, + ); + + const second = yield* manager.branchPullRequest({ + cwd: repoDir, + branch: "feature/repointed-lookup", + }); + + expect(second).toBeNull(); + expect(ghCalls.filter((call) => call.startsWith("pr list "))).toHaveLength(2); + }), + ); + + it.effect("branch PR lookup shares the status cache for the same repository identity", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["checkout", "-b", "feature/shared-pr-cache"]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/shared-pr-cache"]); + const { manager, ghCalls } = yield* makeManager({ + ghScenario: { + prListSequence: [ + // Fake gh returns raw JSON stdout, matching the CLI boundary under test. + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([ + { + number: 220, + title: "Shared cache PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/220", + baseRefName: "main", + headRefName: "feature/shared-pr-cache", + state: "MERGED", + updatedAt: "2026-04-07T15:00:00Z", + }, + ]), + ], + }, + }); + + const status = yield* manager.status({ cwd: repoDir }); + const pullRequest = yield* manager.branchPullRequest({ + cwd: repoDir, + branch: "feature/shared-pr-cache", + }); + + expect(status.pr?.state).toBe("merged"); + expect(pullRequest?.state).toBe("merged"); + expect(ghCalls.filter((call) => call.startsWith("pr list "))).toHaveLength(1); + }), + ); + + it.effect("branch PR lookup propagates provider failures", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["checkout", "-b", "feature/lookup-failure"]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/lookup-failure"]); + yield* runGit(repoDir, ["checkout", "main"]); + + const { manager } = yield* makeManager({ + ghScenario: { + failWith: new GitHubCli.GitHubCliUnavailableError({ + command: "gh", + cwd: repoDir, + cause: new Error("gh is not available on PATH"), + }), + }, + }); + + const error = yield* manager + .branchPullRequest({ cwd: repoDir, branch: "feature/lookup-failure" }) + .pipe(Effect.flip); + + expect(error._tag).toBe("SourceControlProviderError"); + }), + ); + it.effect("status finds a merged PR after its remote branch was deleted", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index a546ac9b227c..9fff7aab2c2f 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -90,6 +90,14 @@ export class GitManager extends Context.Service< input: VcsStatusInput, options?: GitVcsDriver.GitRemoteStatusOptions, ) => Effect.Effect; + /** Resolve the PR for a saved branch without changing the current checkout. */ + readonly branchPullRequest: (input: { + readonly cwd: string; + readonly branch: string; + }) => Effect.Effect< + { readonly state: "open" | "closed" | "merged"; readonly updatedAt: string | null } | null, + GitManagerServiceError + >; readonly invalidateLocalStatus: (cwd: string) => Effect.Effect; readonly invalidateRemoteStatus: (cwd: string) => Effect.Effect; readonly invalidateStatus: (cwd: string) => Effect.Effect; @@ -181,6 +189,7 @@ interface BranchHeadContext { preferredHeadSelector: string; remoteName: string | null; headRemoteUrlKey: string | null; + targetRemoteUrlKey: string | null; headRepositoryNameWithOwner: string | null; headRepositoryOwnerLogin: string | null; isCrossRepository: boolean; @@ -939,15 +948,16 @@ export const make = Effect.gen(function* () { prLookupEpochByCwd.set(cacheKey, prLookupEpoch(cacheKey) + 1); }), ); - // Cache keys are NUL-joined [cwd, branch, upstreamRef, defaultBranch, epoch] — none of the - // segments can contain a NUL byte, and refs are never empty, so "" decodes - // back to a null ref. + // Cache keys are NUL-joined. Automatic settlement validates repository URLs + // against the cached value before it uses a pull request decision. const prLookupCacheKey = ( cwd: string, details: { branch: string; upstreamRef: string | null; defaultBranch: string | null; + localBranchExists?: boolean; + remoteName?: string | null; }, ) => [ @@ -955,6 +965,8 @@ export const make = Effect.gen(function* () { details.branch, details.upstreamRef ?? "", details.defaultBranch ?? "", + details.localBranchExists === false ? "0" : "1", + details.remoteName ?? "", String(prLookupEpoch(cwd)), ].join("\u0000"); // Consecutive failures per cache key, so a branch that keeps failing waits @@ -976,11 +988,20 @@ export const make = Effect.gen(function* () { }; const prLookupCache = yield* Cache.makeWith( (key: string) => { - const [cwd = "", branch = "", upstreamRef = "", defaultBranch = ""] = key.split("\u0000"); + const [ + cwd = "", + branch = "", + upstreamRef = "", + defaultBranch = "", + branchExists = "1", + remoteName = "", + ] = key.split("\u0000"); const details = { branch, upstreamRef: upstreamRef.length > 0 ? upstreamRef : null, defaultBranch: defaultBranch.length > 0 ? defaultBranch : null, + localBranchExists: branchExists !== "0", + ...(remoteName.length > 0 ? { remoteName } : {}), }; return Effect.gen(function* () { const headContext = yield* resolveBranchHeadContext(cwd, details); @@ -1001,7 +1022,11 @@ export const make = Effect.gen(function* () { } // Only skip when the branch is untracked as well: anything carrying an // upstream keeps the old behaviour. - if (details.upstreamRef === null && (yield* isUnpublishedBranch(cwd, headContext))) { + if ( + details.localBranchExists && + details.upstreamRef === null && + (yield* isUnpublishedBranch(cwd, headContext)) + ) { return { latest: null, headContext }; } const latest = yield* findLatestPrForHeadContext(cwd, headContext); @@ -1219,11 +1244,33 @@ export const make = Effect.gen(function* () { }; }); + const resolvePrLookupRepositoryIdentity = Effect.fn("resolvePrLookupRepositoryIdentity")( + function* (cwd: string, branch: string, remoteNameOverride?: string) { + const remoteName = + remoteNameOverride ?? (yield* readConfigValueNullable(cwd, `branch.${branch}.remote`)); + const [headRemote, targetRemote] = yield* Effect.all( + [ + resolveRemoteRepositoryContext(cwd, remoteName), + resolveRemoteRepositoryContext(cwd, "origin"), + ], + { concurrency: "unbounded" }, + ); + return { + remoteName, + headRemoteUrlKey: + headRemote.remoteUrlKey ?? (remoteName === null ? targetRemote.remoteUrlKey : null), + targetRemoteUrlKey: targetRemote.remoteUrlKey, + }; + }, + ); + const resolveBranchHeadContext = Effect.fn("resolveBranchHeadContext")(function* ( cwd: string, - details: { branch: string; upstreamRef: string | null }, + details: { branch: string; upstreamRef: string | null; remoteName?: string }, ) { - const remoteName = yield* readConfigValueNullable(cwd, `branch.${details.branch}.remote`); + const remoteName = + details.remoteName ?? + (yield* readConfigValueNullable(cwd, `branch.${details.branch}.remote`)); const headBranchFromUpstream = details.upstreamRef ? extractBranchNameFromRemoteRef(details.upstreamRef, { remoteName }) : ""; @@ -1287,6 +1334,7 @@ export const make = Effect.gen(function* () { headRemoteUrlKey: remoteRepository.remoteUrlKey ?? (remoteName === null ? originRepository.remoteUrlKey : null), + targetRemoteUrlKey: originRepository.remoteUrlKey, headRepositoryNameWithOwner: remoteRepository.repositoryNameWithOwner, headRepositoryOwnerLogin: remoteRepository.ownerLogin, isCrossRepository, @@ -1870,6 +1918,140 @@ export const make = Effect.gen(function* () { }); return mergeGitStatusParts(local, remote); }); + const branchPullRequest: GitManager["Service"]["branchPullRequest"] = Effect.fn( + "branchPullRequest", + )(function* ({ cwd, branch }) { + const cacheCwd = yield* normalizeStatusCacheKey(cwd); + const remotes = yield* gitCore.execute({ + operation: "GitManager.branchPullRequest.remotes", + cwd: cacheCwd, + args: ["remote"], + }); + const remoteNames = remotes.stdout + .split("\n") + .map((remoteName) => remoteName.trim()) + .filter((remoteName) => remoteName.length > 0); + const [firstRemoteName] = remoteNames; + if (firstRemoteName === undefined) return null; + const branchRef = yield* gitCore.execute({ + operation: "GitManager.branchPullRequest.branchRef", + cwd: cacheCwd, + args: [ + "for-each-ref", + "--format=%(refname)%00%(upstream:short)%00%(upstream:remotename)%00%(upstream:remoteref)", + `refs/heads/${branch}`, + ], + }); + const expectedRefName = `refs/heads/${branch}`; + const exactBranch = branchRef.stdout + .split("\n") + .find((line) => line.split("\u0000", 1)[0] === expectedRefName); + const [refName = "", savedUpstream = "", savedRemoteName = "", savedRemoteRef = ""] = + exactBranch?.split("\u0000") ?? []; + const localBranchExists = refName.length > 0; + let upstreamRef: string | null = null; + let remoteName: string | null = null; + if (savedUpstream.length > 0) { + if (savedRemoteName.length === 0 || savedRemoteRef.length === 0) { + return yield* new GitManagerError({ + operation: "branchPullRequest", + cwd: cacheCwd, + detail: `Saved upstream for ${branch} is incomplete.`, + }); + } + remoteName = savedRemoteName; + const upstreamBranch = savedRemoteRef.replace(/^refs\/heads\//, ""); + upstreamRef = `${remoteName}/${upstreamBranch}`; + } else if (!localBranchExists) { + const trackingRefs = yield* gitCore.execute({ + operation: "GitManager.branchPullRequest.remoteTrackingRefs", + cwd: cacheCwd, + args: ["for-each-ref", "--format=%(refname)", "refs/remotes"], + }); + const refNames = new Set( + trackingRefs.stdout + .split("\n") + .map((remoteRef) => remoteRef.trim()) + .filter((remoteRef) => remoteRef.length > 0), + ); + const matchingRemoteNames = remoteNames.filter((candidate) => + refNames.has(`refs/remotes/${candidate}/${branch}`), + ); + if (matchingRemoteNames.length > 1) { + return yield* new GitManagerError({ + operation: "branchPullRequest", + cwd: cacheCwd, + detail: `Multiple remotes track ${branch}. Its pull request is ambiguous.`, + }); + } + remoteName = matchingRemoteNames[0] ?? null; + if (remoteName !== null) { + upstreamRef = `${remoteName}/${branch}`; + } + } + const defaultRemoteName = remoteNames.includes("origin") ? "origin" : firstRemoteName; + const defaultBranch = yield* gitCore + .resolveDefaultBranchName(cacheCwd, defaultRemoteName) + .pipe(Effect.orElseSucceed(() => null)); + const cacheKey = prLookupCacheKey(cacheCwd, { + branch, + upstreamRef, + defaultBranch, + localBranchExists, + ...(localBranchExists ? {} : { remoteName }), + }); + let cached = yield* Cache.get(prLookupCache, cacheKey); + const currentIdentity = yield* resolvePrLookupRepositoryIdentity( + cacheCwd, + branch, + remoteName ?? undefined, + ); + const canVerifyIdentity = (headContext: BranchHeadContext, identity: typeof currentIdentity) => + !( + (headContext.headRemoteUrlKey !== null && identity.headRemoteUrlKey === null) || + (headContext.targetRemoteUrlKey !== null && identity.targetRemoteUrlKey === null) + ); + const hasSameIdentity = (headContext: BranchHeadContext, identity: typeof currentIdentity) => + headContext.headRemoteUrlKey === identity.headRemoteUrlKey && + headContext.targetRemoteUrlKey === identity.targetRemoteUrlKey; + if (!canVerifyIdentity(cached.headContext, currentIdentity)) { + return yield* new GitManagerError({ + operation: "branchPullRequest", + cwd: cacheCwd, + detail: `Repository identity for ${branch} could not be verified.`, + }); + } + if (!hasSameIdentity(cached.headContext, currentIdentity)) { + yield* Cache.invalidate(prLookupCache, cacheKey); + cached = yield* Cache.get(prLookupCache, cacheKey); + const refreshedIdentity = yield* resolvePrLookupRepositoryIdentity( + cacheCwd, + branch, + remoteName ?? undefined, + ); + if ( + !canVerifyIdentity(cached.headContext, refreshedIdentity) || + !hasSameIdentity(cached.headContext, refreshedIdentity) + ) { + return yield* new GitManagerError({ + operation: "branchPullRequest", + cwd: cacheCwd, + detail: `Repository identity for ${branch} changed during pull request lookup.`, + }); + } + } + const { latest } = cached; + if (latest === null) return null; + if ( + (branch === defaultBranch || + (defaultBranch === null && (branch === "main" || branch === "master"))) && + latest.state !== "open" + ) { + return null; + } + const statusPr = toStatusPr(latest); + return { state: statusPr.state, updatedAt: statusPr.updatedAt }; + }); const invalidateLocalStatus: GitManager["Service"]["invalidateLocalStatus"] = Effect.fn( "invalidateLocalStatus", )(function* (cwd) { @@ -2417,6 +2599,7 @@ export const make = Effect.gen(function* () { localStatus, remoteStatus, status, + branchPullRequest, invalidateLocalStatus, invalidateRemoteStatus, invalidateStatus, diff --git a/apps/server/src/http.test.ts b/apps/server/src/http.test.ts index 9ee36ffebddf..7ae036bdc99e 100644 --- a/apps/server/src/http.test.ts +++ b/apps/server/src/http.test.ts @@ -1,13 +1,102 @@ import { expect, it } from "@effect/vitest"; import { describe } from "vite-plus/test"; +import * as NodeHttpPlatform from "@effect/platform-node/NodeHttpPlatform"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import { HttpServerResponse } from "effect/unstable/http"; import { assetResponseHeaders, + assetFileResponse, downloadContentDisposition, isLoopbackHostname, resolveDevRedirectUrl, } from "./http.ts"; +const fileResponseLayer = Layer.mergeAll(NodeHttpPlatform.layer, NodeServices.layer); + +describe("video asset byte ranges", () => { + it.effect("streams exactly the requested bytes and leaves full downloads intact", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-video-range-" }); + const file = path.join(directory, "clip.mp4"); + yield* fs.writeFileString(file, "0123456789"); + const asset = { path: file, mimeType: "video/mp4" }; + for (const [header, expected, contentRange] of [ + ["bytes=0-1", "01", "bytes 0-1/10"], + ["bytes=4-", "456789", "bytes 4-9/10"], + ["bytes=-3", "789", "bytes 7-9/10"], + ["bytes=-999999999999999999999999", "0123456789", "bytes 0-9/10"], + ["bytes=8-999999999999999999999999", "89", "bytes 8-9/10"], + ] as const) { + const response = HttpServerResponse.toWeb(yield* assetFileResponse(asset, header)); + expect(response.status).toBe(206); + expect(response.headers.get("accept-ranges")).toBe("bytes"); + expect(response.headers.get("content-range")).toBe(contentRange); + expect(response.headers.get("content-length")).toBe(String(expected.length)); + expect(yield* Effect.promise(() => response.text())).toBe(expected); + } + for (const header of [ + undefined, + "items=0-1", + "bytes=0-1,4-5", + "bytes=8-2", + "bytes=-", + "bytes=bad", + ]) { + const response = HttpServerResponse.toWeb(yield* assetFileResponse(asset, header)); + expect(response.status).toBe(200); + expect(yield* Effect.promise(() => response.text())).toBe("0123456789"); + } + const conditional = HttpServerResponse.toWeb( + yield* assetFileResponse(asset, "bytes=0-1", '"old-etag"'), + ); + expect(conditional.status).toBe(200); + expect(yield* Effect.promise(() => conditional.text())).toBe("0123456789"); + const uppercase = HttpServerResponse.toWeb( + yield* assetFileResponse({ ...asset, mimeType: "Video/MP4" }, "bytes=0-1"), + ); + expect(uppercase.status).toBe(206); + expect(yield* Effect.promise(() => uppercase.text())).toBe("01"); + const image = HttpServerResponse.toWeb( + yield* assetFileResponse({ path: file, mimeType: "image/png" }, "bytes=0-1"), + ); + expect(image.status).toBe(200); + expect(image.headers.has("accept-ranges")).toBe(false); + expect(yield* Effect.promise(() => image.text())).toBe("0123456789"); + }).pipe(Effect.provide(fileResponseLayer)), + ); + + it.effect("rejects ranges outside the file, including empty files", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-video-range-" }); + const file = path.join(directory, "clip.mp4"); + yield* fs.writeFileString(file, "0123456789"); + for (const header of ["bytes=10-", "bytes=-0", "bytes=999999999999999999999999-"]) { + const response = HttpServerResponse.toWeb( + yield* assetFileResponse({ path: file, mimeType: "video/mp4" }, header), + ); + expect(response.status).toBe(416); + expect(response.headers.get("content-range")).toBe("bytes */10"); + expect(yield* Effect.promise(() => response.text())).toBe(""); + } + yield* fs.writeFileString(file, ""); + const empty = HttpServerResponse.toWeb( + yield* assetFileResponse({ path: file, mimeType: "video/mp4" }, "bytes=0-1"), + ); + expect(empty.status).toBe(416); + expect(empty.headers.get("content-range")).toBe("bytes */0"); + }).pipe(Effect.provide(fileResponseLayer)), + ); +}); + describe("http dev routing", () => { it("treats localhost and loopback addresses as local", () => { expect(isLoopbackHostname("127.0.0.1")).toBe(true); diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index 5e3f46711b18..b83461775e91 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -112,6 +112,63 @@ export function assetResponseHeaders( }; } +/** A single byte range for native video readers; unsupported range syntax uses the full file. */ +function assetByteRange(header: string, size: bigint) { + const match = /^bytes=(\d*)-(\d*)$/i.exec(header.trim()); + if (!match || (!match[1] && !match[2])) return null; + const first = match[1] ? BigInt(match[1]) : null; + const last = match[2] ? BigInt(match[2]) : null; + if (first !== null && last !== null && last < first) return null; + if (size === 0n || (first !== null && first >= size) || (first === null && last === 0n)) { + return { _tag: "Unsatisfiable" as const }; + } + const start = first ?? (last! >= size ? 0n : size - last!); + const end = first === null || last === null || last >= size ? size - 1n : last; + return { + _tag: "Range" as const, + offset: start, + bytesToRead: end - start + 1n, + contentRange: `bytes ${start}-${end}/${size}`, + }; +} + +export const assetFileResponse = Effect.fn("assetFileResponse")(function* ( + asset: { + readonly path: string; + readonly download?: boolean; + readonly fileName?: string; + readonly mimeType?: string; + }, + rangeHeader?: string, + ifRangeHeader?: string, +) { + const headers = assetResponseHeaders(asset.path, asset); + if (headers["Content-Type"]?.toLowerCase().startsWith("video/")) { + headers["Accept-Ranges"] = "bytes"; + // If-Range requires a matching validator. A full response is safe when we cannot validate it. + if (rangeHeader && !ifRangeHeader) { + const fs = yield* FileSystem.FileSystem; + const info = yield* fs.stat(asset.path); + const range = assetByteRange(rangeHeader, info.size); + if (range?._tag === "Unsatisfiable") { + return HttpServerResponse.empty({ + status: 416, + headers: { ...headers, "Content-Range": `bytes */${info.size}` }, + }); + } + if (range?._tag === "Range") { + return yield* HttpServerResponse.file(asset.path, { + status: 206, + offset: range.offset, + bytesToRead: range.bytesToRead, + headers: { ...headers, "Content-Range": range.contentRange }, + }); + } + } + } + return yield* HttpServerResponse.file(asset.path, { status: 200, headers }); +}); + export const httpCompressionLayer = HttpRouter.middleware(HttpMiddleware.compression(), { global: true, }); @@ -277,19 +334,11 @@ export const assetRouteLayer = HttpRouter.add( if (!asset) { return HttpServerResponse.text("Not Found", { status: 404 }); } - return yield* HttpServerResponse.file(asset.path, { - status: 200, - headers: assetResponseHeaders( - asset.path, - asset.download || asset.mimeType !== undefined - ? { - ...(asset.download ? { download: true } : {}), - ...(asset.fileName !== undefined ? { fileName: asset.fileName } : {}), - ...(asset.mimeType !== undefined ? { mimeType: asset.mimeType } : {}), - } - : undefined, - ), - }).pipe( + return yield* assetFileResponse( + asset, + request.method === "GET" ? request.headers.range : undefined, + request.headers["if-range"], + ).pipe( Effect.orElseSucceed(() => HttpServerResponse.text("Internal Server Error", { status: 500 })), ); }), diff --git a/apps/server/src/keybindings.test.ts b/apps/server/src/keybindings.test.ts index 391e040f6d59..079e7a14edac 100644 --- a/apps/server/src/keybindings.test.ts +++ b/apps/server/src/keybindings.test.ts @@ -195,6 +195,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => { assert.equal(defaultsByCommand.get("thread.previous"), "mod+shift+["); assert.equal(defaultsByCommand.get("thread.next"), "mod+shift+]"); + assert.equal(defaultsByCommand.get("thread.copyReference"), "mod+shift+c"); assert.equal(defaultsByCommand.get("thread.settle"), "mod+shift+s"); assert.equal(defaultsByCommand.get("thread.pin"), "mod+shift+p"); assert.equal(defaultsByCommand.get("thread.jump.1"), "mod+1"); diff --git a/apps/server/src/orchestration/ActivityPayloadProjection.test.ts b/apps/server/src/orchestration/ActivityPayloadProjection.test.ts index 2cdfef19fd18..18732fa4ea37 100644 --- a/apps/server/src/orchestration/ActivityPayloadProjection.test.ts +++ b/apps/server/src/orchestration/ActivityPayloadProjection.test.ts @@ -64,6 +64,28 @@ describe("projectActivityPayload", () => { expect(JSON.stringify(projected.payload).length).toBeLessThan(500); }); + it("keeps preview normalization and fence-only fallback while scanning lines", () => { + const preview = projectActivityPayload( + activity({ + itemType: "command_execution", + data: { rawOutput: `\`\`\`\n actual\tresult \n${"x".repeat(5000)}` }, + }), + ); + const fences = projectActivityPayload( + activity({ + itemType: "command_execution", + data: { rawOutput: "```\r\n \t \n```\n" }, + }), + ); + + expect((preview.payload as { data: { rawOutput: unknown } }).data.rawOutput).toEqual({ + content: "actual result", + }); + expect((fences.payload as { data: { rawOutput: unknown } }).data.rawOutput).toEqual({ + content: "2 lines", + }); + }); + it("keeps bounded Claude and ACP command output summaries", () => { const claude = projectActivityPayload( activity({ diff --git a/apps/server/src/orchestration/ActivityPayloadProjection.ts b/apps/server/src/orchestration/ActivityPayloadProjection.ts index 32f249c251d5..0b1cb15d3dbc 100644 --- a/apps/server/src/orchestration/ActivityPayloadProjection.ts +++ b/apps/server/src/orchestration/ActivityPayloadProjection.ts @@ -144,22 +144,29 @@ function projectCommandValue(data: Record): unknown { } function summarizeToolTextOutput(value: string): string | null { - const lines: string[] = []; - for (const rawLine of value.split(/\r?\n/u)) { - const line = rawLine.replace(/\s+/g, " ").trim(); + let meaningfulLineCount = 0; + let offset = 0; + + while (offset <= value.length) { + const newlineIndex = value.indexOf("\n", offset); + const lineEnd = newlineIndex === -1 ? value.length : newlineIndex; + const line = value.slice(offset, lineEnd).replace(/\s+/g, " ").trim(); if (line.length > 0) { - lines.push(line); + meaningfulLineCount += 1; + if (line !== "```") { + const summary = line.length <= 84 ? line : `${line.slice(0, 83).trimEnd()}…`; + // V8 can retain the full tool output behind a short sliced string. + // Join a tiny character array so the returned preview owns its bytes. + return Array.from(summary).join(""); + } } + if (newlineIndex === -1) { + break; + } + offset = newlineIndex + 1; } - const firstLine = lines.find((line) => line !== "```"); - if (firstLine) { - return firstLine.length <= 84 ? firstLine : `${firstLine.slice(0, 83).trimEnd()}…`; - } - if (lines.length > 1) { - return `${lines.length.toLocaleString()} lines`; - } - return null; + return meaningfulLineCount > 1 ? `${meaningfulLineCount.toLocaleString()} lines` : null; } /** @@ -488,9 +495,6 @@ function toolLifecycleIdentity(activity: OrchestrationThreadActivity): string | * update within the turn — a later update belongs to a subsequent call that * reuses the same identity and is still in flight. Rows without a lifecycle * identity pass through, matching the clients, which never collapse them. - * Live `thread.activity-appended` events are untouched: updates still stream - * in real time and the completion supersedes them on the client as before. - * * Deliberate divergence from client collapse: clients fold only *adjacent* * lifecycle rows, so a superseded update separated from its completion by an * interleaved parallel call renders as its own row today, and this drop @@ -517,7 +521,7 @@ function dropSupersededToolUpdatedActivities( if (!identity) { continue; } - const key = `${activity.turnId ?? ""}${identity}`; + const key = `${activity.turnId ?? ""}\u0000${identity}`; const indices = completionIndicesByKey.get(key); if (indices) { indices.push(index); @@ -537,7 +541,7 @@ function dropSupersededToolUpdatedActivities( if (!identity) { return true; } - const indices = completionIndicesByKey.get(`${activity.turnId ?? ""}${identity}`); + const indices = completionIndicesByKey.get(`${activity.turnId ?? ""}\u0000${identity}`); return !indices?.some((completionIndex) => completionIndex > index); }); } diff --git a/apps/server/src/orchestration/Errors.ts b/apps/server/src/orchestration/Errors.ts index 7abd567704f1..dc29dcbfa6f8 100644 --- a/apps/server/src/orchestration/Errors.ts +++ b/apps/server/src/orchestration/Errors.ts @@ -1,3 +1,4 @@ +import { ThreadId } from "@t3tools/contracts"; import * as SchemaIssue from "effect/SchemaIssue"; import * as Schema from "effect/Schema"; @@ -40,6 +41,24 @@ export class OrchestrationCommandInvariantError extends Schema.TaggedErrorClass< } } +export class OrchestrationThreadSettleBlockedError extends Schema.TaggedErrorClass()( + "OrchestrationThreadSettleBlockedError", + { + threadId: ThreadId, + }, +) { + override get message(): string { + return "This thread still needs attention. Resolve or interrupt it first, then try again."; + } +} + +export const OrchestrationCommandRejection = Schema.Union([ + OrchestrationCommandInvariantError, + OrchestrationThreadSettleBlockedError, +]); +export type OrchestrationCommandRejection = typeof OrchestrationCommandRejection.Type; +export const isOrchestrationCommandRejection = Schema.is(OrchestrationCommandRejection); + export class OrchestrationCommandPreviouslyRejectedError extends Schema.TaggedErrorClass()( "OrchestrationCommandPreviouslyRejectedError", { @@ -96,7 +115,7 @@ export class OrchestrationListenerCallbackError extends Schema.TaggedErrorClass< export type OrchestrationDispatchError = | ProjectionRepositoryError - | OrchestrationCommandInvariantError + | OrchestrationCommandRejection | OrchestrationCommandIdConflictError | OrchestrationCommandPreviouslyRejectedError | OrchestrationProjectorDecodeError diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.ts index 95adee0cf7f8..dd9d397200e7 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.ts @@ -164,7 +164,7 @@ const make = Effect.gen(function* () { const resolveThreadDetail = Effect.fn("resolveThreadDetail")(function* (threadId: ThreadId) { return yield* projectionSnapshotQuery - .getThreadDetailById(threadId) + .getThreadDetailById(threadId, { activityKinds: [] }) .pipe(Effect.map(Option.getOrUndefined)); }); diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index ba23d56b5e07..3952cf34bddb 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -10,6 +10,7 @@ import { ProviderInstanceId, } from "@t3tools/contracts"; import * as NodeServices from "@effect/platform-node/NodeServices"; +import { it as effectIt } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as ManagedRuntime from "effect/ManagedRuntime"; @@ -17,10 +18,12 @@ import * as Metric from "effect/Metric"; import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; import * as Stream from "effect/Stream"; +import { TestClock } from "effect/testing"; import { describe, expect, it } from "vite-plus/test"; import { PersistenceSqlError } from "../../persistence/Errors.ts"; import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts"; +import * as OrchestrationCommandReceipts from "../../persistence/Services/OrchestrationCommandReceipts.ts"; import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts"; import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; import { @@ -46,27 +49,30 @@ const asMessageId = (value: string): MessageId => MessageId.make(value); const asTurnId = (value: string): TurnId => TurnId.make(value); const asCheckpointRef = (value: string): CheckpointRef => CheckpointRef.make(value); -async function createOrchestrationSystem() { +function makeOrchestrationLayer() { const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { prefix: "t3-orchestration-engine-test-", }); - const orchestrationLayer = Layer.mergeAll( + return Layer.mergeAll( OrchestrationEngineLive.pipe( Layer.provide(OrchestrationProjectionSnapshotQueryLive), Layer.provide(OrchestrationProjectionPipelineLive), ), OrchestrationProjectionSnapshotQueryLive, ).pipe( - Layer.provide(ThreadBackgroundLiveness.layer), + Layer.provideMerge(ThreadBackgroundLiveness.layer), Layer.provide(ThreadPlanProgress.layer), Layer.provide(OrchestrationEventStoreLive), - Layer.provide(OrchestrationCommandReceiptRepositoryLive), + Layer.provideMerge(OrchestrationCommandReceiptRepositoryLive), Layer.provide(RepositoryIdentityResolver.layer), Layer.provide(SqlitePersistenceMemory), Layer.provideMerge(ServerConfigLayer), Layer.provideMerge(NodeServices.layer), ); - const runtime = ManagedRuntime.make(orchestrationLayer); +} + +async function createOrchestrationSystem() { + const runtime = ManagedRuntime.make(makeOrchestrationLayer()); const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService)); const snapshotQuery = await runtime.runPromise(Effect.service(ProjectionSnapshotQuery)); return { @@ -218,6 +224,7 @@ describe("OrchestrationEngine", () => { } satisfies OrchestrationProjectionPipelineShape), ), Layer.provide(Layer.succeed(OrchestrationEventStore, eventStore)), + Layer.provide(ThreadBackgroundLiveness.layer), Layer.provide(OrchestrationCommandReceiptRepositoryLive), Layer.provide(SqlitePersistenceMemory), Layer.provideMerge(NodeServices.layer), @@ -243,6 +250,205 @@ describe("OrchestrationEngine", () => { await runtime.dispose(); }); + effectIt.effect("preserves the blocked-settle error and persists its rejected receipt", () => + Effect.gen(function* () { + const engine = yield* OrchestrationEngineService; + const receipts = yield* OrchestrationCommandReceipts.OrchestrationCommandReceiptRepository; + const projectId = ProjectId.make("project-blocked-settle"); + const threadId = ThreadId.make("thread-blocked-settle"); + const commandId = CommandId.make("cmd-blocked-settle"); + const createdAt = now(); + + yield* engine.dispatch({ + type: "project.create", + commandId: CommandId.make("cmd-blocked-settle-project-create"), + projectId, + title: "Project", + workspaceRoot: "/tmp/project-blocked-settle", + createdAt, + }); + yield* engine.dispatch({ + type: "thread.create", + commandId: CommandId.make("cmd-blocked-settle-thread-create"), + threadId, + projectId, + title: "Thread", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "full-access", + branch: null, + worktreePath: null, + createdAt, + }); + yield* engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-blocked-settle-session-set"), + threadId, + createdAt, + session: { + threadId, + status: "running", + providerName: "codex", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: createdAt, + }, + }); + + const sequence = yield* engine.latestSequence; + const error = yield* engine + .dispatch({ type: "thread.settle", commandId, threadId }) + .pipe(Effect.flip); + const message = + "This thread still needs attention. Resolve or interrupt it first, then try again."; + expect(error).toMatchObject({ + _tag: "OrchestrationThreadSettleBlockedError", + threadId, + message, + }); + expect(Option.getOrNull(yield* receipts.getByCommandId({ commandId }))).toMatchObject({ + commandId, + aggregateKind: "thread", + aggregateId: threadId, + status: "rejected", + error: message, + resultSequence: sequence, + }); + expect(yield* engine.latestSequence).toBe(sequence); + }).pipe(Effect.provide(makeOrchestrationLayer())), + ); + + effectIt.effect( + "rejects persisted changes and live background work without blocking unrelated threads", + () => + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(now())); + const engine = yield* OrchestrationEngineService; + const snapshots = yield* ProjectionSnapshotQuery; + const backgroundLiveness = yield* ThreadBackgroundLiveness.ThreadBackgroundLivenessService; + const projectId = ProjectId.make("project-auto-settle-guard"); + const guardedThreadId = ThreadId.make("thread-auto-settle-guarded"); + const unrelatedThreadId = ThreadId.make("thread-auto-settle-unrelated"); + const liveThreadId = ThreadId.make("thread-auto-settle-live"); + + yield* engine.dispatch({ + type: "project.create", + commandId: CommandId.make("cmd-auto-settle-guard-project"), + projectId, + title: "Project", + workspaceRoot: "/tmp/project-auto-settle-guard", + createdAt: now(), + }); + for (const threadId of [guardedThreadId, unrelatedThreadId, liveThreadId]) { + yield* engine.dispatch({ + type: "thread.create", + commandId: CommandId.make(`cmd-create-${threadId}`), + threadId, + projectId, + title: "Thread", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "full-access", + branch: null, + worktreePath: null, + createdAt: now(), + }); + } + + const beforeUpdate = yield* snapshots.getSnapshot(); + const snapshotSequence = beforeUpdate.snapshotSequence; + const originalUpdatedAt = beforeUpdate.threads.find( + (thread) => thread.id === guardedThreadId, + )?.updatedAt; + yield* engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("cmd-auto-settle-guard-meta"), + threadId: guardedThreadId, + branch: "new-branch", + }); + const afterUpdate = yield* snapshots.getSnapshot(); + expect(afterUpdate.threads.find((thread) => thread.id === guardedThreadId)?.updatedAt).toBe( + originalUpdatedAt, + ); + + const staleError = yield* engine + .dispatch({ + type: "thread.auto-settle", + commandId: CommandId.make("cmd-auto-settle-stale-snapshot"), + threadId: guardedThreadId, + snapshotSequence, + }) + .pipe(Effect.flip); + expect(staleError._tag).toBe("OrchestrationCommandInvariantError"); + + const livenessSnapshotSequence = yield* engine.latestSequence; + for (const [taskType, expectedLiveness] of [ + ["subagent", "working"], + ["local_bash", "monitoring"], + ] as const) { + backgroundLiveness.recordTaskLiveness({ + threadId: liveThreadId, + taskId: `task-${expectedLiveness}`, + taskType, + status: undefined, + kind: "started", + }); + expect(backgroundLiveness.getThreadBackgroundLiveness(liveThreadId)).toBe( + expectedLiveness, + ); + expect(yield* engine.latestSequence).toBe(livenessSnapshotSequence); + + const livenessError = yield* engine + .dispatch({ + type: "thread.auto-settle", + commandId: CommandId.make(`cmd-auto-settle-${expectedLiveness}`), + threadId: liveThreadId, + snapshotSequence: livenessSnapshotSequence, + }) + .pipe(Effect.flip); + expect(livenessError._tag).toBe("OrchestrationCommandInvariantError"); + expect(yield* engine.latestSequence).toBe(livenessSnapshotSequence); + backgroundLiveness.clearThreadLiveness(liveThreadId); + } + + yield* engine.dispatch({ + type: "thread.auto-settle", + commandId: CommandId.make("cmd-auto-settle-after-liveness-cleared"), + threadId: liveThreadId, + snapshotSequence: livenessSnapshotSequence, + }); + + const freshSnapshotSequence = yield* engine.latestSequence; + yield* engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("cmd-auto-settle-unrelated-meta"), + threadId: unrelatedThreadId, + title: "Unrelated update", + }); + yield* engine.dispatch({ + type: "thread.auto-settle", + commandId: CommandId.make("cmd-auto-settle-after-unrelated-update"), + threadId: guardedThreadId, + snapshotSequence: freshSnapshotSequence, + }); + + const settled = yield* snapshots.getSnapshot(); + expect( + settled.threads.find((thread) => thread.id === guardedThreadId)?.settledOverride, + ).toBe("settled"); + expect(settled.threads.find((thread) => thread.id === liveThreadId)?.settledOverride).toBe( + "settled", + ); + }).pipe(Effect.provide(makeOrchestrationLayer())), + ); + it("persists deterministic read models for repeated snapshot reads", async () => { const createdAt = now(); const system = await createOrchestrationSystem(); diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts index 423a44a6ff15..f6a928fdc704 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts @@ -33,6 +33,7 @@ import { toPersistenceSqlError } from "../../persistence/Errors.ts"; import { OrchestrationEventStore } from "../../persistence/Services/OrchestrationEventStore.ts"; import { OrchestrationCommandReceiptRepository } from "../../persistence/Services/OrchestrationCommandReceipts.ts"; import { + isOrchestrationCommandRejection, OrchestrationCommandIdConflictError, OrchestrationCommandInvariantError, OrchestrationCommandPreviouslyRejectedError, @@ -43,6 +44,7 @@ import { decideOrchestrationCommand } from "../decider.ts"; import { createEmptyReadModel, projectEvent } from "../projector.ts"; import { OrchestrationProjectionPipeline } from "../Services/ProjectionPipeline.ts"; import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; +import { ThreadBackgroundLivenessService } from "../ThreadBackgroundLiveness.ts"; import { OrchestrationEngineService, type OrchestrationEngineShape, @@ -51,7 +53,6 @@ const isOrchestrationCommandPreviouslyRejectedError = Schema.is( OrchestrationCommandPreviouslyRejectedError, ); const isOrchestrationCommandIdConflictError = Schema.is(OrchestrationCommandIdConflictError); -const isOrchestrationCommandInvariantError = Schema.is(OrchestrationCommandInvariantError); interface CommandEnvelope { command: OrchestrationCommand; @@ -86,6 +87,7 @@ const makeOrchestrationEngine = Effect.gen(function* () { const commandReceiptRepository = yield* OrchestrationCommandReceiptRepository; const projectionPipeline = yield* OrchestrationProjectionPipeline; const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; + const threadBackgroundLiveness = yield* ThreadBackgroundLivenessService; const crypto = yield* Crypto.Crypto; const nowIso = Effect.map(DateTime.now, DateTime.formatIso); @@ -169,13 +171,37 @@ const makeOrchestrationEngine = Effect.gen(function* () { }); } + if ( + envelope.command.type === "thread.auto-settle" && + (yield* eventStore.hasEventAfter({ + aggregateKind: "thread", + aggregateId: envelope.command.threadId, + sequenceExclusive: envelope.command.snapshotSequence, + })) + ) { + return yield* new OrchestrationCommandInvariantError({ + commandType: envelope.command.type, + detail: `thread ${envelope.command.threadId} changed before automatic settlement`, + }); + } + + if ( + envelope.command.type === "thread.auto-settle" && + threadBackgroundLiveness.getThreadBackgroundLiveness(envelope.command.threadId) !== null + ) { + return yield* new OrchestrationCommandInvariantError({ + commandType: envelope.command.type, + detail: `thread ${envelope.command.threadId} has live background work`, + }); + } + const eventBase = yield* decideOrchestrationCommand({ command: envelope.command, readModel: commandReadModel, }).pipe( Effect.provideService(Crypto.Crypto, crypto), Effect.mapError((cause) => - isOrchestrationCommandInvariantError(cause) + isOrchestrationCommandRejection(cause) ? cause : new OrchestrationCommandInvariantError({ commandType: envelope.command.type, @@ -307,7 +333,7 @@ const makeOrchestrationEngine = Effect.gen(function* () { ), ); - if (isOrchestrationCommandInvariantError(error)) { + if (isOrchestrationCommandRejection(error)) { yield* commandReceiptRepository .upsert({ commandId: envelope.command.commandId, diff --git a/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts b/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts index b05ce3b1e235..1340480bce55 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts @@ -9,6 +9,7 @@ import { CheckpointReactor } from "../Services/CheckpointReactor.ts"; import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts"; import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts"; import { ThreadDeletionReactor } from "../Services/ThreadDeletionReactor.ts"; +import * as ThreadSettlementReactor from "../ThreadSettlementReactor.ts"; import { OrchestrationReactor } from "../Services/OrchestrationReactor.ts"; import { makeOrchestrationReactor } from "./OrchestrationReactor.ts"; import * as AgentAwarenessRelay from "../../relay/AgentAwarenessRelay.ts"; @@ -23,7 +24,7 @@ describe("OrchestrationReactor", () => { runtime = null; }); - it("starts provider ingestion, provider command, checkpoint, and thread deletion reactors", async () => { + it("starts every orchestration reactor", async () => { const started: string[] = []; runtime = ManagedRuntime.make( @@ -64,6 +65,15 @@ describe("OrchestrationReactor", () => { drainThrough: () => Effect.void, }), ), + Layer.provideMerge( + Layer.succeed(ThreadSettlementReactor.ThreadSettlementReactor, { + start: () => { + started.push("thread-settlement-reactor"); + return Effect.void; + }, + drain: Effect.void, + }), + ), Layer.provideMerge( Layer.succeed(AgentAwarenessRelay.AgentAwarenessRelay, { publishThread: () => Effect.void, @@ -85,6 +95,7 @@ describe("OrchestrationReactor", () => { "provider-command-reactor", "checkpoint-reactor", "thread-deletion-reactor", + "thread-settlement-reactor", "agent-awareness-relay", ]); diff --git a/apps/server/src/orchestration/Layers/OrchestrationReactor.ts b/apps/server/src/orchestration/Layers/OrchestrationReactor.ts index fb7543e31af0..649e803809db 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationReactor.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationReactor.ts @@ -9,6 +9,7 @@ import { CheckpointReactor } from "../Services/CheckpointReactor.ts"; import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts"; import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts"; import { ThreadDeletionReactor } from "../Services/ThreadDeletionReactor.ts"; +import * as ThreadSettlementReactor from "../ThreadSettlementReactor.ts"; import * as AgentAwarenessRelay from "../../relay/AgentAwarenessRelay.ts"; export const makeOrchestrationReactor = Effect.gen(function* () { @@ -16,6 +17,7 @@ export const makeOrchestrationReactor = Effect.gen(function* () { const providerCommandReactor = yield* ProviderCommandReactor; const checkpointReactor = yield* CheckpointReactor; const threadDeletionReactor = yield* ThreadDeletionReactor; + const threadSettlementReactor = yield* ThreadSettlementReactor.ThreadSettlementReactor; const agentAwarenessRelay = yield* AgentAwarenessRelay.AgentAwarenessRelay; const start: OrchestrationReactorShape["start"] = Effect.fn("start")(function* () { @@ -23,6 +25,7 @@ export const makeOrchestrationReactor = Effect.gen(function* () { yield* providerCommandReactor.start(); yield* checkpointReactor.start(); yield* threadDeletionReactor.start(); + yield* threadSettlementReactor.start(); yield* agentAwarenessRelay.start(); }); diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index 08719ecd8e8c..481229d22ecf 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -1462,6 +1462,8 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { const eventStore = yield* OrchestrationEventStore; const sql = yield* SqlClient.SqlClient; const now = "2026-01-01T00:00:00.000Z"; + const streamingAt = "2026-01-01T00:00:01.000Z"; + const completedAt = "2026-01-01T00:00:02.000Z"; yield* eventStore.append({ type: "project.created", @@ -1526,7 +1528,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { role: "assistant", text: "hello", turnId: null, - streaming: false, + streaming: true, createdAt: now, updatedAt: now, }, @@ -1539,7 +1541,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { eventId: EventId.make("evt-a4"), aggregateKind: "thread", aggregateId: ThreadId.make("thread-a"), - occurredAt: now, + occurredAt: streamingAt, commandId: CommandId.make("cmd-a4"), causationEventId: null, correlationId: CorrelationId.make("cmd-a4"), @@ -1551,18 +1553,61 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { text: " world", turnId: null, streaming: true, - createdAt: now, - updatedAt: now, + createdAt: streamingAt, + updatedAt: streamingAt, + }, + }); + + yield* projectionPipeline.bootstrap; + yield* projectionPipeline.bootstrap; + + yield* eventStore.append({ + type: "thread.message-sent", + eventId: EventId.make("evt-a5"), + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-a"), + occurredAt: completedAt, + commandId: CommandId.make("cmd-a5"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-a5"), + metadata: {}, + payload: { + threadId: ThreadId.make("thread-a"), + messageId: MessageId.make("message-a"), + role: "assistant", + text: "", + turnId: null, + streaming: false, + createdAt: completedAt, + updatedAt: completedAt, }, }); yield* projectionPipeline.bootstrap; yield* projectionPipeline.bootstrap; - const messageRows = yield* sql<{ readonly text: string }>` - SELECT text FROM projection_thread_messages WHERE message_id = 'message-a' + const messageRows = yield* sql<{ + readonly text: string; + readonly isStreaming: number; + readonly createdAt: string; + readonly updatedAt: string; + }>` + SELECT + text, + is_streaming AS "isStreaming", + created_at AS "createdAt", + updated_at AS "updatedAt" + FROM projection_thread_messages + WHERE message_id = 'message-a' `; - assert.deepEqual(messageRows, [{ text: "hello world" }]); + assert.deepEqual(messageRows, [ + { + text: "hello world", + isStreaming: 0, + createdAt: now, + updatedAt: completedAt, + }, + ]); const stateRows = yield* sql<{ readonly projector: string; @@ -2235,7 +2280,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { }), ); - it.effect("clears stale pending user input from projected shell summaries", () => + it.effect("reads only user-input activities when refreshing shell summaries", () => Effect.gen(function* () { const projectionPipeline = yield* OrchestrationProjectionPipeline; const eventStore = yield* OrchestrationEventStore; @@ -2293,70 +2338,128 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { }, }); + // Invalid JSON proves the summary query filters tool rows before decoding payloads. + yield* sql` + INSERT INTO projection_thread_activities ( + activity_id, + thread_id, + turn_id, + tone, + kind, + summary, + payload_json, + sequence, + created_at + ) + VALUES + ( + 'activity-malformed-tool-output', + 'thread-stale-user-input', + NULL, + 'info', + 'tool.completed', + 'Tool completed', + '{not-json', + NULL, + '2026-02-26T12:35:02.000Z' + ), + ( + 'activity-user-input-resolved-requested', + 'thread-stale-user-input', + NULL, + 'info', + 'user-input.requested', + 'User input requested', + json_object('requestId', 'user-input-resolved'), + NULL, + '2026-02-26T12:35:03.000Z' + ), + ( + 'activity-user-input-resolved', + 'thread-stale-user-input', + NULL, + 'info', + 'user-input.resolved', + 'User input resolved', + json_object('requestId', 'user-input-resolved'), + NULL, + '2026-02-26T12:35:04.000Z' + ), + ( + 'activity-user-input-stale-requested', + 'thread-stale-user-input', + NULL, + 'info', + 'user-input.requested', + 'User input requested', + json_object('requestId', 'user-input-stale'), + NULL, + '2026-02-26T12:35:05.000Z' + ), + ( + 'activity-user-input-stale-failed', + 'thread-stale-user-input', + NULL, + 'error', + 'provider.user-input.respond.failed', + 'Provider user input response failed', + json_object( + 'requestId', + 'user-input-stale', + 'detail', + 'Unknown pending Codex user input request: user-input-stale' + ), + NULL, + '2026-02-26T12:35:06.000Z' + ), + ( + 'activity-user-input-active-requested', + 'thread-stale-user-input', + NULL, + 'info', + 'user-input.requested', + 'User input requested', + json_object('requestId', 'user-input-active'), + NULL, + '2026-02-26T12:35:07.000Z' + ), + ( + 'activity-user-input-active-failed', + 'thread-stale-user-input', + NULL, + 'error', + 'provider.user-input.respond.failed', + 'Provider user input response failed', + json_object( + 'requestId', + 'user-input-active', + 'detail', + 'Provider is temporarily unavailable' + ), + NULL, + '2026-02-26T12:35:08.000Z' + ) + `; + yield* appendAndProject({ - type: "thread.activity-appended", + type: "thread.message-sent", eventId: EventId.make("evt-stale-user-input-3"), aggregateKind: "thread", aggregateId: ThreadId.make("thread-stale-user-input"), - occurredAt: "2026-02-26T12:35:02.000Z", + occurredAt: "2026-02-26T12:35:09.000Z", commandId: CommandId.make("cmd-stale-user-input-3"), causationEventId: null, correlationId: CorrelationId.make("cmd-stale-user-input-3"), metadata: {}, payload: { threadId: ThreadId.make("thread-stale-user-input"), - activity: { - id: EventId.make("activity-stale-user-input-requested"), - tone: "info", - kind: "user-input.requested", - summary: "User input requested", - payload: { - requestId: "user-input-request-stale-1", - questions: [ - { - id: "sandbox_mode", - header: "Sandbox", - question: "Which mode should be used?", - options: [ - { - label: "workspace-write", - description: "Allow workspace writes only", - }, - ], - }, - ], - }, - turnId: null, - createdAt: "2026-02-26T12:35:02.000Z", - }, - }, - }); - - yield* appendAndProject({ - type: "thread.activity-appended", - eventId: EventId.make("evt-stale-user-input-4"), - aggregateKind: "thread", - aggregateId: ThreadId.make("thread-stale-user-input"), - occurredAt: "2026-02-26T12:35:03.000Z", - commandId: CommandId.make("cmd-stale-user-input-4"), - causationEventId: null, - correlationId: CorrelationId.make("cmd-stale-user-input-4"), - metadata: {}, - payload: { - threadId: ThreadId.make("thread-stale-user-input"), - activity: { - id: EventId.make("activity-stale-user-input-failed"), - tone: "error", - kind: "provider.user-input.respond.failed", - summary: "Provider user input response failed", - payload: { - requestId: "user-input-request-stale-1", - detail: - "Provider adapter request failed (codex) for item/tool/requestUserInput: Unknown pending Codex user input request: user-input-request-stale-1", - }, - turnId: null, - createdAt: "2026-02-26T12:35:03.000Z", - }, + messageId: MessageId.make("message-stale-user-input"), + role: "user", + text: "Continue", + turnId: null, + streaming: false, + createdAt: "2026-02-26T12:35:09.000Z", + updatedAt: "2026-02-26T12:35:09.000Z", }, }); @@ -2367,7 +2470,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { FROM projection_threads WHERE thread_id = 'thread-stale-user-input' `; - assert.deepEqual(threadRows, [{ pendingUserInputCount: 0 }]); + assert.deepEqual(threadRows, [{ pendingUserInputCount: 1 }]); }), ); diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index ac514d7eb282..22daeee69365 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -130,7 +130,7 @@ function isStalePendingApprovalFailureDetail(detail: string | null): boolean { ); } -// A full refresh loads all thread history, so skip events that cannot change the summary. +// A refresh reads each persisted summary source, so skip events that cannot change the result. function shouldRefreshThreadShellSummary(event: OrchestrationEvent): boolean { if (event.type === "thread.message-sent") { return event.payload.role === "user"; @@ -588,7 +588,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti const [messages, proposedPlans, activities, pendingApprovals] = yield* Effect.all([ projectionThreadMessageRepository.listByThreadId({ threadId }), projectionThreadProposedPlanRepository.listByThreadId({ threadId }), - projectionThreadActivityRepository.listByThreadId({ threadId }), + projectionThreadActivityRepository.listUserInputLifecycleByThreadId({ threadId }), projectionPendingApprovalRepository.listByThreadId({ threadId }), ]); @@ -1008,21 +1008,34 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti return; case "thread.message-sent": { + if (event.payload.streaming) { + const attachments = + event.payload.attachments !== undefined + ? yield* materializeAttachmentsForProjection({ + attachments: event.payload.attachments, + }) + : undefined; + yield* projectionThreadMessageRepository.appendStreaming({ + messageId: event.payload.messageId, + threadId: event.payload.threadId, + turnId: event.payload.turnId, + role: event.payload.role, + text: event.payload.text, + ...(attachments !== undefined ? { attachments: [...attachments] } : {}), + createdAt: event.payload.createdAt, + updatedAt: event.payload.updatedAt, + }); + return; + } + const existingMessage = yield* projectionThreadMessageRepository.getByMessageId({ messageId: event.payload.messageId, }); const previousMessage = Option.getOrUndefined(existingMessage); const nextText = Option.match(existingMessage, { onNone: () => event.payload.text, - onSome: (message) => { - if (event.payload.streaming) { - return `${message.text}${event.payload.text}`; - } - if (event.payload.text.length === 0) { - return message.text; - } - return event.payload.text; - }, + onSome: (message) => + event.payload.text.length === 0 ? message.text : event.payload.text, }); const nextAttachments = event.payload.attachments !== undefined @@ -1037,7 +1050,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti role: event.payload.role, text: nextText, ...(nextAttachments !== undefined ? { attachments: [...nextAttachments] } : {}), - isStreaming: event.payload.streaming, + isStreaming: false, createdAt: previousMessage?.createdAt ?? event.payload.createdAt, updatedAt: event.payload.updatedAt, }); diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index 728c4075ac89..5ce08dc3ed9e 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -21,6 +21,7 @@ import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts"; import * as ThreadPlanProgress from "../ThreadPlanProgress.ts"; import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; import { encodeThreadDetailPageCursor } from "../threadDetailCursor.ts"; +import { projectThreadDetailSnapshot } from "../ActivityPayloadProjection.ts"; const asProjectId = (value: string): ProjectId => ProjectId.make(value); const asTurnId = (value: string): TurnId => TurnId.make(value); @@ -484,6 +485,77 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { if (threadDetail._tag === "Some") { assert.deepEqual(threadDetail.value, snapshot.threads[0]); } + + yield* sql` + INSERT INTO projection_thread_activities ( + activity_id, + thread_id, + turn_id, + tone, + kind, + summary, + payload_json, + created_at + ) + VALUES + ( + 'activity-task-started', + 'thread-1', + 'turn-1', + 'info', + 'task.started', + 'Ship the query filter', + '{"taskId":"task-1","detail":"Ship the query filter"}', + '2026-02-24T00:00:06.100Z' + ), + ( + 'activity-malformed-tool', + 'thread-1', + 'turn-1', + 'info', + 'tool.completed', + 'Malformed tool output', + 'not-json', + '2026-02-24T00:00:06.200Z' + ) + `; + + const detailWithoutActivities = yield* snapshotQuery.getThreadDetailById( + ThreadId.make("thread-1"), + { activityKinds: [] }, + ); + assert.equal(detailWithoutActivities._tag, "Some"); + if (detailWithoutActivities._tag === "Some") { + assert.deepEqual(detailWithoutActivities.value.activities, []); + assert.deepEqual(detailWithoutActivities.value.messages, snapshot.threads[0]?.messages); + assert.deepEqual( + detailWithoutActivities.value.proposedPlans, + snapshot.threads[0]?.proposedPlans, + ); + assert.deepEqual( + detailWithoutActivities.value.checkpoints, + snapshot.threads[0]?.checkpoints, + ); + } + + const detailWithTaskActivities = yield* snapshotQuery.getThreadDetailById( + ThreadId.make("thread-1"), + { activityKinds: ["task.started", "task.progress"] }, + ); + assert.equal(detailWithTaskActivities._tag, "Some"); + if (detailWithTaskActivities._tag === "Some") { + assert.deepEqual(detailWithTaskActivities.value.activities, [ + { + id: asEventId("activity-task-started"), + tone: "info", + kind: "task.started", + summary: "Ship the query filter", + payload: { taskId: "task-1", detail: "Ship the query filter" }, + turnId: asTurnId("turn-1"), + createdAt: "2026-02-24T00:00:06.100Z", + }, + ]); + } }), ); @@ -2324,9 +2396,84 @@ projectionSnapshotLayer("ProjectionSnapshotQuery windowed thread detail", (it) = 'thread-w', 'turn-5', 'tool', - 'tool.completed', + CASE + WHEN sequence = 2 THEN 'tool.updated' + WHEN sequence IN (3, 70) THEN 'context-window.updated' + ELSE 'tool.completed' + END, 'ran tool', - printf('{"sequence":%d}', sequence), + CASE + WHEN sequence IN (2, 80) THEN json_object( + 'itemType', 'command_execution', + 'toolCallId', 'cross-batch-call', + 'title', CASE WHEN sequence = 80 THEN 'Build completed' ELSE 'Build' END, + 'status', 'completed', + 'data', json_object( + 'toolCallId', 'cross-batch-call', + 'item', json_object( + 'command', 'vp test run', + 'aggregatedOutput', printf( + 'command output%s%s', + char(10), + replace(hex(zeroblob(8192)), '00', 'x') + ) + ), + 'rawOutput', printf( + 'raw output%s%s', + char(10), + replace(hex(zeroblob(8192)), '00', 'y') + ), + 'files', json_array(json_object('path', 'apps/server/src/snapshot.ts')) + ) + ) + WHEN sequence = 10 THEN json_object( + 'itemType', 'mcp_tool_call', + 'status', 'completed', + 'data', json_object( + 'item', json_object( + 'type', 'mcpToolCall', + 'id', 'mcp-item-10', + 'tool', 'fetch_pr', + 'server', 'github', + 'status', 'completed', + 'arguments', json_object('pr', 42), + 'result', json_object( + 'content', json_array(json_object( + 'type', 'text', + 'text', printf( + 'PR body line one%s%s', + char(10), + replace(hex(zeroblob(8192)), '00', 'z') + ) + )) + ), + '_meta', json_object('raw', replace(hex(zeroblob(8192)), '00', 'q')) + ) + ) + ) + WHEN sequence = 11 THEN json_object( + 'itemType', 'command_execution', + 'status', 'completed', + 'data', json_object( + 'item', json_object( + 'status', 'failed', + 'command', 'vp test run', + 'aggregatedOutput', printf( + 'failed command%s%s', + char(10), + replace(hex(zeroblob(8192)), '00', 'w') + ) + ), + 'rawOutput', json_object('stdout', 'failed output'), + 'files', json_array(json_object('path', 'apps/server/src/failed.ts')) + ) + ) + WHEN sequence IN (3, 70) THEN json_object( + 'usedTokens', sequence * 100, + 'modelContextWindow', 100000 + ) + ELSE json_object('sequence', sequence) + END, sequence, '2026-03-01T00:04:00.000Z' FROM activity_rows @@ -2404,12 +2551,14 @@ projectionSnapshotLayer("ProjectionSnapshotQuery windowed thread detail", (it) = const detailWithPinnedRequests = yield* snapshotQuery.getThreadDetailById(threadW); assert.equal(detailWithPinnedRequests._tag, "Some"); if (detailWithPinnedRequests._tag === "Some") { - const ids = detailWithPinnedRequests.value.activities.map((activity) => activity.id); + const ids = new Set( + detailWithPinnedRequests.value.activities.map((activity) => activity.id), + ); assert.equal(detailWithPinnedRequests.value.activities.length, 503); - assert.equal(ids.includes(asEventId("approval-old")), true); - assert.equal(ids.includes(asEventId("user-input-old")), true); - assert.equal(ids.includes(asEventId("user-input-closed")), false); - assert.equal(ids.includes(asEventId("user-input-tied-z-request")), true); + assert.equal(ids.has(asEventId("approval-old")), true); + assert.equal(ids.has(asEventId("user-input-old")), true); + assert.equal(ids.has(asEventId("user-input-closed")), false); + assert.equal(ids.has(asEventId("user-input-tied-z-request")), true); } const windowWithPinnedRequests = yield* snapshotQuery.getThreadDetailSnapshot(threadW, { @@ -2417,12 +2566,67 @@ projectionSnapshotLayer("ProjectionSnapshotQuery windowed thread detail", (it) = }); assert.equal(windowWithPinnedRequests._tag, "Some"); if (windowWithPinnedRequests._tag === "Some") { - const ids = windowWithPinnedRequests.value.thread.activities.map((activity) => activity.id); + const ids = new Set( + windowWithPinnedRequests.value.thread.activities.map((activity) => activity.id), + ); assert.equal(windowWithPinnedRequests.value.thread.activities.length, 503); - assert.equal(ids.includes(asEventId("approval-old")), true); - assert.equal(ids.includes(asEventId("user-input-old")), true); - assert.equal(ids.includes(asEventId("user-input-closed")), false); - assert.equal(ids.includes(asEventId("user-input-tied-z-request")), true); + assert.equal(ids.has(asEventId("approval-old")), true); + assert.equal(ids.has(asEventId("user-input-old")), true); + assert.equal(ids.has(asEventId("user-input-closed")), false); + assert.equal(ids.has(asEventId("user-input-tied-z-request")), true); + } + + const fullSnapshot = yield* snapshotQuery.getThreadDetailSnapshot(threadW); + assert.equal(fullSnapshot._tag, "Some"); + if ( + detailWithPinnedRequests._tag === "Some" && + fullSnapshot._tag === "Some" && + windowWithPinnedRequests._tag === "Some" + ) { + const projectedFullSnapshot = projectThreadDetailSnapshot(fullSnapshot.value); + const projectedRawBaseline = projectThreadDetailSnapshot({ + snapshotSequence: fullSnapshot.value.snapshotSequence, + thread: detailWithPinnedRequests.value, + }); + assert.deepStrictEqual(projectedFullSnapshot, projectedRawBaseline); + + const rawActivitiesById = new Map( + detailWithPinnedRequests.value.activities.map((activity) => [activity.id, activity]), + ); + const projectedWindowSnapshot = projectThreadDetailSnapshot(windowWithPinnedRequests.value); + const projectedWindowBaseline = projectThreadDetailSnapshot({ + ...windowWithPinnedRequests.value, + thread: { + ...windowWithPinnedRequests.value.thread, + activities: windowWithPinnedRequests.value.thread.activities.map( + (activity) => rawActivitiesById.get(activity.id) ?? activity, + ), + }, + }); + assert.deepStrictEqual(projectedWindowSnapshot, projectedWindowBaseline); + + const projectedIds = new Set( + projectedFullSnapshot.thread.activities.map((activity) => activity.id), + ); + assert.equal(projectedIds.has(asEventId("activity-0002")), false); + assert.equal(projectedIds.has(asEventId("activity-0003")), false); + assert.equal(projectedIds.has(asEventId("activity-0070")), true); + + const failedCommand = projectedFullSnapshot.thread.activities.find( + (activity) => activity.id === asEventId("activity-0011"), + ); + assert.deepStrictEqual(failedCommand?.payload, { + itemType: "command_execution", + status: "failed", + data: { + item: { + command: "vp test run", + aggregatedOutput: "failed command", + }, + files: [{ path: "apps/server/src/failed.ts" }], + rawOutput: { content: "failed output" }, + }, + }); } }), ); diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index b777233ed12f..770c5a3a8a25 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -57,6 +57,7 @@ import { decodeThreadDetailPageCursor, encodeThreadDetailPageCursor, } from "../threadDetailCursor.ts"; +import { projectActivityPayload } from "../ActivityPayloadProjection.ts"; import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityResolver.ts"; import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts"; import { @@ -64,6 +65,7 @@ import { type ProjectionFullThreadDiffContext, type ProjectionSnapshotCounts, type ProjectionThreadCheckpointContext, + type ProjectionThreadDetailQuery, type ProjectionSnapshotQueryShape, } from "../Services/ProjectionSnapshotQuery.ts"; @@ -74,6 +76,9 @@ const decodeThread = Schema.decodeUnknownEffect(OrchestrationThread); // activity window. Applying the limit in SQL avoids decoding an unbounded // payload_json set before the projector can enforce that invariant. const THREAD_DETAIL_ACTIVITY_LIMIT = 500; +// Snapshot payloads are decoded and projected in small sequential batches so +// one client read does not retain the raw payloads for the full activity window. +const THREAD_DETAIL_ACTIVITY_PAYLOAD_BATCH_SIZE = 25; const ProjectionProjectDbRowSchema = ProjectionProject.mapFields( Struct.assign({ defaultModelSelection: Schema.NullOr(Schema.fromJsonString(ModelSelection)), @@ -99,6 +104,9 @@ const ProjectionThreadActivityDbRowSchema = ProjectionThreadActivity.mapFields( sequence: Schema.NullOr(NonNegativeInt), }), ); +const ProjectionThreadActivityIdRowSchema = Schema.Struct({ + activityId: ProjectionThreadActivity.fields.activityId, +}); const ProjectionThreadSessionDbRowSchema = ProjectionThreadSession; const ProjectionCheckpointDbRowSchema = ProjectionCheckpoint.mapFields( Struct.assign({ @@ -141,6 +149,13 @@ const ProjectIdLookupInput = Schema.Struct({ const ThreadIdLookupInput = Schema.Struct({ threadId: ThreadId, }); +const ThreadActivityKindsLookupInput = Schema.Struct({ + threadId: ThreadId, + activityKinds: Schema.Array(Schema.String), +}); +const ThreadActivityIdsLookupInput = Schema.Struct({ + activityIds: Schema.Array(ProjectionThreadActivity.fields.activityId), +}); // Windowed reads order turns by the stable keyset (anchor, turn key), where // anchor is requested_at and turn key is // COALESCE(turn_id, ''). Both are event-derived, so cursors survive the @@ -344,6 +359,61 @@ function mapProposedPlanRow( }; } +function mapThreadActivityRow( + row: Schema.Schema.Type, +): OrchestrationThreadActivity { + return { + id: row.activityId, + tone: row.tone, + kind: row.kind, + summary: row.summary, + payload: row.payload, + turnId: row.turnId, + createdAt: row.createdAt, + ...(row.sequence !== null ? { sequence: row.sequence } : {}), + }; +} + +// Canonical activity order — mirror threadActivityOrder.ts and the SQL ORDER BY +// clauses above: null sequences sink, then createdAt, the lifecycle rank, and +// byte-order ids (not the ICU collator). Ids are passed alongside the row so +// both the decoded activities and the raw DB rows can share one comparator. +function activityLifecycleRank(kind: string): number { + return kind.endsWith(".started") + ? 0 + : kind.endsWith(".completed") || kind.endsWith(".resolved") + ? 2 + : 1; +} + +interface CanonicalActivityOrderRow { + readonly kind: string; + readonly createdAt: string; + readonly sequence?: number | null | undefined; +} + +function compareCanonicalActivityOrder( + left: CanonicalActivityOrderRow, + leftId: string, + right: CanonicalActivityOrderRow, + rightId: string, +): number { + const leftSequence = left.sequence ?? Number.MAX_SAFE_INTEGER; + const rightSequence = right.sequence ?? Number.MAX_SAFE_INTEGER; + if (leftSequence !== rightSequence) { + return leftSequence < rightSequence ? -1 : 1; + } + if (left.createdAt !== right.createdAt) { + return left.createdAt < right.createdAt ? -1 : 1; + } + const leftRank = activityLifecycleRank(left.kind); + const rightRank = activityLifecycleRank(right.kind); + if (leftRank !== rightRank) { + return leftRank - rightRank; + } + return leftId < rightId ? -1 : leftId > rightId ? 1 : 0; +} + function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) { return (cause: unknown): ProjectionRepositoryError => Schema.isSchemaError(cause) @@ -1076,6 +1146,86 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { `, }); + const listThreadActivityIdsByThread = SqlSchema.findAll({ + Request: ThreadIdLookupInput, + Result: ProjectionThreadActivityIdRowSchema, + execute: ({ threadId }) => + sql` + SELECT activity_id AS "activityId" + FROM projection_thread_activities + WHERE thread_id = ${threadId} + ORDER BY + sequence DESC, + created_at DESC, + activity_id DESC + LIMIT ${THREAD_DETAIL_ACTIVITY_LIMIT} + `, + }); + + const listThreadActivityRowsByIds = SqlSchema.findAll({ + Request: ThreadActivityIdsLookupInput, + Result: ProjectionThreadActivityDbRowSchema, + execute: ({ activityIds }) => + sql` + SELECT + activity_id AS "activityId", + thread_id AS "threadId", + turn_id AS "turnId", + tone, + kind, + summary, + payload_json AS "payload", + sequence, + created_at AS "createdAt" + FROM projection_thread_activities + -- The selectors already scoped these globally unique ids to the + -- thread inside this transaction. Keep this as a primary-key lookup. + WHERE ${sql.in("activity_id", activityIds)} + `, + }); + + const listThreadActivityRowsByThreadAndKinds = SqlSchema.findAll({ + Request: ThreadActivityKindsLookupInput, + Result: ProjectionThreadActivityDbRowSchema, + execute: ({ threadId, activityKinds }) => + sql` + SELECT + activity_id AS "activityId", + thread_id AS "threadId", + turn_id AS "turnId", + tone, + kind, + summary, + payload_json AS "payload", + sequence, + created_at AS "createdAt" + FROM ( + SELECT + activity_id, + thread_id, + turn_id, + tone, + kind, + summary, + payload_json, + sequence, + created_at + FROM projection_thread_activities + WHERE thread_id = ${threadId} + AND ${sql.in("kind", activityKinds)} + ORDER BY + sequence DESC, + created_at DESC, + activity_id DESC + LIMIT ${THREAD_DETAIL_ACTIVITY_LIMIT} + ) AS recent_activities + ORDER BY + sequence ASC, + created_at ASC, + activity_id ASC + `, + }); + const getThreadSessionRowByThread = SqlSchema.findOneOption({ Request: ThreadIdLookupInput, Result: ProjectionThreadSessionDbRowSchema, @@ -1284,15 +1434,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { `, }); - // Blocking request payloads must remain available even if they predate the - // recent activity window. Each CTE returns at most one unresolved row per - // request, so the merge below stays bounded by actionable work. - const listPinnedThreadActivityRowsByThread = SqlSchema.findAll({ - Request: ThreadIdLookupInput, - Result: ProjectionThreadActivityDbRowSchema, - execute: ({ threadId }) => - sql` - WITH pending_approval_requests AS ( + const pinnedThreadActivityIdsCte = (threadId: string) => sql` +pending_approval_requests AS ( SELECT request_id, thread_id FROM projection_pending_approvals WHERE thread_id = ${threadId} @@ -1356,6 +1499,17 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { WHERE request_order = 1 AND kind = 'user-input.requested' ) + `; + + // Blocking request payloads must remain available even if they predate the + // recent activity window. Each CTE returns at most one unresolved row per + // request, so the merge below stays bounded by actionable work. + const listPinnedThreadActivityRowsByThread = SqlSchema.findAll({ + Request: ThreadIdLookupInput, + Result: ProjectionThreadActivityDbRowSchema, + execute: ({ threadId }) => + sql` + WITH ${pinnedThreadActivityIdsCte(threadId)} SELECT activity.activity_id AS "activityId", activity.thread_id AS "threadId", @@ -1373,6 +1527,17 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { `, }); + const listPinnedThreadActivityIdsByThread = SqlSchema.findAll({ + Request: ThreadIdLookupInput, + Result: ProjectionThreadActivityIdRowSchema, + execute: ({ threadId }) => + sql` + WITH ${pinnedThreadActivityIdsCte(threadId)} + SELECT activity_id AS "activityId" + FROM pinned_activity_ids + `, + }); + const listThreadActivityRowsByThreadWindow = SqlSchema.findAll({ Request: ThreadTurnRangeLookupInput, Result: ProjectionThreadActivityDbRowSchema, @@ -1446,6 +1611,48 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { `, }); + const listThreadActivityIdsByThreadWindow = SqlSchema.findAll({ + Request: ThreadTurnRangeLookupInput, + Result: ProjectionThreadActivityIdRowSchema, + execute: ({ threadId, minAnchorAt, minTurnKey, beforeAnchorAt, beforeTurnKey }) => + sql` + SELECT activity_id AS "activityId" + FROM projection_thread_activities + WHERE thread_id = ${threadId} + AND ( + turn_id IN ( + SELECT turn_id FROM projection_turns + WHERE thread_id = ${threadId} + AND turn_id IS NOT NULL + AND ( + requested_at > ${minAnchorAt} + OR ( + requested_at = ${minAnchorAt} + AND turn_id >= ${minTurnKey} + ) + ) + AND ( + requested_at < ${beforeAnchorAt} + OR ( + requested_at = ${beforeAnchorAt} + AND turn_id < ${beforeTurnKey} + ) + ) + ) + OR ( + turn_id IS NULL + AND created_at >= ${minAnchorAt} + AND created_at < ${beforeAnchorAt} + ) + ) + ORDER BY + sequence DESC, + created_at DESC, + activity_id DESC + LIMIT ${THREAD_DETAIL_ACTIVITY_LIMIT} + `, + }); + const getFullThreadDiffContextRow = SqlSchema.findOneOption({ Request: FullThreadDiffContextLookupInput, Result: ProjectionFullThreadDiffContextRowSchema, @@ -2551,14 +2758,130 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { readonly beforeTurnKey: string; } - const getThreadDetailByIdBounded = (threadId: ThreadId, bounds: ThreadDetailBounds | undefined) => + type ThreadDetailActivityRead = + | { + readonly mode: "raw"; + readonly query?: ProjectionThreadDetailQuery; + } + | { + readonly mode: "client"; + }; + + const listProjectedThreadActivities = Effect.fn( + "ProjectionSnapshotQuery.listProjectedThreadActivities", + )(function* (threadId: ThreadId, bounds: ThreadDetailBounds | undefined) { + const [activityIdRows, pinnedActivityIdRows] = yield* Effect.all([ + (bounds === undefined + ? listThreadActivityIdsByThread({ threadId }) + : listThreadActivityIdsByThreadWindow({ threadId, ...bounds }) + ).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getThreadDetailById:listActivityIds:query", + "ProjectionSnapshotQuery.getThreadDetailById:listActivityIds:decodeRows", + ), + ), + ), + listPinnedThreadActivityIdsByThread({ threadId }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getThreadDetailById:listPinnedActivityIds:query", + "ProjectionSnapshotQuery.getThreadDetailById:listPinnedActivityIds:decodeRows", + ), + ), + ), + ]); + const activityIds = [ + ...new Set([...activityIdRows, ...pinnedActivityIdRows].map(({ activityId }) => activityId)), + ]; + const activities: OrchestrationThreadActivity[] = []; + + for ( + let offset = 0; + offset < activityIds.length; + offset += THREAD_DETAIL_ACTIVITY_PAYLOAD_BATCH_SIZE + ) { + const batchIds = activityIds.slice( + offset, + offset + THREAD_DETAIL_ACTIVITY_PAYLOAD_BATCH_SIZE, + ); + const batchRows = yield* listThreadActivityRowsByIds({ activityIds: batchIds }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getThreadDetailById:listActivityPayloadBatch:query", + "ProjectionSnapshotQuery.getThreadDetailById:listActivityPayloadBatch:decodeRows", + ), + ), + ); + for (const row of batchRows) { + activities.push(projectActivityPayload(mapThreadActivityRow(row))); + } + } + + return activities.toSorted((left, right) => + compareCanonicalActivityOrder(left, left.id, right, right.id), + ); + }); + + const getThreadDetailByIdBounded = ( + threadId: ThreadId, + bounds: ThreadDetailBounds | undefined, + activityRead: ThreadDetailActivityRead = { mode: "raw" }, + ) => Effect.gen(function* () { + const activitiesEffect = + activityRead.mode === "client" + ? listProjectedThreadActivities(threadId, bounds) + : Effect.all([ + (activityRead.query?.activityKinds === undefined + ? bounds === undefined + ? listThreadActivityRowsByThread({ threadId }) + : listThreadActivityRowsByThreadWindow({ threadId, ...bounds }) + : activityRead.query.activityKinds.length === 0 + ? Effect.succeed([]) + : listThreadActivityRowsByThreadAndKinds({ + threadId, + activityKinds: activityRead.query.activityKinds, + }) + ).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getThreadDetailById:listActivities:query", + "ProjectionSnapshotQuery.getThreadDetailById:listActivities:decodeRows", + ), + ), + ), + activityRead.query?.activityKinds === undefined + ? listPinnedThreadActivityRowsByThread({ threadId }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getThreadDetailById:listPinnedActivities:query", + "ProjectionSnapshotQuery.getThreadDetailById:listPinnedActivities:decodeRows", + ), + ), + ) + : Effect.succeed([]), + ]).pipe( + Effect.map(([activityRows, pinnedActivityRows]) => + [ + ...new Map( + [...activityRows, ...pinnedActivityRows].map( + (row) => [row.activityId, row] as const, + ), + ).values(), + ] + .toSorted((left, right) => + compareCanonicalActivityOrder(left, left.activityId, right, right.activityId), + ) + .map(mapThreadActivityRow), + ), + ); + const [ threadRow, messageRows, proposedPlanRows, - activityRows, - pinnedActivityRows, + activities, checkpointRows, latestTurnRow, sessionRow, @@ -2590,25 +2913,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ), ), ), - (bounds === undefined - ? listThreadActivityRowsByThread({ threadId }) - : listThreadActivityRowsByThreadWindow({ threadId, ...bounds }) - ).pipe( - Effect.mapError( - toPersistenceSqlOrDecodeError( - "ProjectionSnapshotQuery.getThreadDetailById:listActivities:query", - "ProjectionSnapshotQuery.getThreadDetailById:listActivities:decodeRows", - ), - ), - ), - listPinnedThreadActivityRowsByThread({ threadId }).pipe( - Effect.mapError( - toPersistenceSqlOrDecodeError( - "ProjectionSnapshotQuery.getThreadDetailById:listPinnedActivities:query", - "ProjectionSnapshotQuery.getThreadDetailById:listPinnedActivities:decodeRows", - ), - ), - ), + activitiesEffect, listCheckpointRowsByThread({ threadId }).pipe( Effect.mapError( toPersistenceSqlOrDecodeError( @@ -2639,36 +2944,6 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { return Option.none(); } - const selectedActivityRows = [ - ...new Map( - [...activityRows, ...pinnedActivityRows].map((row) => [row.activityId, row] as const), - ).values(), - ].toSorted((left, right) => { - // Canonical activity order — mirror threadActivityOrder.ts and the - // SQL ORDER BY above: null sequences sink, then createdAt, the - // lifecycle rank, and byte-order ids (not the ICU collator). - const leftSequence = left.sequence ?? Number.MAX_SAFE_INTEGER; - const rightSequence = right.sequence ?? Number.MAX_SAFE_INTEGER; - if (leftSequence !== rightSequence) { - return leftSequence < rightSequence ? -1 : 1; - } - if (left.createdAt !== right.createdAt) { - return left.createdAt < right.createdAt ? -1 : 1; - } - const lifecycleRank = (kind: string) => - kind.endsWith(".started") - ? 0 - : kind.endsWith(".completed") || kind.endsWith(".resolved") - ? 2 - : 1; - const leftRank = lifecycleRank(left.kind); - const rightRank = lifecycleRank(right.kind); - if (leftRank !== rightRank) { - return leftRank - rightRank; - } - return left.activityId < right.activityId ? -1 : left.activityId > right.activityId ? 1 : 0; - }); - const thread = { id: threadRow.value.threadId, projectId: threadRow.value.projectId, @@ -2710,21 +2985,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { return message; }), proposedPlans: proposedPlanRows.map(mapProposedPlanRow), - activities: selectedActivityRows.map((row) => { - const activity = { - id: row.activityId, - tone: row.tone, - kind: row.kind, - summary: row.summary, - payload: row.payload, - turnId: row.turnId, - createdAt: row.createdAt, - }; - if (row.sequence !== null) { - return Object.assign(activity, { sequence: row.sequence }); - } - return activity; - }), + activities, checkpoints: checkpointRows.map((row) => ({ turnId: row.turnId, checkpointTurnCount: row.checkpointTurnCount, @@ -2746,8 +3007,14 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ); }); - const getThreadDetailById: ProjectionSnapshotQueryShape["getThreadDetailById"] = (threadId) => - getThreadDetailByIdBounded(threadId, undefined); + const getThreadDetailById: ProjectionSnapshotQueryShape["getThreadDetailById"] = ( + threadId, + query, + ) => + getThreadDetailByIdBounded(threadId, undefined, { + mode: "raw", + ...(query === undefined ? {} : { query }), + }); // Bounds pathological fan-out: one user turn that spawned hundreds of // subagent turns still pages in bounded chunks, at the cost of splitting the @@ -2771,7 +3038,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { .withTransaction( Effect.gen(function* () { if (window?.turnLimit === undefined) { - const thread = yield* getThreadDetailById(threadId); + const thread = yield* getThreadDetailByIdBounded(threadId, undefined, { + mode: "client", + }); if (Option.isNone(thread)) { return Option.none(); } @@ -2824,7 +3093,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ? { minAnchorAt: "", minTurnKey: "", beforeAnchorAt: "", beforeTurnKey: "" } : undefined; - const thread = yield* getThreadDetailByIdBounded(threadId, emptyBounds ?? bounds); + const thread = yield* getThreadDetailByIdBounded(threadId, emptyBounds ?? bounds, { + mode: "client", + }); if (Option.isNone(thread)) { return Option.none(); } diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 4ad35e54cca8..ff246128c44c 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -3229,4 +3229,49 @@ describe("ProviderCommandReactor", () => { expect(thread?.session?.providerInstanceId).toBe(ProviderInstanceId.make("codex_work")); expect(thread?.session?.activeTurnId).toBeNull(); }); + + effectIt.effect("stops a ready provider session after automatic settlement", () => + Effect.gen(function* () { + const sessionStopped = yield* Deferred.make(); + const harness = yield* Effect.promise(() => + createHarness({ + stopSessionEffect: () => Deferred.succeed(sessionStopped, undefined).pipe(Effect.asVoid), + }), + ); + const now = "2026-01-01T00:00:00.000Z"; + + yield* harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-session-set-for-auto-settle"), + threadId: ThreadId.make("thread-1"), + session: { + threadId: ThreadId.make("thread-1"), + status: "ready", + providerName: "codex", + providerInstanceId: ProviderInstanceId.make("codex_work"), + runtimeMode: "approval-required", + activeTurnId: null, + lastError: null, + updatedAt: now, + }, + createdAt: now, + }); + const beforeSettlement = yield* Effect.promise(() => harness.readModel()); + + yield* harness.engine.dispatch({ + type: "thread.auto-settle", + commandId: CommandId.make("cmd-auto-settle-with-session"), + threadId: ThreadId.make("thread-1"), + snapshotSequence: beforeSettlement.snapshotSequence, + }); + + yield* Deferred.await(sessionStopped); + yield* Effect.promise(() => harness.drain()); + const readModel = yield* Effect.promise(() => harness.readModel()); + const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); + expect(thread?.settledOverride).toBe("settled"); + expect(thread?.session?.status).toBe("stopped"); + expect(thread?.session?.providerInstanceId).toBe(ProviderInstanceId.make("codex_work")); + }), + ); }); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 22c70094ce0e..84d472089d8d 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -61,7 +61,8 @@ type ProviderIntentEvent = Extract< | "thread.turn-interrupt-requested" | "thread.approval-response-requested" | "thread.user-input-response-requested" - | "thread.session-stop-requested"; + | "thread.session-stop-requested" + | "thread.settled"; } >; @@ -481,7 +482,7 @@ const make = Effect.gen(function* () { const resolveThread = Effect.fnUntraced(function* (threadId: ThreadId) { return yield* projectionSnapshotQuery - .getThreadDetailById(threadId) + .getThreadDetailById(threadId, { activityKinds: [] }) .pipe(Effect.map(Option.getOrUndefined)); }); @@ -1490,6 +1491,24 @@ const make = Effect.gen(function* () { case "thread.session-stop-requested": yield* processSessionStopRequested(event); return; + case "thread.settled": { + const thread = yield* projectionSnapshotQuery.getThreadShellById(event.payload.threadId); + if ( + Option.isNone(thread) || + thread.value.session == null || + thread.value.session.status === "stopped" + ) { + return; + } + yield* orchestrationEngine.dispatch({ + type: "thread.session.stop", + commandId: CommandId.make(`session-stop-for-settle:${event.commandId ?? event.eventId}`), + threadId: event.payload.threadId, + createdAt: event.occurredAt, + onlyIfSettled: true, + }); + return; + } } }); @@ -1528,7 +1547,8 @@ const make = Effect.gen(function* () { event.type === "thread.turn-interrupt-requested" || event.type === "thread.approval-response-requested" || event.type === "thread.user-input-response-requested" || - event.type === "thread.session-stop-requested" + event.type === "thread.session-stop-requested" || + event.type === "thread.settled" ) { return yield* worker.enqueue(event); } diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 84858b6affe9..26332f9f8c9c 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -973,6 +973,29 @@ describe("ProviderRuntimeIngestion", () => { ); }); + it("ignores provider content deltas that cannot change thread state", async () => { + const harness = await createHarness(); + const initial = await harness.readModel(); + + for (const streamKind of ["reasoning_text", "command_output", "file_change_output"] as const) { + harness.emit({ + type: "content.delta", + eventId: asEventId(`evt-ignored-${streamKind}`), + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:00.000Z", + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-ignored"), + payload: { + streamKind, + delta: "ignored output", + }, + }); + } + + await harness.drain(); + expect(await harness.readModel()).toEqual(initial); + }); + it("maps canonical content delta/item completed into finalized assistant messages", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 7ec3a7e64243..a90010f0b6e2 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -48,6 +48,7 @@ import { canReplaceThreadTitle } from "../threadTitles.ts"; const providerTurnKey = (threadId: ThreadId, turnId: TurnId) => `${threadId}:${turnId}`; const providerTaskKey = (threadId: ThreadId, taskId: string) => `${threadId}:${taskId}`; +const TASK_TITLE_ACTIVITY_KINDS = ["task.started", "task.progress"] as const; // Fallback when the in-memory description cache no longer has the task name // (server restart, session-exit sweep, TTL/capacity eviction): earlier @@ -949,9 +950,12 @@ const make = Effect.gen(function* () { ), ); - const resolveThreadDetail = Effect.fn("resolveThreadDetail")(function* (threadId: ThreadId) { + const resolveThreadDetail = Effect.fn("resolveThreadDetail")(function* ( + threadId: ThreadId, + activityKinds: ReadonlyArray = [], + ) { return yield* projectionSnapshotQuery - .getThreadDetailById(threadId) + .getThreadDetailById(threadId, { activityKinds }) .pipe(Effect.map(Option.getOrUndefined)); }); @@ -1495,6 +1499,10 @@ const make = Effect.gen(function* () { const processRuntimeEvent = (event: ProviderRuntimeEvent) => Effect.gen(function* () { + if (event.type === "content.delta" && event.payload.streamKind !== "assistant_text") { + return; + } + const thread = yield* resolveThreadShell(event.threadId); if (!thread) return; @@ -1511,9 +1519,17 @@ const make = Effect.gen(function* () { const now = event.createdAt; const eventTurnId = toTurnId(event.turnId); const activeTurnId = thread.session?.activeTurnId ?? null; - const pendingTurnStart = yield* projectionTurnRepository.getPendingTurnStartByThreadId({ - threadId: thread.id, - }); + const pendingTurnStart = + event.type === "session.started" || + event.type === "session.state.changed" || + event.type === "session.exited" || + event.type === "thread.started" || + event.type === "turn.started" || + event.type === "turn.completed" + ? yield* projectionTurnRepository.getPendingTurnStartByThreadId({ + threadId: thread.id, + }) + : Option.none(); const hasPendingTurnStart = Option.isSome(pendingTurnStart) && thread.session?.status === "starting"; @@ -2022,7 +2038,7 @@ const make = Effect.gen(function* () { if (event.type === "task.completed") { taskTitle = yield* lookupTaskDescription(thread.id, event.payload.taskId); if (!taskTitle) { - const threadDetail = yield* getLoadedThreadDetail(); + const threadDetail = yield* resolveThreadDetail(thread.id, TASK_TITLE_ACTIVITY_KINDS); taskTitle = findTaskTitleInActivities(threadDetail?.activities, event.payload.taskId); } } diff --git a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts index 0a00253a2285..9428e84747cd 100644 --- a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts @@ -54,6 +54,15 @@ export interface ProjectionFullThreadDiffContext { readonly toCheckpointRef: CheckpointRef | null; } +export interface ProjectionThreadDetailQuery { + /** + * Limit activities before SQLite returns and decodes their payloads. + * Any explicit filter omits pinned-request reads. An empty list also skips + * the activity query. Omit this option to preserve the full detail response. + */ + readonly activityKinds?: ReadonlyArray; +} + /** * ProjectionSnapshotQueryShape - Service API for read-model snapshots. */ @@ -168,6 +177,7 @@ export interface ProjectionSnapshotQueryShape { */ readonly getThreadDetailById: ( threadId: ThreadId, + query?: ProjectionThreadDetailQuery, ) => Effect.Effect, ProjectionRepositoryError>; /** @@ -181,6 +191,10 @@ export interface ProjectionSnapshotQueryShape { * response carries `page` metadata (see `OrchestrationThreadDetailWindow`). * Without a window the full thread is returned with no `page` field — * pagination is strictly opt-in. + * + * Activity payloads are projected for clients as they are read in small + * sequential batches. Callers still apply the full snapshot projector for + * collection-level activity pruning. */ readonly getThreadDetailSnapshot: ( threadId: ThreadId, diff --git a/apps/server/src/orchestration/ThreadLiveEventCoalescer.test.ts b/apps/server/src/orchestration/ThreadLiveEventCoalescer.test.ts new file mode 100644 index 000000000000..0a9915294d03 --- /dev/null +++ b/apps/server/src/orchestration/ThreadLiveEventCoalescer.test.ts @@ -0,0 +1,171 @@ +import { + EventId, + MessageId, + ThreadId, + TurnId, + type OrchestrationEvent, + type OrchestrationThreadActivity, +} from "@t3tools/contracts"; +import { it } from "@effect/vitest"; +import * as Clock from "effect/Clock"; +import * as Effect from "effect/Effect"; +import * as TestClock from "effect/testing/TestClock"; +import { describe, expect } from "vite-plus/test"; + +import { + coalesceLiveToolUpdatedEvents, + makeThreadLiveEventCoalescer, +} from "./ThreadLiveEventCoalescer.ts"; + +const threadId = ThreadId.make("thread-coalescer-test"); +const turnId = TurnId.make("turn-coalescer-test"); + +function makeToolActivity( + sequence: number, + options: { + readonly kind?: "tool.updated" | "tool.completed"; + readonly toolCallId?: string; + readonly turnId?: TurnId; + } = {}, +): OrchestrationEvent { + const { + kind = "tool.updated", + toolCallId = "call-edit", + turnId: activityTurnId = turnId, + } = options; + const activity: OrchestrationThreadActivity = { + id: EventId.make(`activity-${sequence}`), + tone: "tool", + kind, + summary: "Editing app.ts", + payload: { + itemType: "file_change", + title: "Editing app.ts", + data: toolCallId ? { toolCallId } : {}, + }, + turnId: activityTurnId, + createdAt: "2026-01-01T00:00:01.000Z", + }; + return { + sequence, + eventId: EventId.make(`event-${sequence}`), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: "2026-01-01T00:00:01.000Z", + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + type: "thread.activity-appended", + payload: { threadId, activity }, + }; +} + +function makeMessage(sequence: number): OrchestrationEvent { + return { + sequence, + eventId: EventId.make(`event-${sequence}`), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: "2026-01-01T00:00:02.000Z", + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + type: "thread.message-sent", + payload: { + threadId, + messageId: MessageId.make(`message-${sequence}`), + role: "assistant", + text: "Still working", + turnId, + streaming: false, + createdAt: "2026-01-01T00:00:02.000Z", + updatedAt: "2026-01-01T00:00:02.000Z", + }, + }; +} + +describe("ThreadLiveEventCoalescer", () => { + it("coalesces only calls with a stable toolCallId", () => { + const events = [ + makeToolActivity(1, { toolCallId: "call-a" }), + makeToolActivity(2, { toolCallId: "call-b" }), + makeToolActivity(3, { toolCallId: "call-a" }), + ]; + + expect(coalesceLiveToolUpdatedEvents(events).map((event) => event.sequence)).toEqual([2, 3]); + }); + + it("preserves parallel same-label calls without a stable toolCallId", () => { + const events = [ + makeToolActivity(1, { toolCallId: "" }), + makeToolActivity(2, { toolCallId: "" }), + makeToolActivity(3, { kind: "tool.completed", toolCallId: "" }), + ]; + + expect(coalesceLiveToolUpdatedEvents(events).map((event) => event.sequence)).toEqual([1, 2, 3]); + }); + + it("does not coalesce stable tool calls across turns", () => { + const events = [ + makeToolActivity(1, { turnId: TurnId.make("turn-old") }), + makeToolActivity(2, { turnId: TurnId.make("turn-new") }), + ]; + + expect(coalesceLiveToolUpdatedEvents(events).map((event) => event.sequence)).toEqual([1, 2]); + }); + + it("flushes a stable update run before a completion boundary", () => { + const events = [ + makeToolActivity(1), + makeToolActivity(2), + makeToolActivity(3, { kind: "tool.completed" }), + makeToolActivity(4), + ]; + + expect(coalesceLiveToolUpdatedEvents(events).map((event) => event.sequence)).toEqual([2, 3, 4]); + }); + + it.effect("flushes pending tool updates as soon as an unrelated event arrives", () => + Effect.scoped( + Effect.gen(function* () { + const coalescer = yield* makeThreadLiveEventCoalescer({ coalesceWindow: "500 millis" }); + const startedAt = yield* Clock.currentTimeMillis; + yield* Effect.forEach( + Array.from({ length: 10 }, (_, index) => index + 2), + (sequence) => + coalescer.offerAndWait({ kind: "event", event: makeToolActivity(sequence) }), + { discard: true }, + ); + yield* coalescer.offerAndWait({ kind: "event", event: makeMessage(12) }); + + expect(yield* Clock.currentTimeMillis).toBe(startedAt); + expect( + Array.from(yield* coalescer.takeAll).map((item) => + item.kind === "event" ? item.event.sequence : item.kind, + ), + ).toEqual([11, 12]); + }), + ).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("flushes pending tool updates as soon as a synchronization marker arrives", () => + Effect.scoped( + Effect.gen(function* () { + const coalescer = yield* makeThreadLiveEventCoalescer({ coalesceWindow: "500 millis" }); + const startedAt = yield* Clock.currentTimeMillis; + yield* coalescer.offerAndWait({ kind: "event", event: makeToolActivity(2) }); + yield* coalescer.offerAndWait({ kind: "event", event: makeToolActivity(3) }); + yield* coalescer.offerAndWait({ kind: "synchronized" }); + + expect(yield* Clock.currentTimeMillis).toBe(startedAt); + expect( + Array.from(yield* coalescer.takeAll).map((item) => + item.kind === "event" ? item.event.sequence : item.kind, + ), + ).toEqual([3, "synchronized"]); + }), + ).pipe(Effect.provide(TestClock.layer())), + ); +}); diff --git a/apps/server/src/orchestration/ThreadLiveEventCoalescer.ts b/apps/server/src/orchestration/ThreadLiveEventCoalescer.ts new file mode 100644 index 000000000000..8271f6a550fb --- /dev/null +++ b/apps/server/src/orchestration/ThreadLiveEventCoalescer.ts @@ -0,0 +1,207 @@ +import type { OrchestrationEvent, OrchestrationThreadStreamItem } from "@t3tools/contracts"; +import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Predicate from "effect/Predicate"; +import * as Queue from "effect/Queue"; +import * as Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; + +import { projectActivityEvent } from "./ActivityPayloadProjection.ts"; + +const COALESCE_WINDOW = Duration.millis(50); +const MAX_PENDING_UPDATES = 512; + +export type ThreadLiveInput = + | { readonly kind: "event"; readonly event: OrchestrationEvent } + | { readonly kind: "synchronized" }; + +function isToolUpdated(event: OrchestrationEvent): boolean { + return ( + event.type === "thread.activity-appended" && event.payload.activity.kind === "tool.updated" + ); +} + +function asTrimmedString(value: unknown): string | null { + if (!Predicate.isString(value)) { + return null; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function stableToolCallIdentity(event: OrchestrationEvent): string | null { + if (event.type !== "thread.activity-appended") { + return null; + } + const payload = event.payload.activity.payload; + if (!Predicate.isObject(payload)) { + return null; + } + const data = Predicate.isObject(payload.data) ? payload.data : null; + return asTrimmedString(payload.toolCallId) ?? asTrimmedString(data?.toolCallId); +} + +/** + * Retain only the latest in-flight update for each stable tool-call id in a + * live run. Anonymous calls pass through because labels are not unique when + * tools execute in parallel. Survivors remain in sequence order. + */ +export function coalesceLiveToolUpdatedEvents( + events: ReadonlyArray, +): ReadonlyArray { + const survivors: Array = []; + let pendingUpdates: Array = []; + + const flushUpdates = () => { + const seen = new Set(); + const latestUpdates: Array = []; + for (let index = pendingUpdates.length - 1; index >= 0; index -= 1) { + const event = pendingUpdates[index]!; + const identity = stableToolCallIdentity(event); + const activity = + event.type === "thread.activity-appended" ? event.payload.activity : undefined; + const key = identity ? `${activity?.turnId ?? ""}\u0000${identity}` : null; + if (key && seen.has(key)) { + continue; + } + if (key) { + seen.add(key); + } + latestUpdates.push(event); + } + latestUpdates.reverse(); + survivors.push(...latestUpdates); + pendingUpdates = []; + }; + + for (const event of events) { + if (isToolUpdated(event)) { + pendingUpdates.push(event); + continue; + } + flushUpdates(); + survivors.push(event); + } + flushUpdates(); + return survivors; +} + +export const makeThreadLiveEventCoalescer = Effect.fn("makeThreadLiveEventCoalescer")( + function* (options?: { readonly coalesceWindow?: Duration.Input }) { + const output = yield* Queue.unbounded(); + const input = yield* Queue.unbounded<{ + readonly value: ThreadLiveInput; + readonly processed?: Deferred.Deferred; + }>(); + const mutex = yield* Semaphore.make(1); + const coalesceWindow = options?.coalesceWindow ?? COALESCE_WINDOW; + let pendingUpdates: Array = []; + let windowGeneration = 0; + let windowFiber: Fiber.Fiber | null = null; + + const cancelWindow = Effect.fn("ThreadLiveEventCoalescer.cancelWindow")(function* () { + const fiber = windowFiber; + if (!fiber) { + return; + } + windowFiber = null; + yield* Fiber.interrupt(fiber); + }); + + const flushPending = Effect.fn("ThreadLiveEventCoalescer.flushPending")(function* ( + boundary?: OrchestrationEvent, + ) { + const events = boundary ? [...pendingUpdates, boundary] : pendingUpdates; + pendingUpdates = []; + if (events.length === 0) { + return; + } + yield* Queue.offerAll( + output, + coalesceLiveToolUpdatedEvents(events).map((event) => ({ + kind: "event" as const, + event: projectActivityEvent(event), + })), + ); + }); + + const flushWindow = (generation: number) => + Effect.sleep(coalesceWindow).pipe( + Effect.andThen( + mutex.withPermits(1)( + Effect.suspend(() => (generation === windowGeneration ? flushPending() : Effect.void)), + ), + ), + Effect.ensuring( + Effect.sync(() => { + if (generation === windowGeneration) { + windowFiber = null; + } + }), + ), + ); + + const process = Effect.fn("ThreadLiveEventCoalescer.process")(function* ( + input: ThreadLiveInput, + ) { + yield* mutex.withPermits(1)( + Effect.gen(function* () { + if (input.kind === "event" && isToolUpdated(input.event)) { + pendingUpdates.push(input.event); + if (pendingUpdates.length === 1) { + const generation = ++windowGeneration; + windowFiber = yield* Effect.forkScoped(flushWindow(generation)); + } + if (pendingUpdates.length >= MAX_PENDING_UPDATES) { + yield* cancelWindow(); + windowGeneration += 1; + yield* flushPending(); + } + return; + } + + yield* cancelWindow(); + windowGeneration += 1; + // A non-update event closes the run immediately. The coalescer keeps + // that boundary after the final update from the run. + if (input.kind === "event") { + yield* flushPending(input.event); + } else { + yield* flushPending(); + yield* Queue.offer(output, { kind: "synchronized" }); + } + }), + ); + }); + + yield* Stream.fromQueue(input).pipe( + Stream.runForEach(({ value, processed }) => + process(value).pipe( + Effect.andThen(processed ? Deferred.succeed(processed, undefined) : Effect.void), + ), + ), + Effect.forkScoped, + ); + + const offer = (value: ThreadLiveInput) => Queue.offer(input, { value }).pipe(Effect.asVoid); + + // Synchronization callers wait for their marker to pass through the same + // ordered input queue before draining output produced ahead of it. + const offerAndWait = Effect.fn("ThreadLiveEventCoalescer.offerAndWait")(function* ( + value: ThreadLiveInput, + ) { + const processed = yield* Deferred.make(); + yield* Queue.offer(input, { value, processed }); + yield* Deferred.await(processed); + }); + + return { + offer, + offerAndWait, + stream: Stream.fromQueue(output), + takeAll: Queue.takeAll(output), + } as const; + }, +); diff --git a/apps/server/src/orchestration/ThreadSettlementPolicy.test.ts b/apps/server/src/orchestration/ThreadSettlementPolicy.test.ts new file mode 100644 index 000000000000..08d2d2af24af --- /dev/null +++ b/apps/server/src/orchestration/ThreadSettlementPolicy.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, it } from "vite-plus/test"; +import { + ProviderInstanceId, + ThreadId, + ProjectId, + TurnId, + type OrchestrationThreadShell, +} from "@t3tools/contracts"; +import { shouldAutoSettleThread } from "./ThreadSettlementPolicy.ts"; + +const NOW = "2026-08-28T12:00:00.000Z"; +const makeThread = ( + overrides: Partial = {}, +): OrchestrationThreadShell => ({ + id: ThreadId.make("thread-1"), + projectId: ProjectId.make("project-1"), + title: "Thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: "feature", + worktreePath: "/repo", + latestTurn: null, + createdAt: "2026-08-01T00:00:00.000Z", + updatedAt: "2026-08-20T00:00:00.000Z", + archivedAt: null, + settledOverride: null, + settledAt: null, + session: null, + latestUserMessageAt: "2026-08-20T00:00:00.000Z", + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + ...overrides, +}); + +const decide = ( + thread: OrchestrationThreadShell, + pullRequest: { state: "open" | "closed" | "merged"; updatedAt: string | null } | null = null, + settings: { days?: number | null; merge?: boolean } = {}, +) => + shouldAutoSettleThread({ + thread, + pullRequest, + now: NOW, + autoSettleAfterDays: settings.days === undefined ? 3 : settings.days, + autoSettleOnMerge: settings.merge ?? true, + }); + +describe("shouldAutoSettleThread", () => { + it("settles inactive threads and leaves never-used threads active", () => { + expect(decide(makeThread())).toBe(true); + expect(decide(makeThread({ latestUserMessageAt: null }))).toBe(false); + expect(decide(makeThread(), null, { days: null })).toBe(false); + }); + + it("keeps a thread active at the exact inactivity boundary", () => { + expect(decide(makeThread({ latestUserMessageAt: "2026-08-25T12:00:00.000Z" }))).toBe(false); + }); + + it("keeps open pull requests active", () => { + expect(decide(makeThread(), { state: "open", updatedAt: NOW })).toBe(false); + }); + + it("settles closed requests and honors the merge setting", () => { + expect(decide(makeThread(), { state: "closed", updatedAt: NOW }, { merge: false })).toBe(true); + expect(decide(makeThread(), { state: "merged", updatedAt: NOW }, { merge: false })).toBe(true); + expect( + decide(makeThread(), { state: "merged", updatedAt: NOW }, { merge: false, days: null }), + ).toBe(false); + }); + + it("does not settle again after user activity newer than the PR", () => { + expect( + decide( + makeThread({ latestUserMessageAt: "2026-08-27T00:00:00.000Z" }), + { state: "merged", updatedAt: "2026-08-26T00:00:00.000Z" }, + { days: null }, + ), + ).toBe(false); + }); + + it("does not inherit a terminal pull request older than the thread", () => { + expect( + decide( + makeThread({ createdAt: "2026-08-20T00:00:00.000Z", latestUserMessageAt: null }), + { state: "closed", updatedAt: "2026-08-19T00:00:00.000Z" }, + { days: null }, + ), + ).toBe(false); + }); + + it("requires a comparable PR timestamp for immediate settlement", () => { + const recentThread = makeThread({ latestUserMessageAt: "2026-08-27T00:00:00.000Z" }); + expect(decide(recentThread, { state: "closed", updatedAt: null })).toBe(false); + expect(decide(recentThread, { state: "merged", updatedAt: "unknown" })).toBe(false); + expect(decide(makeThread(), { state: "closed", updatedAt: null })).toBe(true); + }); + + it("uses user request time instead of completion time as the PR anchor", () => { + const thread = makeThread({ + latestTurn: { + turnId: TurnId.make("turn-1"), + state: "completed", + requestedAt: "2026-08-25T00:00:00.000Z", + startedAt: "2026-08-25T00:01:00.000Z", + completedAt: "2026-08-27T00:00:00.000Z", + assistantMessageId: null, + }, + }); + expect(decide(thread, { state: "merged", updatedAt: "2026-08-26T00:00:00.000Z" })).toBe(true); + }); + + it("blocks pins, snooze, pending work, live sessions, and queued starts", () => { + expect(decide(makeThread({ settledOverride: "active" }))).toBe(false); + expect(decide(makeThread({ snoozedUntil: "2026-08-29T00:00:00.000Z" }))).toBe(false); + expect(decide(makeThread({ hasPendingApprovals: true }))).toBe(false); + expect(decide(makeThread({ hasPendingUserInput: true }))).toBe(false); + expect(decide(makeThread({ backgroundLiveness: "working" }))).toBe(false); + expect(decide(makeThread({ backgroundLiveness: "monitoring" }))).toBe(false); + expect( + decide( + makeThread({ + session: { + threadId: ThreadId.make("thread-1"), + status: "running", + providerName: "codex", + runtimeMode: "full-access", + activeTurnId: TurnId.make("turn-1"), + lastError: null, + updatedAt: NOW, + }, + }), + ), + ).toBe(false); + expect( + decide(makeThread({ latestUserMessageAt: "2026-08-28T11:59:00.000Z", latestTurn: null })), + ).toBe(false); + }); + + it("allows a fresh completion to wake snooze before settlement", () => { + expect( + decide( + makeThread({ + snoozedAt: "2026-08-19T00:00:00.000Z", + snoozedUntil: "2026-08-29T00:00:00.000Z", + latestTurn: { + turnId: TurnId.make("turn-woke"), + state: "completed", + requestedAt: "2026-08-18T00:00:00.000Z", + startedAt: "2026-08-18T00:01:00.000Z", + completedAt: "2026-08-20T00:00:00.000Z", + assistantMessageId: null, + }, + }), + ), + ).toBe(true); + }); +}); diff --git a/apps/server/src/orchestration/ThreadSettlementPolicy.ts b/apps/server/src/orchestration/ThreadSettlementPolicy.ts new file mode 100644 index 000000000000..5a10307956aa --- /dev/null +++ b/apps/server/src/orchestration/ThreadSettlementPolicy.ts @@ -0,0 +1,108 @@ +import type { OrchestrationThreadShell } from "@t3tools/contracts"; + +export interface SettlementPullRequest { + readonly state: "open" | "closed" | "merged"; + readonly updatedAt: string | null; +} + +const DAY_MS = 24 * 60 * 60 * 1_000; +export const QUEUED_TURN_START_GRACE_MS = 2 * 60 * 1_000; + +function latestTimestamp(values: ReadonlyArray): string | null { + let latest: string | null = null; + let latestMs = Number.NEGATIVE_INFINITY; + for (const value of values) { + if (value == null) continue; + const valueMs = Date.parse(value); + if (valueMs > latestMs) { + latest = value; + latestMs = valueMs; + } + } + return latest; +} + +/** A recent user message stays queued until a turn adopts its timestamp. + * Absolute age bounds client clock skew in both directions and stops stale + * pre-adoption data from blocking the thread forever. */ +export function threadHasQueuedTurnStart( + thread: Pick, + now: string, +): boolean { + if (thread.latestUserMessageAt === null || thread.session?.status === "error") return false; + const messageAt = Date.parse(thread.latestUserMessageAt); + const age = Date.parse(now) - messageAt; + if (Number.isNaN(age) || Math.abs(age) > QUEUED_TURN_START_GRACE_MS) return false; + if (thread.latestTurn === null) return true; + return [ + thread.latestTurn.requestedAt, + thread.latestTurn.startedAt, + thread.latestTurn.completedAt, + ].every((value) => value == null || Date.parse(value) < messageAt); +} + +function pullRequestSettles( + thread: Pick, + pullRequest: SettlementPullRequest, + autoSettleOnMerge: boolean, +): boolean { + if (pullRequest.state !== "closed" && (pullRequest.state !== "merged" || !autoSettleOnMerge)) { + return false; + } + if (pullRequest.updatedAt === null) return false; + const userAnchor = latestTimestamp([ + thread.createdAt, + thread.latestUserMessageAt, + thread.latestTurn?.requestedAt, + ]); + if (userAnchor === null) return false; + const pullRequestAt = Date.parse(pullRequest.updatedAt); + const userAnchorAt = Date.parse(userAnchor); + if (Number.isNaN(pullRequestAt) || Number.isNaN(userAnchorAt)) return false; + return pullRequestAt >= userAnchorAt; +} + +export function shouldAutoSettleThread(input: { + readonly thread: OrchestrationThreadShell; + readonly pullRequest: SettlementPullRequest | null; + readonly now: string; + readonly autoSettleAfterDays: number | null; + readonly autoSettleOnMerge: boolean; +}): boolean { + const { thread, pullRequest } = input; + if (!isAutoSettlementCandidate(thread, input.now)) return false; + if (pullRequest !== null) { + if (pullRequestSettles(thread, pullRequest, input.autoSettleOnMerge)) return true; + if (pullRequest.state === "open") return false; + } + if (input.autoSettleAfterDays === null) return false; + const activityAt = latestTimestamp([ + thread.latestUserMessageAt, + thread.latestTurn?.requestedAt, + thread.latestTurn?.startedAt, + thread.latestTurn?.completedAt, + ]); + if (activityAt === null) return false; + return Date.parse(activityAt) < Date.parse(input.now) - input.autoSettleAfterDays * DAY_MS; +} + +/** Cheap checks that run before any source control lookup. */ +export function isAutoSettlementCandidate(thread: OrchestrationThreadShell, now: string): boolean { + if (thread.archivedAt !== null || thread.settledOverride !== null) return false; + if (thread.hasPendingApprovals || thread.hasPendingUserInput) return false; + if (thread.session?.status === "starting" || thread.session?.status === "running") return false; + if (thread.backgroundLiveness != null) return false; + if (threadHasQueuedTurnStart(thread, now)) return false; + if (thread.snoozedUntil == null || Date.parse(thread.snoozedUntil) <= Date.parse(now)) + return true; + const wokeOnError = + thread.session?.status === "error" && + (thread.snoozedAt == null || + Date.parse(thread.session.updatedAt) > Date.parse(thread.snoozedAt)); + const wokeOnCompletion = + thread.snoozedAt != null && + thread.latestTurn?.state === "completed" && + thread.latestTurn.completedAt != null && + Date.parse(thread.latestTurn.completedAt) > Date.parse(thread.snoozedAt); + return wokeOnError || wokeOnCompletion; +} diff --git a/apps/server/src/orchestration/ThreadSettlementReactor.test.ts b/apps/server/src/orchestration/ThreadSettlementReactor.test.ts new file mode 100644 index 000000000000..5d8d47109faa --- /dev/null +++ b/apps/server/src/orchestration/ThreadSettlementReactor.test.ts @@ -0,0 +1,640 @@ +import { + DEFAULT_SERVER_SETTINGS, + ProjectId, + ProviderInstanceId, + PullRequestOperationError, + ThreadId, + type OrchestrationCommand, + type OrchestrationProjectShell, + type OrchestrationShellSnapshot, + type OrchestrationThreadShell, + type PullRequestDetail, + type ServerSettings, + type ServerSettingsPatch, +} from "@t3tools/contracts"; +import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; +import { assert, describe, it } from "@effect/vitest"; +import * as Crypto from "effect/Crypto"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as PubSub from "effect/PubSub"; +import * as Queue from "effect/Queue"; +import * as Ref from "effect/Ref"; +import * as Stream from "effect/Stream"; +import { TestClock } from "effect/testing"; + +import { GitManager } from "../git/GitManager.ts"; +import { PullRequestService } from "../pullRequest/PullRequestService.ts"; +import { ServerActivation } from "../serverActivation.ts"; +import { ServerSettingsService } from "../serverSettings.ts"; +import { OrchestrationCommandInvariantError } from "./Errors.ts"; +import { + OrchestrationEngineService, + type OrchestrationEngineShape, +} from "./Services/OrchestrationEngine.ts"; +import { ProjectionSnapshotQuery } from "./Services/ProjectionSnapshotQuery.ts"; +import * as ThreadSettlementReactor from "./ThreadSettlementReactor.ts"; + +const NOW = "2026-08-28T12:00:00.000Z"; +const PROJECT_ID = ProjectId.make("settlement-project"); +const LINKED_PROJECT_ID = ProjectId.make("linked-settlement-project"); + +type AutoSettleCommand = Extract; + +const testCrypto = Crypto.make({ + randomBytes: (size) => new Uint8Array(size).fill(1), + digest: (_algorithm, data) => Effect.succeed(data), +}); + +function makeProject( + id: ProjectId = PROJECT_ID, + workspaceRoot = "/workspace/project", +): OrchestrationProjectShell { + return { + id, + title: `Project ${id}`, + workspaceRoot, + defaultModelSelection: null, + scripts: [], + createdAt: "2026-08-01T00:00:00.000Z", + updatedAt: NOW, + }; +} + +function makeThread( + id: string, + overrides: Partial = {}, +): OrchestrationThreadShell { + return { + id: ThreadId.make(id), + projectId: PROJECT_ID, + title: id, + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5", + }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + latestTurn: null, + createdAt: "2026-08-01T00:00:00.000Z", + updatedAt: "2026-08-20T00:00:00.000Z", + archivedAt: null, + settledOverride: null, + settledAt: null, + session: null, + latestUserMessageAt: "2026-08-20T00:00:00.000Z", + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + ...overrides, + }; +} + +function makeSnapshot( + threads: ReadonlyArray, + projects: ReadonlyArray = [makeProject()], +): OrchestrationShellSnapshot { + return { + snapshotSequence: 1, + projects, + threads, + updatedAt: NOW, + }; +} + +function makePullRequestDetail(input: { + readonly projectId: ProjectId; + readonly repository: string; + readonly number: number; + readonly state: "open" | "closed" | "merged"; + readonly updatedAt?: string; +}): PullRequestDetail { + return { + provider: "github", + capabilities: { + diff: true, + comment: true, + actions: [], + mergeMethods: [], + search: true, + review: { inlineComment: true, reply: true, resolve: true, verdicts: [] }, + reviewers: { request: true, listCandidates: true }, + }, + viewerPermissions: { + actions: [], + comment: true, + resolve: true, + verdicts: [], + requestReviewers: true, + }, + projectId: input.projectId, + projectTitle: "Linked project", + workspaceRoot: "/workspace/linked", + repository: input.repository, + number: input.number, + title: "Pull request", + body: "", + url: `https://example.test/${input.repository}/pull/${input.number}`, + author: null, + state: input.state, + isDraft: false, + mergeability: "mergeable", + additions: 0, + deletions: 0, + changedFiles: 0, + headBranch: "feature", + baseBranch: "main", + createdAt: "2026-08-01T00:00:00.000Z", + updatedAt: input.updatedAt ?? NOW, + mergedAt: input.state === "merged" ? (input.updatedAt ?? NOW) : null, + closedAt: input.state === "closed" ? (input.updatedAt ?? NOW) : null, + reviewers: [], + labels: [], + checks: [], + mergeCapabilities: { merge: true, squash: true, rebase: true }, + }; +} + +interface HarnessOptions { + readonly snapshot: OrchestrationShellSnapshot; + readonly settings?: ServerSettings; + readonly branchPullRequest?: GitManager["Service"]["branchPullRequest"]; + readonly pullRequestDetail?: PullRequestService["Service"]["detail"]; + readonly onDispatch?: ( + command: AutoSettleCommand, + ) => Effect.Effect; +} + +const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options: HarnessOptions) { + const activation = yield* Deferred.make(); + const snapshots = yield* Ref.make(options.snapshot); + const snapshotReadCount = yield* Ref.make(0); + const snapshotReads = yield* Queue.unbounded(); + const settings = yield* Ref.make(options.settings ?? DEFAULT_SERVER_SETTINGS); + const settingsChanges = yield* PubSub.unbounded(); + const commands = yield* Ref.make>([]); + const branchCalls = yield* Ref.make< + ReadonlyArray<{ readonly cwd: string; readonly branch: string }> + >([]); + const detailCalls = yield* Ref.make< + ReadonlyArray<{ + readonly projectId: ProjectId; + readonly repository: string; + readonly number: number; + }> + >([]); + + const updateSettings = (patch: ServerSettingsPatch) => + Effect.gen(function* () { + const next = applyServerSettingsPatch(yield* Ref.get(settings), patch); + yield* Ref.set(settings, next); + yield* PubSub.publish(settingsChanges, next); + return next; + }); + + const branchPullRequest: GitManager["Service"]["branchPullRequest"] = (input) => + Ref.update(branchCalls, (calls) => [...calls, input]).pipe( + Effect.andThen(options.branchPullRequest?.(input) ?? Effect.succeed(null)), + ); + + const pullRequestDetail: PullRequestService["Service"]["detail"] = (input) => + Ref.update(detailCalls, (calls) => [...calls, input]).pipe( + Effect.andThen( + options.pullRequestDetail?.(input) ?? + Effect.succeed( + makePullRequestDetail({ + ...input, + state: "open", + }), + ), + ), + ); + + const dispatch: OrchestrationEngineShape["dispatch"] = (command) => { + if (command.type !== "thread.auto-settle") { + return Effect.die(new Error(`Unexpected command: ${command.type}`)); + } + return Ref.update(commands, (recorded) => [...recorded, command]).pipe( + Effect.andThen(options.onDispatch?.(command) ?? Effect.void), + Effect.as({ sequence: 1 }), + ); + }; + + const serverSettings = ServerSettingsService.of({ + start: Effect.void, + ready: Effect.void, + getSettings: Ref.get(settings), + updateSettings, + streamChanges: Stream.fromPubSub(settingsChanges), + subscribeChanges: PubSub.subscribe(settingsChanges).pipe( + Effect.map((subscription) => Stream.fromSubscription(subscription)), + ), + }); + + const dependencies = Layer.mergeAll( + Layer.mock(ProjectionSnapshotQuery)({ + getShellSnapshot: () => + Ref.updateAndGet(snapshotReadCount, (count) => count + 1).pipe( + Effect.tap((count) => Queue.offer(snapshotReads, count)), + Effect.andThen(Ref.get(snapshots)), + ), + }), + Layer.mock(GitManager)({ branchPullRequest }), + Layer.mock(PullRequestService)({ detail: pullRequestDetail }), + Layer.mock(OrchestrationEngineService)({ + readEvents: () => Stream.empty, + dispatch, + streamDomainEvents: Stream.empty, + latestSequence: Effect.succeed(0), + }), + Layer.succeed(ServerSettingsService, serverSettings), + Layer.succeed(ServerActivation, Deferred.await(activation)), + Layer.succeed(Crypto.Crypto, testCrypto), + ); + + return { + activation, + snapshots, + snapshotReadCount, + snapshotReads, + commands, + branchCalls, + detailCalls, + updateSettings, + layer: ThreadSettlementReactor.layer.pipe(Layer.provide(dependencies)), + }; +}); + +const startHarness = Effect.fn("startThreadSettlementHarness")(function* ( + reactor: ThreadSettlementReactor.ThreadSettlementReactor["Service"], + activation: Deferred.Deferred, + snapshotReads: Queue.Queue, +) { + yield* reactor.start(); + yield* Deferred.succeed(activation, undefined); + yield* Queue.take(snapshotReads); + yield* reactor.drain; +}); + +describe("ThreadSettlementReactor", () => { + it.effect("starts without clients and skips protected threads before pull request lookup", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const linkedPullRequest = { + projectId: LINKED_PROJECT_ID, + repository: "owner/repository", + number: 42, + url: "https://example.test/owner/repository/pull/42", + } as const; + const skipped = [ + makeThread("pending-approval", { + branch: "skip-approval", + hasPendingApprovals: true, + }), + makeThread("snoozed", { + branch: "skip-snoozed", + snoozedUntil: "2026-08-29T00:00:00.000Z", + }), + ]; + const fixture = yield* makeHarness({ + snapshot: makeSnapshot( + [ + makeThread("inactive", { branch: "inactive-feature" }), + makeThread("closed-pr", { linkedPullRequest }), + ...skipped, + ], + [makeProject(), makeProject(LINKED_PROJECT_ID, "/workspace/linked")], + ), + branchPullRequest: () => Effect.succeed(null), + pullRequestDetail: (input) => + Effect.succeed(makePullRequestDetail({ ...input, state: "closed" })), + }); + + yield* Effect.gen(function* () { + const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor; + yield* reactor.start(); + assert.strictEqual(yield* Ref.get(fixture.snapshotReadCount), 0); + + yield* Deferred.succeed(fixture.activation, undefined); + yield* Queue.take(fixture.snapshotReads); + yield* reactor.drain; + + const commands = yield* Ref.get(fixture.commands); + assert.deepStrictEqual( + commands + .map(({ threadId, snapshotSequence }) => ({ threadId, snapshotSequence })) + .sort((left, right) => left.threadId.localeCompare(right.threadId)), + [ + { + threadId: ThreadId.make("closed-pr"), + snapshotSequence: 1, + }, + { + threadId: ThreadId.make("inactive"), + snapshotSequence: 1, + }, + ], + ); + assert.deepStrictEqual(yield* Ref.get(fixture.branchCalls), [ + { cwd: "/workspace/project", branch: "inactive-feature" }, + ]); + assert.deepStrictEqual(yield* Ref.get(fixture.detailCalls), [ + { projectId: LINKED_PROJECT_ID, repository: "owner/repository", number: 42 }, + ]); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + + it.effect("reevaluates inactivity and pull request state once per minute", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const pullRequest = yield* Ref.make<"open" | "merged">("open"); + const fixture = yield* makeHarness({ + snapshot: makeSnapshot([ + makeThread("at-boundary", { + latestUserMessageAt: "2026-08-25T12:00:00.000Z", + }), + makeThread("open-pr", { + branch: "saved-feature", + latestUserMessageAt: "2026-08-27T00:00:00.000Z", + }), + ]), + branchPullRequest: () => + Ref.get(pullRequest).pipe(Effect.map((state) => ({ state, updatedAt: NOW }))), + }); + + yield* Effect.gen(function* () { + const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor; + yield* startHarness(reactor, fixture.activation, fixture.snapshotReads); + assert.deepStrictEqual(yield* Ref.get(fixture.commands), []); + + yield* Ref.set(pullRequest, "merged"); + yield* TestClock.adjust("1 minute"); + yield* Queue.take(fixture.snapshotReads); + yield* reactor.drain; + + assert.deepStrictEqual( + (yield* Ref.get(fixture.commands)) + .map((command) => command.threadId) + .sort((left, right) => left.localeCompare(right)), + [ThreadId.make("at-boundary"), ThreadId.make("open-pr")], + ); + assert.strictEqual((yield* Ref.get(fixture.branchCalls)).length, 2); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + + it.effect("uses fresh settlement settings after lookup and ignores unrelated changes", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const state = yield* Ref.make<"merged" | "closed">("merged"); + const firstLookupStarted = yield* Deferred.make(); + const releaseFirstLookup = yield* Deferred.make(); + const laterLookupStarted = yield* Deferred.make(); + const releaseLaterLookup = yield* Deferred.make(); + const lookupCount = yield* Ref.make(0); + const fixture = yield* makeHarness({ + snapshot: makeSnapshot([makeThread("settings-thread", { branch: "saved-feature" })]), + settings: { + ...DEFAULT_SERVER_SETTINGS, + sidebarAutoSettleAfterDays: null, + sidebarAutoSettleOnMerge: true, + }, + branchPullRequest: () => + Ref.updateAndGet(lookupCount, (count) => count + 1).pipe( + Effect.tap((count) => + count === 1 + ? Deferred.succeed(firstLookupStarted, undefined) + : count === 3 + ? Deferred.succeed(laterLookupStarted, undefined) + : Effect.void, + ), + Effect.tap((count) => + count === 1 + ? Deferred.await(releaseFirstLookup) + : count === 3 + ? Deferred.await(releaseLaterLookup) + : Effect.void, + ), + Effect.andThen(Ref.get(state)), + Effect.map((pullRequestState) => ({ state: pullRequestState, updatedAt: NOW })), + ), + }); + + yield* Effect.gen(function* () { + const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor; + yield* reactor.start(); + yield* Deferred.succeed(fixture.activation, undefined); + yield* Queue.take(fixture.snapshotReads); + yield* Deferred.await(firstLookupStarted); + + yield* fixture.updateSettings({ sidebarAutoSettleOnMerge: false }); + yield* Deferred.succeed(releaseFirstLookup, undefined); + yield* Queue.take(fixture.snapshotReads); + yield* reactor.drain; + assert.deepStrictEqual(yield* Ref.get(fixture.commands), []); + assert.strictEqual(yield* Ref.get(fixture.snapshotReadCount), 2); + + yield* Ref.set(state, "closed"); + yield* fixture.updateSettings({ enableAgentBrowserAccess: false }); + yield* fixture.updateSettings({ sidebarAutoSettleAfterDays: 1 }); + yield* Deferred.await(laterLookupStarted); + yield* Deferred.succeed(releaseLaterLookup, undefined); + yield* reactor.drain; + + assert.strictEqual(yield* Ref.get(fixture.snapshotReadCount), 3); + assert.strictEqual(yield* Ref.get(lookupCount), 3); + assert.deepStrictEqual( + (yield* Ref.get(fixture.commands)).map((command) => command.threadId), + [ThreadId.make("settings-thread")], + ); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + + it.effect("keeps an unknown pull request active and continues with other candidates", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const fixture = yield* makeHarness({ + snapshot: makeSnapshot( + [ + makeThread("lookup-failed", { + linkedPullRequest: { + projectId: LINKED_PROJECT_ID, + repository: "owner/repository", + number: 9, + url: "https://example.test/owner/repository/pull/9", + }, + }), + makeThread("inactive-without-pr"), + ], + [makeProject(), makeProject(LINKED_PROJECT_ID, "/workspace/linked")], + ), + pullRequestDetail: () => + Effect.fail( + new PullRequestOperationError({ + operation: "detail", + detail: "host unavailable", + }), + ), + }); + + yield* Effect.gen(function* () { + const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor; + yield* startHarness(reactor, fixture.activation, fixture.snapshotReads); + + assert.deepStrictEqual( + (yield* Ref.get(fixture.commands)).map((command) => command.threadId), + [ThreadId.make("inactive-without-pr")], + ); + assert.strictEqual((yield* Ref.get(fixture.detailCalls)).length, 1); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + + it.effect("keeps threads active when their pull request project is unavailable", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const linkedPullRequest = { + projectId: LINKED_PROJECT_ID, + repository: "owner/repository", + number: 10, + url: "https://example.test/owner/repository/pull/10", + } as const; + const fixture = yield* makeHarness({ + snapshot: makeSnapshot( + [ + makeThread("missing-own-project", { linkedPullRequest }), + makeThread("missing-branch-project", { branch: "saved-feature" }), + ], + [makeProject(LINKED_PROJECT_ID, "/workspace/linked")], + ), + pullRequestDetail: (input) => + Effect.succeed(makePullRequestDetail({ ...input, state: "open" })), + }); + + yield* Effect.gen(function* () { + const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor; + yield* startHarness(reactor, fixture.activation, fixture.snapshotReads); + + assert.deepStrictEqual(yield* Ref.get(fixture.commands), []); + assert.deepStrictEqual(yield* Ref.get(fixture.detailCalls), [ + { projectId: LINKED_PROJECT_ID, repository: "owner/repository", number: 10 }, + ]); + assert.deepStrictEqual(yield* Ref.get(fixture.branchCalls), []); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + + it.effect("deduplicates saved-branch and linked pull request lookups within a sweep", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const linkedPullRequest = { + projectId: LINKED_PROJECT_ID, + repository: "owner/repository", + number: 77, + url: "https://example.test/owner/repository/pull/77", + } as const; + const fixture = yield* makeHarness({ + snapshot: makeSnapshot( + [ + makeThread("branch-one", { + branch: "saved-feature", + worktreePath: "/deleted/worktree-one", + }), + makeThread("branch-two", { + branch: "saved-feature", + worktreePath: "/deleted/worktree-two", + }), + makeThread("linked-one", { linkedPullRequest }), + makeThread("linked-two", { linkedPullRequest }), + ], + [ + makeProject(PROJECT_ID, "/workspace/project-root"), + makeProject(LINKED_PROJECT_ID, "/workspace/linked-root"), + ], + ), + branchPullRequest: () => Effect.succeed({ state: "closed", updatedAt: NOW }), + pullRequestDetail: (input) => + Effect.succeed(makePullRequestDetail({ ...input, state: "merged" })), + }); + + yield* Effect.gen(function* () { + const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor; + yield* startHarness(reactor, fixture.activation, fixture.snapshotReads); + + assert.deepStrictEqual(yield* Ref.get(fixture.branchCalls), [ + { cwd: "/workspace/project-root", branch: "saved-feature" }, + ]); + assert.deepStrictEqual(yield* Ref.get(fixture.detailCalls), [ + { projectId: LINKED_PROJECT_ID, repository: "owner/repository", number: 77 }, + ]); + assert.deepStrictEqual( + new Set((yield* Ref.get(fixture.commands)).map((command) => command.threadId)), + new Set([ + ThreadId.make("branch-one"), + ThreadId.make("branch-two"), + ThreadId.make("linked-one"), + ThreadId.make("linked-two"), + ]), + ); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + + it.effect("carries the snapshot guard and survives a stale dispatch rejection", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const fixture = yield* makeHarness({ + snapshot: makeSnapshot([makeThread("stale"), makeThread("next-candidate")]), + onDispatch: (command) => + command.threadId === ThreadId.make("stale") + ? Effect.fail( + new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: "thread changed after settlement evaluation", + }), + ) + : Effect.void, + }); + + yield* Effect.gen(function* () { + const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor; + yield* startHarness(reactor, fixture.activation, fixture.snapshotReads); + + const firstSweep = yield* Ref.get(fixture.commands); + assert.strictEqual( + firstSweep.find((command) => command.threadId === ThreadId.make("stale")) + ?.snapshotSequence, + 1, + ); + assert.strictEqual( + firstSweep.some((command) => command.threadId === ThreadId.make("next-candidate")), + true, + ); + + yield* fixture.updateSettings({ sidebarAutoSettleAfterDays: 4 }); + yield* Queue.take(fixture.snapshotReads); + yield* reactor.drain; + assert.strictEqual((yield* Ref.get(fixture.commands)).length, 4); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); +}); diff --git a/apps/server/src/orchestration/ThreadSettlementReactor.ts b/apps/server/src/orchestration/ThreadSettlementReactor.ts new file mode 100644 index 000000000000..fd4486a9c406 --- /dev/null +++ b/apps/server/src/orchestration/ThreadSettlementReactor.ts @@ -0,0 +1,185 @@ +import { CommandId } from "@t3tools/contracts"; +import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; +import * as Cause from "effect/Cause"; +import * as Context from "effect/Context"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schedule from "effect/Schedule"; +import type * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; + +import * as GitManager from "../git/GitManager.ts"; +import * as PullRequestService from "../pullRequest/PullRequestService.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import { forkParked } from "../serverActivation.ts"; +import * as OrchestrationEngine from "./Services/OrchestrationEngine.ts"; +import * as ProjectionSnapshotQuery from "./Services/ProjectionSnapshotQuery.ts"; +import { + isAutoSettlementCandidate, + shouldAutoSettleThread, + type SettlementPullRequest, +} from "./ThreadSettlementPolicy.ts"; + +export class ThreadSettlementReactor extends Context.Service< + ThreadSettlementReactor, + { + readonly start: () => Effect.Effect; + readonly drain: Effect.Effect; + } +>()("t3/orchestration/ThreadSettlementReactor") {} + +export const make = Effect.gen(function* () { + const engine = yield* OrchestrationEngine.OrchestrationEngineService; + const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + const settingsService = yield* ServerSettings.ServerSettingsService; + const git = yield* GitManager.GitManager; + const pullRequests = yield* PullRequestService.PullRequestService; + const crypto = yield* Crypto.Crypto; + + const sweep = Effect.fn("ThreadSettlementReactor.sweep")(function* () { + const snapshot = yield* snapshots.getShellSnapshot(); + const now = DateTime.formatIso(yield* DateTime.now); + const projects = new Map(snapshot.projects.map((project) => [project.id, project])); + const candidates = snapshot.threads.filter((thread) => isAutoSettlementCandidate(thread, now)); + const lookupKey = (thread: (typeof candidates)[number]) => { + if (thread.linkedPullRequest != null) { + return JSON.stringify([ + "linked", + thread.linkedPullRequest.projectId, + thread.linkedPullRequest.repository, + thread.linkedPullRequest.number, + ]); + } + if (thread.branch === null) return JSON.stringify(["none", thread.id]); + const project = projects.get(thread.projectId); + return JSON.stringify( + project === undefined + ? ["missing-project", thread.id] + : ["branch", project.workspaceRoot, thread.branch], + ); + }; + const groups = Map.groupBy(candidates, lookupKey); + + const pullRequestFor = Effect.fn("ThreadSettlementReactor.pullRequestFor")(function* ( + thread: (typeof candidates)[number], + ) { + if (thread.linkedPullRequest != null) { + if (!projects.has(thread.linkedPullRequest.projectId)) { + return yield* Effect.die(new Error("linked pull request project not found")); + } + const detail = yield* pullRequests.detail({ + projectId: thread.linkedPullRequest.projectId, + repository: thread.linkedPullRequest.repository, + number: thread.linkedPullRequest.number, + }); + return { state: detail.state, updatedAt: detail.updatedAt } satisfies SettlementPullRequest; + } + if (thread.branch === null) return null; + const project = projects.get(thread.projectId); + if (project === undefined) { + return yield* Effect.die(new Error("thread project not found")); + } + return yield* git.branchPullRequest({ cwd: project.workspaceRoot, branch: thread.branch }); + }); + + yield* Effect.forEach( + groups.values(), + (group) => + Effect.gen(function* () { + const pullRequest = yield* pullRequestFor(group[0]!); + yield* Effect.forEach( + group, + (thread) => + Effect.gen(function* () { + const settings = yield* settingsService.getSettings; + const decisionNow = DateTime.formatIso(yield* DateTime.now); + if ( + !shouldAutoSettleThread({ + thread, + pullRequest, + now: decisionNow, + autoSettleAfterDays: settings.sidebarAutoSettleAfterDays, + autoSettleOnMerge: settings.sidebarAutoSettleOnMerge, + }) + ) { + return; + } + const uuid = yield* crypto.randomUUIDv4; + yield* engine.dispatch({ + type: "thread.auto-settle", + commandId: CommandId.make(`server:auto-settle:${thread.id}:${uuid}`), + threadId: thread.id, + snapshotSequence: snapshot.snapshotSequence, + }); + }).pipe( + Effect.catchCause((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.failCause(cause) + : Effect.logWarning("automatic thread settlement skipped", { + threadId: thread.id, + cause: Cause.pretty(cause), + }), + ), + ), + { discard: true }, + ); + }).pipe( + Effect.catchCause((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.failCause(cause) + : Effect.logWarning("automatic thread settlement skipped", { + threadIds: group.map((thread) => thread.id), + cause: Cause.pretty(cause), + }), + ), + ), + { concurrency: 8, discard: true }, + ); + }); + + const worker = yield* makeDrainableWorker(() => + sweep().pipe( + Effect.catchCause((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.failCause(cause) + : Effect.logWarning("automatic thread settlement sweep failed", { + cause: Cause.pretty(cause), + }), + ), + ), + ); + + const start: ThreadSettlementReactor["Service"]["start"] = Effect.fn( + "ThreadSettlementReactor.start", + )(function* () { + const settingsChanges = yield* settingsService.subscribeChanges; + const initialSettings = yield* settingsService.getSettings.pipe(Effect.orDie); + let lastAfterDays = initialSettings.sidebarAutoSettleAfterDays; + let lastOnMerge = initialSettings.sidebarAutoSettleOnMerge; + yield* forkParked( + Effect.gen(function* () { + yield* worker.enqueue(undefined); + yield* worker.drain; + }).pipe(Effect.repeat(Schedule.spaced("1 minute")), Effect.asVoid), + ); + yield* forkParked( + Stream.runForEach(settingsChanges, (settings) => { + if ( + settings.sidebarAutoSettleAfterDays === lastAfterDays && + settings.sidebarAutoSettleOnMerge === lastOnMerge + ) { + return Effect.void; + } + lastAfterDays = settings.sidebarAutoSettleAfterDays; + lastOnMerge = settings.sidebarAutoSettleOnMerge; + return worker.enqueue(undefined); + }), + ); + }); + + return { start, drain: worker.drain } satisfies ThreadSettlementReactor["Service"]; +}); + +export const layer = Layer.effect(ThreadSettlementReactor, make); diff --git a/apps/server/src/orchestration/decider.settled.test.ts b/apps/server/src/orchestration/decider.settled.test.ts index 19c8ec91cc28..0317c6711441 100644 --- a/apps/server/src/orchestration/decider.settled.test.ts +++ b/apps/server/src/orchestration/decider.settled.test.ts @@ -19,6 +19,8 @@ import { projectEvent } from "./projector.ts"; const NOW = "2026-01-01T00:00:00.000Z"; const SETTLED_AT = "2025-12-30T00:00:00.000Z"; +const SETTLE_BLOCKED_MESSAGE = + "This thread still needs attention. Resolve or interrupt it first, then try again."; function makeReadModel( settledOverride: OrchestrationThread["settledOverride"], @@ -79,6 +81,22 @@ function makeSession(status: OrchestrationSession["status"]): OrchestrationSessi } it.layer(NodeServices.layer)("settled thread decider", (it) => { + it.effect("rejects an automatic settle when the thread is pinned active", () => + Effect.gen(function* () { + const command = { + type: "thread.auto-settle" as const, + commandId: CommandId.make("cmd-auto-settle"), + threadId: ThreadId.make("thread-1"), + snapshotSequence: 0, + }; + const pinnedActive = yield* decideOrchestrationCommand({ + command, + readModel: makeReadModel("active"), + }).pipe(Effect.flip); + expect(pinnedActive._tag).toBe("OrchestrationCommandInvariantError"); + }), + ); + it.effect("settles awake threads without a redundant wake and re-emits idempotently", () => Effect.gen(function* () { const event = yield* decideOrchestrationCommand({ @@ -198,7 +216,11 @@ it.layer(NodeServices.layer)("settled thread decider", (it) => { }, readModel: makeReadModel(null, null, makeSession(status)), }).pipe(Effect.flip); - expect(error._tag).toBe("OrchestrationCommandInvariantError"); + expect(error).toMatchObject({ + _tag: "OrchestrationThreadSettleBlockedError", + threadId: ThreadId.make("thread-1"), + message: SETTLE_BLOCKED_MESSAGE, + }); } // Stopped/error sessions are settleable — only live work is protected. const settled = yield* decideOrchestrationCommand({ @@ -238,7 +260,11 @@ it.layer(NodeServices.layer)("settled thread decider", (it) => { requestActivity("approval.requested", "req-1", NOW), ]), }).pipe(Effect.flip); - expect(openError._tag).toBe("OrchestrationCommandInvariantError"); + expect(openError).toMatchObject({ + _tag: "OrchestrationThreadSettleBlockedError", + threadId: ThreadId.make("thread-1"), + message: SETTLE_BLOCKED_MESSAGE, + }); // Same request later resolved: settleable again. const settled = yield* decideOrchestrationCommand({ @@ -266,7 +292,11 @@ it.layer(NodeServices.layer)("settled thread decider", (it) => { requestActivity("user-input.requested", "req-2", NOW), ]), }).pipe(Effect.flip); - expect(inputError._tag).toBe("OrchestrationCommandInvariantError"); + expect(inputError).toMatchObject({ + _tag: "OrchestrationThreadSettleBlockedError", + threadId: ThreadId.make("thread-1"), + message: SETTLE_BLOCKED_MESSAGE, + }); }), ); @@ -287,8 +317,7 @@ it.layer(NodeServices.layer)("settled thread decider", (it) => { createdAt: NOW, }) as OrchestrationThread["activities"][number]; - // Stale-failure detail clears the request — mirrors the projection's - // pending accounting, which is what the client's canSettle sees. + // Stale-failure details clear the request, matching the projection flags. const settled = yield* decideOrchestrationCommand({ command: { type: "thread.settle", @@ -324,7 +353,11 @@ it.layer(NodeServices.layer)("settled thread decider", (it) => { }), ]), }).pipe(Effect.flip); - expect(stillOpen._tag).toBe("OrchestrationCommandInvariantError"); + expect(stillOpen).toMatchObject({ + _tag: "OrchestrationThreadSettleBlockedError", + threadId: ThreadId.make("thread-1"), + message: SETTLE_BLOCKED_MESSAGE, + }); }), ); @@ -352,7 +385,11 @@ it.layer(NodeServices.layer)("settled thread decider", (it) => { }, readModel: makeReadModel(null, null, null, [], [userMessage("1969-12-31T23:59:30.000Z")]), }).pipe(Effect.flip); - expect(queuedError._tag).toBe("OrchestrationCommandInvariantError"); + expect(queuedError).toMatchObject({ + _tag: "OrchestrationThreadSettleBlockedError", + threadId: ThreadId.make("thread-1"), + message: SETTLE_BLOCKED_MESSAGE, + }); // Message timestamp far in the FUTURE (client clock ahead of server): // a negative age must not read as queued forever — past the grace diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 30dcef8c9fab..5e07c6f1f772 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -3,13 +3,18 @@ import { type OrchestrationCommand, type OrchestrationEvent, type OrchestrationReadModel, + type OrchestrationThread, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import type * as PlatformError from "effect/PlatformError"; -import { OrchestrationCommandInvariantError } from "./Errors.ts"; +import { + OrchestrationCommandInvariantError, + OrchestrationThreadSettleBlockedError, + type OrchestrationCommandRejection, +} from "./Errors.ts"; import { listThreadsByProjectId, requireActiveProjectWorkspaceRootAbsent, @@ -21,14 +26,10 @@ import { requireThreadNotArchived, } from "./commandInvariants.ts"; import { projectEvent } from "./projector.ts"; +import { threadHasQueuedTurnStart } from "./ThreadSettlementPolicy.ts"; const nowIso = Effect.map(DateTime.now, DateTime.formatIso); -// Session adoption takes seconds; a user message still unadopted after this -// window is a failed/stale start, not pending work. Mirrors the client's -// QUEUED_TURN_START_GRACE_MS in client-runtime threadSettled.ts. -const QUEUED_TURN_START_GRACE_MS = 2 * 60 * 1_000; - /** * Blocked-on-you work derived from the thread's retained activities: an * approval or user-input request with no later resolution for the same @@ -86,59 +87,28 @@ function hasOpenBlockingRequest(thread: { return openRequestIds.size > 0; } -/** - * A queued turn start — a user message no turn has picked up yet — is work - * in flight even though session is still null (turn.start emits - * message-sent + turn-start-requested; the session arrives later). Detection - * mirrors the client's hasQueuedTurnStart: the newest user message is - * strictly newer than every latestTurn timestamp (adoption stamps the new - * turn's requestedAt with the message time, clearing this), and only within - * the adoption grace window — historical threads whose last user message - * postdates their turn timestamps (older-server data, mid-turn messages) - * must not be blocked forever. A failed session start (status "error") - * clears the block immediately. - * - * The age check is bounded on BOTH sides: message timestamps are - * client-supplied, so a client clock ahead of the server yields a negative - * age. Without the lower bound that negative age satisfies `<= grace` for - * as long as the skew lasts, extending the block far past the intended two - * minutes. - */ -function threadHasQueuedTurnStart( - thread: { - readonly messages: ReadonlyArray<{ readonly role: string; readonly createdAt: string }>; - readonly latestTurn: { - readonly requestedAt: string; - readonly startedAt: string | null; - readonly completedAt: string | null; - } | null; - readonly session: { readonly status: string } | null; - }, - occurredAt: string, +/** Apply the shared shell-level rule to the detailed command read model. */ +function hasQueuedTurnStartForThread( + thread: Pick, + now: string, ): boolean { - const latestUserMessageAtMs = thread.messages.reduce( - (latest, message) => - message.role === "user" ? Math.max(latest, Date.parse(message.createdAt)) : latest, - Number.NEGATIVE_INFINITY, - ); - const latestTurnAtMs = - thread.latestTurn === null - ? Number.NEGATIVE_INFINITY - : Math.max( - ...[ - thread.latestTurn.requestedAt, - thread.latestTurn.startedAt, - thread.latestTurn.completedAt, - ].map((candidate) => - candidate == null ? Number.NEGATIVE_INFINITY : Date.parse(candidate), - ), - ); - const queuedAgeMs = Date.parse(occurredAt) - latestUserMessageAtMs; - return ( - thread.session?.status !== "error" && - Number.isFinite(latestUserMessageAtMs) && - latestUserMessageAtMs > latestTurnAtMs && - Math.abs(queuedAgeMs) <= QUEUED_TURN_START_GRACE_MS + let latestUserMessageAt: string | null = null; + let latestUserMessageAtMs = Number.NEGATIVE_INFINITY; + for (const message of thread.messages) { + if (message.role !== "user") continue; + const messageAtMs = Date.parse(message.createdAt); + latestUserMessageAtMs = Math.max(latestUserMessageAtMs, messageAtMs); + if (messageAtMs === latestUserMessageAtMs) { + latestUserMessageAt = message.createdAt; + } + } + return threadHasQueuedTurnStart( + { + latestUserMessageAt: Number.isFinite(latestUserMessageAtMs) ? latestUserMessageAt : null, + latestTurn: thread.latestTurn, + session: thread.session, + }, + now, ); } @@ -186,7 +156,7 @@ const decideCommandSequence = Effect.fn("decideCommandSequence")(function* ({ readonly readModel: OrchestrationReadModel; }): Effect.fn.Return< ReadonlyArray, - OrchestrationCommandInvariantError | PlatformError.PlatformError, + OrchestrationCommandRejection | PlatformError.PlatformError, Crypto.Crypto > { let nextReadModel = readModel; @@ -220,7 +190,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" readonly readModel: OrchestrationReadModel; }): Effect.fn.Return< DecideOrchestrationCommandResult, - OrchestrationCommandInvariantError | PlatformError.PlatformError, + OrchestrationCommandRejection | PlatformError.PlatformError, Crypto.Crypto > { switch (command.type) { @@ -450,43 +420,36 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }; } - case "thread.settle": { + case "thread.settle": + case "thread.auto-settle": { const thread = yield* requireThreadNotArchived({ readModel, command, threadId: command.threadId, }); - // Server-side twin of the client's canSettle session check: a stale - // or raced client must not settle a thread whose session is coming - // alive or working. - if (thread.session?.status === "starting" || thread.session?.status === "running") { + if (command.type === "thread.auto-settle" && thread.settledOverride !== null) { return yield* Effect.fail( new OrchestrationCommandInvariantError({ commandType: command.type, - detail: `thread ${command.threadId} has an active session and cannot be settled`, + detail: `thread ${command.threadId} changed before automatic settlement`, }), ); } + // The server owns settle eligibility. A stale command must not settle + // a thread whose session is coming alive or working. + if (thread.session?.status === "starting" || thread.session?.status === "running") { + return yield* new OrchestrationThreadSettleBlockedError({ threadId: command.threadId }); + } // Pending approval / user-input requests are blocked-on-you work: a // raced or stale client must not park them behind a settled override // that would surface only after the request resolves. if (hasOpenBlockingRequest(thread)) { - return yield* Effect.fail( - new OrchestrationCommandInvariantError({ - commandType: command.type, - detail: `thread ${command.threadId} has a pending approval or user-input request and cannot be settled`, - }), - ); + return yield* new OrchestrationThreadSettleBlockedError({ threadId: command.threadId }); } const occurredAt = yield* nowIso; // Settling inside the adoption window would hide just-requested work. - if (threadHasQueuedTurnStart(thread, occurredAt)) { - return yield* Effect.fail( - new OrchestrationCommandInvariantError({ - commandType: command.type, - detail: `thread ${command.threadId} has a queued turn start and cannot be settled`, - }), - ); + if (hasQueuedTurnStartForThread(thread, occurredAt)) { + return yield* new OrchestrationThreadSettleBlockedError({ threadId: command.threadId }); } // Settling an already-settled thread re-emits with the original // settledAt: the engine rejects zero-event commands, and bulk-settle / @@ -610,7 +573,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" // invisible pending work: no session, no pending flags. Snoozing in // that window would hide a just-requested turn exactly the way settle // would. - if (threadHasQueuedTurnStart(thread, occurredAt)) { + if (hasQueuedTurnStartForThread(thread, occurredAt)) { return yield* Effect.fail( new OrchestrationCommandInvariantError({ commandType: command.type, @@ -1156,7 +1119,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" if ( thread.settledOverride !== "settled" || sessionComingAlive || - threadHasQueuedTurnStart(thread, command.createdAt) + hasQueuedTurnStartForThread(thread, command.createdAt) ) { return yield* Effect.fail( new OrchestrationCommandInvariantError({ diff --git a/apps/server/src/persistence/Layers/OrchestrationEventStore.test.ts b/apps/server/src/persistence/Layers/OrchestrationEventStore.test.ts index 2bac5de920cb..1e21501e4096 100644 --- a/apps/server/src/persistence/Layers/OrchestrationEventStore.test.ts +++ b/apps/server/src/persistence/Layers/OrchestrationEventStore.test.ts @@ -17,7 +17,7 @@ const layer = it.layer( ); layer("OrchestrationEventStore", (it) => { - it.effect("stores json columns as strings and replays decoded events", () => + it.effect("stores json columns as strings and replays CLI-origin events", () => Effect.gen(function* () { const eventStore = yield* OrchestrationEventStore; const sql = yield* SqlClient.SqlClient; @@ -34,6 +34,9 @@ layer("OrchestrationEventStore", (it) => { correlationId: CommandId.make("cmd-store-roundtrip"), metadata: { adapterKey: "codex", + origin: { + surface: "cli", + }, }, payload: { projectId: ProjectId.make("project-roundtrip"), @@ -66,6 +69,7 @@ layer("OrchestrationEventStore", (it) => { assert.equal(replayed.length, 1); assert.equal(replayed[0]?.type, "project.created"); assert.equal(replayed[0]?.metadata.adapterKey, "codex"); + assert.deepEqual(replayed[0]?.metadata.origin, { surface: "cli" }); }), ); diff --git a/apps/server/src/persistence/Layers/OrchestrationEventStore.ts b/apps/server/src/persistence/Layers/OrchestrationEventStore.ts index edd8620c3b3f..e801c34af582 100644 --- a/apps/server/src/persistence/Layers/OrchestrationEventStore.ts +++ b/apps/server/src/persistence/Layers/OrchestrationEventStore.ts @@ -64,7 +64,7 @@ const OrchestrationEventPersistedRowSchema = Schema.Struct({ const HasEventAfterRequestSchema = Schema.Struct({ aggregateKind: Schema.String, aggregateId: Schema.String, - type: Schema.String, + type: Schema.optional(Schema.String), sequenceExclusive: NonNegativeInt, }); @@ -271,16 +271,17 @@ const makeEventStore = Effect.gen(function* () { const findEventAfter = SqlSchema.findOneOption({ Request: HasEventAfterRequestSchema, Result: Schema.Struct({ sequence: Schema.Number }), - execute: (request) => - sql` - SELECT sequence - FROM orchestration_events - WHERE aggregate_kind = ${request.aggregateKind} - AND stream_id = ${request.aggregateId} - AND event_type = ${request.type} - AND sequence > ${request.sequenceExclusive} - LIMIT 1 - `, + execute: (request) => sql` + SELECT sequence + FROM orchestration_events + WHERE aggregate_kind = ${request.aggregateKind} + AND stream_id = ${request.aggregateId} + AND ${sql.and([ + sql`sequence > ${request.sequenceExclusive}`, + ...(request.type === undefined ? [] : [sql`event_type = ${request.type}`]), + ])} + LIMIT 1 + `, }); const hasEventAfter: OrchestrationEventStoreShape["hasEventAfter"] = (input) => diff --git a/apps/server/src/persistence/Layers/ProjectionThreadActivities.ts b/apps/server/src/persistence/Layers/ProjectionThreadActivities.ts index 4fcf0a926a8a..f4a90bce5c51 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreadActivities.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreadActivities.ts @@ -23,6 +23,21 @@ const ProjectionThreadActivityDbRowSchema = ProjectionThreadActivity.mapFields( }), ); +const mapActivityRows = ( + rows: ReadonlyArray>, +): ReadonlyArray => + rows.map((row) => ({ + activityId: row.activityId, + threadId: row.threadId, + turnId: row.turnId, + tone: row.tone, + kind: row.kind, + summary: row.summary, + payload: row.payload, + ...(row.sequence !== null ? { sequence: row.sequence } : {}), + createdAt: row.createdAt, + })); + function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) { return (cause: unknown) => Schema.isSchemaError(cause) @@ -112,6 +127,36 @@ const makeProjectionThreadActivityRepository = Effect.gen(function* () { `, }); + const listUserInputLifecycleActivityRows = SqlSchema.findAll({ + Request: ListProjectionThreadActivitiesInput, + Result: ProjectionThreadActivityDbRowSchema, + execute: ({ threadId }) => + sql` + SELECT + activity_id AS "activityId", + thread_id AS "threadId", + turn_id AS "turnId", + tone, + kind, + summary, + payload_json AS "payload", + sequence, + created_at AS "createdAt" + FROM projection_thread_activities + WHERE thread_id = ${threadId} + AND kind IN ( + 'user-input.requested', + 'user-input.resolved', + 'provider.user-input.respond.failed' + ) + ORDER BY + CASE WHEN sequence IS NULL THEN 0 ELSE 1 END ASC, + sequence ASC, + created_at ASC, + activity_id ASC + `, + }); + const deleteProjectionThreadActivityRows = SqlSchema.void({ Request: DeleteProjectionThreadActivitiesInput, execute: ({ threadId }) => @@ -139,21 +184,21 @@ const makeProjectionThreadActivityRepository = Effect.gen(function* () { "ProjectionThreadActivityRepository.listByThreadId:decodeRows", ), ), - Effect.map((rows) => - rows.map((row) => ({ - activityId: row.activityId, - threadId: row.threadId, - turnId: row.turnId, - tone: row.tone, - kind: row.kind, - summary: row.summary, - payload: row.payload, - ...(row.sequence !== null ? { sequence: row.sequence } : {}), - createdAt: row.createdAt, - })), - ), + Effect.map(mapActivityRows), ); + const listUserInputLifecycleByThreadId: ProjectionThreadActivityRepositoryShape["listUserInputLifecycleByThreadId"] = + (input) => + listUserInputLifecycleActivityRows(input).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionThreadActivityRepository.listUserInputLifecycleByThreadId:query", + "ProjectionThreadActivityRepository.listUserInputLifecycleByThreadId:decodeRows", + ), + ), + Effect.map(mapActivityRows), + ); + const deleteByThreadId: ProjectionThreadActivityRepositoryShape["deleteByThreadId"] = (input) => deleteProjectionThreadActivityRows(input).pipe( Effect.mapError( @@ -164,6 +209,7 @@ const makeProjectionThreadActivityRepository = Effect.gen(function* () { return { upsert, listByThreadId, + listUserInputLifecycleByThreadId, deleteByThreadId, } satisfies ProjectionThreadActivityRepositoryShape; }); diff --git a/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts b/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts index b1f394a9e577..30e0f42cab89 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts @@ -12,6 +12,71 @@ const layer = it.layer( ); layer("ProjectionThreadMessageRepository", (it) => { + it.effect("appends streaming text and applies attachment updates", () => + Effect.gen(function* () { + const repository = yield* ProjectionThreadMessageRepository; + const threadId = ThreadId.make("thread-streaming-append"); + const messageId = MessageId.make("message-streaming-append"); + const createdAt = "2026-02-28T19:05:00.000Z"; + const attachments = [ + { + type: "image" as const, + id: "thread-streaming-append-att-1", + name: "example.png", + mimeType: "image/png", + sizeBytes: 5, + }, + ]; + + yield* repository.appendStreaming({ + messageId, + threadId, + turnId: null, + role: "assistant", + text: "hello", + attachments, + createdAt, + updatedAt: createdAt, + }); + yield* repository.appendStreaming({ + messageId, + threadId, + turnId: null, + role: "assistant", + text: " world", + createdAt: "2026-02-28T19:05:01.000Z", + updatedAt: "2026-02-28T19:05:01.000Z", + }); + + const rowWithPreservedAttachments = yield* repository.getByMessageId({ messageId }); + assert.equal(rowWithPreservedAttachments._tag, "Some"); + if (rowWithPreservedAttachments._tag === "Some") { + assert.deepEqual(rowWithPreservedAttachments.value.attachments, attachments); + } + + yield* repository.appendStreaming({ + messageId, + threadId, + turnId: null, + role: "assistant", + text: "", + attachments: [], + createdAt: "2026-02-28T19:05:02.000Z", + updatedAt: "2026-02-28T19:05:02.000Z", + }); + + const row = yield* repository.getByMessageId({ messageId }); + assert.equal(row._tag, "Some"); + if (row._tag === "Some") { + assert.equal(row.value.text, "hello world"); + assert.deepEqual(row.value.attachments, []); + assert.equal(row.value.createdAt, createdAt); + assert.equal(row.value.updatedAt, "2026-02-28T19:05:02.000Z"); + assert.isTrue(row.value.isStreaming); + } + }), + ); + it.effect("preserves existing attachments when upsert omits attachments", () => Effect.gen(function* () { const repository = yield* ProjectionThreadMessageRepository; diff --git a/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts b/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts index 719191668869..85e854dc6606 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts @@ -9,6 +9,7 @@ import { ChatAttachment } from "@t3tools/contracts"; import { toPersistenceSqlError } from "../Errors.ts"; import { + AppendStreamingProjectionThreadMessage, GetProjectionThreadMessageInput, ProjectionThreadMessageRepository, type ProjectionThreadMessageRepositoryShape, @@ -95,6 +96,50 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { }, }); + const appendStreamingProjectionThreadMessageRow = SqlSchema.void({ + Request: AppendStreamingProjectionThreadMessage, + execute: (row) => { + const nextAttachmentsJson = + row.attachments !== undefined ? JSON.stringify(row.attachments) : null; + return sql` + INSERT INTO projection_thread_messages ( + message_id, + thread_id, + turn_id, + role, + text, + attachments_json, + is_streaming, + created_at, + updated_at + ) + VALUES ( + ${row.messageId}, + ${row.threadId}, + ${row.turnId}, + ${row.role}, + ${row.text}, + ${nextAttachmentsJson}, + 1, + ${row.createdAt}, + ${row.updatedAt} + ) + ON CONFLICT (message_id) + DO UPDATE SET + thread_id = excluded.thread_id, + turn_id = excluded.turn_id, + role = excluded.role, + text = projection_thread_messages.text || excluded.text, + attachments_json = COALESCE( + excluded.attachments_json, + projection_thread_messages.attachments_json + ), + is_streaming = 1, + updated_at = excluded.updated_at + `; + }, + }); + const getProjectionThreadMessageRow = SqlSchema.findOneOption({ Request: GetProjectionThreadMessageInput, Result: ProjectionThreadMessageDbRowSchema, @@ -151,6 +196,13 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { Effect.mapError(toPersistenceSqlError("ProjectionThreadMessageRepository.upsert:query")), ); + const appendStreaming: ProjectionThreadMessageRepositoryShape["appendStreaming"] = (row) => + appendStreamingProjectionThreadMessageRow(row).pipe( + Effect.mapError( + toPersistenceSqlError("ProjectionThreadMessageRepository.appendStreaming:query"), + ), + ); + const getByMessageId: ProjectionThreadMessageRepositoryShape["getByMessageId"] = (input) => getProjectionThreadMessageRow(input).pipe( Effect.mapError( @@ -176,6 +228,7 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { return { upsert, + appendStreaming, getByMessageId, listByThreadId, deleteByThreadId, diff --git a/apps/server/src/persistence/Services/OrchestrationEventStore.ts b/apps/server/src/persistence/Services/OrchestrationEventStore.ts index 488210ab74a5..b865957c06b3 100644 --- a/apps/server/src/persistence/Services/OrchestrationEventStore.ts +++ b/apps/server/src/persistence/Services/OrchestrationEventStore.ts @@ -54,7 +54,8 @@ export interface OrchestrationEventStoreShape { readonly readAll: () => Stream.Stream; /** - * Check whether an aggregate has an event of the given type after a sequence. + * Check whether an aggregate has an event after a sequence, optionally + * restricted to one event type. * * Used during replay to tell whether a later event supersedes the one being * applied, without streaming the rest of the log. @@ -62,7 +63,7 @@ export interface OrchestrationEventStoreShape { readonly hasEventAfter: (input: { readonly aggregateKind: OrchestrationEvent["aggregateKind"]; readonly aggregateId: string; - readonly type: OrchestrationEvent["type"]; + readonly type?: OrchestrationEvent["type"]; readonly sequenceExclusive: number; }) => Effect.Effect; } diff --git a/apps/server/src/persistence/Services/ProjectionThreadActivities.ts b/apps/server/src/persistence/Services/ProjectionThreadActivities.ts index 47cb6073c479..e8c1e47a328b 100644 --- a/apps/server/src/persistence/Services/ProjectionThreadActivities.ts +++ b/apps/server/src/persistence/Services/ProjectionThreadActivities.ts @@ -67,6 +67,15 @@ export interface ProjectionThreadActivityRepositoryShape { input: ListProjectionThreadActivitiesInput, ) => Effect.Effect, ProjectionRepositoryError>; + /** + * List activity rows used to derive pending user-input state. + * + * Filters in SQLite so unrelated payloads do not enter server memory. + */ + readonly listUserInputLifecycleByThreadId: ( + input: ListProjectionThreadActivitiesInput, + ) => Effect.Effect, ProjectionRepositoryError>; + /** * Delete projected thread activity rows by thread. */ diff --git a/apps/server/src/persistence/Services/ProjectionThreadMessages.ts b/apps/server/src/persistence/Services/ProjectionThreadMessages.ts index d50ff3202563..17b659a2f8da 100644 --- a/apps/server/src/persistence/Services/ProjectionThreadMessages.ts +++ b/apps/server/src/persistence/Services/ProjectionThreadMessages.ts @@ -16,6 +16,7 @@ import { } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; import * as Context from "effect/Context"; +import * as Struct from "effect/Struct"; import type * as Option from "effect/Option"; import type * as Effect from "effect/Effect"; @@ -34,6 +35,12 @@ export const ProjectionThreadMessage = Schema.Struct({ }); export type ProjectionThreadMessage = typeof ProjectionThreadMessage.Type; +export const AppendStreamingProjectionThreadMessage = Schema.Struct( + Struct.omit(ProjectionThreadMessage.fields, ["isStreaming"]), +); +export type AppendStreamingProjectionThreadMessage = + typeof AppendStreamingProjectionThreadMessage.Type; + export const ListProjectionThreadMessagesInput = Schema.Struct({ threadId: ThreadId, }); @@ -62,6 +69,11 @@ export interface ProjectionThreadMessageRepositoryShape { message: ProjectionThreadMessage, ) => Effect.Effect; + /** Insert a streaming message or append text to its existing row. */ + readonly appendStreaming: ( + message: AppendStreamingProjectionThreadMessage, + ) => Effect.Effect; + /** * Read a projected thread message by id. */ diff --git a/apps/server/src/project/ProjectFaviconResolver.test.ts b/apps/server/src/project/ProjectFaviconResolver.test.ts index c610781ea9be..2c7b0f7bdc62 100644 --- a/apps/server/src/project/ProjectFaviconResolver.test.ts +++ b/apps/server/src/project/ProjectFaviconResolver.test.ts @@ -1,10 +1,12 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { it, describe, expect } from "@effect/vitest"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; +import { TestClock } from "effect/testing"; import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; import * as ProjectFaviconResolver from "./ProjectFaviconResolver.ts"; @@ -49,6 +51,64 @@ const makeResolverWithFileSystem = (fileSystem: FileSystem.FileSystem) => it.layer(TestLayer)("ProjectFaviconResolverLive", (it) => { describe("resolvePath", () => { + it.effect("serves repeated resolves from cache instead of re-walking candidates", () => + Effect.gen(function* () { + const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; + const cwd = yield* makeTempDir; + yield* writeTextFile(cwd, "public/favicon.svg", "public"); + + const resolved = yield* resolver.resolvePath(cwd); + expect(resolved?.endsWith("public/favicon.svg")).toBe(true); + + // `favicon.svg` outranks `public/favicon.svg`, so a resolver that walked + // the candidate list again would switch to it. Staying on the original + // answer is only possible from cache. + yield* writeTextFile(cwd, "favicon.svg", "root"); + + for (const _attempt of [1, 2, 3]) { + expect(yield* resolver.resolvePath(cwd)).toBe(resolved); + } + + yield* TestClock.adjust(Duration.minutes(11)); + + expect((yield* resolver.resolvePath(cwd))?.endsWith("/favicon.svg")).toBe(true); + expect(yield* resolver.resolvePath(cwd)).not.toBe(resolved); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("falls back at once when a cached favicon is deleted", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; + const cwd = yield* makeTempDir; + yield* writeTextFile(cwd, "favicon.svg", "favicon"); + + expect(yield* resolver.resolvePath(cwd)).not.toBeNull(); + + yield* fileSystem.remove(path.join(cwd, "favicon.svg")).pipe(Effect.orDie); + + // Still inside the positive TTL: the cached path must not be served. + expect(yield* resolver.resolvePath(cwd)).toBeNull(); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("re-probes for a favicon added after a miss once the negative TTL expires", () => + Effect.gen(function* () { + const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; + const cwd = yield* makeTempDir; + + expect(yield* resolver.resolvePath(cwd)).toBeNull(); + + yield* writeTextFile(cwd, "favicon.svg", "favicon"); + expect(yield* resolver.resolvePath(cwd)).toBeNull(); + + yield* TestClock.adjust(Duration.minutes(2)); + + expect(yield* resolver.resolvePath(cwd)).not.toBeNull(); + }).pipe(Effect.provide(TestClock.layer())), + ); + it.effect("prefers well-known favicon files", () => Effect.gen(function* () { const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; diff --git a/apps/server/src/project/ProjectFaviconResolver.ts b/apps/server/src/project/ProjectFaviconResolver.ts index 9d9a5bddc791..2b68f5310d90 100644 --- a/apps/server/src/project/ProjectFaviconResolver.ts +++ b/apps/server/src/project/ProjectFaviconResolver.ts @@ -6,8 +6,11 @@ * * @module ProjectFaviconResolver */ +import * as Cache from "effect/Cache"; import * as Context from "effect/Context"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -18,6 +21,30 @@ import * as Schema from "effect/Schema"; import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; import * as T3ProjectFileLoader from "./T3ProjectFileLoader.ts"; +// Resolution walks up to 12 well-known paths plus 7 source files, so a miss +// costs ~20 filesystem probes. AssetAccess resolves on every project-favicon +// asset URL, and a project's icon does not move, so the answer is cached. +const FAVICON_CACHE_CAPACITY = 512; +const FAVICON_POSITIVE_CACHE_TTL = Duration.minutes(10); +const FAVICON_NEGATIVE_CACHE_TTL = Duration.minutes(1); + +function faviconCacheKey(cwd: string, faviconPath?: string): string { + return `${faviconPath ?? ""}\0${cwd}`; +} + +function parseFaviconCacheKey(key: string): { + readonly cwd: string; + readonly faviconPath?: string; +} { + const separatorIndex = key.indexOf("\0"); + if (separatorIndex === -1) { + return { cwd: key }; + } + const faviconPath = key.slice(0, separatorIndex); + const cwd = key.slice(separatorIndex + 1); + return faviconPath.length === 0 ? { cwd } : { cwd, faviconPath }; +} + // Well-known favicon paths checked in order. const FAVICON_CANDIDATES = [ "favicon.svg", @@ -178,9 +205,10 @@ export const make = Effect.gen(function* () { return null; }); - const resolvePath: ProjectFaviconResolver["Service"]["resolvePath"] = Effect.fn( - "ProjectFaviconResolver.resolvePath", - )(function* (cwd, faviconPath) { + const resolvePathUncached = Effect.fn("ProjectFaviconResolver.resolvePathUncached")(function* ( + cwd: string, + faviconPath?: string, + ): Effect.fn.Return { const projectCwd = yield* workspacePaths.normalizeWorkspaceRoot(cwd).pipe( Effect.mapError( (cause) => @@ -267,6 +295,52 @@ export const make = Effect.gen(function* () { return null; }); + const faviconCache = yield* Cache.makeWith( + (key) => { + const { cwd, faviconPath } = parseFaviconCacheKey(key); + return resolvePathUncached(cwd, faviconPath); + }, + { + capacity: FAVICON_CACHE_CAPACITY, + timeToLive: Exit.match({ + onSuccess: (value: string | null) => + value === null ? FAVICON_NEGATIVE_CACHE_TTL : FAVICON_POSITIVE_CACHE_TTL, + onFailure: () => Duration.zero, + }), + }, + ); + + const resolvePath: ProjectFaviconResolver["Service"]["resolvePath"] = Effect.fn( + "ProjectFaviconResolver.resolvePath", + )(function* (cwd, faviconPath) { + const key = faviconCacheKey(cwd, faviconPath); + const cached = yield* Cache.get(faviconCache, key); + if (cached === null) { + return null; + } + + // A hit still confirms the file with one stat rather than the ~20 probes a + // full walk costs, so a deleted icon falls back at once instead of after + // the TTL. + const stats = yield* optionOnNotFound(fileSystem.stat(cached)).pipe( + Effect.mapError( + (cause) => + new ProjectFaviconResolutionError({ + operation: "stat-candidate", + workspaceRoot: cwd, + absolutePath: cached, + cause, + }), + ), + ); + if (Option.isSome(stats) && stats.value.type === "File") { + return cached; + } + + yield* Cache.invalidate(faviconCache, key); + return yield* Cache.get(faviconCache, key); + }); + return ProjectFaviconResolver.of({ resolvePath }); }); diff --git a/apps/server/src/project/RepositoryIdentityResolver.test.ts b/apps/server/src/project/RepositoryIdentityResolver.test.ts index a997459e63d7..72232a78b689 100644 --- a/apps/server/src/project/RepositoryIdentityResolver.test.ts +++ b/apps/server/src/project/RepositoryIdentityResolver.test.ts @@ -5,6 +5,7 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import { TestClock } from "effect/testing"; import * as ProcessRunner from "../processRunner.ts"; @@ -35,6 +36,89 @@ const makeRepositoryIdentityResolverTestLayer = (options: { ).pipe(Layer.provide(ProcessRunner.layer)); it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { + it.effect("reuses the cached Git root for repeated workspace lookups", () => { + const calls: Array> = []; + const processRunner = Layer.succeed(ProcessRunner.ProcessRunner, { + run: (input) => + Effect.sync(() => { + calls.push(input.args); + return { + stdout: input.args.includes("rev-parse") + ? "/repo\n" + : "origin\tgit@github.com:T3Tools/t3code.git (fetch)\n", + stderr: "", + code: ChildProcessSpawner.ExitCode(0), + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + stdoutInvalidUtf8: false, + stderrInvalidUtf8: false, + }; + }), + }); + const resolverLayer = Layer.effect( + RepositoryIdentityResolver.RepositoryIdentityResolver, + RepositoryIdentityResolver.make(), + ).pipe(Layer.provide(processRunner)); + + return Effect.gen(function* () { + const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; + const first = yield* resolver.resolve("/repo/packages/web"); + const second = yield* resolver.resolve("/repo/packages/web"); + + expect(first?.canonicalKey).toBe("github.com/t3tools/t3code"); + expect(second).toEqual(first); + expect(calls).toEqual([ + ["-C", "/repo/packages/web", "rev-parse", "--show-toplevel"], + ["-C", "/repo", "remote", "-v"], + ]); + }).pipe(Effect.provide(resolverLayer)); + }); + + it.effect("retries Git root discovery after a failed lookup", () => { + const calls: Array> = []; + let rootAttempts = 0; + const processRunner = Layer.succeed(ProcessRunner.ProcessRunner, { + run: (input) => + Effect.sync(() => { + calls.push(input.args); + const rootLookup = input.args.includes("rev-parse"); + const failed = rootLookup && rootAttempts++ === 0; + return { + stdout: rootLookup + ? failed + ? "" + : "/repo\n" + : "origin\tgit@github.com:T3Tools/t3code.git (fetch)\n", + stderr: failed ? "temporary Git failure" : "", + code: ChildProcessSpawner.ExitCode(failed ? 1 : 0), + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + stdoutInvalidUtf8: false, + stderrInvalidUtf8: false, + }; + }), + }); + const resolverLayer = Layer.effect( + RepositoryIdentityResolver.RepositoryIdentityResolver, + RepositoryIdentityResolver.make(), + ).pipe(Layer.provide(processRunner)); + + return Effect.gen(function* () { + const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; + expect(yield* resolver.resolve("/repo/packages/web")).toBeNull(); + + const recovered = yield* resolver.resolve("/repo/packages/web"); + expect(recovered?.rootPath).toBe("/repo"); + expect(calls).toEqual([ + ["-C", "/repo/packages/web", "rev-parse", "--show-toplevel"], + ["-C", "/repo/packages/web", "rev-parse", "--show-toplevel"], + ["-C", "/repo", "remote", "-v"], + ]); + }).pipe(Effect.provide(resolverLayer)); + }); + it.effect("normalizes equivalent GitHub remotes into a stable repository identity", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/project/RepositoryIdentityResolver.ts b/apps/server/src/project/RepositoryIdentityResolver.ts index 50608e7704c7..bf3c570c3cac 100644 --- a/apps/server/src/project/RepositoryIdentityResolver.ts +++ b/apps/server/src/project/RepositoryIdentityResolver.ts @@ -90,7 +90,6 @@ function buildRepositoryIdentity(input: { const resolveRepositoryIdentityCacheKey = Effect.fn("RepositoryIdentityResolver.resolveCacheKey")( function* (cwd: string) { const processRunner = yield* ProcessRunner.ProcessRunner; - let cacheKey = cwd; // git is a real executable on every platform — no cmd.exe shell mode, which // would split paths containing spaces during cmd's re-tokenization. @@ -102,15 +101,11 @@ const resolveRepositoryIdentityCacheKey = Effect.fn("RepositoryIdentityResolver. }) .pipe(Effect.option); if (topLevelResult._tag === "None" || topLevelResult.value.code !== 0) { - return cacheKey; + return null; } const candidate = topLevelResult.value.stdout.trim(); - if (candidate.length > 0) { - cacheKey = candidate; - } - - return cacheKey; + return candidate.length > 0 ? candidate : null; }, ); @@ -139,6 +134,22 @@ export const make = Effect.fn("RepositoryIdentityResolver.make")(function* ( options: RepositoryIdentityResolverOptions = {}, ) { const processRunner = yield* ProcessRunner.ProcessRunner; + const cacheCapacity = options.cacheCapacity ?? DEFAULT_REPOSITORY_IDENTITY_CACHE_CAPACITY; + + const repositoryRootCache = yield* Cache.makeWith( + (cwd) => + resolveRepositoryIdentityCacheKey(cwd).pipe( + Effect.provideService(ProcessRunner.ProcessRunner, processRunner), + ), + { + capacity: cacheCapacity, + timeToLive: Exit.match({ + onSuccess: (value) => + value === null ? Duration.zero : (options.positiveCacheTtl ?? DEFAULT_POSITIVE_CACHE_TTL), + onFailure: () => Duration.zero, + }), + }, + ); const repositoryIdentityCache = yield* Cache.makeWith( (cacheKey) => @@ -146,7 +157,7 @@ export const make = Effect.fn("RepositoryIdentityResolver.make")(function* ( Effect.provideService(ProcessRunner.ProcessRunner, processRunner), ), { - capacity: options.cacheCapacity ?? DEFAULT_REPOSITORY_IDENTITY_CACHE_CAPACITY, + capacity: cacheCapacity, timeToLive: Exit.match({ onSuccess: (value) => value === null @@ -160,9 +171,8 @@ export const make = Effect.fn("RepositoryIdentityResolver.make")(function* ( const resolve: RepositoryIdentityResolver["Service"]["resolve"] = Effect.fn( "RepositoryIdentityResolver.resolve", )(function* (cwd) { - const cacheKey = yield* resolveRepositoryIdentityCacheKey(cwd).pipe( - Effect.provideService(ProcessRunner.ProcessRunner, processRunner), - ); + const cacheKey = yield* Cache.get(repositoryRootCache, cwd); + if (cacheKey === null) return null; return yield* Cache.get(repositoryIdentityCache, cacheKey); }); diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 34e7be163a42..ba3b35252a0b 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -525,6 +525,29 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("preserves xhigh effort for Claude Fable 5.1", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + modelSelection: createModelSelection( + ProviderInstanceId.make("claudeAgent"), + "claude-fable-5-1", + [{ id: "effort", value: "xhigh" }], + ), + runtimeMode: "full-access", + }); + + const createInput = harness.getLastCreateQueryInput(); + assert.equal(createInput?.options.effort, "xhigh"); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("preserves xhigh effort for Claude Fable 5", () => { const harness = makeHarness(); return Effect.gen(function* () { diff --git a/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts b/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts index 7e5fa2611f0f..2f842bf581f7 100644 --- a/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts +++ b/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts @@ -22,6 +22,7 @@ it("isolates Claude capability probes without dropping workspace setting sources environment: { HOME: "/home/user", ENABLE_CLAUDEAI_MCP_SERVERS: "true", + FORCE_CODE_TERMINAL: "1", }, cwd: "/workspace/project", }); @@ -37,6 +38,9 @@ it("isolates Claude capability probes without dropping workspace setting sources assert.equal(options.abortController, abortController); assert.equal(options.env?.HOME, "/home/user"); assert.equal(options.env?.ENABLE_CLAUDEAI_MCP_SERVERS, "false"); + assert.equal(options.env?.FORCE_CODE_TERMINAL, undefined); + assert.equal(options.env?.CLAUDE_CODE_AUTO_CONNECT_IDE, "0"); + assert.equal(options.env?.CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL, "1"); }); it.layer(NodeServices.layer)("Claude capability probe SDK boundary", (it) => { diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index b1858e6a2b31..8cce4c59fe84 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -51,12 +51,48 @@ const CLAUDE_PRESENTATION = { displayName: "Claude", showInteractionModeToggle: true, } as const; +const MINIMUM_CLAUDE_FABLE_5_1_VERSION = "2.1.257"; const MINIMUM_CLAUDE_OPUS_5_VERSION = "2.1.219"; const MINIMUM_CLAUDE_FABLE_5_VERSION = "2.1.169"; const MINIMUM_CLAUDE_OPUS_4_8_VERSION = "2.1.154"; const MINIMUM_CLAUDE_OPUS_4_7_VERSION = "2.1.111"; const CLAUDE_MODEL_CATALOG: ReadonlyArray = [ + { + slug: "claude-fable-5-1", + name: "Claude Fable 5.1", + isCustom: false, + capabilities: createModelCapabilities({ + optionDescriptors: [ + buildSelectOptionDescriptor({ + id: "effort", + label: "Reasoning", + options: [ + { value: "low", label: "Low" }, + { value: "medium", label: "Medium" }, + { value: "high", label: "High", isDefault: true }, + { value: "xhigh", label: "Extra High" }, + { value: "max", label: "Max" }, + { + value: "ultracode", + label: "Ultracode", + description: "xhigh effort plus multi-agent workflow orchestration", + }, + { value: "ultrathink", label: "Ultrathink" }, + ], + promptInjectedValues: ["ultrathink"], + }), + buildSelectOptionDescriptor({ + id: "contextWindow", + label: "Context Window", + options: [ + { value: "200k", label: "200k" }, + { value: "1m", label: "1M", isDefault: true }, + ], + }), + ], + }), + }, { slug: "claude-fable-5", name: "Claude Fable 5", @@ -325,6 +361,10 @@ const CLAUDE_MODEL_CATALOG: ReadonlyArray = [ // so the catalog itself carries no `isLegacy` flags. const BUILT_IN_MODELS: ReadonlyArray = CLAUDE_MODEL_CATALOG; +function supportsClaudeFable51(version: string | null | undefined): boolean { + return version ? compareSemverVersions(version, MINIMUM_CLAUDE_FABLE_5_1_VERSION) >= 0 : false; +} + function supportsClaudeOpus5(version: string | null | undefined): boolean { return version ? compareSemverVersions(version, MINIMUM_CLAUDE_OPUS_5_VERSION) >= 0 : false; } @@ -345,6 +385,9 @@ function getBuiltInClaudeModelsForVersion( version: string | null | undefined, ): ReadonlyArray { return BUILT_IN_MODELS.filter((model) => { + if (model.slug === "claude-fable-5-1") { + return supportsClaudeFable51(version); + } if (model.slug === "claude-opus-5") { return supportsClaudeOpus5(version); } @@ -361,6 +404,11 @@ function getBuiltInClaudeModelsForVersion( }); } +function formatClaudeFable51UpgradeMessage(version: string | null): string { + const versionLabel = version ? `v${version}` : "the installed version"; + return `Claude Code ${versionLabel} is too old for Claude Fable 5.1. Upgrade to v${MINIMUM_CLAUDE_FABLE_5_1_VERSION} or newer to access it.`; +} + function formatClaudeOpus5UpgradeMessage(version: string | null): string { const versionLabel = version ? `v${version}` : "the installed version"; return `Claude Code ${versionLabel} is too old for Claude Opus 5. Upgrade to v${MINIMUM_CLAUDE_OPUS_5_VERSION} or newer to access it.`; @@ -424,6 +472,7 @@ export function normalizeClaudeCliEffort( } if ( effort === "xhigh" && + model !== "claude-fable-5-1" && model !== "claude-fable-5" && model !== "claude-opus-5" && model !== "claude-opus-4-8" && @@ -614,6 +663,12 @@ export function buildClaudeCapabilitiesProbeQueryOptions(input: { // Connected claude.ai MCP servers are discovered outside filesystem // config; disable them independently for this health check. ENABLE_CLAUDEAI_MCP_SERVERS: "false", + // This is a noninteractive health check, so IDE discovery cannot add any + // useful capability data. Skipping it also avoids Claude spawning a + // Windows `tasklist | findstr` process tree on every periodic refresh. + FORCE_CODE_TERMINAL: undefined, + CLAUDE_CODE_AUTO_CONNECT_IDE: "0", + CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL: "1", }, ...(input.cwd ? { cwd: input.cwd } : {}), stderr: () => {}, @@ -907,15 +962,17 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( claudeSettings.customModels, DEFAULT_CLAUDE_MODEL_CAPABILITIES, ); - const versionUpgradeMessage = supportsClaudeOpus5(parsedVersion) + const versionUpgradeMessage = supportsClaudeFable51(parsedVersion) ? undefined - : supportsClaudeFable5(parsedVersion) - ? formatClaudeOpus5UpgradeMessage(parsedVersion) - : supportsClaudeOpus48(parsedVersion) - ? formatClaudeFable5UpgradeMessage(parsedVersion) - : supportsClaudeOpus47(parsedVersion) - ? formatClaudeOpus48UpgradeMessage(parsedVersion) - : formatClaudeOpus47UpgradeMessage(parsedVersion); + : supportsClaudeOpus5(parsedVersion) + ? formatClaudeFable51UpgradeMessage(parsedVersion) + : supportsClaudeFable5(parsedVersion) + ? formatClaudeOpus5UpgradeMessage(parsedVersion) + : supportsClaudeOpus48(parsedVersion) + ? formatClaudeFable5UpgradeMessage(parsedVersion) + : supportsClaudeOpus47(parsedVersion) + ? formatClaudeOpus48UpgradeMessage(parsedVersion) + : formatClaudeOpus47UpgradeMessage(parsedVersion); const capabilities = resolveCapabilities ? yield* resolveCapabilities(claudeSettings).pipe(Effect.orElseSucceed(() => undefined)) diff --git a/apps/server/src/provider/Layers/EventNdjsonLogger.test.ts b/apps/server/src/provider/Layers/EventNdjsonLogger.test.ts index f6fb557e4b43..c072e6e5148f 100644 --- a/apps/server/src/provider/Layers/EventNdjsonLogger.test.ts +++ b/apps/server/src/provider/Layers/EventNdjsonLogger.test.ts @@ -286,7 +286,7 @@ describe("EventNdjsonLogger", () => { }), ); - it.effect("drops transient canonical events before serialization", () => + it.effect("drops transient provider events before serialization", () => Effect.gen(function* () { const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); const basePath = NodePath.join(tempDir, "events.log"); @@ -302,6 +302,46 @@ describe("EventNdjsonLogger", () => { yield* canonical.write(circularDelta, threadId); yield* canonical.write({ type: "item.completed", id: "final" }, threadId); yield* native.write({ type: "content.delta", id: "native-delta" }, threadId); + yield* native.write( + { method: "item/agentMessage/delta", payload: circularDelta }, + threadId, + ); + yield* native.write( + { method: "thread/realtime/outputAudio/delta", payload: circularDelta }, + threadId, + ); + yield* native.write( + { method: "thread/realtime/transcript/delta", payload: circularDelta }, + threadId, + ); + yield* native.write( + { + event: { + method: "claude/stream_event/content_block_delta/text_delta", + payload: circularDelta, + }, + }, + threadId, + ); + yield* native.write( + { + event: { + method: "session/update", + payload: { update: { sessionUpdate: "agent_message_chunk" } }, + }, + }, + threadId, + ); + yield* native.write( + { + event: { + type: "message.part.updated", + payload: { properties: { part: { type: "text" } } }, + }, + }, + threadId, + ); + yield* native.write({ type: "turn.completed", id: "native-final" }, threadId); yield* store.close(); const lines = NodeFS.readFileSync(ownedLogPath(basePath, "thread-filtered"), "utf8") @@ -313,7 +353,7 @@ describe("EventNdjsonLogger", () => { lines.map(({ stream, payload }) => ({ stream, payload })), [ { stream: "CANON", payload: '{"type":"item.completed","id":"final"}' }, - { stream: "NTIVE", payload: '{"type":"content.delta","id":"native-delta"}' }, + { stream: "NTIVE", payload: '{"type":"turn.completed","id":"native-final"}' }, ], ); } finally { diff --git a/apps/server/src/provider/Layers/EventNdjsonLogger.ts b/apps/server/src/provider/Layers/EventNdjsonLogger.ts index e07121ea76c1..241eddb3b9cb 100644 --- a/apps/server/src/provider/Layers/EventNdjsonLogger.ts +++ b/apps/server/src/provider/Layers/EventNdjsonLogger.ts @@ -45,6 +45,17 @@ const transientCanonicalEventTypes = new Set([ "tool.progress", "turn.proposed.delta", ]); +const transientNativeMethods = new Set([ + "item/agentMessage/delta", + "item/commandExecution/outputDelta", + "item/fileChange/outputDelta", + "item/plan/delta", + "item/reasoning/summaryTextDelta", + "item/reasoning/textDelta", + "thread/realtime/outputAudio/delta", + "thread/realtime/transcript/delta", +]); +const transientAcpUpdates = new Set(["agent_message_chunk", "agent_thought_chunk"]); export type EventNdjsonStream = "native" | "canonical" | "orchestration"; @@ -126,7 +137,7 @@ export interface PendingRecord { } interface StoreState { - readonly pending: ReadonlyArray; + readonly pending: Array; readonly pendingBytes: number; readonly sinks: ReadonlyMap; readonly flushScheduled: boolean; @@ -178,12 +189,50 @@ function providerLogPath(directory: string, prefix: string, threadSegment: strin } function shouldPersist(stream: EventNdjsonStream, event: unknown): boolean { - if (stream !== "canonical" || typeof event !== "object" || event === null) { + if (stream === "orchestration" || typeof event !== "object" || event === null) { return true; } try { const type = Reflect.get(event, "type"); - return typeof type !== "string" || !transientCanonicalEventTypes.has(type); + if (typeof type === "string" && transientCanonicalEventTypes.has(type)) { + return false; + } + if (stream !== "native") return true; + + const nested = Reflect.get(event, "event"); + const nativeEvent = typeof nested === "object" && nested !== null ? nested : event; + const method = Reflect.get(nativeEvent, "method"); + if ( + typeof method === "string" && + (transientNativeMethods.has(method) || + method.startsWith("claude/stream_event/content_block_delta/")) + ) { + return false; + } + + const nativeType = Reflect.get(nativeEvent, "type"); + if (nativeType === "message.part.delta") return false; + + const payload = Reflect.get(nativeEvent, "payload"); + if (typeof payload !== "object" || payload === null) return true; + + if (method === "session/update") { + const update = Reflect.get(payload, "update"); + if (typeof update !== "object" || update === null) return true; + const updateType = Reflect.get(update, "sessionUpdate"); + return typeof updateType !== "string" || !transientAcpUpdates.has(updateType); + } + + if (nativeType === "message.part.updated") { + const properties = Reflect.get(payload, "properties"); + if (typeof properties !== "object" || properties === null) return true; + const part = Reflect.get(properties, "part"); + if (typeof part !== "object" || part === null) return true; + const partType = Reflect.get(part, "type"); + return partType !== "text" && partType !== "reasoning"; + } + + return true; } catch { return true; } @@ -566,10 +615,8 @@ export const makeEventNdjsonLogStore = Effect.fnUntraced(function* ( if (state.closed) { return Effect.succeed([{ flush: false }, state] as const); } - const pending = [ - ...state.pending, - { stream, threadSegment: resolveThreadSegment(threadId), line, bytes }, - ]; + const pending = state.pending; + pending.push({ stream, threadSegment: resolveThreadSegment(threadId), line, bytes }); const pendingBytes = state.pendingBytes + bytes; const flush = resolved.batchWindowMs === 0 || diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index 9823a68708c2..d297360e6d34 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -69,6 +69,11 @@ const runtimeMock = { abortImplementation: null as | ((sessionID: string, signal?: AbortSignal) => Promise) | null, + sessionChildrenCalls: [] as string[], + sessionChildrenById: new Map>(), + sessionChildrenImplementation: null as + | ((sessionID: string) => Promise>) + | null, closeCalls: [] as string[], revertCalls: [] as Array<{ sessionID: string; messageID?: string }>, messageCalls: [] as Array<{ sessionID: string; messageID: string }>, @@ -116,6 +121,9 @@ const runtimeMock = { this.state.abortCalls.length = 0; this.state.abortSignals.length = 0; this.state.abortImplementation = null; + this.state.sessionChildrenCalls.length = 0; + this.state.sessionChildrenById.clear(); + this.state.sessionChildrenImplementation = null; this.state.closeCalls.length = 0; this.state.revertCalls.length = 0; this.state.messageCalls.length = 0; @@ -252,6 +260,14 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { } await runtimeMock.state.abortImplementation?.(sessionID, options?.signal); }, + children: async ({ sessionID }: { sessionID: string }) => { + runtimeMock.state.sessionChildrenCalls.push(sessionID); + return { + data: runtimeMock.state.sessionChildrenImplementation + ? await runtimeMock.state.sessionChildrenImplementation(sessionID) + : (runtimeMock.state.sessionChildrenById.get(sessionID) ?? []), + }; + }, status: async () => { runtimeMock.state.sessionStatusCalls += 1; if (runtimeMock.state.sessionStatusImplementation) { @@ -1129,6 +1145,9 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { it.effect("stops a configured-server session without trying to own server lifecycle", () => Effect.gen(function* () { const adapter = yield* OpenCodeAdapter; + const rootSessionId = "http://127.0.0.1:9999/session"; + runtimeMock.state.sessionChildrenById.set(rootSessionId, [{ id: "ses_stop_child" }]); + runtimeMock.state.sessionChildrenById.set("ses_stop_child", [{ id: "ses_stop_grandchild" }]); yield* adapter.startSession({ provider: ProviderDriverKind.make("opencode"), threadId: asThreadId("thread-opencode"), @@ -1138,10 +1157,11 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { yield* adapter.stopSession(asThreadId("thread-opencode")); NodeAssert.deepEqual(runtimeMock.state.startCalls, []); - NodeAssert.deepEqual( - runtimeMock.state.abortCalls.includes("http://127.0.0.1:9999/session"), - true, - ); + NodeAssert.deepEqual(runtimeMock.state.abortCalls, [ + rootSessionId, + "ses_stop_child", + "ses_stop_grandchild", + ]); }), ); @@ -2999,6 +3019,262 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { }), ); + it.effect("stops the full OpenCode child tree before it completes the interrupt", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-interrupt-child-tree"); + const parentAbortEvent = promiseWithResolvers(); + const markerEvent = promiseWithResolvers(); + const parentAbortStarted = promiseWithResolvers(); + const parentAbortRelease = promiseWithResolvers(); + const childAbortStarted = promiseWithResolvers(); + const childAbortRelease = promiseWithResolvers(); + const rootSessionId = "http://127.0.0.1:9999/session"; + runtimeMock.state.subscribedEvents = [parentAbortEvent.promise, markerEvent.promise]; + runtimeMock.state.sessionChildrenById.set(rootSessionId, [ + { id: "ses_child_a" }, + { id: "ses_child_b" }, + ]); + runtimeMock.state.sessionChildrenById.set("ses_child_a", [{ id: "ses_grandchild" }]); + runtimeMock.state.sessionChildrenById.set("ses_unrelated", [{ id: "ses_unrelated_child" }]); + runtimeMock.state.abortImplementation = async (sessionID) => { + if (sessionID === rootSessionId) { + parentAbortStarted.resolve(undefined); + await parentAbortRelease.promise; + } + if (sessionID === "ses_child_a") { + childAbortStarted.resolve(undefined); + await childAbortRelease.promise; + } + }; + + const markerFiber = yield* adapter.streamEvents.pipe( + Stream.filter( + (event) => event.threadId === threadId && event.type === "thread.metadata.updated", + ), + Stream.runHead, + Effect.forkChild, + ); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + const turn = yield* adapter.sendTurn({ + threadId, + input: "Run child agents", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }); + + const interruptFiber = yield* adapter + .interruptTurn(threadId, turn.turnId) + .pipe(Effect.result, Effect.forkChild); + yield* Effect.promise(() => parentAbortStarted.promise); + runtimeMock.state.sessionChildrenById.get(rootSessionId)?.push({ id: "ses_late_child" }); + parentAbortEvent.resolve({ + id: "evt-parent-aborted", + type: "session.error", + properties: { + sessionID: rootSessionId, + error: { name: "MessageAbortedError", data: { message: "Aborted" } }, + }, + }); + markerEvent.resolve({ + id: "evt-after-parent-abort", + type: "session.updated", + properties: { info: { id: rootSessionId, title: "Parent abort received" } }, + }); + yield* Fiber.join(markerFiber); + + NodeAssert.equal(interruptFiber.pollUnsafe(), undefined); + yield* Effect.promise(() => childAbortStarted.promise); + NodeAssert.equal(interruptFiber.pollUnsafe(), undefined); + NodeAssert.equal(runtimeMock.state.abortCalls.includes("ses_unrelated"), false); + NodeAssert.equal(runtimeMock.state.abortCalls.includes("ses_unrelated_child"), false); + const sessionsDuringCleanup = yield* adapter.listSessions(); + const sessionDuringCleanup = sessionsDuringCleanup.find( + (candidate) => candidate.threadId === threadId, + ); + NodeAssert.equal(sessionDuringCleanup?.status, "running"); + NodeAssert.equal(sessionDuringCleanup?.activeTurnId, turn.turnId); + const nextTurnFiber = yield* adapter + .sendTurn({ + threadId, + input: "Start after every child stops", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }) + .pipe(Effect.forkChild); + yield* Effect.yieldNow; + NodeAssert.equal(runtimeMock.state.promptCalls.length, 1); + + childAbortRelease.resolve(undefined); + parentAbortRelease.resolve(undefined); + const result = yield* Fiber.join(interruptFiber); + const nextTurn = yield* Fiber.join(nextTurnFiber); + NodeAssert.equal(result._tag, "Success"); + NodeAssert.notEqual(nextTurn.turnId, turn.turnId); + NodeAssert.equal(runtimeMock.state.promptCalls.length, 2); + NodeAssert.equal(runtimeMock.state.abortCalls[0], rootSessionId); + NodeAssert.deepEqual( + new Set(runtimeMock.state.abortCalls.slice(1)), + new Set(["ses_child_a", "ses_child_b", "ses_grandchild", "ses_late_child"]), + ); + NodeAssert.deepEqual( + new Set(runtimeMock.state.sessionChildrenCalls), + new Set([rootSessionId, "ses_child_a", "ses_child_b", "ses_grandchild", "ses_late_child"]), + ); + const sessions = yield* adapter.listSessions(); + const session = sessions.find((candidate) => candidate.threadId === threadId); + NodeAssert.equal(session?.status, "running"); + NodeAssert.equal(session?.activeTurnId, nextTurn.turnId); + + runtimeMock.state.abortImplementation = null; + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("limits SDK requests across the full OpenCode child tree", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-interrupt-child-request-limit"); + const rootSessionId = "http://127.0.0.1:9999/session"; + const requestRelease = promiseWithResolvers(); + const limitReached = promiseWithResolvers(); + let inFlight = 0; + let maxInFlight = 0; + const holdRequest = async (result: T): Promise => { + inFlight += 1; + maxInFlight = Math.max(maxInFlight, inFlight); + if (inFlight === 8) { + limitReached.resolve(undefined); + } + await requestRelease.promise; + inFlight -= 1; + return result; + }; + + const children = Array.from({ length: 8 }, (_, index) => ({ id: `ses_child_${index}` })); + runtimeMock.state.sessionChildrenById.set(rootSessionId, children); + for (const child of children.slice(1)) { + runtimeMock.state.sessionChildrenById.set( + child.id, + Array.from({ length: 8 }, (_, index) => ({ id: `${child.id}_nested_${index}` })), + ); + } + runtimeMock.state.abortImplementation = async (sessionID) => { + if (sessionID.includes("_nested_")) { + await holdRequest(undefined); + } + }; + runtimeMock.state.sessionChildrenImplementation = async (sessionID) => { + if (sessionID === "ses_child_0") { + return await holdRequest([]); + } + return runtimeMock.state.sessionChildrenById.get(sessionID) ?? []; + }; + + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + const turn = yield* adapter.sendTurn({ + threadId, + input: "Run a nested child tree", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }); + + const interruptFiber = yield* adapter + .interruptTurn(threadId, turn.turnId) + .pipe(Effect.forkChild); + yield* Effect.promise(() => limitReached.promise); + yield* Effect.yieldNow; + + NodeAssert.equal(inFlight, 8); + NodeAssert.equal(maxInFlight, 8); + + requestRelease.resolve(undefined); + yield* Fiber.join(interruptFiber); + + runtimeMock.state.abortImplementation = null; + runtimeMock.state.sessionChildrenImplementation = null; + runtimeMock.state.sessionChildrenById.clear(); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("attempts every child abort and fails the interrupt when one child abort fails", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-interrupt-child-failure"); + const rootSessionId = "http://127.0.0.1:9999/session"; + const failingChildStarted = promiseWithResolvers(); + const failingChildRelease = promiseWithResolvers(); + const siblingAbortStarted = promiseWithResolvers(); + runtimeMock.state.sessionChildrenById.set(rootSessionId, [ + { id: "ses_failing_child" }, + { id: "ses_surviving_sibling" }, + ]); + runtimeMock.state.abortImplementation = async (sessionID) => { + if (sessionID === "ses_failing_child") { + failingChildStarted.resolve(undefined); + await failingChildRelease.promise; + throw new Error("child abort failed"); + } + if (sessionID === "ses_surviving_sibling") { + siblingAbortStarted.resolve(undefined); + } + }; + + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + const turn = yield* adapter.sendTurn({ + threadId, + input: "Run child agents", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }); + + const interruptFiber = yield* adapter + .interruptTurn(threadId, turn.turnId) + .pipe(Effect.result, Effect.forkChild); + yield* Effect.promise(() => failingChildStarted.promise); + yield* Effect.promise(() => siblingAbortStarted.promise); + NodeAssert.equal(interruptFiber.pollUnsafe(), undefined); + failingChildRelease.resolve(undefined); + const result = yield* Fiber.join(interruptFiber); + + NodeAssert.equal(result._tag, "Failure"); + if (result._tag === "Failure") { + NodeAssert.equal(result.failure._tag, "ProviderAdapterRequestError"); + NodeAssert.equal(result.failure.detail, "child abort failed"); + } + NodeAssert.equal(runtimeMock.state.abortCalls.includes("ses_failing_child"), true); + NodeAssert.equal(runtimeMock.state.abortCalls.includes("ses_surviving_sibling"), true); + const sessions = yield* adapter.listSessions(); + const session = sessions.find((candidate) => candidate.threadId === threadId); + NodeAssert.equal(session?.status, "running"); + NodeAssert.equal(session?.activeTurnId, turn.turnId); + + runtimeMock.state.abortImplementation = null; + yield* adapter.stopSession(threadId); + }), + ); + it.effect("keeps an idle event from completing a turn while its abort request is pending", () => Effect.gen(function* () { const adapter = yield* OpenCodeAdapter; @@ -4278,11 +4554,20 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { const threadId = asThreadId("thread-interrupt-provider-error"); const errorEvent = promiseWithResolvers(); const abortStarted = promiseWithResolvers(); - const abortRelease = promiseWithResolvers(); + const childAbortStarted = promiseWithResolvers(); + const childAbortRelease = promiseWithResolvers(); + const rootSessionId = "http://127.0.0.1:9999/session"; runtimeMock.state.subscribedEvents = [errorEvent.promise]; - runtimeMock.state.abortImplementation = async () => { - abortStarted.resolve(undefined); - await abortRelease.promise; + runtimeMock.state.sessionChildrenById.set(rootSessionId, [{ id: "ses_error_child" }]); + runtimeMock.state.abortImplementation = async (sessionID) => { + if (sessionID === rootSessionId) { + abortStarted.resolve(undefined); + await new Promise(() => {}); + } + if (sessionID === "ses_error_child") { + childAbortStarted.resolve(undefined); + await childAbortRelease.promise; + } }; const eventsFiber = yield* adapter.streamEvents.pipe( @@ -4313,16 +4598,14 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { id: "evt-provider-error-after-stop", type: "session.error", properties: { - sessionID: "http://127.0.0.1:9999/session", + sessionID: rootSessionId, error: { name: "APIError", data: { message: "Upstream failed", isRetryable: false }, }, }, }); - yield* Effect.yieldNow; - abortRelease.resolve(undefined); - yield* Fiber.join(interruptFiber); + yield* Effect.promise(() => childAbortStarted.promise); const events = Array.from(yield* Fiber.join(eventsFiber).pipe(Effect.timeout("1 second"))); NodeAssert.deepEqual( @@ -4341,7 +4624,41 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { failed?.type === "turn.completed" ? failed.payload.state : undefined, "failed", ); + const sessionsDuringCleanup = yield* adapter.listSessions(); + const sessionDuringCleanup = sessionsDuringCleanup.find( + (candidate) => candidate.threadId === threadId, + ); + NodeAssert.equal(sessionDuringCleanup?.status, "error"); + NodeAssert.equal(sessionDuringCleanup?.activeTurnId, undefined); + + const secondInterruptFiber = yield* adapter.interruptTurn(threadId).pipe(Effect.forkChild); + const nextTurnFiber = yield* adapter + .sendTurn({ + threadId, + input: "Start after child cleanup", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }) + .pipe(Effect.forkChild); + yield* Effect.yieldNow; + NodeAssert.equal( + runtimeMock.state.abortCalls.filter((sessionID) => sessionID === rootSessionId).length, + 1, + ); + NodeAssert.equal(secondInterruptFiber.pollUnsafe(), undefined); + NodeAssert.equal(nextTurnFiber.pollUnsafe(), undefined); + NodeAssert.equal(runtimeMock.state.promptCalls.length, 1); + childAbortRelease.resolve(undefined); + yield* Fiber.join(interruptFiber); + yield* Fiber.join(secondInterruptFiber); + const nextTurn = yield* Fiber.join(nextTurnFiber); + NodeAssert.notEqual(nextTurn.turnId, turn.turnId); + NodeAssert.equal(runtimeMock.state.promptCalls.length, 2); + + runtimeMock.state.abortImplementation = null; yield* adapter.stopSession(threadId); }), ); @@ -4607,12 +4924,27 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { const firstUpdate = mergeOpenCodeAssistantText(undefined, "Hello"); const overlapDelta = appendOpenCodeAssistantTextDelta(firstUpdate.latestText, "lo world"); const secondUpdate = mergeOpenCodeAssistantText(overlapDelta.nextText, "Hellolo world"); + const appendedUpdate = mergeOpenCodeAssistantText("Hello", "Hello world"); + const changedUpdate = mergeOpenCodeAssistantText("Hello world", "Hello there"); + const staleUpdate = mergeOpenCodeAssistantText("Hello world", "Hello"); NodeAssert.deepEqual( [firstUpdate.deltaToEmit, overlapDelta.deltaToEmit, secondUpdate.deltaToEmit], ["Hello", "lo world", ""], ); NodeAssert.equal(secondUpdate.latestText, "Hellolo world"); + NodeAssert.deepEqual(appendedUpdate, { + latestText: "Hello world", + deltaToEmit: " world", + }); + NodeAssert.deepEqual(changedUpdate, { + latestText: "Hello there", + deltaToEmit: "there", + }); + NodeAssert.deepEqual(staleUpdate, { + latestText: "Hello world", + deltaToEmit: "", + }); }), ); diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index c049eedb62b5..d0b4f0de78ce 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -192,8 +192,10 @@ const decodeOpenCodeSessionStatusMap = Schema.decodeUnknownOption(OpenCodeSessio interface OpenCodeCancellation { readonly turnId: TurnId | undefined; + readonly acknowledgment: Deferred.Deferred; readonly completion: Deferred.Deferred; acknowledged?: boolean; + turnSettled?: boolean; deferredIdleEvent?: OpenCodeSessionStatusEvent; } @@ -565,9 +567,13 @@ export function mergeOpenCodeAssistantText( readonly deltaToEmit: string; } { const latestText = resolveLatestAssistantText(previousText, nextText); + const previous = previousText ?? ""; + const prefixLength = latestText.startsWith(previous) + ? previous.length + : commonPrefixLength(previous, latestText); return { latestText, - deltaToEmit: latestText.slice(commonPrefixLength(previousText ?? "", latestText)), + deltaToEmit: latestText.slice(prefixLength), }; } @@ -698,10 +704,88 @@ const failPendingOpenCodeCancellation = Effect.fn("failPendingOpenCodeCancellati ).pipe(Effect.ignore); }); -const abortOpenCodeSessionForTeardown = (context: OpenCodeSessionContext) => - runOpenCodeSdk("session.abort", (signal) => +const abortOpenCodeDescendants = Effect.fn("abortOpenCodeDescendants")(function* ( + context: OpenCodeSessionContext, +) { + const visited = new Set([context.openCodeSessionId]); + const requestSemaphore = Semaphore.makeUnsafe(8); + + const visit = ( + sessionId: string, + abortSession: boolean, + ): Effect.Effect => + Effect.gen(function* () { + let firstFailure: OpenCodeRuntimeError | undefined; + if (abortSession) { + const abortResult = yield* requestSemaphore + .withPermit( + runOpenCodeSdk("session.abort", (signal) => + context.client.session.abort({ sessionID: sessionId }, { signal }), + ), + ) + .pipe( + Effect.catchIf( + (cause) => isOpenCodeNotFound(cause), + () => Effect.void, + ), + Effect.result, + ); + if (abortResult._tag === "Failure") { + firstFailure = abortResult.failure; + } + } + + const childrenResult = yield* requestSemaphore + .withPermit( + runOpenCodeSdk("session.children", (signal) => + context.client.session.children({ sessionID: sessionId }, { signal }), + ), + ) + .pipe( + Effect.catchIf( + (cause) => isOpenCodeNotFound(cause), + () => Effect.void, + ), + Effect.result, + ); + if (childrenResult._tag === "Failure") { + return firstFailure ?? childrenResult.failure; + } + + const children = childrenResult.success?.data ?? []; + const newChildren = children.filter((child) => { + if (visited.has(child.id)) { + return false; + } + visited.add(child.id); + return true; + }); + const childFailures = yield* Effect.forEach(newChildren, (child) => visit(child.id, true), { + concurrency: 8, + }); + firstFailure ??= childFailures.find((failure) => failure !== undefined); + return firstFailure; + }); + + const firstFailure = yield* visit(context.openCodeSessionId, false); + if (firstFailure) { + return yield* firstFailure; + } +}); + +const abortOpenCodeSessionForTeardown = Effect.fn("abortOpenCodeSessionForTeardown")(function* ( + context: OpenCodeSessionContext, +) { + // Stop the parent before the snapshot so it cannot add another child after + // the adapter reads the tree. + yield* runOpenCodeSdk("session.abort", (signal) => context.client.session.abort({ sessionID: context.openCodeSessionId }, { signal }), ).pipe(Effect.timeout("1 second"), Effect.ignore({ log: true })); + yield* abortOpenCodeDescendants(context).pipe( + Effect.timeout("1 second"), + Effect.ignore({ log: true }), + ); +}); const cancelPendingOpenCodePrompt = Effect.fn("cancelPendingOpenCodePrompt")(function* ( context: OpenCodeSessionContext, @@ -2154,13 +2238,12 @@ export function makeOpenCodeAdapter( if (isOpenCodeAbortError(event.properties.error)) { if (cancellation !== undefined && cancellation.turnId === undefined) { cancellation.acknowledged = true; - context.cancellation = undefined; - context.reconcileIdleStatus = true; - yield* Deferred.succeed(cancellation.completion, undefined).pipe(Effect.ignore); + yield* Deferred.succeed(cancellation.acknowledgment, undefined).pipe(Effect.ignore); break; } if (activeTurnId !== undefined && cancellation?.turnId === activeTurnId) { - yield* interruptOpenCodeTurn(context, activeTurnId, event); + cancellation.acknowledged = true; + yield* Deferred.succeed(cancellation.acknowledgment, undefined).pipe(Effect.ignore); break; } if (context.interruptedTurnId !== undefined || context.reconcileIdleStatus) { @@ -2168,9 +2251,13 @@ export function makeOpenCodeAdapter( } } yield* cancelIdleReconciliation(context); - if (activeTurnId !== undefined && cancellation?.turnId === activeTurnId) { - context.cancellation = undefined; - yield* Deferred.succeed(cancellation.completion, undefined).pipe(Effect.ignore); + const terminalCancellation = + activeTurnId !== undefined && cancellation?.turnId === activeTurnId + ? cancellation + : undefined; + if (terminalCancellation) { + terminalCancellation.turnSettled = true; + terminalCancellation.acknowledged = true; } context.activeTurnId = undefined; context.activeAgent = undefined; @@ -2210,6 +2297,11 @@ export function makeOpenCodeAdapter( detail: event.properties.error, }, }); + if (terminalCancellation) { + yield* Deferred.succeed(terminalCancellation.acknowledgment, undefined).pipe( + Effect.ignore, + ); + } break; } @@ -2905,14 +2997,12 @@ export function makeOpenCodeAdapter( return; } const existingCancellation = context.cancellation; - if ( - existingCancellation !== undefined && - existingCancellation.turnId === interruptedTurnId - ) { + if (existingCancellation !== undefined) { return yield* Deferred.await(existingCancellation.completion); } const cancellation: OpenCodeCancellation = { turnId: interruptedTurnId, + acknowledgment: Deferred.makeUnsafe(), completion: Deferred.makeUnsafe(), }; context.cancellation = cancellation; @@ -2925,10 +3015,11 @@ export function makeOpenCodeAdapter( yield* Deferred.await(promptAdmission.submissionSettled); } - const abortOutcome = yield* Effect.raceFirst( + const parentAbortOutcome = yield* Effect.raceFirst( runOpenCodeSdk("session.abort", (signal) => context.client.session.abort({ sessionID: context.openCodeSessionId }, { signal }), ).pipe( + Effect.asVoid, Effect.timeout("10 seconds"), Effect.catchTags({ OpenCodeRuntimeError: (cause) => Effect.fail(toRequestError(cause)), @@ -2945,33 +3036,67 @@ export function makeOpenCodeAdapter( Effect.exit, Effect.map((exit) => ({ source: "request" as const, exit })), ), + Effect.raceFirst( + Deferred.await(cancellation.acknowledgment).pipe( + Effect.map(() => ({ source: "acknowledgment" as const })), + ), + Deferred.await(cancellation.completion).pipe( + Effect.exit, + Effect.map((exit) => ({ source: "completion" as const, exit })), + ), + ), + ); + if (parentAbortOutcome.source === "completion") { + return Exit.isFailure(parentAbortOutcome.exit) + ? yield* Effect.failCause(parentAbortOutcome.exit.cause) + : undefined; + } + const parentAbortExit = + parentAbortOutcome.source === "request" ? parentAbortOutcome.exit : Exit.void; + + const descendantAbortOutcome = yield* Effect.raceFirst( + abortOpenCodeDescendants(context).pipe( + Effect.timeout("10 seconds"), + Effect.catchTags({ + OpenCodeRuntimeError: (cause) => Effect.fail(toRequestError(cause)), + TimeoutError: (cause) => + Effect.fail( + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session.abort", + detail: "OpenCode child session cleanup did not complete within 10 seconds.", + cause, + }), + ), + }), + Effect.exit, + Effect.map((exit) => ({ source: "request" as const, exit })), + ), Deferred.await(cancellation.completion).pipe( Effect.exit, - Effect.map((exit) => ({ source: "event" as const, exit })), + Effect.map((exit) => ({ source: "completion" as const, exit })), ), ); - if (abortOutcome.source === "event") { - return Exit.isFailure(abortOutcome.exit) - ? yield* Effect.failCause(abortOutcome.exit.cause) + if (descendantAbortOutcome.source === "completion") { + return Exit.isFailure(descendantAbortOutcome.exit) + ? yield* Effect.failCause(descendantAbortOutcome.exit.cause) : undefined; } - const abortExit = abortOutcome.exit; - if (Exit.isFailure(abortExit)) { - if (interruptedTurnId && context.interruptedTurnId === interruptedTurnId) { - yield* Deferred.succeed(cancellation.completion, undefined).pipe(Effect.ignore); - return; - } - if (cancellation.turnId === undefined && cancellation.acknowledged) { - if (context.cancellation === cancellation) { - context.cancellation = undefined; - context.reconcileIdleStatus = true; - } - yield* Deferred.succeed(cancellation.completion, undefined).pipe(Effect.ignore); - return; - } + + const parentAbortFailed = Exit.isFailure(parentAbortExit) && !cancellation.acknowledged; + const failedExit = parentAbortFailed + ? parentAbortExit + : Exit.isFailure(descendantAbortOutcome.exit) + ? descendantAbortOutcome.exit + : undefined; + if (failedExit !== undefined && Exit.isFailure(failedExit)) { if (context.cancellation === cancellation) { context.cancellation = undefined; - if (cancellation.turnId !== undefined && cancellation.deferredIdleEvent) { + if ( + parentAbortFailed && + cancellation.turnId !== undefined && + cancellation.deferredIdleEvent + ) { yield* scheduleIdleReconciliation( context, cancellation.turnId, @@ -2979,12 +3104,14 @@ export function makeOpenCodeAdapter( ); } } - yield* Deferred.done(cancellation.completion, abortExit).pipe(Effect.ignore); - return yield* Effect.failCause(abortExit.cause); + yield* Deferred.done(cancellation.completion, failedExit).pipe(Effect.ignore); + return yield* Effect.failCause(failedExit.cause); } if (context.cancellation === cancellation) { - if (cancellation.turnId !== undefined) { + if (cancellation.turnSettled) { + context.cancellation = undefined; + } else if (cancellation.turnId !== undefined) { yield* interruptOpenCodeTurn(context, cancellation.turnId); } else { context.cancellation = undefined; diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index b104607e7932..ad4ddd0d0fd1 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -2144,6 +2144,62 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ), ); + it.effect("includes Claude Fable 5.1 on supported Claude Code versions", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus( + defaultClaudeSettings, + claudeCapabilities(), + ); + const fable51 = status.models.find((model) => model.slug === "claude-fable-5-1"); + assert.strictEqual(fable51?.name, "Claude Fable 5.1"); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "2.1.257\n", stderr: "", code: 0 }; + if (joined === "auth status") + return { + stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', + stderr: "", + code: 0, + }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + + it.effect("hides Claude Fable 5.1 on older Claude Code versions", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus( + defaultClaudeSettings, + claudeCapabilities(), + ); + assert.strictEqual( + status.models.some((model) => model.slug === "claude-fable-5-1"), + false, + ); + assert.strictEqual( + status.message, + "Claude Code v2.1.256 is too old for Claude Fable 5.1. Upgrade to v2.1.257 or newer to access it.", + ); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "2.1.256\n", stderr: "", code: 0 }; + if (joined === "auth status") + return { + stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', + stderr: "", + code: 0, + }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + it.effect("hides Claude Fable 5 on older Claude Code versions", () => Effect.gen(function* () { const status = yield* checkClaudeProviderStatus( diff --git a/apps/server/src/provider/ModelManifest.test.ts b/apps/server/src/provider/ModelManifest.test.ts index fdcfa9335424..102940ea2374 100644 --- a/apps/server/src/provider/ModelManifest.test.ts +++ b/apps/server/src/provider/ModelManifest.test.ts @@ -43,12 +43,16 @@ describe("isLegacyModel (bundled manifest)", () => { it("keeps only the Claude 5 family out of legacy models", () => { assert.deepStrictEqual( - ["claude-fable-5", "claude-opus-5", "claude-sonnet-5", "claude-opus-4-8"].map((model) => [ - model, - isLegacyModel(BUNDLED_MODEL_MANIFEST, CLAUDE, model), - ]), [ - ["claude-fable-5", false], + "claude-fable-5-1", + "claude-fable-5", + "claude-opus-5", + "claude-sonnet-5", + "claude-opus-4-8", + ].map((model) => [model, isLegacyModel(BUNDLED_MODEL_MANIFEST, CLAUDE, model)]), + [ + ["claude-fable-5-1", false], + ["claude-fable-5", true], ["claude-opus-5", false], ["claude-sonnet-5", false], ["claude-opus-4-8", true], diff --git a/apps/server/src/provider/acp/AcpNativeLogging.test.ts b/apps/server/src/provider/acp/AcpNativeLogging.test.ts index 7c949e040599..84926fbe1d61 100644 --- a/apps/server/src/provider/acp/AcpNativeLogging.test.ts +++ b/apps/server/src/provider/acp/AcpNativeLogging.test.ts @@ -28,6 +28,7 @@ nodeServicesIt("ACP native logging", (it) => { nativeEventLogger, provider: ProviderDriverKind.make("cursor"), threadId: ThreadId.make("thread-1"), + verboseProtocolLogging: true, }); const secret = "secret-token-value"; const requestLogger = logger.requestLogger; @@ -67,6 +68,174 @@ nodeServicesIt("ACP native logging", (it) => { }), ); + it.effect("keeps request diagnostics without enabling full protocol logging", () => + Effect.gen(function* () { + const records: Array = []; + const makeLogger = yield* makeAcpNativeLoggerFactory(); + const logger = makeLogger({ + nativeEventLogger: { + filePath: "/tmp/provider-native.ndjson", + write: (event) => Effect.sync(() => void records.push(event)), + close: () => Effect.void, + }, + provider: ProviderDriverKind.make("grok"), + threadId: ThreadId.make("thread-1"), + }); + + assert.isUndefined(logger.protocolLogging); + const requestLogger = logger.requestLogger; + assert.exists(requestLogger); + if (!requestLogger) return; + yield* requestLogger({ + method: "session/prompt", + payload: {}, + status: "started", + }); + assert.lengthOf(records, 1); + }), + ); + + it.effect("drops transient ACP chunks before formatting verbose protocol logs", () => + Effect.gen(function* () { + const records: Array = []; + const makeLogger = yield* makeAcpNativeLoggerFactory(); + const logger = makeLogger({ + nativeEventLogger: { + filePath: "/tmp/provider-native.ndjson", + write: (event) => Effect.sync(() => void records.push(event)), + close: () => Effect.void, + }, + provider: ProviderDriverKind.make("cursor"), + threadId: ThreadId.make("thread-1"), + verboseProtocolLogging: true, + }); + const protocolLogger = logger.protocolLogging?.logger; + assert.exists(protocolLogger); + if (!protocolLogger) return; + + for (const updateType of ["agent_message_chunk", "agent_thought_chunk"] as const) { + yield* protocolLogger({ + direction: "incoming", + stage: "raw", + payload: `${encodeUnknownJson({ + method: "session/update", + params: { update: { sessionUpdate: updateType } }, + })}\n`, + }); + yield* protocolLogger({ + direction: "incoming", + stage: "decoded", + payload: [ + { + _tag: "Request", + tag: "session/update", + payload: { update: { sessionUpdate: updateType } }, + }, + ], + }); + } + + assert.lengthOf(records, 0); + + yield* protocolLogger({ + direction: "incoming", + stage: "decoded", + payload: [ + { + _tag: "Request", + tag: "session/update", + payload: { update: { sessionUpdate: "tool_call" } }, + }, + ], + }); + assert.lengthOf(records, 1); + }), + ); + + it.effect("keeps mixed and incomplete raw diagnostics", () => + Effect.gen(function* () { + const records: Array = []; + const makeLogger = yield* makeAcpNativeLoggerFactory(); + const logger = makeLogger({ + nativeEventLogger: { + filePath: "/tmp/provider-native.ndjson", + write: (event) => Effect.sync(() => void records.push(event)), + close: () => Effect.void, + }, + provider: ProviderDriverKind.make("cursor"), + threadId: ThreadId.make("thread-1"), + verboseProtocolLogging: true, + }); + const protocolLogger = logger.protocolLogging?.logger; + assert.exists(protocolLogger); + if (!protocolLogger) return; + + const transient = encodeUnknownJson({ + method: "session/update", + params: { update: { sessionUpdate: "agent_message_chunk" } }, + }); + const lifecycle = encodeUnknownJson({ method: "session/new", params: {} }); + + yield* protocolLogger({ + direction: "incoming", + stage: "raw", + payload: `${transient}\n${lifecycle}\n`, + }); + yield* protocolLogger({ + direction: "incoming", + stage: "raw", + payload: transient, + }); + yield* protocolLogger({ + direction: "incoming", + stage: "raw", + payload: `${transient}\n{malformed}\n`, + }); + + assert.lengthOf(records, 3); + }), + ); + + it.effect("filters transient entries from mixed decoded batches", () => + Effect.gen(function* () { + const records: Array = []; + const makeLogger = yield* makeAcpNativeLoggerFactory(); + const logger = makeLogger({ + nativeEventLogger: { + filePath: "/tmp/provider-native.ndjson", + write: (event) => Effect.sync(() => void records.push(event)), + close: () => Effect.void, + }, + provider: ProviderDriverKind.make("grok"), + threadId: ThreadId.make("thread-1"), + verboseProtocolLogging: true, + }); + const protocolLogger = logger.protocolLogging?.logger; + assert.exists(protocolLogger); + if (!protocolLogger) return; + + yield* protocolLogger({ + direction: "incoming", + stage: "decoded", + payload: [ + { + _tag: "Request", + tag: "session/update", + payload: { update: { sessionUpdate: "agent_thought_chunk" } }, + }, + { + _tag: "Request", + tag: "session/new", + payload: {}, + }, + ], + }); + + assert.lengthOf(records, 1); + assert.include(encodeUnknownJson(records), '"itemCount":1'); + }), + ); + it.effect("logs a structural tag when the native writer defects", () => { const messages: Array = []; const logCapture = Logger.make(({ message }) => { diff --git a/apps/server/src/provider/acp/AcpNativeLogging.ts b/apps/server/src/provider/acp/AcpNativeLogging.ts index 06bff3aa6113..6d1bf6209d5d 100644 --- a/apps/server/src/provider/acp/AcpNativeLogging.ts +++ b/apps/server/src/provider/acp/AcpNativeLogging.ts @@ -9,6 +9,8 @@ import type * as EffectAcpProtocol from "effect-acp/protocol"; import type { EventNdjsonLogger } from "../Layers/EventNdjsonLogger.ts"; import type * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; +const transientProtocolUpdates = new Set(["agent_message_chunk", "agent_thought_chunk"]); + function structuralMethod(value: string): string { return value.length <= 128 && /^[A-Za-z][A-Za-z0-9._:/-]*$/.test(value) ? value : "unknown"; } @@ -64,12 +66,61 @@ function formatProtocolLogPayload(event: EffectAcpProtocol.AcpProtocolLogEvent) }; } +function isTransientProtocolMessage(message: unknown): boolean { + if (typeof message !== "object" || message === null) return false; + const method = Reflect.get(message, "tag") ?? Reflect.get(message, "method"); + if (method !== "session/update") return false; + + const payload = Reflect.get(message, "payload") ?? Reflect.get(message, "params"); + if (typeof payload !== "object" || payload === null) return false; + const update = Reflect.get(payload, "update"); + if (typeof update !== "object" || update === null) return false; + const updateType = Reflect.get(update, "sessionUpdate"); + return typeof updateType === "string" && transientProtocolUpdates.has(updateType); +} + +function rawChunkContainsOnlyTransientMessages(payload: string): boolean { + const lines = payload.split("\n"); + const remainder = lines.pop() ?? ""; + if (remainder.trim().length > 0) return false; + + const messages: Array = []; + for (const line of lines) { + if (line.trim().length === 0) continue; + try { + messages.push(JSON.parse(line)); + } catch { + return false; + } + } + return messages.length > 0 && messages.every(isTransientProtocolMessage); +} + +function filterTransientProtocolLog( + event: EffectAcpProtocol.AcpProtocolLogEvent, +): EffectAcpProtocol.AcpProtocolLogEvent | undefined { + if (event.direction !== "incoming") return event; + + if (event.stage === "raw" && typeof event.payload === "string") { + return rawChunkContainsOnlyTransientMessages(event.payload) ? undefined : event; + } + + if (event.stage !== "decoded") return event; + if (!Array.isArray(event.payload)) { + return isTransientProtocolMessage(event.payload) ? undefined : event; + } + + const payload = event.payload.filter((message) => !isTransientProtocolMessage(message)); + return payload.length === 0 ? undefined : { ...event, payload }; +} + export const makeAcpNativeLoggerFactory = Effect.fn("makeAcpNativeLoggerFactory")(function* () { const crypto = yield* Crypto.Crypto; return (input: { readonly nativeEventLogger: EventNdjsonLogger | undefined; readonly provider: ProviderDriverKind; readonly threadId: ThreadId; + readonly verboseProtocolLogging?: boolean; }): Pick => { const writeNativeAcpLog = (logInput: { readonly kind: "request" | "protocol"; @@ -111,16 +162,20 @@ export const makeAcpNativeLoggerFactory = Effect.fn("makeAcpNativeLoggerFactory" kind: "request", payload: formatRequestLogPayload(event), }), - ...(input.nativeEventLogger + ...(input.nativeEventLogger && input.verboseProtocolLogging ? { protocolLogging: { logIncoming: true, logOutgoing: true, - logger: (event: EffectAcpProtocol.AcpProtocolLogEvent) => - writeNativeAcpLog({ - kind: "protocol", - payload: formatProtocolLogPayload(event), - }), + logger: (event: EffectAcpProtocol.AcpProtocolLogEvent) => { + const filtered = filterTransientProtocolLog(event); + return filtered + ? writeNativeAcpLog({ + kind: "protocol", + payload: formatProtocolLogPayload(filtered), + }) + : Effect.void; + }, } satisfies NonNullable, } : {}), diff --git a/apps/server/src/provider/model-manifest.json b/apps/server/src/provider/model-manifest.json index 7022ce226170..337c32e1fda1 100644 --- a/apps/server/src/provider/model-manifest.json +++ b/apps/server/src/provider/model-manifest.json @@ -8,6 +8,6 @@ "gpt-daybreak-blue-latest", "gpt-daybreak-red-latest" ], - "claudeAgent": ["claude-fable-5", "claude-opus-5", "claude-sonnet-5"] + "claudeAgent": ["claude-fable-5-1", "claude-opus-5", "claude-sonnet-5"] } } diff --git a/apps/server/src/resourceTelemetry/NativeTelemetryClient.test.ts b/apps/server/src/resourceTelemetry/NativeTelemetryClient.test.ts index 61a67d116069..8a595bc8b480 100644 --- a/apps/server/src/resourceTelemetry/NativeTelemetryClient.test.ts +++ b/apps/server/src/resourceTelemetry/NativeTelemetryClient.test.ts @@ -44,7 +44,7 @@ describe("resolveNativeSampleIntervalMs", () => { expect(resolveNativeSampleIntervalMs({ ...basePower, onBattery: "true" }, 1)).toBe(5_000); }); - it("keeps unknown background telemetry cheap but serves live diagnostics at 1Hz", () => { + it("slows background telemetry and serves live diagnostics at 1Hz", () => { const unknown: HostPowerSnapshot = { ...basePower, source: "unknown", @@ -58,7 +58,8 @@ describe("resolveNativeSampleIntervalMs", () => { 0, ), ).toBe(5_000); - expect(resolveNativeSampleIntervalMs(basePower, 0)).toBe(1_000); + expect(resolveNativeSampleIntervalMs(basePower, 0)).toBe(5_000); + expect(resolveNativeSampleIntervalMs(basePower, 1)).toBe(1_000); }); }); diff --git a/apps/server/src/resourceTelemetry/NativeTelemetryClient.ts b/apps/server/src/resourceTelemetry/NativeTelemetryClient.ts index e8d81cc4c1c0..232079d9dc9b 100644 --- a/apps/server/src/resourceTelemetry/NativeTelemetryClient.ts +++ b/apps/server/src/resourceTelemetry/NativeTelemetryClient.ts @@ -268,7 +268,7 @@ export function resolveNativeSampleIntervalMs( return CONSTRAINED_SAMPLE_INTERVAL_MS; } if (snapshot.onBattery === "true") return BATTERY_SAMPLE_INTERVAL_MS; - return SAMPLE_INTERVAL_MS; + return liveSubscriberCount > 0 ? SAMPLE_INTERVAL_MS : UNKNOWN_BACKGROUND_SAMPLE_INTERVAL_MS; } export function commitCollectionControlUpdate( @@ -462,13 +462,16 @@ export const make = Effect.fn("resourceTelemetry.nativeTelemetryClient.make")(fu return Effect.gen(function* () { const nativeSnapshot = { generation, snapshot: event } satisfies NativeTelemetrySnapshot; const sampledAt = DateTime.makeUnsafe(event.sampledAtUnixMs); - yield* Ref.update(state, (current) => ({ - ...current, - status: "healthy" as const, - lastSampleAt: Option.some(sampledAt), - lastError: Option.none(), - })); - yield* publishHealth; + const healthChanged = yield* Ref.modify(state, (current) => [ + current.status !== "healthy" || Option.isSome(current.lastError), + { + ...current, + status: "healthy" as const, + lastSampleAt: Option.some(sampledAt), + lastError: Option.none(), + }, + ]); + if (healthChanged) yield* publishHealth; yield* PubSub.publish(snapshots, nativeSnapshot); if (event.requestId) { const deferred = yield* Ref.modify(pendingSamples, (pending) => { @@ -485,15 +488,18 @@ export const make = Effect.fn("resourceTelemetry.nativeTelemetryClient.make")(fu case "historyChunk": return Effect.gen(function* () { const latestSnapshot = event.snapshots.at(-1); - yield* Ref.update(state, (current) => ({ - ...current, - status: "healthy" as const, - lastSampleAt: latestSnapshot - ? Option.some(DateTime.makeUnsafe(latestSnapshot.sampledAtUnixMs)) - : current.lastSampleAt, - lastError: Option.none(), - })); - yield* publishHealth; + const healthChanged = yield* Ref.modify(state, (current) => [ + current.status !== "healthy" || Option.isSome(current.lastError), + { + ...current, + status: "healthy" as const, + lastSampleAt: latestSnapshot + ? Option.some(DateTime.makeUnsafe(latestSnapshot.sampledAtUnixMs)) + : current.lastSampleAt, + lastError: Option.none(), + }, + ]); + if (healthChanged) yield* publishHealth; const completed = yield* Ref.modify(pendingHistories, (pending) => { const request = pending.get(event.requestId); if (!request) return [Option.none(), pending] as const; diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index b6ee6007f9e4..bc1c240e6317 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -20,6 +20,7 @@ import { ExternalLauncherInvalidPathError, OrchestrationThreadDetailSnapshot, type OrchestrationThreadStreamItem, + type OrchestrationThreadActivity, type OrchestrationThreadShell, TerminalNotRunningError, type OrchestrationCommand, @@ -31,6 +32,7 @@ import { ProviderInstanceId, ResolvedKeybindingRule, ThreadId, + TurnId, WS_METHODS, WsRpcGroup, EditorId, @@ -104,7 +106,7 @@ const collectQueueUntil = Effect.fn("TransferBudget.collectQueueUntil")(function import * as BackgroundPolicy from "./background/BackgroundPolicy.ts"; import * as ServerConfig from "./config.ts"; -import { makeRoutesLayer } from "./server.ts"; +import { HTTP_ROUTER_CONFIG, makeRoutesLayer } from "./server.ts"; import { isThreadDetailEvent, resolveAvailableEditorsForConfig, @@ -117,7 +119,10 @@ import * as Keybindings from "./keybindings.ts"; import * as ExternalLauncher from "./process/externalLauncher.ts"; import * as RemoteOpenTargets from "./environment/RemoteOpenTargets.ts"; import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts"; -import { OrchestrationListenerCallbackError } from "./orchestration/Errors.ts"; +import { + OrchestrationListenerCallbackError, + OrchestrationThreadSettleBlockedError, +} from "./orchestration/Errors.ts"; import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts"; import { ThreadDeletionReactor } from "./orchestration/Services/ThreadDeletionReactor.ts"; import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite.ts"; @@ -199,6 +204,44 @@ const defaultModelSelection = { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", } as const; + +const makeLiveToolActivityEvent = ( + sequence: number, + kind: "tool.updated" | "tool.completed" = "tool.updated", + options: { + readonly toolCallId?: string; + readonly title?: string; + readonly path?: string; + } = {}, +): Extract => { + const { toolCallId = "call-edit", title = "Editing app.ts", path = "src/app.ts" } = options; + const activity: OrchestrationThreadActivity = { + id: EventId.make(`activity-${sequence}`), + tone: "tool", + kind, + summary: title, + payload: { + itemType: "file_change", + title, + data: { toolCallId, path }, + }, + turnId: TurnId.make("turn-edit"), + createdAt: "2026-01-01T00:00:01.000Z", + }; + return { + sequence, + eventId: EventId.make(`event-tool-${sequence}`), + aggregateKind: "thread", + aggregateId: defaultThreadId, + occurredAt: "2026-01-01T00:00:01.000Z", + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + type: "thread.activity-appended", + payload: { threadId: defaultThreadId, activity }, + }; +}; const testEnvironmentDescriptor = { environmentId: EnvironmentId.make("environment-test"), label: "Test environment", @@ -293,6 +336,11 @@ const makeAuthTestLayer = () => EnvironmentAuth.layer.pipe( Layer.provide(SqlitePersistenceMemory), Layer.provide(ServerSecretStore.layer), + Layer.provide( + Layer.mock(ServerEnvironment.ServerEnvironmentIdentity)({ + getEnvironmentId: Effect.succeed(testEnvironmentDescriptor.environmentId), + }), + ), ); const makeBrowserOtlpPayload = (spanName: string) => @@ -632,6 +680,7 @@ const buildAppUnderTest = (options?: { { disableListenLog: true, disableLogger: true, + routerConfig: HTTP_ROUTER_CONFIG, }, ).pipe( Layer.provide( @@ -1534,6 +1583,41 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("serves snapshots for MCP handoff thread IDs above the router default", () => + Effect.gen(function* () { + const threadId = ThreadId.make( + "thread:mcp:abfba0d2-b591-4b7e-aad1-e943d89811fa:handoff%3A0ae5edf4-2ea3-4ee3-ba7c-48de3ac92896%3A2026-08-24T17%3A08%3A52.138Z:0", + ); + const thread = { + ...makeDefaultOrchestrationReadModel().threads[0]!, + id: threadId, + }; + yield* buildAppUnderTest({ + layers: { + projectionSnapshotQuery: { + getThreadDetailSnapshot: (requestedThreadId) => + Effect.succeed( + requestedThreadId === threadId + ? Option.some({ snapshotSequence: 1, thread }) + : Option.none(), + ), + }, + }, + }); + + const response = yield* fetchEffect( + yield* getHttpServerUrl(`/api/orchestration/threads/${encodeURIComponent(threadId)}`), + { headers: { cookie: yield* getAuthenticatedSessionCookieHeader() } }, + ); + const snapshot = yield* responseJsonEffect<{ + readonly thread: { readonly id: ThreadId }; + }>(response); + + assert.equal(response.status, 200); + assert.equal(snapshot.thread.id, threadId); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("compresses large JSON responses through the composed routes", () => Effect.gen(function* () { const descriptor = { @@ -1645,6 +1729,48 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("migrates a valid legacy remote-web session cookie", () => + Effect.gen(function* () { + yield* buildAppUnderTest({ config: { mode: "web", host: "192.168.1.50" } }); + + const { cookie } = yield* bootstrapBrowserSession(); + const currentCookie = cookie?.split(";")[0] ?? ""; + const legacyCookie = currentCookie.replace(/^t3_session_[^=]+=/, "t3_session="); + const sessionUrl = yield* getHttpServerUrl("/api/auth/session"); + const response = yield* fetchEffect(sessionUrl, { + headers: { cookie: legacyCookie }, + }); + const body = yield* responseJsonEffect<{ readonly authenticated: boolean }>(response); + + assert.equal(body.authenticated, true); + assert.equal(response.headers["set-cookie"], cookie); + assert.equal(response.headers["cache-control"], "no-store"); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect.each(["cookie", "bearer"])( + "does not migrate a stale legacy cookie when %s auth succeeds", + (source) => + Effect.gen(function* () { + yield* buildAppUnderTest({ config: { mode: "web", host: "192.168.1.50" } }); + + const { cookie } = yield* bootstrapBrowserSession(); + const sessionCookie = cookie?.split(";")[0] ?? ""; + const sessionToken = extractSessionTokenFromSetCookie(cookie ?? ""); + const sessionUrl = yield* getHttpServerUrl("/api/auth/session"); + const response = yield* fetchEffect(sessionUrl, { + headers: + source === "cookie" + ? { cookie: `${sessionCookie}; t3_session=stale` } + : { authorization: `Bearer ${sessionToken}`, cookie: "t3_session=stale" }, + }); + const body = yield* responseJsonEffect<{ readonly authenticated: boolean }>(response); + + assert.equal(body.authenticated, true); + assert.isUndefined(response.headers["set-cookie"]); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("exchanges a bootstrap grant for a scoped bearer access token", () => Effect.gen(function* () { yield* buildAppUnderTest(); @@ -6977,6 +7103,206 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest), TestClock.withLive), ); + it.effect("coalesces buffered live tool updates to the latest state", () => + Effect.gen(function* () { + const thread = makeDefaultOrchestrationReadModel().threads[0]!; + const liveEvents = yield* PubSub.unbounded(); + + yield* buildAppUnderTest({ + layers: { + orchestrationEngine: { + streamDomainEvents: Stream.fromPubSub(liveEvents), + }, + projectionSnapshotQuery: { + getThreadDetailSnapshot: () => + Effect.gen(function* () { + yield* Effect.sleep("25 millis"); + yield* PubSub.publishAll(liveEvents, [ + makeLiveToolActivityEvent(2), + makeLiveToolActivityEvent(3), + makeLiveToolActivityEvent(4), + ]); + return Option.some({ snapshotSequence: 1, thread }); + }), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const items = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.subscribeThread]({ + threadId: defaultThreadId, + }).pipe(Stream.take(2), Stream.runCollect), + ), + ).pipe(Effect.timeout("2 seconds")); + + assert.equal(items[0]?.kind, "snapshot"); + assert.equal(items[1]?.kind, "event"); + assert.equal(items[1]?.kind === "event" ? items[1].event.sequence : null, 4); + }).pipe(Effect.provide(NodeHttpServer.layerTest), TestClock.withLive), + ); + + it.effect("flushes more than one tool chunk before the synchronization marker", () => + Effect.gen(function* () { + const thread = makeDefaultOrchestrationReadModel().threads[0]!; + const liveEvents = yield* PubSub.unbounded(); + + yield* buildAppUnderTest({ + layers: { + orchestrationEngine: { + streamDomainEvents: Stream.fromPubSub(liveEvents), + }, + projectionSnapshotQuery: { + getThreadDetailSnapshot: () => + Effect.gen(function* () { + yield* Effect.sleep("25 millis"); + yield* PubSub.publishAll(liveEvents, [ + ...Array.from({ length: 512 }, (_, index) => + makeLiveToolActivityEvent(index + 2), + ), + makeLiveToolActivityEvent(514, "tool.updated", { + toolCallId: "call-read", + title: "Reading server.test.ts", + path: "apps/server/src/server.test.ts", + }), + ]); + return Option.some({ snapshotSequence: 1, thread }); + }), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const items = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.subscribeThread]({ + threadId: defaultThreadId, + requestCompletionMarker: true, + }).pipe(Stream.take(4), Stream.runCollect), + ), + ).pipe(Effect.timeout("2 seconds")); + + assert.equal(items[0]?.kind, "snapshot"); + assert.deepEqual( + items.slice(1, 3).map((item) => { + assert.equal(item?.kind, "event"); + if (item?.kind !== "event" || item.event.type !== "thread.activity-appended") { + return null; + } + return { + sequence: item.event.sequence, + summary: item.event.payload.activity.summary, + payload: item.event.payload.activity.payload, + }; + }), + [ + { + sequence: 513, + summary: "Editing app.ts", + payload: { + itemType: "file_change", + title: "Editing app.ts", + data: { + files: [{ path: "src/app.ts" }], + toolCallId: "call-edit", + }, + }, + }, + { + sequence: 514, + summary: "Reading server.test.ts", + payload: { + itemType: "file_change", + title: "Reading server.test.ts", + data: { + files: [{ path: "apps/server/src/server.test.ts" }], + toolCallId: "call-read", + }, + }, + }, + ], + ); + assert.deepEqual(items[3], { kind: "synchronized" }); + }).pipe(Effect.provide(NodeHttpServer.layerTest), TestClock.withLive), + ); + + it.effect("flushes a tool update before an interleaved message", () => + Effect.gen(function* () { + const thread = makeDefaultOrchestrationReadModel().threads[0]!; + const liveEvents = yield* PubSub.unbounded(); + const messageEvent = { + sequence: 3, + eventId: EventId.make("event-interleaved-message"), + aggregateKind: "thread", + aggregateId: defaultThreadId, + occurredAt: "2026-01-01T00:00:02.000Z", + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + type: "thread.message-sent", + payload: { + threadId: defaultThreadId, + messageId: MessageId.make("message-interleaved"), + role: "assistant", + text: "Still working", + turnId: TurnId.make("turn-edit"), + streaming: false, + createdAt: "2026-01-01T00:00:02.000Z", + updatedAt: "2026-01-01T00:00:02.000Z", + }, + } satisfies Extract; + + yield* buildAppUnderTest({ + layers: { + orchestrationEngine: { + streamDomainEvents: Stream.fromPubSub(liveEvents), + }, + projectionSnapshotQuery: { + getThreadDetailSnapshot: () => + Effect.gen(function* () { + yield* Effect.sleep("25 millis"); + yield* PubSub.publishAll(liveEvents, [ + makeLiveToolActivityEvent(2), + messageEvent, + makeLiveToolActivityEvent(4, "tool.completed"), + ]); + return Option.some({ snapshotSequence: 1, thread }); + }), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const items = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.subscribeThread]({ + threadId: defaultThreadId, + }).pipe(Stream.take(4), Stream.runCollect), + ), + ).pipe(Effect.timeout("2 seconds")); + + assert.equal(items[0]?.kind, "snapshot"); + assert.deepEqual( + items + .slice(1) + .map((item) => (item.kind === "event" ? [item.event.sequence, item.event.type] : null)), + [ + [2, "thread.activity-appended"], + [3, "thread.message-sent"], + [4, "thread.activity-appended"], + ], + ); + assert.equal( + items[3]?.kind === "event" && items[3].event.type === "thread.activity-appended" + ? items[3].event.payload.activity.kind + : null, + "tool.completed", + ); + }).pipe(Effect.provide(NodeHttpServer.layerTest), TestClock.withLive), + ); + it.effect("subscribeThread sends a fresh snapshot instead of replaying a large gap", () => Effect.gen(function* () { let readEventsCalls = 0; @@ -7855,7 +8181,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); - it.effect("stops the provider session after settle without closing terminals", () => + it.effect("leaves settle cleanup to the event reactor", () => Effect.gen(function* () { const threadId = ThreadId.make("thread-settle"); const effects: string[] = []; @@ -7913,64 +8239,40 @@ it.layer(NodeServices.layer)("server router seam", (it) => { ); assert.equal(dispatchResult.sequence, 1); - assert.deepEqual(effects, ["dispatch:thread.settle", "dispatch:thread.session.stop"]); - const sessionStopCommand = dispatchedCommands[1]; - assert.equal(sessionStopCommand?.type, "thread.session.stop"); - if (sessionStopCommand?.type === "thread.session.stop") { - assert.equal(sessionStopCommand.threadId, threadId); - assert.equal(sessionStopCommand.commandId, "session-stop-for-settle:cmd-thread-settle"); - assert.equal(sessionStopCommand.onlyIfSettled, true); - } + assert.deepEqual(effects, ["dispatch:thread.settle"]); + assert.deepEqual( + dispatchedCommands.map((command) => command.type), + ["thread.settle"], + ); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); - it.effect("settles without dispatching session stop when the thread has no session", () => + it.effect("forwards the friendly blocked-settlement message over websocket rpc", () => Effect.gen(function* () { - const threadId = ThreadId.make("thread-settle-no-session"); - const effects: string[] = []; - const dispatchedCommands: Array = []; - + const threadId = ThreadId.make("thread-settle-blocked"); yield* buildAppUnderTest({ layers: { - terminalManager: { - close: (input) => - Effect.sync(() => { - effects.push(`terminal.close:${input.threadId}`); - }), - }, orchestrationEngine: { - dispatch: (command) => - Effect.sync(() => { - dispatchedCommands.push(command); - effects.push(`dispatch:${command.type}`); - return { sequence: dispatchedCommands.length }; - }), - }, - projectionSnapshotQuery: { - getThreadShellById: () => - Effect.succeed( - Option.some(makeDefaultOrchestrationThreadShell({ id: threadId, session: null })), - ), + dispatch: () => Effect.fail(new OrchestrationThreadSettleBlockedError({ threadId })), }, }, }); const wsUrl = yield* getWsServerUrl("/ws"); - const dispatchResult = yield* Effect.scoped( + const error = yield* Effect.scoped( withWsRpcClient(wsUrl, (client) => client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ type: "thread.settle", - commandId: CommandId.make("cmd-thread-settle-no-session"), + commandId: CommandId.make("cmd-thread-settle-blocked"), threadId, }), - ), + ).pipe(Effect.flip), ); - assert.equal(dispatchResult.sequence, 1); - assert.deepEqual(effects, ["dispatch:thread.settle"]); - assert.deepEqual( - dispatchedCommands.map((command) => command.type), - ["thread.settle"], + assert.equal(error._tag, "OrchestrationDispatchCommandError"); + assert.equal( + error.message, + "This thread still needs attention. Resolve or interrupt it first, then try again.", ); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 8a1a2a068448..acedd768013f 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -62,6 +62,7 @@ import { ProviderRuntimeIngestionLive } from "./orchestration/Layers/ProviderRun import { ProviderCommandReactorLive } from "./orchestration/Layers/ProviderCommandReactor.ts"; import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor.ts"; import { ThreadDeletionReactorLive } from "./orchestration/Layers/ThreadDeletionReactor.ts"; +import * as ThreadSettlementReactor from "./orchestration/ThreadSettlementReactor.ts"; import * as AgentAwarenessRelay from "./relay/AgentAwarenessRelay.ts"; import { hasCloudPublicConfig } from "./cloud/publicConfig.ts"; import { ProviderRegistryLive } from "./provider/Layers/ProviderRegistry.ts"; @@ -126,6 +127,12 @@ import * as RelayClient from "@t3tools/shared/relayClient"; import { disableTailscaleServe, ensureTailscaleServe } from "@t3tools/tailscale"; import { forkParked, ServerActivation } from "./serverActivation.ts"; +// MCP handoff thread IDs include escaped provenance and can exceed find-my-way's +// 100-character default for one path segment. +export const HTTP_ROUTER_CONFIG = { + maxParamLength: 512, +} as const; + // Effect's default preemptive shutdown waits 20s before finalizing request scopes. // T3's primary transport is long-lived WebSocket RPC, whose Effect scope finalizer // already closes the websocket gracefully. Do not add an artificial drain before @@ -254,6 +261,7 @@ const ReactorLayerLive = Layer.empty.pipe( Layer.provideMerge(ProviderCommandReactorLive), Layer.provideMerge(CheckpointReactorLive), Layer.provideMerge(ThreadDeletionReactorLive), + Layer.provideMerge(ThreadSettlementReactor.layer), Layer.provideMerge(AgentAwarenessRelay.layer.pipe(Layer.provide(ServerSecretStore.layer))), Layer.provideMerge(RuntimeReceiptBusLive), ); @@ -287,6 +295,13 @@ const SourceControlProviderRegistryLayerLive = SourceControlProviderRegistry.lay Layer.provideMerge(VcsDriverRegistryLayerLive), ); +const PullRequestServiceLive = PullRequestService.layer.pipe( + Layer.provide(PullRequestProviderRegistry.layer), + Layer.provide(SourceControlProviderRegistryLayerLive), + Layer.provide(SourceControlRateLimit.layer), + Layer.provide(VcsProcess.layer), +); + const GitManagerLayerLive = GitManager.layer.pipe( Layer.provideMerge(ProjectSetupScriptRunner.layer), Layer.provideMerge(GitVcsDriver.layer), @@ -359,8 +374,13 @@ const ProjectFaviconResolverLayerLive = ProjectFaviconResolver.layer.pipe( Layer.provide(T3ProjectFileLoader.layer), ); +const ServerEnvironmentLayerLive = ServerEnvironment.layer.pipe( + Layer.provide(ServerSecretStore.layer), +); + const AuthLayerLive = EnvironmentAuth.layer.pipe( Layer.provideMerge(PersistenceLayerLive), + Layer.provide(ServerEnvironmentLayerLive), Layer.provide(ServerSecretStore.layer), ); @@ -389,7 +409,9 @@ const RuntimeCoreDependenciesLive = Layer.mergeAll( // Core Services Layer.provideMerge(ServerSettingsLayerLive), Layer.provideMerge(CheckpointingLayerLive), - Layer.provideMerge(SourceControlProviderRegistryLayerLive), + Layer.provideMerge( + Layer.mergeAll(SourceControlProviderRegistryLayerLive, PullRequestServiceLive), + ), Layer.provideMerge(GitLayerLive), Layer.provideMerge(VcsLayerLive), Layer.provideMerge(ProviderRuntimeLayerLive), @@ -431,7 +453,7 @@ const RuntimeCoreDependenciesLive = Layer.mergeAll( Layer.provideMerge(WorkspaceLayerLive), Layer.provideMerge(ProjectFaviconResolverLayerLive), Layer.provideMerge(RepositoryIdentityResolver.layer), - Layer.provideMerge(ServerEnvironment.layer), + Layer.provideMerge(ServerEnvironmentLayerLive), Layer.provideMerge(AuthLayerLive), Layer.provideMerge(ServerSecretStore.layer), Layer.provideMerge( @@ -466,14 +488,6 @@ const commandReadinessLayer = HttpRouter.middleware( { global: true }, ); -const PullRequestServiceLive = PullRequestService.layer.pipe( - // One registry entry per supported host; the service only knows the registry. - Layer.provide(PullRequestProviderRegistry.layer), - Layer.provide(SourceControlProviderRegistryLayerLive), - Layer.provide(SourceControlRateLimit.layer), - Layer.provide(VcsProcess.layer), -); - export const makeRoutesLayer = Layer.mergeAll( Layer.mergeAll( HttpApiBuilder.layer(EnvironmentHttpApi).pipe( @@ -694,6 +708,7 @@ export const makeServerLayer = Layer.unwrap( const routesLayer = HttpRouter.serve(makeRoutesLayer.pipe(Layer.provide(launcherLayer)), { disableLogger: !config.logWebSocketEvents, + routerConfig: HTTP_ROUTER_CONFIG, }).pipe(Layer.tap(() => Deferred.succeed(routesReady, undefined).pipe(Effect.orDie))); const serverApplicationLayer = Layer.mergeAll( routesLayer, diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 521865839bde..82ed8525b9b5 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -272,6 +272,34 @@ it.layer(NodeServices.layer)("server settings", (it) => { ).pipe(Effect.provide(makeServerSettingsLayer())), ); + it.effect("persists and broadcasts thread settlement settings", () => + Effect.scoped( + Effect.gen(function* () { + const serverConfig = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + const changes = yield* serverSettings.subscribeChanges; + + const next = yield* serverSettings.updateSettings({ + sidebarAutoSettleAfterDays: null, + sidebarAutoSettleOnMerge: false, + }); + const change = Option.getOrUndefined(yield* Stream.runHead(changes)); + const raw = yield* fileSystem.readFileString(serverConfig.settingsPath); + // Inspect raw persisted JSON before schema decoding can apply defaults. + // @effect-diagnostics-next-line preferSchemaOverJson:off + const persisted = JSON.parse(raw) as Record; + + assert.strictEqual(next.sidebarAutoSettleAfterDays, null); + assert.isFalse(next.sidebarAutoSettleOnMerge); + assert.strictEqual(change?.sidebarAutoSettleAfterDays, null); + assert.isFalse(change?.sidebarAutoSettleOnMerge); + assert.strictEqual(persisted.sidebarAutoSettleAfterDays, null); + assert.isFalse(persisted.sidebarAutoSettleOnMerge); + }), + ).pipe(Effect.provide(makeServerSettingsLayer())), + ); + it.effect("preserves model when switching providers via textGenerationModelSelection", () => Effect.gen(function* () { const serverSettings = yield* ServerSettingsModule.ServerSettingsService; diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts new file mode 100644 index 000000000000..8fc86ee3d462 --- /dev/null +++ b/apps/server/src/usage/UsageService.test.ts @@ -0,0 +1,226 @@ +// @effect-diagnostics nodeBuiltinImport:off - the suite seeds and grows real +// transcript trees on disk, outside the service's Effect FileSystem. +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +import { assert, describe, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; +import { UsageDay, type UsageSummaryInput } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Scheduler from "effect/Scheduler"; +import { HttpClient, HttpClientResponse } from "effect/unstable/http"; + +import * as ServerConfig from "../config.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import * as UsageService from "./UsageService.ts"; + +function claudeLine(id: number, outputTokens: number): string { + return `${JSON.stringify({ + type: "assistant", + timestamp: "2026-08-01T10:00:00Z", + requestId: `req_${id}`, + sessionId: "session-1", + message: { + id: `msg_${id}`, + model: "claude-fable-5", + usage: { input_tokens: 10, output_tokens: outputTokens }, + }, + })}\n`; +} + +const WINDOW: UsageSummaryInput = { + timeZone: "UTC", + sinceDay: UsageDay.make("2026-07-31"), + untilDay: UsageDay.make("2026-08-02"), +}; + +const setup = Effect.gen(function* () { + const home = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "usage-service-test-")), + ); + yield* Effect.addFinalizer(() => + Effect.promise(() => NodeFSP.rm(home, { recursive: true, force: true })), + ); + const transcriptDir = NodePath.join(home, "claude", "projects", "proj"); + yield* Effect.promise(() => NodeFSP.mkdir(transcriptDir, { recursive: true })); + return { + home, + transcript: NodePath.join(transcriptDir, "session.jsonl"), + settings: { + providers: { + claudeAgent: { homePath: NodePath.join(home, "claude") }, + codex: { homePath: NodePath.join(home, "codex") }, + }, + }, + }; +}); + +const serviceLayers = (input: { + readonly prefix: string; + readonly home: string; + readonly settings: Parameters[0]; + readonly onRatesFetch?: () => void; +}) => + ServerConfig.layerTest(process.cwd(), { prefix: input.prefix }).pipe( + Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(ServerSettings.layerTest(input.settings)), + Layer.provideMerge( + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.sync(() => { + input.onRatesFetch?.(); + // Unparsable rates: every scan retries the fetch, which makes the + // fetch count a boundary-level observation of how many scans ran. + return HttpClientResponse.fromWeb(request, Response.json({})); + }), + ), + ), + ), + Layer.provideMerge( + Layer.succeed(HostProcessEnvironment, { GROK_HOME: NodePath.join(input.home, "grok") }), + ), + ); + +function totalOutputTokens(summary: { buckets: readonly { totals: { outputTokens: number } }[] }) { + return summary.buckets.reduce((sum, bucket) => sum + bucket.totals.outputTokens, 0); +} + +describe("UsageService", () => { + it.live("counts appended usage on a rescan of a grown transcript", () => + Effect.gen(function* () { + const { transcript, settings, home } = yield* setup; + yield* Effect.promise(() => NodeFSP.writeFile(transcript, claudeLine(1, 5))); + + const service = yield* UsageService.make.pipe( + Effect.provide(serviceLayers({ prefix: "usage-service-grow-test", home, settings })), + ); + + const first = yield* service.readSummary(WINDOW); + assert.strictEqual(totalOutputTokens(first), 5); + + yield* Effect.promise(() => NodeFSP.appendFile(transcript, claudeLine(2, 7))); + const second = yield* service.readSummary(WINDOW); + assert.strictEqual(totalOutputTokens(second), 12); + }).pipe(Effect.scoped), + ); + + it.live("shares one scan between concurrent identical requests", () => + Effect.gen(function* () { + const { transcript, settings, home } = yield* setup; + yield* Effect.promise(() => NodeFSP.writeFile(transcript, claudeLine(1, 5))); + + let ratesFetches = 0; + const service = yield* UsageService.make.pipe( + Effect.provide( + serviceLayers({ + prefix: "usage-service-flight-test", + home, + settings, + onRatesFetch: () => { + ratesFetches += 1; + }, + }), + ), + ); + + const [first, second] = yield* Effect.all( + [service.readSummary(WINDOW), service.readSummary(WINDOW)], + { concurrency: 2 }, + ); + assert.deepStrictEqual(first, second); + assert.strictEqual(ratesFetches, 1); + + // A later request is fresh work again, not a stale cached answer. + yield* service.readSummary(WINDOW); + assert.strictEqual(ratesFetches, 2); + }).pipe(Effect.scoped), + ); + + it.live("does not orphan an in-flight scan when its first caller is interrupted", () => + Effect.gen(function* () { + const { settings, home } = yield* setup; + const service = yield* UsageService.make.pipe( + Effect.provide( + serviceLayers({ prefix: "usage-service-interruption-test", home, settings }), + ), + ); + + let orphanedAt: number | undefined; + for (let interruptAt = 1; interruptAt <= 31; interruptAt += 1) { + const tasks: Array<() => void> = []; + const dispatcher: Scheduler.SchedulerDispatcher = { + scheduleTask: (task) => tasks.push(task), + flush: () => { + let task: (() => void) | undefined; + while ((task = tasks.shift()) !== undefined) task(); + }, + }; + + let requestFiber: Fiber.Fiber | undefined; + let requestChecks = 0; + const scheduler: Scheduler.Scheduler = { + executionMode: "async", + makeDispatcher: () => dispatcher, + shouldYield: (fiber) => { + if (fiber !== requestFiber) return false; + requestChecks += 1; + if (requestChecks !== interruptAt) return false; + fiber.interruptUnsafe(); + return true; + }, + }; + + // Each candidate needs a distinct key because the broken case leaves + // its entry in the service's private in-flight map. The invalid window + // keeps the real scan synchronous once its detached fiber starts. + const input: UsageSummaryInput = { + ...WINDOW, + sinceDay: UsageDay.make("2026-09-01"), + untilDay: UsageDay.make(`2026-08-${String(interruptAt).padStart(2, "0")}`), + }; + const first = yield* service + .readSummary(input) + .pipe( + Effect.exit, + Effect.provideService(Scheduler.Scheduler, scheduler), + Effect.forkChild, + ); + requestFiber = first; + yield* Effect.yieldNow; + dispatcher.flush(); + + const second = yield* service.readSummary(input).pipe( + Effect.match({ + onFailure: (error) => error.reason, + onSuccess: () => "success" as const, + }), + Effect.provideService(Scheduler.Scheduler, scheduler), + Effect.forkChild, + ); + yield* Effect.yieldNow; + dispatcher.flush(); + const secondExit = second.pollUnsafe(); + if (secondExit === undefined) { + second.interruptUnsafe(); + orphanedAt = interruptAt; + break; + } + if (Exit.isFailure(secondExit)) { + assert.fail("the matching request fiber was interrupted"); + } + assert.strictEqual(secondExit.value, "invalidWindow"); + } + + assert.isUndefined( + orphanedAt, + `interruption left the next matching request pending at scheduler check ${orphanedAt}`, + ); + }).pipe(Effect.scoped), + ); +}); diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index 224662e9dca7..16a7478d954e 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -7,7 +7,8 @@ * * Transcripts are append-only, so parsed records are memoised per file by * `(size, mtime)`. A cold 30-day scan of ~1.4 GB lands around 2-3 seconds; warm - * scans only reparse files that changed. + * scans only reparse files that changed, and a file that merely grew resumes + * from its cached parse position so only the appended bytes are read. * * @module UsageService */ @@ -26,6 +27,7 @@ import * as Cause from "effect/Cause"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -272,7 +274,14 @@ export const make = Effect.gen(function* () { ); }); - /** Parses one transcript, reusing the cached result when it is unchanged. */ + /** + * Parses one transcript, reusing the cached result when it is unchanged. + * + * A file that only grew re-parses from the cached position, so an actively + * written multi-hundred-megabyte rollout costs its appended bytes per scan + * rather than a full re-read. The reader verifies the position's guard bytes + * and silently restarts from byte 0 when they no longer match. + */ const readFileRecords = ( filePath: string, size: number, @@ -289,23 +298,85 @@ export const make = Effect.gen(function* () { cached.mtimeMs === mtimeMs && cached.provider === provider ) { - return cached.records; + return cached.tailRecords.length === 0 + ? cached.records + : [...cached.records, ...cached.tailRecords]; } - const parsed = yield* Effect.promise(() => readTranscriptRecords(filePath, provider)); + // Only a strictly grown file may resume. Same size with a new mtime, or + // a shrunken file, means rewritten content; re-parse it whole. + const resumeFrom = + cached !== undefined && cached.provider === provider && size > cached.size + ? cached.position + : undefined; + + const parsed = yield* Effect.promise(() => + readTranscriptRecords(filePath, provider, resumeFrom), + ); // A read failure is not an empty transcript: caching it under this // (size, mtime) would silently drop the file's usage until it changes. if (parsed === null) return []; - // Stored already de-duplicated within the file, which is 99% of all - // duplicates. The aggregator still runs the cross-file dedupe pass. - const records = dedupeWithinFile(parsed); - fileCache.set(filePath, { size, mtimeMs, provider, records }); + // Stored already de-duplicated within the file, which is 99% of all + // duplicates. The aggregator still runs the cross-file dedupe pass. One + // seen set spans the cached base, the new lines, and the tail so a + // resumed parse dedupes exactly like a full one. + const base = parsed.resumed && cached !== undefined ? cached.records : []; + const seen = new Set(); + const records = dedupeWithinFile([...base, ...parsed.records], seen); + const tailRecords = dedupeWithinFile(parsed.tailRecords, seen); + + fileCache.set(filePath, { + size, + mtimeMs, + provider, + records, + tailRecords, + position: parsed.position, + }); cacheDirty = true; - return records; + return tailRecords.length === 0 ? records : [...records, ...tailRecords]; }); - const readSummary = Effect.fn("UsageService.readSummary")(function* (input: UsageSummaryInput) { + /** One provider directory's walk and parse, before rates are involved. */ + interface ScannedDir { + readonly provider: UsageProviderKind; + readonly dir: string; + readonly volumeId: string; + /** Parsed records per file, or `null` when the directory does not exist. */ + readonly files: + | readonly { readonly path: string; readonly records: readonly UsageRecord[] }[] + | null; + } + + const collectDirs = Effect.fn("UsageService.collectDirs")(function* (windowStartMs: number) { + // The home resolvers ask for `Path` themselves; satisfy them from the + // instance we already hold so the scan stays context-free. + const dirs = yield* resolveTranscriptDirs().pipe(Effect.provideService(Path.Path, path)); + const scanned: ScannedDir[] = []; + for (const { provider, dir, fileName } of dirs) { + const volumeId = yield* Effect.promise(() => readDirectoryVolumeId(dir)); + const exists = yield* fileSystem + .exists(dir) + .pipe(Effect.catchCause(() => Effect.succeed(false))); + if (!exists) { + scanned.push({ provider, dir, volumeId, files: null }); + continue; + } + const files = yield* Effect.promise(() => + listTranscriptFiles(dir, windowStartMs, fileName === undefined ? undefined : { fileName }), + ); + const parsedFiles: { path: string; records: readonly UsageRecord[] }[] = []; + for (const file of files) { + const records = yield* readFileRecords(file.path, file.size, file.mtimeMs, provider); + parsedFiles.push({ path: file.path, records }); + } + scanned.push({ provider, dir, volumeId, files: parsedFiles }); + } + return scanned; + }); + + const scanSummary = Effect.fn("UsageService.scanSummary")(function* (input: UsageSummaryInput) { if (input.sinceDay > input.untilDay) { return yield* new UsageReadError({ reason: "invalidWindow", @@ -338,13 +409,9 @@ export const make = Effect.gen(function* () { } const startedAtMs = yield* Clock.currentTimeMillis; - yield* ensureRates(); yield* ensureScanCacheLoaded; const hostId = NodeOS.hostname(); - // The home resolvers ask for `Path` themselves; satisfy them from the - // instance we already hold so `readSummary` stays context-free. - const dirs = yield* resolveTranscriptDirs().pipe(Effect.provideService(Path.Path, path)); const windowStart = DateTime.make(`${input.sinceDay}T00:00:00Z`); if (Option.isNone(windowStart)) { return yield* new UsageReadError({ @@ -355,6 +422,13 @@ export const make = Effect.gen(function* () { const windowStartMs = (hourlyWindow?.sinceTimeMs ?? DateTime.toEpochMillis(windowStart.value)) - MTIME_SLACK_MS; + // Pricing only matters once records are aggregated, so the rate table + // loads while transcripts stream instead of gating them: a cold rates + // fetch on a slow network no longer delays the scan by its own timeout. + const [, scannedDirs] = yield* Effect.all([ensureRates(), collectDirs(windowStartMs)], { + concurrency: 2, + }); + const aggregator = new UsageAggregator({ timeZone: input.timeZone, sinceDay: input.sinceDay, @@ -368,13 +442,8 @@ export const make = Effect.gen(function* () { const livePaths = new Set(); const walkedRoots: string[] = []; - for (const { provider, dir, fileName } of dirs) { - const volumeId = yield* Effect.promise(() => readDirectoryVolumeId(dir)); - const exists = yield* fileSystem - .exists(dir) - .pipe(Effect.catchCause(() => Effect.succeed(false))); - - if (!exists) { + for (const { provider, dir, volumeId, files } of scannedDirs) { + if (files === null) { sources.push({ fingerprint: { hostId, provider, resolvedHomePath: dir, volumeId }, status: "missing", @@ -388,9 +457,6 @@ export const make = Effect.gen(function* () { } walkedRoots.push(dir); - const files = yield* Effect.promise(() => - listTranscriptFiles(dir, windowStartMs, fileName === undefined ? undefined : { fileName }), - ); let scannedFiles = 0; let skippedFiles = 0; // Distinct per directory. Buckets carry per-cell session counts, but a @@ -399,13 +465,12 @@ export const make = Effect.gen(function* () { for (const file of files) { livePaths.add(file.path); - const records = yield* readFileRecords(file.path, file.size, file.mtimeMs, provider); - if (records.length === 0) { + if (file.records.length === 0) { skippedFiles += 1; continue; } scannedFiles += 1; - for (const record of records) { + for (const record of file.records) { // Only sessions that contributed in-window count: the mtime slack // admits boundary files whose records fall outside the range. if (aggregator.add(record) && record.sessionId.length > 0) { @@ -459,6 +524,52 @@ export const make = Effect.gen(function* () { } satisfies UsageSummary; }); + /** + * In-flight scans by window, so concurrent identical requests (the usage + * page open on two clients at once) share one scan instead of racing over + * the same corpus twice. + */ + const inflightScans = new Map>(); + + const scanKey = (input: UsageSummaryInput): string => + JSON.stringify([ + input.timeZone, + input.sinceDay, + input.untilDay, + input.resolution ?? "day", + input.sinceTime ?? null, + input.untilTime ?? null, + ]); + + const readSummary = Effect.fn("UsageService.readSummary")(function* (input: UsageSummaryInput) { + const key = scanKey(input); + const deferred = yield* Effect.uninterruptible( + Effect.gen(function* () { + const existing = inflightScans.get(key); + if (existing !== undefined) return existing; + + // Enrollment and detached-fiber creation must be atomic. Otherwise a + // canceled first caller can leave a Deferred with no scan to finish it. + const created = Deferred.makeUnsafe(); + inflightScans.set(key, created); + // Detached so one departing client cannot tear the scan out from under + // the fibers awaiting it; a finished scan warms the cache either way. + yield* scanSummary(input).pipe( + Effect.onExit((exit) => + Effect.sync(() => inflightScans.delete(key)).pipe( + Effect.andThen(Deferred.done(created, exit)), + ), + ), + Effect.forkDetach, + ); + return created; + }), + ); + // Waiting stays interruptible. The detached scan continues for other + // callers and still warms the cache if this caller leaves. + return yield* Deferred.await(deferred); + }); + return { readSummary } as const; }); diff --git a/apps/server/src/usage/usageScanCache.test.ts b/apps/server/src/usage/usageScanCache.test.ts index 24fc5376cbc8..fdb0aabafa40 100644 --- a/apps/server/src/usage/usageScanCache.test.ts +++ b/apps/server/src/usage/usageScanCache.test.ts @@ -5,6 +5,7 @@ import { dedupeWithinFile, encodeScanCache, pruneScanCache, + type CachedFile, type ScanCache, } from "./usageScanCache.ts"; import type { UsageRecord } from "./usageTranscripts.ts"; @@ -28,10 +29,27 @@ function record(overrides: Partial = {}): UsageRecord { }; } +function position(overrides: Partial = {}): CachedFile["position"] { + return { + resumeOffset: 120, + guardLength: 64, + guardHash: 0xdeadbeef, + codexState: null, + ...overrides, + }; +} + function cacheWith(entries: readonly [string, number, readonly UsageRecord[]][]): ScanCache { const cache: ScanCache = new Map(); for (const [path, mtimeMs, records] of entries) { - cache.set(path, { size: records.length * 10, mtimeMs, provider: "claude", records }); + cache.set(path, { + size: records.length * 10, + mtimeMs, + provider: "claude", + records, + tailRecords: [], + position: position(), + }); } return cache; } @@ -49,14 +67,67 @@ describe("scan cache round trip", () => { records: [ record({ provider: "grok", model: "grok-4.5-build", dedupeKey: "s:p:grok-4.5-build" }), ], + tailRecords: [record({ provider: "grok", model: "grok-4.5-build", dedupeKey: null })], + position: position({ resumeOffset: 30, guardLength: 30, guardHash: 123 }), + }); + original.set("/codex.jsonl", { + size: 80, + mtimeMs: 400, + provider: "codex", + records: [record({ provider: "codex", model: "gpt-5.2-codex", dedupeKey: null })], + tailRecords: [], + position: position({ + codexState: { + model: "gpt-5.2-codex", + sessionId: "session-c", + lastUsageSignature: '{"input_tokens":1}', + sawSessionMeta: true, + suppressingForkCopies: false, + forkCopyAnchorMs: 0, + }, + }), }); const restored = decodeScanCache(JSON.parse(JSON.stringify(encodeScanCache(original)))); - expect(restored.size).toBe(3); + expect(restored.size).toBe(4); expect(restored.get("/a.jsonl")).toEqual(original.get("/a.jsonl")); expect(restored.get("/b.jsonl")).toEqual(original.get("/b.jsonl")); expect(restored.get("/grok.jsonl")).toEqual(original.get("/grok.jsonl")); + expect(restored.get("/codex.jsonl")).toEqual(original.get("/codex.jsonl")); + }); + + it("drops an entry whose persisted parse state is corrupt", () => { + // Resuming with a bad reducer state would attach appended usage to the + // wrong model or replay fork-copied history; that entry must cold parse. + const encoded = encodeScanCache(cacheWith([["/a.jsonl", 100, [record()]]])); + const poisoned = { + ...encoded, + files: { + "/a.jsonl": { ...encoded.files["/a.jsonl"]!, cs: { model: 42 } }, + }, + }; + + expect(decodeScanCache(JSON.parse(JSON.stringify(poisoned))).has("/a.jsonl")).toBe(false); + }); + + it("drops an entry whose guard length is outside the supported range", () => { + // The guard length sizes a Buffer in the reader; a bogus value would make + // every parse of that file fail and silently drop its usage. + const encoded = encodeScanCache(cacheWith([["/a.jsonl", 100, [record()]]])); + const poisoned = { + ...encoded, + files: { "/a.jsonl": { ...encoded.files["/a.jsonl"]!, gl: 1e20 } }, + }; + + expect(decodeScanCache(JSON.parse(JSON.stringify(poisoned))).has("/a.jsonl")).toBe(false); + }); + + it("rejects a document from the previous cache version", () => { + const encoded = encodeScanCache(cacheWith([["/a.jsonl", 100, [record()]]])); + const previous = { ...encoded, version: 2 }; + + expect(decodeScanCache(JSON.parse(JSON.stringify(previous))).size).toBe(0); }); it("interns repeated model and session strings", () => { @@ -193,6 +264,20 @@ describe("pruneScanCache with an unwalked root", () => { expect(removed).toBe(0); expect(cache.size).toBe(1); }); + + it("keeps entries under a sibling path that only shares the walked root prefix", () => { + const cache = cacheWith([["/claude/projects-copy/a.jsonl", 5000, [record()]]]); + + const removed = pruneScanCache(cache, { + livePaths: new Set(), + walkedRoots: ["/claude/projects"], + windowStartMs: 4000, + retentionCutoffMs: 1000, + }); + + expect(removed).toBe(0); + expect(cache.size).toBe(1); + }); }); describe("dedupeWithinFile", () => { diff --git a/apps/server/src/usage/usageScanCache.ts b/apps/server/src/usage/usageScanCache.ts index 10c97e49e15f..102058a07d35 100644 --- a/apps/server/src/usage/usageScanCache.ts +++ b/apps/server/src/usage/usageScanCache.ts @@ -14,19 +14,33 @@ * * @module usageScanCache */ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodePath from "node:path"; + import type { UsageProviderKind } from "@t3tools/contracts"; -import type { UsageRecord } from "./usageTranscripts.ts"; +import { GUARD_LENGTH, type TranscriptParsePosition } from "./usageTranscriptReader.ts"; +import type { CodexScanState, UsageRecord } from "./usageTranscripts.ts"; // v2: Codex fork-copy suppression changed what a file parses to, so v1 // entries would keep serving double-counted records forever. -export const USAGE_SCAN_CACHE_VERSION = 2 as const; +// v3: entries carry the parse position and reducer state so a grown file +// re-parses only its appended bytes instead of starting over. +export const USAGE_SCAN_CACHE_VERSION = 3 as const; export interface CachedFile { readonly size: number; readonly mtimeMs: number; readonly provider: UsageProviderKind; + /** Records from newline-terminated lines, up to `position.resumeOffset`. */ readonly records: readonly UsageRecord[]; + /** + * Records from a trailing segment the writer had not newline-terminated at + * parse time. Kept apart from `records` because an incremental parse + * re-reads that segment and would otherwise double count it. + */ + readonly tailRecords: readonly UsageRecord[]; + readonly position: TranscriptParsePosition; } export type ScanCache = Map; @@ -54,6 +68,14 @@ interface SerializedFile { readonly m: number; readonly p: UsageProviderKind; readonly r: readonly SerializedRecord[]; + /** Tail records; see `CachedFile.tailRecords`. */ + readonly t: readonly SerializedRecord[]; + /** Parse position: resume offset, guard length, guard hash. */ + readonly o: number; + readonly gl: number; + readonly gh: number; + /** Codex reducer state at `o`; `null` for stateless providers. */ + readonly cs: CodexScanState | null; } interface SerializedCache { @@ -79,24 +101,31 @@ export function encodeScanCache(cache: ScanCache): SerializedCache { return next; }; + const serializeRecord = (record: UsageRecord): SerializedRecord => [ + record.timestampMs, + intern(models, modelIndex, record.model), + intern(sessions, sessionIndex, record.sessionId), + record.totals.uncachedInputTokens, + record.totals.cachedInputTokens, + record.totals.cacheCreationTokens, + record.totals.outputTokens, + record.totals.reasoningTokens, + record.dedupeKey, + record.reportedCostUsd, + ]; + const files: Record = {}; for (const [path, entry] of cache) { files[path] = { s: entry.size, m: entry.mtimeMs, p: entry.provider, - r: entry.records.map((record) => [ - record.timestampMs, - intern(models, modelIndex, record.model), - intern(sessions, sessionIndex, record.sessionId), - record.totals.uncachedInputTokens, - record.totals.cachedInputTokens, - record.totals.cacheCreationTokens, - record.totals.outputTokens, - record.totals.reasoningTokens, - record.dedupeKey, - record.reportedCostUsd, - ]), + r: entry.records.map(serializeRecord), + t: entry.tailRecords.map(serializeRecord), + o: entry.position.resumeOffset, + gl: entry.position.guardLength, + gh: entry.position.guardHash, + cs: entry.position.codexState, }; } @@ -130,24 +159,16 @@ export function decodeScanCache(document: unknown): ScanCache { const models = root.models as readonly string[]; const sessions = root.sessions as readonly string[]; - for (const [path, raw] of Object.entries(root.files)) { - if (typeof raw !== "object" || raw === null) continue; - const entry = raw as Partial; - if (typeof entry.s !== "number" || typeof entry.m !== "number") continue; - if (entry.p !== "claude" && entry.p !== "codex" && entry.p !== "grok") continue; - if (!isRecordArray(entry.r)) continue; - - const provider: UsageProviderKind = entry.p; + // Any corrupt row disqualifies the whole entry. Keeping the survivors + // under the original (size, mtime) would read as a valid warm hit and the + // file would never be re-parsed, silently losing the dropped rows' usage. + const decodeRecords = ( + rows: readonly unknown[], + provider: UsageProviderKind, + ): UsageRecord[] | null => { const records: UsageRecord[] = []; - // Any corrupt row disqualifies the whole entry. Keeping the survivors - // under the original (size, mtime) would read as a valid warm hit and the - // file would never be re-parsed, silently losing the dropped rows' usage. - let corrupt = false; - for (const row of entry.r) { - if (!isRecordArray(row) || row.length < 10) { - corrupt = true; - break; - } + for (const row of rows) { + if (!isRecordArray(row) || row.length < 10) return null; const [ timestampMs, modelIndex, @@ -172,8 +193,7 @@ export function decodeScanCache(document: unknown): ScanCache { !Number.isFinite(output) || !Number.isFinite(reasoning) ) { - corrupt = true; - break; + return null; } records.push({ @@ -192,14 +212,89 @@ export function decodeScanCache(document: unknown): ScanCache { dedupeKey: typeof dedupeKey === "string" ? dedupeKey : null, }); } + return records; + }; - if (corrupt) continue; - cache.set(path, { size: entry.s, mtimeMs: entry.m, provider, records }); + for (const [path, raw] of Object.entries(root.files)) { + if (typeof raw !== "object" || raw === null) continue; + const entry = raw as Partial; + if (typeof entry.s !== "number" || typeof entry.m !== "number") continue; + if (entry.p !== "claude" && entry.p !== "codex" && entry.p !== "grok") continue; + if (!isRecordArray(entry.r) || !isRecordArray(entry.t)) continue; + // Position fields feed byte offsets and a Buffer allocation in the reader, + // so anything outside their real ranges must reject the entry: a bogus + // guard length would otherwise fail every parse of the file, silently + // dropping its usage instead of costing the documented cold re-parse. + if ( + typeof entry.o !== "number" || + !Number.isSafeInteger(entry.o) || + entry.o < 0 || + typeof entry.gl !== "number" || + !Number.isSafeInteger(entry.gl) || + entry.gl < 0 || + entry.gl > GUARD_LENGTH || + entry.gl > entry.o || + typeof entry.gh !== "number" || + !Number.isFinite(entry.gh) + ) { + continue; + } + const codexState = decodeCodexState(entry.cs); + if (codexState === undefined) continue; + + const provider: UsageProviderKind = entry.p; + const records = decodeRecords(entry.r, provider); + const tailRecords = decodeRecords(entry.t, provider); + if (records === null || tailRecords === null) continue; + + cache.set(path, { + size: entry.s, + mtimeMs: entry.m, + provider, + records, + tailRecords, + position: { + resumeOffset: entry.o, + guardLength: entry.gl, + guardHash: entry.gh, + codexState, + }, + }); } return cache; } +/** + * Validates a persisted Codex reducer state. Returns `undefined` for a corrupt + * value, which disqualifies the entry: resuming with a bad state would attach + * appended usage to the wrong model or replay fork-copied history. + */ +function decodeCodexState(value: unknown): CodexScanState | null | undefined { + if (value === null) return null; + if (typeof value !== "object") return undefined; + const state = value as Partial; + if ( + typeof state.model !== "string" || + typeof state.sessionId !== "string" || + (state.lastUsageSignature !== null && typeof state.lastUsageSignature !== "string") || + typeof state.sawSessionMeta !== "boolean" || + typeof state.suppressingForkCopies !== "boolean" || + typeof state.forkCopyAnchorMs !== "number" || + !Number.isFinite(state.forkCopyAnchorMs) + ) { + return undefined; + } + return { + model: state.model, + sessionId: state.sessionId, + lastUsageSignature: state.lastUsageSignature ?? null, + sawSessionMeta: state.sawSessionMeta, + suppressingForkCopies: state.suppressingForkCopies, + forkCopyAnchorMs: state.forkCopyAnchorMs, + }; +} + export interface PruneOptions { /** Files the walk just saw. Only meaningful inside the walked window. */ readonly livePaths: ReadonlySet; @@ -229,7 +324,15 @@ export function pruneScanCache(cache: ScanCache, options: PruneOptions): number let removed = 0; for (const [path, entry] of cache) { const agedOut = entry.mtimeMs < options.retentionCutoffMs; - const underWalkedRoot = options.walkedRoots.some((root) => path.startsWith(root)); + const underWalkedRoot = options.walkedRoots.some((root) => { + const relative = NodePath.relative(root, path); + return ( + relative === "" || + (relative !== ".." && + !relative.startsWith(`..${NodePath.sep}`) && + !NodePath.isAbsolute(relative)) + ); + }); const deleted = underWalkedRoot && entry.mtimeMs >= options.windowStartMs && !options.livePaths.has(path); if (agedOut || deleted) { @@ -240,9 +343,17 @@ export function pruneScanCache(cache: ScanCache, options: PruneOptions): number return removed; } -/** Within-file de-duplication, applied before an entry is cached. */ -export function dedupeWithinFile(records: readonly UsageRecord[]): readonly UsageRecord[] { - const seen = new Set(); +/** + * Within-file de-duplication, applied before an entry is cached. + * + * Callers stitching an incremental parse together pass one `seen` set across + * the line and tail record batches so the whole file stays deduplicated as a + * unit; the set is mutated in place. + */ +export function dedupeWithinFile( + records: readonly UsageRecord[], + seen: Set = new Set(), +): readonly UsageRecord[] { const kept: UsageRecord[] = []; for (const record of records) { if (record.dedupeKey !== null) { diff --git a/apps/server/src/usage/usageTranscriptReader.test.ts b/apps/server/src/usage/usageTranscriptReader.test.ts new file mode 100644 index 000000000000..5feb68b2ff58 --- /dev/null +++ b/apps/server/src/usage/usageTranscriptReader.test.ts @@ -0,0 +1,210 @@ +// @effect-diagnostics nodeBuiltinImport:off - resume coverage writes, appends +// to, and truncates real transcript files byte-exactly, mirroring the reader's +// own deliberate node:fs usage. +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +import { afterEach, assert, beforeEach, describe, it } from "@effect/vitest"; + +import { readTranscriptRecords } from "./usageTranscriptReader.ts"; + +let dir: string; + +beforeEach(async () => { + dir = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "usage-reader-test-")); +}); + +afterEach(async () => { + await NodeFSP.rm(dir, { recursive: true, force: true }); +}); + +function claudeLine(id: number, outputTokens: number): string { + return `${JSON.stringify({ + type: "assistant", + timestamp: "2026-08-01T10:00:00Z", + requestId: `req_${id}`, + sessionId: "session-1", + message: { + id: `msg_${id}`, + model: "claude-fable-5", + usage: { input_tokens: 10, output_tokens: outputTokens }, + }, + })}\n`; +} + +function codexMetaLine(): string { + return `${JSON.stringify({ + type: "session_meta", + timestamp: "2026-08-01T10:00:00Z", + payload: { type: "session_meta", id: "codex-session-1" }, + })}\n`; +} + +function codexModelLine(model: string): string { + return `${JSON.stringify({ + type: "turn_context", + timestamp: "2026-08-01T10:00:01Z", + payload: { type: "turn_context", model }, + })}\n`; +} + +function codexUsageLine(outputTokens: number, secondsOffset: number): string { + return `${JSON.stringify({ + type: "event_msg", + timestamp: `2026-08-01T10:00:${String(secondsOffset).padStart(2, "0")}Z`, + payload: { + type: "token_count", + info: { last_token_usage: { input_tokens: 100, output_tokens: outputTokens } }, + }, + })}\n`; +} + +describe("readTranscriptRecords resume", () => { + it("parses only appended lines when resuming a grown file", async () => { + const path = NodePath.join(dir, "claude.jsonl"); + await NodeFSP.writeFile(path, claudeLine(1, 5) + claudeLine(2, 7)); + const first = await readTranscriptRecords(path, "claude"); + assert.isNotNull(first); + assert.strictEqual(first.records.length, 2); + assert.isFalse(first.resumed); + + await NodeFSP.appendFile(path, claudeLine(3, 11)); + const second = await readTranscriptRecords(path, "claude", first.position); + assert.isNotNull(second); + assert.isTrue(second.resumed); + assert.strictEqual(second.records.length, 1); + assert.strictEqual(second.records[0]?.totals.outputTokens, 11); + + // The stitched result matches a from-scratch parse of the whole file. + const full = await readTranscriptRecords(path, "claude"); + assert.isNotNull(full); + assert.deepStrictEqual([...first.records, ...second.records], [...full.records]); + }); + + it("carries the Codex reducer state across the resume boundary", async () => { + const path = NodePath.join(dir, "rollout.jsonl"); + await NodeFSP.writeFile(path, codexMetaLine() + codexModelLine("gpt-5.2-codex")); + const first = await readTranscriptRecords(path, "codex"); + assert.isNotNull(first); + assert.strictEqual(first.records.length, 0); + + // The appended usage event has no turn_context or session_meta of its own; + // model and session must come from the state captured before the boundary. + await NodeFSP.appendFile(path, codexUsageLine(9, 5)); + const second = await readTranscriptRecords(path, "codex", first.position); + assert.isNotNull(second); + assert.isTrue(second.resumed); + assert.strictEqual(second.records.length, 1); + assert.strictEqual(second.records[0]?.model, "gpt-5.2-codex"); + assert.strictEqual(second.records[0]?.sessionId, "codex-session-1"); + }); + + it("suppresses a Codex duplicate usage event that straddles the boundary", async () => { + const path = NodePath.join(dir, "rollout.jsonl"); + await NodeFSP.writeFile( + path, + codexMetaLine() + codexModelLine("gpt-5.2-codex") + codexUsageLine(9, 5), + ); + const first = await readTranscriptRecords(path, "codex"); + assert.isNotNull(first); + assert.strictEqual(first.records.length, 1); + + // Codex re-emits an unchanged token_count on stream boundaries; the copy + // lands after the resume point and must still be dropped. + await NodeFSP.appendFile(path, codexUsageLine(9, 5) + codexUsageLine(21, 8)); + const second = await readTranscriptRecords(path, "codex", first.position); + assert.isNotNull(second); + assert.isTrue(second.resumed); + assert.deepStrictEqual( + second.records.map((record) => record.totals.outputTokens), + [21], + ); + }); + + it("defers an unterminated trailing line to tailRecords, then consumes it once terminated", async () => { + const path = NodePath.join(dir, "claude.jsonl"); + const unterminated = claudeLine(2, 7).trimEnd(); + await NodeFSP.writeFile(path, claudeLine(1, 5) + unterminated); + const first = await readTranscriptRecords(path, "claude"); + assert.isNotNull(first); + assert.strictEqual(first.records.length, 1); + assert.strictEqual(first.tailRecords.length, 1); + assert.strictEqual(first.tailRecords[0]?.totals.outputTokens, 7); + + // Completing the line and appending another re-reads from the resume + // point, so the once-tail record arrives exactly once as a line record. + await NodeFSP.appendFile(path, `\n${claudeLine(3, 11)}`); + const second = await readTranscriptRecords(path, "claude", first.position); + assert.isNotNull(second); + assert.isTrue(second.resumed); + assert.deepStrictEqual( + second.records.map((record) => record.totals.outputTokens), + [7, 11], + ); + assert.strictEqual(second.tailRecords.length, 0); + }); + + it("re-parses from the start when the guard bytes no longer match", async () => { + const path = NodePath.join(dir, "claude.jsonl"); + await NodeFSP.writeFile(path, claudeLine(1, 5)); + const first = await readTranscriptRecords(path, "claude"); + assert.isNotNull(first); + + // Same path, larger size, different content: a replaced file, not growth. + await NodeFSP.writeFile(path, claudeLine(4, 13) + claudeLine(5, 17)); + const second = await readTranscriptRecords(path, "claude", first.position); + assert.isNotNull(second); + assert.isFalse(second.resumed); + assert.deepStrictEqual( + second.records.map((record) => record.totals.outputTokens), + [13, 17], + ); + }); + + it("re-parses from the start when the file shrank below the resume point", async () => { + const path = NodePath.join(dir, "claude.jsonl"); + await NodeFSP.writeFile(path, claudeLine(1, 5) + claudeLine(2, 7)); + const first = await readTranscriptRecords(path, "claude"); + assert.isNotNull(first); + + await NodeFSP.writeFile(path, claudeLine(3, 11)); + const second = await readTranscriptRecords(path, "claude", first.position); + assert.isNotNull(second); + assert.isFalse(second.resumed); + assert.deepStrictEqual( + second.records.map((record) => record.totals.outputTokens), + [11], + ); + }); + + it("parses a line larger than one stream chunk", async () => { + // Tool-heavy transcripts carry multi-megabyte single lines; they arrive + // split across many chunks and must reassemble into one record. + const path = NodePath.join(dir, "claude.jsonl"); + const bigLine = `${JSON.stringify({ + type: "assistant", + timestamp: "2026-08-01T10:00:00Z", + requestId: "req_big", + sessionId: "session-1", + padding: "x".repeat(512 * 1024), + message: { + id: "msg_big", + model: "claude-fable-5", + usage: { input_tokens: 10, output_tokens: 42 }, + }, + })}\n`; + await NodeFSP.writeFile(path, bigLine + claudeLine(2, 7)); + + const parsed = await readTranscriptRecords(path, "claude"); + assert.isNotNull(parsed); + assert.deepStrictEqual( + parsed.records.map((record) => record.totals.outputTokens), + [42, 7], + ); + }); + + it("returns null for an unreadable file", async () => { + assert.isNull(await readTranscriptRecords(NodePath.join(dir, "missing.jsonl"), "claude")); + }); +}); diff --git a/apps/server/src/usage/usageTranscriptReader.ts b/apps/server/src/usage/usageTranscriptReader.ts index 33aef8fae25c..9e5ab6e0c9e0 100644 --- a/apps/server/src/usage/usageTranscriptReader.ts +++ b/apps/server/src/usage/usageTranscriptReader.ts @@ -4,16 +4,19 @@ * * Isolated here so the rest of the usage code stays on Effect's `FileSystem`. * The direct `node:fs` streaming is deliberate: a cold 30-day window is ~1.4 GB - * across ~1,500 files, and `readline` over a read stream is roughly an order of + * across ~1,500 files, and buffer-level streaming is roughly an order of * magnitude cheaper than materialising each file. The equivalent Effect stream * pipeline is idiomatic but not fast enough to sit behind a page load. * + * Transcripts are append-only, so a parse also reports the byte position it + * stopped at. A later scan of the same file resumes from that position and + * parses only the appended bytes, which is what keeps a warm scan cheap while a + * session is actively writing a multi-hundred-megabyte rollout. + * * @module usageTranscriptReader */ -import * as NodeFS from "node:fs"; import * as NodeFSP from "node:fs/promises"; import * as NodePath from "node:path"; -import * as NodeReadline from "node:readline"; import type { UsageProviderKind } from "@t3tools/contracts"; @@ -23,6 +26,7 @@ import { parseClaudeLine, parseCodexLine, parseGrokLine, + type CodexScanState, type UsageRecord, } from "./usageTranscripts.ts"; @@ -32,6 +36,56 @@ export interface TranscriptFile { readonly mtimeMs: number; } +/** + * Where a parse stopped, with enough state to continue from there. + * + * The guard hash fingerprints the bytes immediately before `resumeOffset`. A + * resume only proceeds when those bytes still match: transcripts are + * append-only by design, but a rotated or rewritten file silently mis-parsed + * from the middle would corrupt usage totals. The window is a cheap tripwire + * for those realistic failure shapes, all of which disturb the file's tail at + * that exact offset; it deliberately does not hash the whole prefix, which + * would cost the full re-read the resume exists to avoid. + */ +export interface TranscriptParsePosition { + /** Byte offset just past the last newline-terminated line consumed. */ + readonly resumeOffset: number; + /** Length of the fingerprinted window ending at `resumeOffset`. */ + readonly guardLength: number; + /** FNV-1a hash of that window. */ + readonly guardHash: number; + /** Codex reducer state as of `resumeOffset`; `null` for stateless providers. */ + readonly codexState: CodexScanState | null; +} + +export interface TranscriptParseResult { + /** Records from newline-terminated lines at or after the parse start. */ + readonly records: readonly UsageRecord[]; + /** + * Records from a trailing segment the writer has not newline-terminated yet. + * Kept out of `records` because `position` deliberately excludes that + * segment: the next scan re-reads it once the writer finishes the line. + */ + readonly tailRecords: readonly UsageRecord[]; + readonly position: TranscriptParsePosition; + /** Whether the parse continued from `resumeFrom` rather than byte 0. */ + readonly resumed: boolean; +} + +/** 64 bytes of JSONL tail is ample to distinguish a replaced file. */ +export const GUARD_LENGTH = 64; +const NEWLINE = 0x0a; +const CARRIAGE_RETURN = 0x0d; + +function fnv1a(buffer: Buffer): number { + let hash = 0x811c9dc5; + for (let index = 0; index < buffer.length; index += 1) { + hash ^= buffer[index]!; + hash = Math.imul(hash, 0x01000193); + } + return hash >>> 0; +} + /** * Lists `.jsonl` transcripts under `root` last modified at or after `sinceMs`. * @@ -100,6 +154,25 @@ export async function readDirectoryVolumeId(path: string): Promise { } } +async function guardMatches( + handle: NodeFSP.FileHandle, + position: TranscriptParsePosition, +): Promise { + if (position.guardLength <= 0 || position.guardLength > GUARD_LENGTH) return false; + try { + const window = Buffer.alloc(position.guardLength); + const { bytesRead } = await handle.read( + window, + 0, + position.guardLength, + position.resumeOffset - position.guardLength, + ); + return bytesRead === position.guardLength && fnv1a(window) === position.guardHash; + } catch { + return false; + } +} + /** * Streams one transcript and returns the usage records it contains, or `null` * when the file could not be read. @@ -109,6 +182,10 @@ export async function readDirectoryVolumeId(path: string): Promise { * under the same `(size, mtime)` key would silently drop that file's usage * until the file next changes. * + * With `resumeFrom`, parsing continues from that position when its guard bytes + * still match, so only appended lines are read; otherwise the whole file is + * re-parsed from the start and `resumed` reports `false`. + * * Codex carries the active model on `turn_context` lines that hold no usage of * their own, so those still have to pass through the reducer to keep model * attribution correct. @@ -116,43 +193,121 @@ export async function readDirectoryVolumeId(path: string): Promise { export async function readTranscriptRecords( filePath: string, provider: UsageProviderKind, -): Promise { - const records: UsageRecord[] = []; - const codexState = initialCodexScanState(); + resumeFrom?: TranscriptParsePosition, +): Promise { + let handle: NodeFSP.FileHandle; + try { + handle = await NodeFSP.open(filePath, "r"); + } catch { + return null; + } try { - const lines = NodeReadline.createInterface({ - input: NodeFS.createReadStream(filePath, { encoding: "utf8" }), - crlfDelay: Infinity, - }); + let codexState = initialCodexScanState(); + let resumed = false; + let start = 0; + if ( + resumeFrom !== undefined && + resumeFrom.resumeOffset > 0 && + (provider !== "codex" || resumeFrom.codexState !== null) && + (await guardMatches(handle, resumeFrom)) + ) { + if (resumeFrom.codexState !== null) codexState = { ...resumeFrom.codexState }; + start = resumeFrom.resumeOffset; + resumed = true; + } - for await (const line of lines) { + const parseLine = (line: string, state: CodexScanState, out: UsageRecord[]): void => { if (provider === "codex") { if ( !mightCarryUsage(line, provider) && !line.includes('"turn_context"') && !line.includes('"session_meta"') ) { - continue; + return; } - const record = parseCodexLine(line, codexState); - if (record !== null) records.push(record); - continue; + const record = parseCodexLine(line, state); + if (record !== null) out.push(record); + return; } - + if (!mightCarryUsage(line, provider)) return; if (provider === "grok") { - if (!mightCarryUsage(line, provider)) continue; - for (const grokRecord of parseGrokLine(line)) records.push(grokRecord); + for (const grokRecord of parseGrokLine(line)) out.push(grokRecord); + return; + } + const record = parseClaudeLine(line); + if (record !== null) out.push(record); + }; + + const toLineString = (lineBuffer: Buffer): string => { + const content = + lineBuffer.length > 0 && lineBuffer[lineBuffer.length - 1] === CARRIAGE_RETURN + ? lineBuffer.subarray(0, -1) + : lineBuffer; + return content.toString("utf8"); + }; + + const records: UsageRecord[] = []; + // Buffer-level line splitting rather than `readline`, because resuming + // needs byte-exact offsets and decoded strings cannot provide them. + // Newline-free chunks are collected rather than concatenated as they + // arrive, so a single huge line costs one copy instead of one per chunk. + let resumeOffset = start; + let pendingChunks: Buffer[] = []; + const stream = handle.createReadStream({ + start, + autoClose: false, + }) as AsyncIterable; + for await (const chunk of stream) { + if (!chunk.includes(NEWLINE)) { + pendingChunks.push(chunk); continue; } + const buffer: Buffer = + pendingChunks.length === 0 ? chunk : Buffer.concat([...pendingChunks, chunk]); + pendingChunks = []; + let lineStart = 0; + for (;;) { + const newlineIndex = buffer.indexOf(NEWLINE, lineStart); + if (newlineIndex === -1) break; + parseLine(toLineString(buffer.subarray(lineStart, newlineIndex)), codexState, records); + lineStart = newlineIndex + 1; + } + resumeOffset += lineStart; + if (lineStart < buffer.length) pendingChunks.push(buffer.subarray(lineStart)); + } - if (!mightCarryUsage(line, provider)) continue; - const record = parseClaudeLine(line); - if (record !== null) records.push(record); + // A trailing segment without its newline is parsed for this result but not + // consumed: a writer may still be appending to it, and counting a half + // record now and its full form later would double count. + const tailRecords: UsageRecord[] = []; + if (pendingChunks.length > 0) { + const pending = pendingChunks.length === 1 ? pendingChunks[0]! : Buffer.concat(pendingChunks); + if (pending.length > 0) parseLine(toLineString(pending), { ...codexState }, tailRecords); } + + const guardLength = Math.min(GUARD_LENGTH, resumeOffset); + let guardHash = 0; + if (guardLength > 0) { + const window = Buffer.alloc(guardLength); + await handle.read(window, 0, guardLength, resumeOffset - guardLength); + guardHash = fnv1a(window); + } + + return { + records, + tailRecords, + position: { + resumeOffset, + guardLength, + guardHash, + codexState: provider === "codex" ? codexState : null, + }, + resumed, + }; } catch { return null; + } finally { + await handle.close().catch(() => undefined); } - - return records; } diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 71e478cbaa3d..ef2d00291caf 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -475,6 +475,7 @@ function trace2ChildKey(record: Record): string | null { } const Trace2Record = Schema.Record(Schema.String, Schema.Unknown); +const decodeTrace2Record = decodeJsonResult(Trace2Record); const createTrace2Monitor = Effect.fn("createTrace2Monitor")(function* ( input: Pick, @@ -509,7 +510,7 @@ const createTrace2Monitor = Effect.fn("createTrace2Monitor")(function* ( return; } - const traceRecord = decodeJsonResult(Trace2Record)(trimmedLine); + const traceRecord = decodeTrace2Record(trimmedLine); if (Result.isFailure(traceRecord)) { yield* Effect.logDebug( `GitVcsDriver.trace2: failed to parse trace line for ${input.operation} in ${input.cwd} (${input.args.length} arguments)`, diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 716896945de4..d23c21f4f08f 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -83,6 +83,7 @@ import { projectActivityEvent, projectThreadDetailSnapshot, } from "./orchestration/ActivityPayloadProjection.ts"; +import { makeThreadLiveEventCoalescer } from "./orchestration/ThreadLiveEventCoalescer.ts"; import { cleanupFailedUploadedAttachments, normalizeDispatchCommand, @@ -1229,23 +1230,17 @@ const makeWsRpcLayer = ( ORCHESTRATION_WS_METHODS.dispatchCommand, Effect.gen(function* () { const normalizedCommand = yield* normalizeDispatchCommand(command); - // Archive and settle both mean "done with this thread", so a - // live provider session must not keep running background work - // (PR monitors, dev servers, subagent fleets) after either - // lands. The decider rejects settling a starting/running - // session, so for settle this only ever stops an idle one; a - // stopped session-set does not count as activity, so the stop - // cannot un-settle the thread it follows. - const parkingCommand = - normalizedCommand.type === "thread.archive" || - normalizedCommand.type === "thread.settle" - ? normalizedCommand - : undefined; - // Best-effort on purpose: the user's archive/settle must not + // Archive removes the thread from the client, so this transport + // closes its session and terminals after the command lands. + // Settlement cleanup is driven by thread.settled events in the + // provider reactor, including settlements that have no client. + const archiveCommand = + normalizedCommand.type === "thread.archive" ? normalizedCommand : undefined; + // Best-effort on purpose: the user's archive must not // fail because this cleanup read blipped, so a failed read // logs and skips the stop instead of propagating. - const shouldStopSessionAfterCommand = parkingCommand - ? yield* projectionSnapshotQuery.getThreadShellById(parkingCommand.threadId).pipe( + const shouldStopSessionAfterCommand = archiveCommand + ? yield* projectionSnapshotQuery.getThreadShellById(archiveCommand.threadId).pipe( Effect.map( Option.match({ onNone: () => false, @@ -1256,7 +1251,7 @@ const makeWsRpcLayer = ( Effect.catchCause((cause) => Effect.logWarning( "failed to read thread session state before session-stop check", - { threadId: parkingCommand.threadId, cause }, + { threadId: archiveCommand.threadId, cause }, ).pipe(Effect.as(false)), ), ) @@ -1265,50 +1260,39 @@ const makeWsRpcLayer = ( Effect.tapError(() => cleanupFailedUploadedAttachments(command, normalizedCommand)), ); yield* recordClientCommandAnalytics(normalizedCommand); - if (parkingCommand) { - const parkingKind = parkingCommand.type === "thread.archive" ? "archive" : "settle"; + if (archiveCommand) { if (shouldStopSessionAfterCommand) { yield* Effect.gen(function* () { const stopCommand = yield* normalizeDispatchCommand({ type: "thread.session.stop", commandId: CommandId.make( - `session-stop-for-${parkingKind}:${parkingCommand.commandId}`, + `session-stop-for-archive:${archiveCommand.commandId}`, ), - threadId: parkingCommand.threadId, + threadId: archiveCommand.threadId, createdAt: yield* nowIso, - // A settled thread can be re-engaged before this stop is - // decided; the decider then drops the stop instead of - // killing the new session. Archive stops stay - // unconditional: turn starts on archived threads are - // rejected, so there is no new session to protect. - ...(parkingKind === "settle" ? { onlyIfSettled: true } : {}), }); yield* dispatchNormalizedCommand(stopCommand); }).pipe( Effect.catchCause((cause) => - Effect.logWarning(`failed to stop provider session during ${parkingKind}`, { - threadId: parkingCommand.threadId, + Effect.logWarning("failed to stop provider session during archive", { + threadId: archiveCommand.threadId, cause, }), ), ); } - // Terminals are user-opened panes, not thread background - // work: archive removes the thread from view so they close - // with it, but a settled thread stays reachable and may be - // un-settled, so its terminals stay up. - if (parkingCommand.type === "thread.archive") { - yield* terminalManager.close({ threadId: parkingCommand.threadId }).pipe( - Effect.catch((error) => - Effect.logWarning("failed to close thread terminals after archive", { - threadId: parkingCommand.threadId, - error: error.message, - }), - ), - ); - } + // Archive removes the thread from view, so its user-opened + // terminal panes close with it. + yield* terminalManager.close({ threadId: archiveCommand.threadId }).pipe( + Effect.catch((error) => + Effect.logWarning("failed to close thread terminals after archive", { + threadId: archiveCommand.threadId, + error: error.message, + }), + ), + ); } return result; }).pipe( @@ -1508,17 +1492,15 @@ const makeWsRpcLayer = ( Stream.filter(isThisThreadDetailEvent), Stream.map((event) => ({ kind: "event" as const, - event: projectActivityEvent(event), + event, })), ); // Attach live delivery before reading either replay or snapshot state. // Otherwise an event published while the snapshot is loading is lost. - const liveBuffer = yield* Queue.unbounded(); - yield* Effect.forkScoped( - liveStream.pipe(Stream.runForEach((item) => Queue.offer(liveBuffer, item))), - ); - const bufferedLiveStream = Stream.fromQueue(liveBuffer); + const liveBuffer = yield* makeThreadLiveEventCoalescer(); + yield* Effect.forkScoped(liveStream.pipe(Stream.runForEach(liveBuffer.offer))); + const bufferedLiveStream = liveBuffer.stream; // When the client already loaded the snapshot over HTTP it passes // that snapshot's sequence, and we resume the live subscription by @@ -1567,8 +1549,10 @@ const makeWsRpcLayer = ( input.requestCompletionMarker === true ? Stream.concat( Stream.fromEffect( - Queue.offer(liveBuffer, { kind: "synchronized" as const }), - ).pipe(Stream.drain), + liveBuffer + .offerAndWait({ kind: "synchronized" as const }) + .pipe(Effect.andThen(liveBuffer.takeAll)), + ).pipe(Stream.flatMap((items) => Stream.fromIterable(items))), bufferedLiveStream, ) : bufferedLiveStream; @@ -1609,8 +1593,10 @@ const makeWsRpcLayer = ( input.requestCompletionMarker === true ? Stream.concat( Stream.fromEffect( - Queue.offer(liveBuffer, { kind: "synchronized" as const }), - ).pipe(Stream.drain), + liveBuffer + .offerAndWait({ kind: "synchronized" as const }) + .pipe(Effect.andThen(liveBuffer.takeAll)), + ).pipe(Stream.flatMap((items) => Stream.fromIterable(items))), bufferedLiveStream, ) : bufferedLiveStream; diff --git a/apps/web/package.json b/apps/web/package.json index acbd8bdf4ddc..ea8fd3f4de1c 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/web", - "version": "0.0.45", + "version": "0.0.46", "private": true, "type": "module", "scripts": { diff --git a/apps/web/src/cloud/linkEnvironment.test.ts b/apps/web/src/cloud/linkEnvironment.test.ts index 38e205beabbb..7ae5e7ed03a9 100644 --- a/apps/web/src/cloud/linkEnvironment.test.ts +++ b/apps/web/src/cloud/linkEnvironment.test.ts @@ -91,6 +91,7 @@ function registryLayer(options?: { const session: RpcSession = { client, initialConfig: Effect.never, + subscribeServerConfig: (input) => client.subscribeServerConfig(input), ready: Effect.void, probe: Effect.void, closed: Effect.never, diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 5480313f601a..98462d54df67 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -265,8 +265,10 @@ function useLabelsOverflow(element: HTMLDivElement | null): boolean { let needed = 0; let groups = 0; for (const child of current.children) { - if (!(child instanceof HTMLElement) || child.offsetWidth <= 1) continue; - needed += contentWidth(child); + if (!(child instanceof HTMLElement)) continue; + const width = contentWidth(child); + if (width <= 1) continue; + needed += width; groups += 1; } needed += stripGap * Math.max(0, groups - 1); @@ -356,7 +358,7 @@ function useLabelsOverflow(element: HTMLDivElement | null): boolean { // Label widths can change without the strip box moving (font family or // size preferences), so re-measure on every render as well as on resize // and font loads. - useEffect(() => { + useLayoutEffect(() => { measure(); }); @@ -487,7 +489,7 @@ export const BranchToolbar = memo(function BranchToolbar({ onUsePreviousWorktree={onUsePreviousWorktree} /> ) : ( -
+
{showEnvironmentIndicator && availableEnvironments && ( <> {activeWorktreePath ? ( @@ -63,9 +63,14 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe )} - {resolveLockedWorkspaceLabel(activeWorktreePath)} + + {resolveLockedWorkspaceLabel(activeWorktreePath)} + ); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 822cc9501abc..9a413c958696 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -22,6 +22,7 @@ import { type LucideIcon, } from "lucide-react"; import type { + AssetResource, EnvironmentId, ScopedThreadRef, ServerProviderSkill, @@ -181,6 +182,7 @@ interface ChatMarkdownProps { onUseArtifactTemplate?: ((template: CodexArtifactTemplate) => void) | undefined; imageBaseDir?: string | undefined; onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined; + extraRemarkPlugins?: NonNullable; } export function canUseMarkdownFileShellActions( @@ -213,6 +215,7 @@ export function shouldUseMarkdownFileBrowserPrimaryAction(input: { } const EMPTY_MARKDOWN_SKILLS: ReadonlyArray> = []; +const EMPTY_REMARK_PLUGINS: NonNullable = []; const ARTIFACT_TEMPLATE_ICON_BY_KIND = { document: FileTextIcon, @@ -362,6 +365,7 @@ const CHAT_MARKDOWN_SANITIZE_SCHEMA = { code: [...(defaultSchema.attributes?.code ?? []), "dataCodeMeta", "dataInlineCode"], blockquote: [...(defaultSchema.attributes?.blockquote ?? []), "dataAlert"], div: [...(defaultSchema.attributes?.div ?? []), ...CODEX_ARTIFACT_TEMPLATE_HAST_PROPERTIES], + a: [...(defaultSchema.attributes?.a ?? []), "dataPullRequestAutolink"], img: [...(defaultSchema.attributes?.img ?? []), "dataLocalSrc", "dataMarkdownTitle"], }, protocols: { @@ -659,12 +663,7 @@ function MarkdownTable({ children, ...props }: React.ComponentProps<"table">) { className="chat-markdown-table-container" data-expanded={expanded ? "true" : "false"} > - + {children}
@@ -1225,21 +1224,17 @@ function ChatMarkdownImageFallback(props: { ); } -/** Markdown images whose src is a workspace file path load through a signed asset URL. */ -const ChatMarkdownWorkspaceImage = memo(function ChatMarkdownWorkspaceImage(props: { - readonly threadRef: ScopedThreadRef; - readonly path: string; +/** Environment-hosted images load through a signed asset URL. */ +export const ChatMarkdownAssetImage = memo(function ChatMarkdownAssetImage(props: { + readonly environmentId: EnvironmentId; + readonly resource: Extract; readonly alt: string; - readonly copyMarkdown: string; - readonly srcFragment: string; + readonly copyMarkdown?: string; + readonly srcFragment?: string; readonly style?: CSSProperties | undefined; readonly onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined; }) { - const assetUrl = useAssetUrlState(props.threadRef.environmentId, { - _tag: "workspace-file", - threadId: props.threadRef.threadId, - path: props.path, - }); + const assetUrl = useAssetUrlState(props.environmentId, props.resource); const [failedUrl, setFailedUrl] = useState(null); if (assetUrl._tag === "Failure" || (assetUrl._tag === "Success" && failedUrl === assetUrl.url)) { @@ -1260,7 +1255,7 @@ const ChatMarkdownWorkspaceImage = memo(function ChatMarkdownWorkspaceImage(prop /> ); } - const src = assetUrl.url + props.srcFragment; + const src = assetUrl.url + (props.srcFragment ?? ""); return ( )["data-pull-request-autolink"] ?? "", + ); + const pullRequestCopy = + pullRequestAutolink === "commit" + ? /\/commit\/([0-9a-f]{40})$/iu.exec(href ?? "")?.[1] + : pullRequestAutolink === "reference" + ? plainHastText(node) + : undefined; + const isPullRequestAutolink = pullRequestCopy !== undefined; const isSameDocumentLink = href?.startsWith("#") ?? false; const onClick = props.onClick; const canOpenInPreview = Boolean(threadRef) && isPreviewSupportedInRuntime(); @@ -2304,6 +2310,8 @@ function ChatMarkdown({ const link = ( - {faviconHost && hastHasText(node) ? ( + {faviconHost && hastHasText(node) && !isPullRequestAutolink ? ( {linkChildren} @@ -2455,9 +2463,13 @@ function ChatMarkdown({ } if (imageSource._tag === "WorkspaceFile" && threadRef) { return ( - [ + ...(lineBreaks ? CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS : CHAT_MARKDOWN_REMARK_PLUGINS), + ...extraRemarkPlugins, + ], + [extraRemarkPlugins, lineBreaks], + ); + // react-markdown converts unparsed HTML nodes to text when skipHtml is false. // Keep that behavior explicit because literal mode depends on escaping the // complete source token instead of dropping it from the rendered message. @@ -2550,9 +2570,7 @@ function ChatMarkdown({ onCopy={handleCopy} > { expect( resolveDraftPromotionNavigationTarget({ serverThreadRef: { environmentId, threadId }, - serverThreadStarted: true, + serverThread: makeThread({ latestTurn: completedTurn }), backgroundSubmissionPending: true, }), ).toBeNull(); @@ -316,6 +316,66 @@ const readySession = { updatedAt: "2026-03-29T00:00:10.000Z", }; +describe("draft promotion during worktree setup", () => { + const serverThreadRef = { environmentId, threadId }; + + it.each([null, "idle", "starting", "ready"] as const)( + "keeps the draft mounted while the first turn waits with session %s", + (status) => { + const serverThread = makeThread({ + messages: [ + { + id: MessageId.make("submitted-message"), + role: "user", + text: "Start in a new worktree", + turnId: null, + createdAt: now, + updatedAt: now, + streaming: false, + }, + ], + session: status ? { ...readySession, status } : null, + }); + + expect( + resolveDraftPromotionNavigationTarget({ + serverThreadRef, + serverThread, + backgroundSubmissionPending: false, + }), + ).toBeNull(); + }, + ); + + it("promotes when the provider starts the first turn", () => { + const latestTurn = { ...completedTurn, state: "running" as const, completedAt: null }; + + expect( + resolveDraftPromotionNavigationTarget({ + serverThreadRef, + serverThread: makeThread({ + latestTurn, + session: { ...readySession, status: "running", activeTurnId: latestTurn.turnId }, + }), + backgroundSubmissionPending: false, + }), + ).toEqual(serverThreadRef); + }); + + it.each(["error", "stopped", "interrupted"] as const)( + "promotes a startup that ends as %s before a turn starts", + (status) => { + expect( + resolveDraftPromotionNavigationTarget({ + serverThreadRef, + serverThread: makeThread({ session: { ...readySession, status } }), + backgroundSubmissionPending: false, + }), + ).toEqual(serverThreadRef); + }, + ); +}); + describe("buildLoadingThreadFromShell", () => { it("preserves shell metadata and supplies empty detail collections", () => { const shell = { diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index ae0b9885969e..a12bacad50dd 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -110,13 +110,19 @@ export function resolveDraftHeroState(input: { export function resolveDraftPromotionNavigationTarget(input: { serverThreadRef: ScopedThreadRef | null; - serverThreadStarted: boolean; + serverThread: Pick | null | undefined; backgroundSubmissionPending: boolean; }): ScopedThreadRef | null { if (input.backgroundSubmissionPending) { return null; } - return input.serverThreadStarted ? input.serverThreadRef : null; + const sessionStatus = input.serverThread?.session?.status; + const turnStarted = input.serverThread?.latestTurn?.startedAt != null; + const startupStopped = + sessionStatus === "error" || sessionStatus === "stopped" || sessionStatus === "interrupted"; + // Keep local preparation feedback mounted until the server can render the + // running turn or its startup error on the canonical thread route. + return turnStarted || startupStopped ? input.serverThreadRef : null; } export function scheduleEnvironmentReconnectWarning(showWarning: () => void): () => void { diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 3cd0cb966586..40d316f68621 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -30,12 +30,7 @@ import { } from "@t3tools/client-runtime/connection"; import { wasBootstrapThreadDeleted } from "@t3tools/client-runtime/errors"; import { type CodexArtifactTemplate } from "@t3tools/client-runtime/codex-artifact-templates"; -import { - changeRequestAutoSettles, - effectiveSettled, - effectiveSnoozed, - threadWokeAt, -} from "@t3tools/client-runtime/state/thread-settled"; +import { effectiveSnoozed, threadWokeAt } from "@t3tools/client-runtime/state/thread-settled"; import { codexFeedbackMessage, parseCodexFeedbackCommand, @@ -53,9 +48,9 @@ import { createModelSelection, resolvePromptInjectedEffort, } from "@t3tools/shared/model"; -import { CHAT_LIST_ANCHOR_OFFSET } from "@t3tools/shared/chatList"; import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; import { truncate } from "@t3tools/shared/String"; +import { resolveThreadReferenceCopyTarget } from "@t3tools/shared/threadReference"; import { getTerminalLabel, nextTerminalId, @@ -108,7 +103,11 @@ import { isLatestTurnSettled, } from "../session-logic"; import { type LegendListRef } from "@legendapp/list/react"; -import { getAnchoredTurnMetrics, type TimelineScrollMode } from "./chat/timelineScrollAnchoring"; +import { + CHAT_TIMELINE_ANCHOR_OFFSET, + getAnchoredTurnMetrics, + type TimelineScrollMode, +} from "./chat/timelineScrollAnchoring"; import { buildPendingUserInputAnswers, derivePendingUserInputProgress, @@ -541,7 +540,11 @@ const TYPE_TO_FOCUS_INTERACTIVE_SELECTOR = [ '[role="tab"]', ].join(","); const TYPE_TO_FOCUS_FLOATING_LAYER_SELECTOR = [ - '[data-slot="dialog"]', + '[data-slot="alert-dialog-popup"]:is([data-open],[data-ending-style])', + '[data-slot="command-dialog-popup"]:is([data-open],[data-ending-style])', + '[data-slot="dialog-popup"]:is([data-open],[data-ending-style])', + '[data-slot="sheet-popup"]:is([data-open],[data-ending-style])', + '[data-slot="sidebar"][data-mobile="true"]:is([data-open],[data-ending-style])', '[data-slot="menu-popup"]', '[data-slot="select-popup"]', '[data-slot="popover-popup"]', @@ -4168,7 +4171,7 @@ export function ChatViewContent(props: ChatViewProps) { state, anchorIndex, composerOverlayHeight, - anchorOffset: CHAT_LIST_ANCHOR_OFFSET, + anchorOffset: CHAT_TIMELINE_ANCHOR_OFFSET, }); }, [composerOverlayHeight], @@ -4196,7 +4199,7 @@ export function ChatViewContent(props: ChatViewProps) { const realContentBottom = lastRowTop + Math.max(1, lastRowHeight); const visibleScrollLength = Math.max( 0, - (state.scrollLength ?? 0) - composerOverlayHeight - CHAT_LIST_ANCHOR_OFFSET, + (state.scrollLength ?? 0) - composerOverlayHeight - CHAT_TIMELINE_ANCHOR_OFFSET, ); return realContentBottom > visibleScrollLength; }, @@ -4384,7 +4387,7 @@ export function ChatViewContent(props: ChatViewProps) { index: anchorIndex, animated: true, viewPosition: 0, - viewOffset: CHAT_LIST_ANCHOR_OFFSET, + viewOffset: CHAT_TIMELINE_ANCHOR_OFFSET, }) .then(() => { if (positionedTimelineAnchorRef.current !== messageId) { @@ -4594,9 +4597,7 @@ export function ChatViewContent(props: ChatViewProps) { : null, [activeThreadBranch, activeWorktreePath, envMode, gitStatusQuery.data?.refName, isServerThread], ); - // Settled state of the open thread, resolved exactly like the sidebar - // partition (same shell, same capability gate, same PR auto-settle input) - // so the banner and the sidebar row never disagree. + // The server-projected settled state keeps the banner and sidebar in sync. const activeThreadShell = useThreadShell(isServerThread ? activeThreadRef : null); const activeComposerTasksProgress = useMemo(() => { if (!activeLatestTurn || latestTurnSettled || activePlan?.turnId !== activeLatestTurn.turnId) { @@ -4616,7 +4617,6 @@ export function ChatViewContent(props: ChatViewProps) { activeComposerTasksProgress && activePlan && activePlan.turnId === activeLatestTurn?.turnId ? activePlan.steps : null; - useLayoutEffect(() => { if (!composerOverlayElement) return; @@ -4640,9 +4640,6 @@ export function ChatViewContent(props: ChatViewProps) { resizeObserver.disconnect(); }; }, [composerOverlayElement]); - - const autoSettleAfterDays = useClientSettings((settings) => settings.sidebarAutoSettleAfterDays); - const autoSettleOnMerge = useClientSettings((settings) => settings.sidebarAutoSettleOnMerge); const linkedPullRequestStatus = useLinkedThreadPullRequest( activeThreadRef?.environmentId ?? null, linkedThreadPullRequest, @@ -4655,6 +4652,41 @@ export function ChatViewContent(props: ChatViewProps) { linkedPullRequest: linkedThreadPullRequest, linkedPullRequestStatus, }); + const activeThreadReferenceCopyTarget = useMemo( + () => + activeThreadId === null || !isServerThread + ? null + : resolveThreadReferenceCopyTarget({ + threadId: activeThreadId, + linkedPullRequestUrl: linkedThreadPullRequest?.url ?? null, + detectedPullRequestUrl: activeThreadPr?.url ?? null, + }), + [activeThreadId, activeThreadPr?.url, isServerThread, linkedThreadPullRequest?.url], + ); + const copyActiveThreadReference = useCallback(() => { + const target = activeThreadReferenceCopyTarget; + if (target === null) return; + void writeTextToClipboard(target.value, target.clipboardTarget).then( + (didCopy) => { + if (!didCopy) return; + toastManager.add({ + type: "success", + title: target.successTitle, + description: target.value, + }); + }, + (error) => { + console.error(error); + toastManager.add( + stackedThreadToast({ + type: "error", + title: target.failureTitle, + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + }, + ); + }, [activeThreadReferenceCopyTarget]); // The right panel offers the thread's own change request, so it can only offer it once the // branch has one; until then the picker says so rather than opening an empty panel. const addPullRequestSurface = useCallback(() => { @@ -4663,18 +4695,6 @@ export function ChatViewContent(props: ChatViewProps) { }, [activeThreadPr, openThreadPullRequest]); const pullRequestSurfaceAvailable = supportsPullRequests && activeThreadPr !== null && threadRepository !== null; - // Primitive slice of the displayed PR for the settle-rule memos below: - // resolveDisplayedThreadPr returns a fresh object every render, so memoize - // on the fields the rules read instead of the object identity. - const activeThreadPrState = activeThreadPr?.state ?? null; - const activeThreadPrUpdatedAt = activeThreadPr?.updatedAt ?? null; - const activeThreadChangeRequest = useMemo( - () => - activeThreadPrState === null - ? null - : { state: activeThreadPrState, updatedAt: activeThreadPrUpdatedAt }, - [activeThreadPrState, activeThreadPrUpdatedAt], - ); const supportsSettlement = serverConfig?.environment.capabilities.threadSettlement === true; const supportsSnooze = serverConfig?.environment.capabilities.threadSnooze === true; const supportsPinning = serverConfig?.environment.capabilities.threadPinning === true; @@ -4705,21 +4725,13 @@ export function ChatViewContent(props: ChatViewProps) { if (activeThreadRef === null || activeThreadWokeAt === null) return; markThreadVisited(scopedThreadKey(activeThreadRef), activeThreadWokeAt); }, [activeThreadRef, activeThreadWokeAt, markThreadVisited]); - // Mirror of the sidebar's Woke pill for the open thread. It uses the same - // visit comparison and change request settle rule. + // Mirror of the sidebar's Woke pill for the open thread. const activeThreadLastVisitedAt = useUiStateStore((store) => activeThreadKey === null ? undefined : store.threadLastVisitedAtById[activeThreadKey], ); const activeThreadWokeVisible = useMemo(() => { if (activeThreadWokeAt === null) return false; - if ( - changeRequestAutoSettles(activeThreadChangeRequest, { - autoSettleOnMerge, - thread: activeThreadShell, - }) - ) { - return false; - } + if (activeThreadShell?.settledOverride === "settled") return false; const wokeAtMs = Date.parse(activeThreadWokeAt); if (Number.isNaN(wokeAtMs)) return false; // Having the thread open counts as a visit at completedAt (the effect @@ -4739,28 +4751,11 @@ export function ChatViewContent(props: ChatViewProps) { }, [ activeLatestTurn?.completedAt, activeThreadLastVisitedAt, - activeThreadChangeRequest, activeThreadShell, activeThreadWokeAt, - autoSettleOnMerge, - ]); - const activeThreadSettled = useMemo(() => { - if (activeThreadShell === null || !supportsSettlement) return false; - return effectiveSettled(activeThreadShell, { - now: `${nowMinute}:00.000Z`, - autoSettleAfterDays, - autoSettleOnMerge, - changeRequest: activeThreadChangeRequest, - }); - }, [ - activeThreadChangeRequest, - activeThreadShell, - autoSettleAfterDays, - autoSettleOnMerge, - changeRequestSnapshotByKey, - nowMinute, - supportsSettlement, ]); + const activeThreadSettled = + supportsSettlement && activeThreadShell?.settledOverride === "settled"; const unsettleThreadMutation = useAtomCommand(threadEnvironment.unsettle, { reportFailure: false, }); @@ -5371,6 +5366,13 @@ export function ChatViewContent(props: ChatViewProps) { }); if (!command) return; + if (command === "thread.copyReference") { + event.preventDefault(); + event.stopPropagation(); + if (!event.repeat) copyActiveThreadReference(); + return; + } + if (command === "thread.settle") { event.preventDefault(); event.stopPropagation(); @@ -5540,6 +5542,7 @@ export function ChatViewContent(props: ChatViewProps) { supportsPinning, supportsSettlement, confirmAndUnpinThread, + copyActiveThreadReference, toggleRightPanel, toggleRightPanelMaximized, toggleTerminalVisibility, @@ -6220,7 +6223,6 @@ export function ChatViewContent(props: ChatViewProps) { : {}), } : undefined; - beginLocalDispatch({ preparingWorktree: false }); const backgroundThreadRef = resolvedSubmissionIntent === "background" ? scopeThreadRef(activeThread.environmentId, threadIdForSend) @@ -7252,7 +7254,6 @@ export function ChatViewContent(props: ChatViewProps) { {...(routeKind === "draft" && draftId ? { draftId } : {})} activeThreadTitle={activeThread.title} isServerThread={isServerThread} - changeRequest={activeThreadChangeRequest} activeProjectName={activeProject?.title} activeProjectCwd={activeProject?.workspaceRoot ?? null} activeProjectFaviconPath={activeProject?.faviconPath ?? null} @@ -7321,6 +7322,7 @@ export function ChatViewContent(props: ChatViewProps) { onOpenAgents={addAgentsSurface} key={activeThread.id} isWorking={isWorking} + isPreparingWorktree={isPreparingWorktree} activeTurnStartedAt={activeWorkStartedAt} listRef={legendListRef} timelineEntries={timelineEntries} diff --git a/apps/web/src/components/CommandPalette.logic.test.ts b/apps/web/src/components/CommandPalette.logic.test.ts index fc5602b357a7..03f24d8911f6 100644 --- a/apps/web/src/components/CommandPalette.logic.test.ts +++ b/apps/web/src/components/CommandPalette.logic.test.ts @@ -8,6 +8,7 @@ import { enumerateCommandPaletteItems, filterPinnedBrowseEntries, filterCommandPaletteGroups, + normalizeSearchText, reduceCommandPaletteUiState, type CommandPaletteGroup, } from "./CommandPalette.logic"; @@ -272,6 +273,75 @@ describe("buildThreadActionItems", () => { expect(groups[0]?.items.map((item) => item.value)).toEqual(["thread:project-context-only"]); }); + it("ranks an order-independent setting title match above a split context match", () => { + const settingsSearchItems = [ + { + kind: "action" as const, + value: "setting:context-match", + searchTerms: ["Pairing settings", "remote backend"], + title: "Context match", + icon: null, + run: async () => undefined, + }, + { + kind: "action" as const, + value: "setting:remote-pairing", + searchTerms: ["Remote pairing", "connections"], + title: "Remote pairing", + icon: null, + run: async () => undefined, + }, + ]; + + const groups = filterCommandPaletteGroups({ + activeGroups: [], + query: "pairing remote", + isInSubmenu: false, + projectSearchItems: [], + settingsSearchItems, + threadSearchItems: [], + }); + + expect(groups).toHaveLength(1); + expect(groups[0]?.value).toBe("settings-search"); + expect(groups[0]?.items.map((item) => item.value)).toEqual([ + "setting:remote-pairing", + "setting:context-match", + ]); + }); + + it("keeps accent-insensitive setting results", () => { + const groups = filterCommandPaletteGroups({ + activeGroups: [], + query: "thè\u{1ab0}mes", + isInSubmenu: false, + projectSearchItems: [], + settingsSearchItems: [ + { + kind: "action", + value: "setting:theme", + searchTerms: ["Themes", "Appearance"], + title: "Themes", + icon: null, + run: async () => undefined, + }, + ], + threadSearchItems: [], + }); + + expect(groups[0]?.items.map((item) => item.value)).toEqual(["setting:theme"]); + }); + + it("normalizes case independently of the host locale", () => { + const localeLowerCase = vi.spyOn(String.prototype, "toLocaleLowerCase").mockReturnValue("gıt"); + try { + expect(normalizeSearchText("GIT")).toBe("git"); + expect(localeLowerCase).not.toHaveBeenCalled(); + } finally { + localeLowerCase.mockRestore(); + } + }); + it("keeps message excerpts searchable without replacing thread metadata", () => { const [item] = buildThreadActionItems({ threads: [makeThread({ branch: "feat/search" })], diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index 688a8a8ea791..a0af1be0450b 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -9,9 +9,12 @@ import * as Arr from "effect/Array"; import * as Result from "effect/Result"; import { type ReactNode } from "react"; import { sortThreads } from "../lib/threadSort"; +import { normalizeSearchText } from "../lib/utils"; import { formatRelativeTimeLabel } from "../timestampFormat"; import { type Project, type SidebarThreadSummary, type Thread } from "../types"; +export { normalizeSearchText } from "../lib/utils"; + export const RECENT_THREAD_LIMIT = 12; export const ITEM_ICON_CLASS = "size-4 text-icon-muted"; export const ADDON_ICON_CLASS = "size-4"; @@ -138,10 +141,6 @@ export function enumerateCommandPaletteItems( export type CommandPaletteMode = "root" | "root-browse" | "submenu" | "submenu-browse"; -export function normalizeSearchText(value: string): string { - return value.trim().toLowerCase().replace(/\s+/g, " "); -} - export function buildProjectActionItems(input: { projects: ReadonlyArray; valuePrefix: string; @@ -255,9 +254,16 @@ export function buildThreadActionItems, +): number { const normalizedField = normalizeSearchText(field); - if (normalizedField.length === 0 || !normalizedField.includes(normalizedQuery)) { + if ( + normalizedField.length === 0 || + !queryTokens.every((token) => normalizedField.includes(token)) + ) { return Number.NEGATIVE_INFINITY; } if (normalizedField === normalizedQuery) { @@ -266,12 +272,16 @@ function rankSearchFieldMatch(field: string, normalizedQuery: string): number { if (normalizedField.startsWith(normalizedQuery)) { return 2; } - return 1; + if (normalizedField.includes(normalizedQuery)) { + return 1; + } + return 0; } function rankCommandPaletteItemMatch( item: CommandPaletteActionItem | CommandPaletteSubmenuItem, normalizedQuery: string, + queryTokens: ReadonlyArray, ): number { const terms = item.searchTerms.filter((term) => term.length > 0); if (terms.length === 0) { @@ -279,7 +289,7 @@ function rankCommandPaletteItemMatch( } for (const [index, field] of terms.entries()) { - const fieldRank = rankSearchFieldMatch(field, normalizedQuery); + const fieldRank = rankSearchFieldMatch(field, normalizedQuery, queryTokens); if (fieldRank !== Number.NEGATIVE_INFINITY) { return 1_000 - index * 100 + fieldRank; } @@ -293,6 +303,7 @@ export function filterCommandPaletteGroups(input: { query: string; isInSubmenu: boolean; projectSearchItems: ReadonlyArray; + settingsSearchItems?: ReadonlyArray; threadSearchItems: ReadonlyArray; }): CommandPaletteGroup[] { const isActionsFilter = input.query.startsWith(">"); @@ -305,6 +316,7 @@ export function filterCommandPaletteGroups(input: { } return [...input.activeGroups]; } + const queryTokens = normalizedQuery.split(" "); let baseGroups = [...input.activeGroups]; if (isActionsFilter) { @@ -322,6 +334,13 @@ export function filterCommandPaletteGroups(input: { items: input.projectSearchItems, }); } + if (input.settingsSearchItems && input.settingsSearchItems.length > 0) { + searchableGroups.push({ + value: "settings-search", + label: "Settings", + items: input.settingsSearchItems, + }); + } if (input.threadSearchItems.length > 0) { searchableGroups.push({ value: "threads-search", @@ -334,14 +353,14 @@ export function filterCommandPaletteGroups(input: { return searchableGroups.flatMap((group) => { const items = Arr.filterMap(group.items, (item, index) => { const haystack = normalizeSearchText(item.searchTerms.join(" ")); - if (!haystack.includes(normalizedQuery)) { + if (!queryTokens.every((token) => haystack.includes(token))) { return Result.failVoid; } return Result.succeed({ item, index, - rank: rankCommandPaletteItemMatch(item, normalizedQuery), + rank: rankCommandPaletteItemMatch(item, normalizedQuery, queryTokens), }); }) .toSorted((left, right) => right.rank - left.rank || left.index - right.index) diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index c5ec3f095167..b2af0f031737 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -1,6 +1,10 @@ "use client"; -import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { + scopedThreadKey, + scopeProjectRef, + scopeThreadRef, +} from "@t3tools/client-runtime/environment"; import { canCreateProjectInEnvironment, getCloneDestinationBrowsePath, @@ -11,6 +15,7 @@ import { } from "@t3tools/client-runtime/operations/projects"; import { connectionStatusText } from "@t3tools/client-runtime/connection"; import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; +import { resolveThreadReferenceCopyTarget } from "@t3tools/shared/threadReference"; import { canPreloadBrowsePath, createBrowseNavigationCoordinator, @@ -32,7 +37,7 @@ import { type SourceControlRepositoryInfo, PRIMARY_LOCAL_ENVIRONMENT_ID, } from "@t3tools/contracts"; -import { useNavigate, useParams } from "@tanstack/react-router"; +import { useLocation, useNavigate, useParams } from "@tanstack/react-router"; import * as Option from "effect/Option"; import { ArrowLeftIcon, @@ -65,6 +70,7 @@ import { useAtomValue } from "@effect/atom-react"; import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal"; import { useDesktopLocalBootstraps } from "../connection/useDesktopLocalBootstraps"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; +import { writeTextToClipboard } from "../hooks/useCopyToClipboard"; import { useClientSettings } from "../hooks/useSettings"; import { useTheme } from "../hooks/useTheme"; import { readLocalApi } from "../localApi"; @@ -73,11 +79,13 @@ import { filesystemEnvironment } from "../state/filesystem"; import { projectEnvironment } from "../state/projects"; import { useEnvironmentQuery } from "../state/query"; import { sourceControlEnvironment } from "../state/sourceControl"; +import { vcsEnvironment } from "../state/vcs"; import { useAtomCommand } from "../state/use-atom-command"; import { useAtomQueryRunner } from "../state/use-atom-query-runner"; import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; -import { useProjects, useThreadShells } from "../state/entities"; +import { useProject, useProjects, useThreadShells } from "../state/entities"; import { useThreadSearch } from "../state/queries"; +import * as ThreadPr from "./ThreadStatusIndicators"; import { resolveThreadActionProjectRef, startNewThreadFromContext } from "../lib/chatThreadActions"; import { appendBrowsePathSegment, @@ -104,6 +112,7 @@ import { } from "../lib/utils"; import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; import { buildThreadRouteParams, resolveThreadRouteTarget } from "../threadRoutes"; +import { useAvailableSettingsSearchItems } from "./settings/useAvailableSettingsSearchItems"; import { applyWslEnvironmentConfiguration, parseWslUncPath, @@ -140,6 +149,7 @@ import { ProjectFavicon } from "./ProjectFavicon"; import { ProjectFilePicker } from "./files/ProjectFilePicker"; import { ProjectContentSearchDialog } from "./search/ProjectContentSearchDialog"; import { toggleThemeEditorForTheme } from "./settings/themeEditorStore"; +import { searchSettings, SETTINGS_SECTION_LABELS } from "./settings/settingsSearch"; import { COMMAND_PALETTE_META_ICON_CLASS, CommandPaletteMetaDot, @@ -563,6 +573,7 @@ function OpenCommandPaletteDialog(props: { readonly clearOpenIntent: () => void; }) { const navigate = useNavigate(); + const pathname = useLocation({ select: (location) => location.pathname }); const { clearOpenIntent, openIntent, openOverlayMode, setOpen } = props; const [query, setQuery] = useState(""); const deferredQuery = useDeferredValue(query); @@ -585,9 +596,69 @@ function OpenCommandPaletteDialog(props: { const { environments } = useEnvironments(); const desktopLocalBootstraps = useDesktopLocalBootstraps(); const primaryEnvironmentId = usePrimaryEnvironmentId(); + const availableSettingsSearchItems = useAvailableSettingsSearchItems(); const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread } = useHandleNewThread(); const projects = useProjects(); + const changeRequestSnapshotByKey = useAtomValue(ThreadPr.threadChangeRequestSnapshotsAtom); + const activeThreadProject = useProject( + activeThread === null + ? null + : scopeProjectRef(activeThread.environmentId, activeThread.projectId), + ); + const activeThreadCwd = activeThread?.worktreePath ?? activeThreadProject?.workspaceRoot ?? null; + const activeThreadGitStatus = useEnvironmentQuery( + activeThread != null && + activeThread.linkedPullRequest == null && + activeThread.branch !== null && + activeThreadCwd !== null + ? vcsEnvironment.status({ + environmentId: activeThread.environmentId, + input: { cwd: activeThreadCwd }, + }) + : null, + ).data; + const detectedPullRequestUrl = + activeThread == null || activeThread.linkedPullRequest != null + ? null + : (ThreadPr.resolveDisplayedThreadPr({ + threadBranch: activeThread.branch, + gitStatus: activeThreadGitStatus ?? null, + snapshot: changeRequestSnapshotByKey.get( + scopedThreadKey(scopeThreadRef(activeThread.environmentId, activeThread.id)), + ), + retainTerminalOnBranchMismatch: activeThread.worktreePath === null, + })?.url ?? null); + const activeThreadReferenceCopyTarget = + activeThread == null + ? null + : resolveThreadReferenceCopyTarget({ + threadId: activeThread.id, + linkedPullRequestUrl: activeThread.linkedPullRequest?.url ?? null, + detectedPullRequestUrl, + }); + const copyActiveThreadReference = useCallback(async () => { + const target = activeThreadReferenceCopyTarget; + if (target === null) return; + try { + const didCopy = await writeTextToClipboard(target.value, target.clipboardTarget); + if (!didCopy) return; + toastManager.add({ + type: "success", + title: target.successTitle, + description: target.value, + }); + } catch (error) { + console.error(error); + toastManager.add( + stackedThreadToast({ + type: "error", + title: target.failureTitle, + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + }, [activeThreadReferenceCopyTarget]); const projectOrder = useUiStateStore((store) => store.projectOrder); const threads = useThreadShells(); const keybindings = useAtomValue(primaryServerKeybindingsAtom); @@ -1525,6 +1596,20 @@ function OpenCommandPaletteDialog(props: { }); } + if (activeThreadReferenceCopyTarget !== null) { + actionItems.push({ + kind: "action", + value: "action:copy-thread-reference", + searchTerms: ["copy", "pull request", "pr link", "thread id", "reference"], + title: + activeThreadReferenceCopyTarget.kind === "pull-request" ? "Copy PR link" : "Copy thread ID", + description: activeThreadReferenceCopyTarget.value, + icon: , + shortcutCommand: "thread.copyReference", + run: copyActiveThreadReference, + }); + } + actionItems.push({ kind: "action", value: "action:open-file-picker", @@ -1637,7 +1722,19 @@ function OpenCommandPaletteDialog(props: { actionItems.push({ kind: "action", value: "action:project-settings", - searchTerms: ["project", "settings", "scripts", "model", "grouping", "checkout"], + searchTerms: [ + "project", + "settings", + "name", + "icon", + "scripts", + "model", + "workspace", + "grouping", + "checkout", + "remove", + "t3.json", + ], title: "Project settings", description: contextualProjectGroup.displayName, icon: , @@ -1651,6 +1748,25 @@ function OpenCommandPaletteDialog(props: { } const rootGroups = buildRootGroups({ actionItems, recentThreadItems }); + const settingsSearchItems: CommandPaletteActionItem[] = searchSettings( + deferredQuery, + availableSettingsSearchItems, + ).map((item) => ({ + kind: "action", + value: `setting:${item.id}`, + searchTerms: [item.title, SETTINGS_SECTION_LABELS[item.to], ...(item.searchTerms ?? [])], + title: item.title, + description: `Settings · ${SETTINGS_SECTION_LABELS[item.to]}`, + icon: , + run: async () => { + await navigate({ + to: item.to, + hash: item.targetId ?? item.id, + replace: pathname === item.to, + hashScrollIntoView: false, + }); + }, + })); const sourceSelectionViewValue = addProjectEnvironmentId === null ? null : `sources:${addProjectEnvironmentId}`; const activeGroups = @@ -1668,6 +1784,7 @@ function OpenCommandPaletteDialog(props: { query: deferredQuery, isInSubmenu: currentView !== null, projectSearchItems: projectSearchItems, + settingsSearchItems, threadSearchItems: allThreadItems, }); @@ -2167,6 +2284,13 @@ function OpenCommandPaletteDialog(props: { return; } } + if (command === "thread.copyReference" && activeThreadReferenceCopyTarget !== null) { + event.preventDefault(); + event.stopPropagation(); + setOpen(false); + void copyActiveThreadReference(); + return; + } if (addProjectCloneFlow?.step === "repository" && event.key === "Enter") { event.preventDefault(); diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 50cac0952dd7..b1b10852eedb 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -641,11 +641,8 @@ type SettledTimestampInput = Pick< "settledAt" | "latestUserMessageAt" | "latestTurn" | "updatedAt" >; -/** The timestamp a settled row sorts and labels by: settledAt when stamped - (explicit settles), otherwise last activity — the same candidates - threadLastActivityAt feeds the auto-settle window (user message plus all - latestTurn stamps), so a thread whose last activity was a turn completion - doesn't sort by an older message time. updatedAt is the final net. */ +/** The timestamp a settled row sorts and labels by: settledAt when stamped, + otherwise the latest message or turn stamp. updatedAt is the final net. */ export function resolveSettledTimestamp(thread: SettledTimestampInput): string | null { const settledAt = firstValidTimestamp(thread.settledAt); if (settledAt !== null) return settledAt; diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 89f2bcc64d45..ec39d2458ff2 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -19,8 +19,6 @@ import { restrictToFirstScrollableAncestor, restrictToVerticalAxis } from "@dnd- import { CSS } from "@dnd-kit/utilities"; import { canSnooze, - changeRequestAutoSettles, - effectiveSettled, effectiveSnoozed, threadWokeAt, } from "@t3tools/client-runtime/state/thread-settled"; @@ -720,7 +718,6 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { // False on environments whose server predates thread.settle/unsettle: // the lifecycle affordances hide entirely rather than fail on click. settlementSupported: boolean; - autoSettleOnMerge: boolean; // Same contract for thread.snooze/unsnooze. snoozeSupported: boolean; // Pinned threads show the same pin marker in active, settled, and snoozed @@ -845,10 +842,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { const isWoke = wokeAtDate !== null && (lastVisitedDate === null || lastVisitedDate < wokeAtDate) && - !changeRequestAutoSettles(pr, { - autoSettleOnMerge: props.autoSettleOnMerge, - thread, - }); + thread.settledOverride !== "settled"; // In-flight rows (working, or waiting on approval/input) fade as a whole: // there is nothing for the user to do yet, so prominence is reserved for // rows that need a human — done (unread), read-but-unsettled, failed, and @@ -1747,8 +1741,6 @@ export default function Sidebar() { const router = useRouter(); const { isMobile, setOpenMobile } = useSidebar(); const keybindings = useAtomValue(primaryServerKeybindingsAtom); - const autoSettleAfterDays = useClientSettings((s) => s.sidebarAutoSettleAfterDays); - const autoSettleOnMerge = useClientSettings((s) => s.sidebarAutoSettleOnMerge); const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete); const confirmThreadArchive = useClientSettings((s) => s.confirmThreadArchive); const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder); @@ -1945,8 +1937,6 @@ export default function Sidebar() { [projectGroups], ); - // now is quantized to the minute so effectiveSettled memoization doesn't - // churn on every render; auto-settle thresholds are day-granular anyway. const nowMinute = useNowMinute(); // Snooze wake times are second-precise, so classifying with the quantized // minute would hold a woken thread on the shelf for up to a minute. The @@ -2088,7 +2078,6 @@ export default function Sidebar() { settledThreads, snoozeNow, } = useMemo(() => { - const now = `${nowMinute}:00.000Z`; // Snooze classification uses a REAL clock, not the quantized minute: // wake times are second-precise and a woken thread must not linger on // the shelf for the rest of the minute. snoozeWakeTick re-runs this @@ -2114,29 +2103,10 @@ export default function Sidebar() { serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSettlement === true; const supportsSnooze = serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true; - const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); - const snapshot = changeRequestSnapshotByKey.get(threadKey); - const changeRequest = - snapshot != null && - (thread.linkedPullRequest == null - ? thread.worktreePath === null || snapshot.branch === thread.branch - : snapshot.linkedPullRequest?.projectId === thread.linkedPullRequest.projectId && - snapshot.linkedPullRequest.repository === thread.linkedPullRequest.repository && - snapshot.linkedPullRequest.number === thread.linkedPullRequest.number) - ? snapshot.pr - : null; // Snooze outranks settlement and pinning until the thread wakes. if (supportsSnooze && effectiveSnoozed(thread, { now: preciseNow })) { snoozed.push(thread); - } else if ( - supportsSettlement && - effectiveSettled(thread, { - now, - autoSettleAfterDays, - autoSettleOnMerge, - changeRequest, - }) - ) { + } else if (supportsSettlement && thread.settledOverride === "settled") { settled.push(thread); } else if (thread.pinnedAt != null) { pinned.push(thread); @@ -2166,16 +2136,7 @@ export default function Sidebar() { settledThreads: sortSettledThreadsForSidebar(settled), snoozeNow: preciseNow, }; - }, [ - autoSettleAfterDays, - autoSettleOnMerge, - changeRequestSnapshotByKey, - nowMinute, - scopedProjectKeys, - serverConfigs, - snoozeWakeTick, - threads, - ]); + }, [nowMinute, scopedProjectKeys, serverConfigs, snoozeWakeTick, threads]); const threadSearchInputRef = useRef(null); const [threadSearchQuery, setThreadSearchQuery] = useState(""); @@ -3114,9 +3075,8 @@ export default function Sidebar() { thread.worktreePath ?? projectCwdByKey.get(`${thread.environmentId}:${thread.projectId}`) ?? null; - // Un-settle works on every settled row: for explicit settles it - // clears the override, for auto-settled rows it pins the thread - // active until real activity clears the pin. Environments without + // Un-settle pins the thread active until real activity clears the pin. + // Environments without // the settlement capability get no lifecycle items at all. const supportsSettlement = serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSettlement === @@ -3789,9 +3749,7 @@ export default function Sidebar() { key={`${threadKey}:${rowVariant}`} thread={thread} variant={rowVariant} - // Snoozed rows wake; settled rows un-settle (explicit - // settles clear the override, auto-settled rows get - // pinned active); cards settle. + // Snoozed rows wake, settled rows un-settle, and cards settle. variantAction={ section === "snoozed" ? "unsnooze" @@ -3803,7 +3761,6 @@ export default function Sidebar() { serverConfigs.get(thread.environmentId)?.environment.capabilities .threadSettlement === true } - autoSettleOnMerge={autoSettleOnMerge} snoozeSupported={ serverConfigs.get(thread.environmentId)?.environment.capabilities .threadSnooze === true diff --git a/apps/web/src/components/ThreadStatusIndicators.test.ts b/apps/web/src/components/ThreadStatusIndicators.test.ts index 3710bcea8b8e..2663af161b55 100644 --- a/apps/web/src/components/ThreadStatusIndicators.test.ts +++ b/apps/web/src/components/ThreadStatusIndicators.test.ts @@ -1,6 +1,4 @@ -import { effectiveSettled } from "@t3tools/client-runtime/state/thread-settled"; -import type { OrchestrationThreadShell } from "@t3tools/contracts"; -import { ProjectId, ProviderInstanceId, ThreadId, type VcsStatusResult } from "@t3tools/contracts"; +import { ProjectId, type VcsStatusResult } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import { AtomRegistry } from "effect/unstable/reactivity"; @@ -495,7 +493,7 @@ describe("resolveDisplayedThreadPr + nextThreadChangeRequestSnapshot", () => { ).toEqual(mergedPr); }); - it("keeps effectiveSettled true for a retained merged PR after a main checkout", () => { + it("retains a merged PR after a main checkout", () => { const matchingStatus = status({ refName: featureBranch, pr: mergedPr, @@ -518,36 +516,6 @@ describe("resolveDisplayedThreadPr + nextThreadChangeRequestSnapshot", () => { retainTerminalOnBranchMismatch: true, }); expect(displayed?.state).toBe("merged"); - - const shell = { - id: ThreadId.make("thread-1"), - projectId: ProjectId.make("project-1"), - title: "Feature thread", - modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, - runtimeMode: "full-access", - interactionMode: "default", - branch: "main", - worktreePath: null, - latestTurn: null, - session: null, - createdAt: "2026-04-09T00:00:00.000Z", - updatedAt: "2026-04-09T00:00:00.000Z", - archivedAt: null, - settledAt: null, - settledOverride: null, - latestUserMessageAt: "2026-04-09T00:00:00.000Z", - hasPendingApprovals: false, - hasPendingUserInput: false, - hasActionableProposedPlan: false, - } as OrchestrationThreadShell; - - expect( - effectiveSettled(shell, { - now: "2026-04-10T00:00:00.000Z", - autoSettleAfterDays: null, - changeRequest: displayed, - }), - ).toBe(true); }); }); diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index f6732411781c..5e83091f7670 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -153,7 +153,10 @@ import { ComposerPendingUserInputPanel } from "./ComposerPendingUserInputPanel"; import { ComposerPlanFollowUpBanner } from "./ComposerPlanFollowUpBanner"; import { ComposerControl, ComposerControlIcon, ComposerSelectControl } from "./ComposerControl"; import { resolveComposerMenuActiveItemId } from "./composerMenuHighlight"; -import { searchSlashCommandItems } from "./composerSlashCommandSearch"; +import { + searchSlashCommandItems, + slashCommandItemsForPromptPosition, +} from "./composerSlashCommandSearch"; import { getComposerPromptInjectionState, getComposerProviderState, @@ -567,9 +570,6 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( providerDisplayName={props.activeThreadProviderDisplayName} onRequestRefresh={props.onRequestUsageRefresh} /> - {props.isPreparingWorktree ? ( - Preparing worktree... - ) : null} (null); @@ -289,13 +284,16 @@ export const ChatHeader = memo(function ChatHeader({ className="@container/header-actions flex min-w-0 flex-1 items-center gap-2 sm:gap-3" onContextMenu={handleHeaderContextMenu} > - + {/* The project always leads the header: knowing which project a thread lives in is priority zero, and the thread title alone doesn't answer it. */} {activeProjectName ? ( <> - + } > @@ -321,7 +319,7 @@ export const ChatHeader = memo(function ChatHeader({ ) : null} - + {renamingTitle !== null ? ( - {item.icon} - {item.title} + + {item.icon} + + + {item.description ? ( + <> + {item.title} + {item.description} + + ) : ( + item.title + )} + {item.actions || item.onDismiss ? ( {item.actions} @@ -284,19 +297,7 @@ function ComposerBannerStackAlert({ ) : null} - {item.description || item.children ? ( - - {item.description ? ( - - - - {item.description} - - - ) : null} - {item.children} - - ) : null} + {item.children ? {item.children} : null} ); } diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 3cd8d338d361..d9bfdae04d49 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vite-plus/test"; +import { MessageId, TurnId } from "@t3tools/contracts"; import { computeStableMessagesTimelineRows, computeMessageDurationStart, @@ -825,9 +826,11 @@ describe("deriveMessagesTimelineRows", () => { "assistant-final-entry", "user-followup-entry", "working-indicator-row", + "thinking-indicator-row", ]); const finalRow = rows.find((row) => row.id === "assistant-final-entry"); expect(finalRow?.kind === "message" && finalRow.showAssistantMeta).toBe(true); + expect(rows.at(-1)).toMatchObject({ kind: "thinking" }); }); it("does not fold the active in-progress turn", () => { @@ -884,18 +887,18 @@ describe("deriveMessagesTimelineRows", () => { const rows = deriveMessagesTimelineRows({ timelineEntries: [ { - id: "completed-command-entry", + id: "running-command-entry", kind: "work", createdAt: "2026-01-01T00:00:05Z", entry: { - id: "completed-command", + id: "running-command", createdAt: "2026-01-01T00:00:05Z", turnId: "turn-1" as never, - label: "Ran rg", + label: "Running rg", command: "rg toolCall", requestKind: "command", tone: "tool" as const, - toolLifecycleStatus: "completed" as const, + toolLifecycleStatus: "inProgress" as const, }, }, { @@ -914,18 +917,18 @@ describe("deriveMessagesTimelineRows", () => { }, }, { - id: "running-command-entry", + id: "completed-command-entry", kind: "work", createdAt: "2026-01-01T00:00:07Z", entry: { - id: "running-command", + id: "completed-command", createdAt: "2026-01-01T00:00:07Z", turnId: "turn-1" as never, - label: "Running tests", + label: "Ran tests", command: "vp test run", requestKind: "command", tone: "tool" as const, - toolLifecycleStatus: "inProgress" as const, + toolLifecycleStatus: "completed" as const, }, }, ], @@ -942,12 +945,13 @@ describe("deriveMessagesTimelineRows", () => { }); expect(rows.map((row) => row.kind)).toEqual(["working", "work-live"]); + expect(rows.some((row) => row.kind === "thinking")).toBe(false); expect(rows.find((row) => row.kind === "work-live")).toMatchObject({ entry: { id: "running-command" }, groupedEntries: [ - { id: "completed-command" }, - { id: "completed-edit" }, { id: "running-command" }, + { id: "completed-edit" }, + { id: "completed-command" }, ], }); }); @@ -1192,7 +1196,7 @@ describe("deriveMessagesTimelineRows", () => { ]); }); - it("keeps the latest completed tool call live while the turn is running", () => { + it("shows thinking after the latest tool call completes while the turn is running", () => { const rows = deriveMessagesTimelineRows({ timelineEntries: [ { @@ -1223,11 +1227,9 @@ describe("deriveMessagesTimelineRows", () => { revertTurnCountByUserMessageId: new Map(), }); - expect(rows.map((row) => row.kind)).toEqual(["working", "work-live"]); - expect(rows.find((row) => row.kind === "work-live")).toMatchObject({ - entry: { id: "latest-command" }, - groupedEntries: [{ id: "latest-command" }], - }); + expect(rows.map((row) => row.kind)).toEqual(["working", "work-live", "thinking"]); + expect(rows.find((row) => row.kind === "work-live")).toMatchObject({ active: false }); + expect(rows.at(-1)).toMatchObject({ kind: "thinking" }); }); it("does not fold the session's running turn when latestTurn regresses", () => { @@ -1375,6 +1377,7 @@ describe("deriveMessagesTimelineRows", () => { expect(assistantRow?.showAssistantMeta).toBe(false); expect(assistantRow?.showAssistantCopyButton).toBe(false); + expect(rows.at(-1)).toMatchObject({ kind: "thinking" }); }); it.each([ @@ -1543,6 +1546,54 @@ describe("deriveMessagesTimelineRows", () => { }); describe("computeStableMessagesTimelineRows", () => { + it.each(["", " \n"])("keeps Thinking after assistant content grows from %j", (text) => { + const startedAt = "2026-01-01T00:00:00Z"; + const turnId = TurnId.make("turn-1"); + const input = { + runningTurnId: turnId, + isWorking: true, + activeTurnStartedAt: startedAt, + turnDiffSummaryByAssistantMessageId: new Map(), + revertTurnCountByUserMessageId: new Map(), + }; + const assistantEntry = { + id: "assistant-entry", + kind: "message" as const, + createdAt: startedAt, + message: { + id: MessageId.make("assistant-1"), + role: "assistant" as const, + text, + turnId, + createdAt: startedAt, + updatedAt: startedAt, + streaming: true, + }, + }; + const initial = computeStableMessagesTimelineRows( + deriveMessagesTimelineRows({ ...input, timelineEntries: [assistantEntry] }), + { byId: new Map(), result: [] }, + ); + const updated = computeStableMessagesTimelineRows( + deriveMessagesTimelineRows({ + ...input, + timelineEntries: [ + { + ...assistantEntry, + message: { ...assistantEntry.message, text: "I will inspect the repository." }, + }, + ], + }), + initial, + ); + + const initialThinking = initial.byId.get("thinking-indicator-row"); + const updatedThinking = updated.byId.get("thinking-indicator-row"); + expect(initialThinking).toMatchObject({ kind: "thinking" }); + expect(updatedThinking).toBe(initialThinking); + expect(updated.result.at(-1)).toBe(updatedThinking); + }); + it("returns the previous result when row order and content are unchanged", () => { const firstUserMessage = { id: "user-1" as never, diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index c190643ee7f3..c787446f738b 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -1,5 +1,17 @@ import * as Equal from "effect/Equal"; import { renderCodexDirectivesForCopy } from "@t3tools/client-runtime/codex-markdown-directives"; +import { + omitSupersededLifecycleMarkers, + summarizeToolGroup, + toolGroupSummaryKind, + type ToolGroupSummaryKind, +} from "@t3tools/client-runtime/work-log/presentation"; +export { + normalizeCompactToolLabel, + summarizeToolGroup, + toolGroupAction, + workLogEntryIsLocalCodeSearch, +} from "@t3tools/client-runtime/work-log/presentation"; import { formatDuration, workEntryDisplayIndicatesToolFailure, @@ -193,6 +205,7 @@ export type MessagesTimelineRow = groupedEntries: WorkLogEntry[]; groupId: string; expanded: boolean; + active: boolean; } | { kind: "work-toggle"; @@ -235,7 +248,11 @@ export type MessagesTimelineRow = kind: "working"; id: string; createdAt: string | null; - showThinking: boolean; + } + | { + kind: "thinking"; + id: string; + createdAt: string | null; }; export interface StableMessagesTimelineRowsState { @@ -262,157 +279,6 @@ export function computeMessageDurationStart( return result; } -export function normalizeCompactToolLabel(value: string): string { - return value.replace(/\s+(?:complete|completed)\s*$/i, "").trim(); -} - -type ToolGroupAction = "read" | "edit" | "command" | "code-search" | "search" | "other" | "update"; -type ToolGroupSummaryKind = ToolGroupAction | "dynamic-tool" | "agent-tool" | "tone-tool" | "mixed"; - -export function workLogEntryIsLocalCodeSearch(entry: WorkLogEntry): boolean { - return ( - entry.itemType === "web_search" && - /\bgrep\b/i.test(normalizeCompactToolLabel(entry.toolTitle ?? entry.label)) - ); -} - -export function toolGroupAction(entry: WorkLogEntry): ToolGroupAction { - if ( - entry.requestKind === "file-read" || - entry.itemType === "image_view" || - (entry.itemType === "dynamic_tool_call" && entry.toolTitle === "Read File") - ) { - return "read"; - } - if ( - entry.requestKind === "file-change" || - entry.itemType === "file_change" || - (entry.changedFiles?.length ?? 0) > 0 - ) { - return "edit"; - } - if (entry.requestKind === "command" || entry.itemType === "command_execution" || entry.command) { - return "command"; - } - if (workLogEntryIsLocalCodeSearch(entry)) return "code-search"; - if (entry.itemType === "web_search") return "search"; - return workLogEntryIsToolLike(entry) ? "other" : "update"; -} - -function toolGroupActionCount( - action: ToolGroupAction, - entries: ReadonlyArray, -): number { - if (action !== "edit") return entries.length; - - const changedFiles = new Set(); - let editsWithoutFileDetails = 0; - for (const entry of entries) { - if (!entry.changedFiles || entry.changedFiles.length === 0) { - editsWithoutFileDetails += 1; - continue; - } - for (const file of entry.changedFiles) changedFiles.add(file); - } - return changedFiles.size + editsWithoutFileDetails; -} - -function toolGroupActionLabel(action: ToolGroupAction, count: number): string { - switch (action) { - case "read": - return `Read ${count} ${count === 1 ? "file" : "files"}`; - case "edit": - return `Changed ${count} ${count === 1 ? "file" : "files"}`; - case "command": - return `Ran ${count} ${count === 1 ? "command" : "commands"}`; - case "search": - return `Searched the web ${count} ${count === 1 ? "time" : "times"}`; - case "code-search": - return `Searched code ${count} ${count === 1 ? "time" : "times"}`; - case "other": - return `Used ${count} ${count === 1 ? "tool" : "tools"}`; - case "update": - return `Received ${count} ${count === 1 ? "update" : "updates"}`; - } -} - -/** Immediate, provider-neutral fallback while generated tool summaries are disabled or unavailable. */ -export function summarizeToolGroup(entries: ReadonlyArray): string { - const summaryEntries = omitSupersededLifecycleMarkers(entries, (entry) => entry); - const groupedEntries = new Map(); - for (const entry of summaryEntries) { - const action = toolGroupAction(entry); - const group = groupedEntries.get(action); - if (group) group.push(entry); - else groupedEntries.set(action, [entry]); - } - const labels = [...groupedEntries].map(([action, actionEntries]) => - toolGroupActionLabel(action, toolGroupActionCount(action, actionEntries)), - ); - const sentenceLabels = labels.map((label, index) => - index === 0 ? label : label.charAt(0).toLowerCase() + label.slice(1), - ); - if (sentenceLabels.length < 2) return sentenceLabels[0] ?? ""; - if (sentenceLabels.length === 2) return sentenceLabels.join(" and "); - return `${sentenceLabels.slice(0, -1).join(", ")}, and ${sentenceLabels.at(-1)}`; -} - -function omitSupersededLifecycleMarkers( - entries: readonly T[], - workEntryFor: (entry: T) => WorkLogEntry, -): T[] { - const laterTerminalIdentities = new Set(); - const reversedEntries: T[] = []; - - for (let index = entries.length - 1; index >= 0; index -= 1) { - const entry = entries[index]!; - const workEntry = workEntryFor(entry); - const normalizedLabel = normalizeCompactToolLabel(workEntry.toolTitle ?? workEntry.label); - const identity = [ - workEntry.turnId ?? "no-turn", - workEntry.itemType ?? "", - normalizedLabel, - ].join("\u001f"); - const isStatuslessIdlessMarker = - workEntry.toolCallId === undefined && - workEntry.toolLifecycleStatus === undefined && - (workEntry.sourceActivityKind === "tool.started" || - workEntry.sourceActivityKind === "tool.updated"); - if (isStatuslessIdlessMarker && laterTerminalIdentities.has(identity)) continue; - - reversedEntries.push(entry); - if ( - workEntry.sourceActivityKind === "tool.completed" || - (workEntry.toolLifecycleStatus !== undefined && - workEntry.toolLifecycleStatus !== "inProgress") - ) { - laterTerminalIdentities.add(identity); - } - } - - return reversedEntries.toReversed(); -} - -function toolGroupSummaryKind(entries: ReadonlyArray): ToolGroupSummaryKind { - const actions = new Set(entries.map(toolGroupAction)); - if (actions.size !== 1) return "mixed"; - - const action = actions.values().next().value!; - if (action !== "other") return action; - - const fallbackKinds = new Set( - entries.map((entry): ToolGroupSummaryKind => { - if (entry.itemType === "mcp_tool_call") return "other"; - if (entry.itemType === "dynamic_tool_call") return "dynamic-tool"; - if (entry.itemType === "collab_agent_tool_call" || entry.taskId) return "agent-tool"; - if (entry.tone === "thinking") return "agent-tool"; - if (entry.tone === "tool") return "tone-tool"; - return "other"; - }), - ); - return fallbackKinds.size === 1 ? fallbackKinds.values().next().value! : "mixed"; -} - function workGroupIdentity(timelineEntryId: string, entry: WorkLogEntry): string { return entry.toolCallId ? `tool:${entry.turnId ?? "no-turn"}:${entry.toolCallId}` @@ -512,6 +378,14 @@ function timelineEntryTurnId(entry: TimelineEntry): TurnId | null { return entry.kind === "work" ? (entry.entry.turnId ?? null) : null; } +function workEntryIsActiveTurnActivity(entry: WorkLogEntry): boolean { + return ( + entry.toolLifecycleStatus === "inProgress" || + entry.sourceActivityKind === "task.progress" || + (entry.toolLifecycleStatus === undefined && workLogEntryIsToolLike(entry)) + ); +} + /** * Settled turns keep only their terminal assistant message visible. * Everything before it folds behind a "Worked for ..." row anchored at the @@ -702,24 +576,6 @@ export function deriveMessagesTimelineRows(input: { unsettledTurnId !== null && entry.toolLifecycleStatus === "inProgress" && entry.turnId === unsettledTurnId; - const activeEntries = input.isWorking - ? input.timelineEntries.filter((entry, index) => entryBelongsToActiveTurn(entry, index)) - : []; - const activeTurnHasVisibleContent = activeEntries.some((entry) => { - if (entry.kind === "message") { - return entry.message.role === "assistant" && (entry.message.text?.trim().length ?? 0) > 0; - } - if (entry.kind === "work") { - return ( - entry.entry.agentSpawn === undefined && - workLogEntryIsToolLike(entry.entry) && - entry.entry.toolLifecycleStatus === "inProgress" - ); - } - if (entry.kind === "proposed-plan") return true; - return false; - }); - const activeToolEntries: Array> = []; for (let index = input.timelineEntries.length - 1; index >= activeTurnHeaderIndex; index -= 1) { const entry = input.timelineEntries[index]!; @@ -733,40 +589,48 @@ export function deriveMessagesTimelineRows(input: { } activeToolEntries.unshift(entry); } - const activeWorkEntryIds = new Set(activeToolEntries.map((entry) => entry.id)); const visibleActiveToolEntries = omitSupersededLifecycleMarkers( activeToolEntries.filter((entry) => workEntryIsVisibleInGroup(entry.entry, true)), (entry) => entry.entry, ); const activeWorkAnchor = activeToolEntries[0]; - const latestActiveToolEntry = visibleActiveToolEntries.at(-1); - const activeWorkPlacementEntryId = latestActiveToolEntry?.id; + const latestVisibleToolEntry = visibleActiveToolEntries.at(-1); + const latestRunningToolEntry = visibleActiveToolEntries.findLast((entry) => + workEntryIsActiveTurnActivity(entry.entry), + ); + const displayedToolEntry = latestRunningToolEntry ?? latestVisibleToolEntry; + const activeWorkPlacementEntryId = latestVisibleToolEntry?.id; const activeWorkRow = - activeWorkAnchor && latestActiveToolEntry + activeWorkAnchor && displayedToolEntry ? (() => { const groupId = workGroupId(activeWorkAnchor.id, activeWorkAnchor.entry); return { kind: "work-live" as const, id: `work-live:${workGroupIdentity(activeWorkAnchor.id, activeWorkAnchor.entry)}`, createdAt: activeWorkAnchor.createdAt, - entry: latestActiveToolEntry.entry, + entry: displayedToolEntry.entry, groupedEntries: visibleActiveToolEntries.map((entry) => entry.entry), groupId, expanded: input.expandedWorkGroupIds?.has(groupId) ?? false, + active: latestRunningToolEntry !== undefined, }; })() : null; + const activeWorkEntryIds = new Set( + activeWorkRow === null ? [] : activeToolEntries.map((entry) => entry.id), + ); const appendWorkingRow = () => { nextRows.push({ kind: "working", id: "working-indicator-row", createdAt: input.activeTurnStartedAt, - showThinking: activeWorkRow === null && !activeTurnHasVisibleContent, }); }; + let hasLiveWorkRow = false; const appendActiveWorkRows = () => { if (activeWorkRow === null) return; nextRows.push(activeWorkRow); + hasLiveWorkRow ||= activeWorkRow.active; if (!activeWorkRow.expanded) return; for (const [entryIndex, workEntry] of activeWorkRow.groupedEntries.entries()) { nextRows.push({ @@ -864,7 +728,9 @@ export function deriveMessagesTimelineRows(input: { groupedEntries: visibleGroupedEntries, groupId, expanded, + active: true, }); + hasLiveWorkRow = true; if (expanded) { for (const [entryIndex, workEntry] of visibleGroupedEntries.entries()) { nextRows.push({ @@ -966,6 +832,13 @@ export function deriveMessagesTimelineRows(input: { if (input.isWorking && activeTurnHeaderIndex === input.timelineEntries.length) { appendWorkingRow(); } + if (input.isWorking && !hasLiveWorkRow) { + nextRows.push({ + kind: "thinking", + id: "thinking-indicator-row", + createdAt: input.activeTurnStartedAt, + }); + } return nextRows; } @@ -996,9 +869,8 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean switch (a.kind) { case "working": - return ( - a.createdAt === (b as typeof a).createdAt && a.showThinking === (b as typeof a).showThinking - ); + case "thinking": + return a.createdAt === (b as typeof a).createdAt; case "turn-fold": { const bf = b as typeof a; @@ -1023,6 +895,7 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean a.createdAt === bw.createdAt && a.groupId === bw.groupId && a.expanded === bw.expanded && + a.active === bw.active && Equal.equals(a.entry, bw.entry) && Equal.equals(a.groupedEntries, bw.groupedEntries) ); diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index b339aab95445..024dfe69d278 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -520,7 +520,7 @@ describe("MessagesTimeline", () => { ); expect(markup).toContain('data-anchor-index="0"'); - expect(markup).toContain('data-anchor-offset="16"'); + expect(markup).toContain('data-anchor-offset="24"'); expect(markup).toContain('data-anchor-on-ready="true"'); expect(markup).not.toContain("data-anchor-max-size="); expect(markup).toContain('data-content-inset-end="144"'); @@ -1308,7 +1308,7 @@ describe("MessagesTimeline", () => { expect(markup).not.toContain("tool call failed"); }); - it("keeps terminal command copy live while the parent turn is active", () => { + it("keeps declined command copy visible while thinking continues", () => { const turnId = TurnId.make("turn-live"); const markup = renderToStaticMarkup( { runningTurnId={turnId} timelineEntries={[ { - id: "entry-failed", + id: "entry-declined", kind: "work", createdAt: MESSAGE_CREATED_AT, entry: { - id: "work-failed", + id: "work-declined", createdAt: MESSAGE_CREATED_AT, turnId, - toolCallId: "call-failed", + toolCallId: "call-declined", label: "Run lint", tone: "tool", itemType: "command_execution", command: "pnpm lint", - toolLifecycleStatus: "failed", + toolLifecycleStatus: "declined", }, }, ]} />, ); - expect(markup).toContain("Running pnpm"); + expect(markup).toContain("Declined pnpm"); + expect(markup).toContain("Thinking"); expect(markup).toContain("tool call failed"); }); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index b304e4fbbb45..4d243ae59a33 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -9,6 +9,10 @@ import { import { parseScopedThreadKey } from "@t3tools/client-runtime/environment"; import type { CodexArtifactTemplate } from "@t3tools/client-runtime/codex-artifact-templates"; import { commandProgramName } from "@t3tools/client-runtime/work-log/command-label"; +import { + resolveViewedImageAsset, + workEntryViewedImagePath, +} from "@t3tools/client-runtime/work-log/presentation"; import type { AgentPanelModel } from "@t3tools/client-runtime/state/subagentRuntime"; import { emptyAgentPanelModel, @@ -55,7 +59,7 @@ import { resolveDiffThemeName, resolveFileDiffPath, } from "../../lib/diffRendering"; -import ChatMarkdown from "../ChatMarkdown"; +import ChatMarkdown, { ChatMarkdownAssetImage } from "../ChatMarkdown"; import { BotIcon, CheckIcon, @@ -84,7 +88,10 @@ import { buildExpandedImagePreview, ExpandedImagePreview } from "./ExpandedImage import { ProposedPlanCard } from "./ProposedPlanCard"; import { ChangedFilesCard } from "./ChangedFilesTree"; import { shouldAutoExpandChangedFiles } from "./changedFilesPresentation"; -import { keepTimelineEndVisibleAfterOverlayGrowth } from "./timelineScrollAnchoring"; +import { + CHAT_TIMELINE_ANCHOR_OFFSET, + keepTimelineEndVisibleAfterOverlayGrowth, +} from "./timelineScrollAnchoring"; import { MessageCopyButton } from "./MessageCopyButton"; import { computeStableMessagesTimelineRows, @@ -171,6 +178,7 @@ interface TimelineRowSharedState { interface TimelineRowActivityState { isWorking: boolean; + isPreparingWorktree: boolean; isRevertingCheckpoint: boolean; latestTurnId: TurnId | null; } @@ -227,6 +235,7 @@ interface MessagesTimelineProps { agentPanelModel?: AgentPanelModel; onOpenAgents?: () => void; isWorking: boolean; + isPreparingWorktree?: boolean; activeTurnStartedAt: string | null; listRef: React.RefObject; timelineEntries: ReturnType; @@ -272,6 +281,7 @@ interface MessagesTimelineProps { export const MessagesTimeline = memo(function MessagesTimeline({ isWorking, + isPreparingWorktree = false, activeTurnStartedAt, agentPanelModel = EMPTY_AGENT_PANEL_MODEL, onOpenAgents = NOOP_OPEN_AGENTS, @@ -469,8 +479,11 @@ export const MessagesTimeline = memo(function MessagesTimeline({ [anchorMessageId, onAnchorReady], ); const anchoredEndSpace = useMemo(() => { - const config = resolveChatListAnchoredEndSpace(rows, anchorMessageId, (row) => - row.kind === "message" && row.message.role === "user" ? row.message.id : null, + const config = resolveChatListAnchoredEndSpace( + rows, + anchorMessageId, + (row) => (row.kind === "message" && row.message.role === "user" ? row.message.id : null), + { anchorOffset: CHAT_TIMELINE_ANCHOR_OFFSET }, ); return config ? { ...config, onReady: handleAnchorReady } : undefined; }, [anchorMessageId, handleAnchorReady, rows]); @@ -579,10 +592,11 @@ export const MessagesTimeline = memo(function MessagesTimeline({ const activityState = useMemo( () => ({ isWorking, + isPreparingWorktree, isRevertingCheckpoint, latestTurnId: latestTurn?.turnId ?? null, }), - [isRevertingCheckpoint, isWorking, latestTurn?.turnId], + [isRevertingCheckpoint, isWorking, isPreparingWorktree, latestTurn?.turnId], ); // Stable renderItem — no closure deps. Row components read shared state @@ -973,7 +987,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time : "pb-0" : isExpandedToolGroupHeader ? "pb-0" - : row.kind === "turn-fold" || row.kind === "working" + : row.kind === "turn-fold" || row.kind === "working" || row.kind === "thinking" ? "pb-1.5" : (row.kind === "message" && row.message.role === "assistant" && @@ -1005,6 +1019,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time ) : null} {row.kind === "proposed-plan" ? : null} {row.kind === "working" ? : null} + {row.kind === "thinking" ? : null}
); }); @@ -1320,26 +1335,38 @@ function ProposedPlanTimelineRow({ } function WorkingTimelineRow({ row }: { row: Extract }) { + const { isPreparingWorktree } = use(TimelineRowActivityCtx); return ( -
-
-
- - {row.createdAt ? ( - <> - Working for - - ) : ( - "Working..." - )} - -
+
+
+ + {isPreparingWorktree ? ( + <> + Setting up worktree… + Setting up worktree… + + ) : row.createdAt ? ( + <> + Working for + + ) : ( + "Working..." + )} +
- {row.showThinking ? ( -
- -
- ) : null} +
+ ); +} + +function ThinkingTimelineRow() { + const { isPreparingWorktree } = use(TimelineRowActivityCtx); + // Reserve the activity row during setup so the handoff keeps the same height. + return ( +
+ {isPreparingWorktree ? null : }
); } @@ -1414,6 +1441,19 @@ const WorkGroupSection = memo(function WorkGroupSection({ ); }); +function ActivityShimmerOverlay({ children }: { children: ReactNode }) { + return ( + + + {children} + + + ); +} + function LiveActivityRow({ label, iconName, @@ -1431,24 +1471,13 @@ function LiveActivityRow({ failed={failed} announceFailure={failed} /> -
-
-
- -
-
-
+ + +
); } -function ThinkingActivityRow() { - return ; -} - function LiveActivityContent({ label, iconName, @@ -1465,7 +1494,7 @@ function LiveActivityContent({ const resolvedIconName = failed ? "circle-alert" : iconName; return ( -
) : null} {label} -
+ ); } function LiveWorkEntryTimelineRow({ row }: { row: Extract }) { const ctx = use(TimelineRowCtx); - const label = liveWorkEntryLabel(row.entry, ctx.workspaceRoot); + const label = liveWorkEntryLabel(row.entry, ctx.workspaceRoot, row.active); const failed = workEntryDisplayIndicatesToolFailure(row.entry); return ( @@ -1505,7 +1534,18 @@ function LiveWorkEntryTimelineRow({ row }: { row: Extract ctx.onToggleWorkGroup(row.groupId, row.id)} > - + {row.active ? ( + + ) : ( +
+ +
+ )} ); } @@ -2185,14 +2225,18 @@ function workEntryRawCommand( function liveWorkEntryLabel( workEntry: TimelineWorkEntry, workspaceRoot: string | undefined, + active: boolean, ): string { const command = workEntry.command?.trim(); if (command) { - // This row describes the active parent turn, not the command lifecycle. - // Keep its live "Running" copy until the turn or contiguous tool run settles. const program = commandProgramName(command); - if (program) return `Running ${program}`; - return "Running command"; + const verb = active + ? "Running" + : workEntry.toolLifecycleStatus === "declined" + ? "Declined" + : "Ran"; + if (program) return `${verb} ${program}`; + return `${verb} command`; } return workEntryPreview(workEntry, workspaceRoot) ?? toolWorkEntryHeading(workEntry); @@ -2400,6 +2444,7 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { isExpandedToolGroupEntry: boolean; }) { const { workEntry, workspaceRoot, isExpandedToolGroupEntry } = props; + const { threadRef, onImageExpand } = use(TimelineRowCtx); const [expanded, setExpanded] = useState(false); const iconConfig = workToneIcon(workEntry.tone); const showWarningIndicator = workEntry.sourceActivityKind === "runtime.warning"; @@ -2408,6 +2453,14 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { showWarningIndicator || showFailedIndicator ? "circle-alert" : workEntryIconName(workEntry); const displayText = workEntryPreview(workEntry, workspaceRoot) ?? toolWorkEntryHeading(workEntry); const expandedBody = buildToolCallExpandedBody(workEntry, workspaceRoot); + const viewedImagePath = workEntryViewedImagePath(workEntry); + const viewedImage = + viewedImagePath && threadRef + ? resolveViewedImageAsset(viewedImagePath, { + threadId: threadRef.threadId, + workspaceRoot, + }) + : null; const canExpand = expandedBody !== null; const showDestructiveRowStyle = showFailedIndicator && @@ -2501,6 +2554,18 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { onClick={stopRowToggle} onPointerDown={stopRowToggle} > + {viewedImage && threadRef ? ( +
+ +
+ ) : null}
{expandedBody}
) : null} diff --git a/apps/web/src/components/chat/composerSlashCommandSearch.test.ts b/apps/web/src/components/chat/composerSlashCommandSearch.test.ts index be749c0aae47..751aced7b835 100644 --- a/apps/web/src/components/chat/composerSlashCommandSearch.test.ts +++ b/apps/web/src/components/chat/composerSlashCommandSearch.test.ts @@ -2,7 +2,10 @@ import { describe, expect, it } from "vite-plus/test"; import { ProviderDriverKind } from "@t3tools/contracts"; import type { ComposerCommandItem } from "./ComposerCommandMenu"; -import { searchSlashCommandItems } from "./composerSlashCommandSearch"; +import { + searchSlashCommandItems, + slashCommandItemsForPromptPosition, +} from "./composerSlashCommandSearch"; describe("searchSlashCommandItems", () => { const claudeDriver = ProviderDriverKind.make("claudeAgent"); @@ -173,4 +176,38 @@ describe("searchSlashCommandItems", () => { "skill:claudeAgent:unslop", ]); }); + + it("hides skills from slash completion after the first message line", () => { + const items = [ + { + id: "slash:model", + type: "slash-command", + command: "model", + label: "/model", + description: "Switch model", + }, + { + id: "skill:claudeAgent:unslop", + type: "skill", + provider: claudeDriver, + skill: { + name: "unslop", + path: "/skills/unslop/SKILL.md", + enabled: true, + }, + label: "/skill:unslop", + description: "Cut AI tells from writing", + }, + ] satisfies Array< + Extract + >; + + expect(slashCommandItemsForPromptPosition(items, false).map((item) => item.id)).toEqual([ + "slash:model", + ]); + expect(slashCommandItemsForPromptPosition(items, true).map((item) => item.id)).toEqual([ + "slash:model", + "skill:claudeAgent:unslop", + ]); + }); }); diff --git a/apps/web/src/components/chat/composerSlashCommandSearch.ts b/apps/web/src/components/chat/composerSlashCommandSearch.ts index 3e60cbf58b33..1578e0ec6f86 100644 --- a/apps/web/src/components/chat/composerSlashCommandSearch.ts +++ b/apps/web/src/components/chat/composerSlashCommandSearch.ts @@ -12,6 +12,16 @@ type SlashSearchItem = Extract< { type: "slash-command" | "provider-slash-command" | "skill" } >; +export function slashCommandItemsForPromptPosition( + items: ReadonlyArray, + isAtPromptStart: boolean, +): SlashSearchItem[] { + if (isAtPromptStart) { + return [...items]; + } + return items.filter((item) => item.type !== "skill"); +} + function scoreSlashCommandItem(item: SlashSearchItem, query: string): number | null { if (item.type === "skill") { if (query === "skill") { diff --git a/apps/web/src/components/chat/timelineScrollAnchoring.ts b/apps/web/src/components/chat/timelineScrollAnchoring.ts index f38d0920b28b..505efef29d21 100644 --- a/apps/web/src/components/chat/timelineScrollAnchoring.ts +++ b/apps/web/src/components/chat/timelineScrollAnchoring.ts @@ -1,3 +1,6 @@ +// Match the titlebar fade inset so draft promotion preserves the first row's position. +export const CHAT_TIMELINE_ANCHOR_OFFSET = 24; + export type TimelineScrollMode = "following-end" | "anchoring-new-turn" | "free-scrolling"; export interface TimelineListMeasurementState { diff --git a/apps/web/src/components/files/FileBrowserPanel.tsx b/apps/web/src/components/files/FileBrowserPanel.tsx index 9b0fcef4691a..ee34b80a5c1f 100644 --- a/apps/web/src/components/files/FileBrowserPanel.tsx +++ b/apps/web/src/components/files/FileBrowserPanel.tsx @@ -3,13 +3,13 @@ import type { ContextMenuOpenContext as TreeContextMenuOpenContext, } from "@pierre/trees"; import type { EnvironmentId, ProjectEntry } from "@t3tools/contracts"; -import { FileTree, useFileTree, useFileTreeSearch } from "@pierre/trees/react"; +import { FileTree, useFileTree, useFileTreeSearch, useFileTreeSelector } from "@pierre/trees/react"; import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger"; import { isAtomCommandInterrupted, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; -import { RotateCw } from "lucide-react"; +import { ChevronsDownUpIcon, ChevronsUpDownIcon, RotateCw } from "lucide-react"; import { useEffect, useMemo, useRef } from "react"; import { Button } from "~/components/ui/button"; @@ -34,6 +34,12 @@ import { rootDirectoryTreePaths, setAllDirectoriesExpanded, } from "./fileTreeBulkExpansion"; +// Upstream's toolbar toggle drives each directory handle directly; the Turbo +// alt-click path resets the whole tree, so both expansion helpers coexist. +import { + areAllDirectoriesExpanded, + setAllDirectoriesExpanded as setAllDirectoryHandlesExpanded, +} from "./fileTreeExpansion"; import { useProjectEntriesQuery } from "./projectFilesQueryState"; interface FileBrowserPanelProps { @@ -369,6 +375,12 @@ export default function FileBrowserPanel({ unsafeCSS: TREE_UNSAFE_CSS, }); const search = useFileTreeSearch(model); + const allDirectoriesExpanded = useFileTreeSelector(model, (currentModel) => + areAllDirectoriesExpanded(currentModel, directoryPaths), + ); + const toggleAllDirectories = () => { + setAllDirectoryHandlesExpanded(model, directoryPaths, !allDirectoriesExpanded); + }; const handleSearchValueChange = (value: string) => { if (value.trim().length === 0) { search.close(); @@ -531,6 +543,32 @@ export default function FileBrowserPanel({ onValueChange={handleSearchValueChange} onClose={search.close} /> + {directoryPaths.length > 0 ? ( + + + } + > + {allDirectoriesExpanded ? ( + + ) : ( + + )} + + + {allDirectoriesExpanded ? "Collapse all folders" : "Expand all folders"} + + + ) : null}
{entriesQuery.error && entriesQuery.data === null ? (
{entriesQuery.error}
diff --git a/apps/web/src/components/files/fileTreeExpansion.test.ts b/apps/web/src/components/files/fileTreeExpansion.test.ts new file mode 100644 index 000000000000..1fba6957728f --- /dev/null +++ b/apps/web/src/components/files/fileTreeExpansion.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it, vi } from "@effect/vitest"; + +import { areAllDirectoriesExpanded, setAllDirectoriesExpanded } from "./fileTreeExpansion"; + +type FakeDirectoryItem = { + isDirectory: () => true; + isExpanded: () => boolean; + expand: () => void; + collapse: () => void; +}; + +function makeModel(expanded: Record) { + const items = new Map(); + return { + getItem: (path: string) => { + const existing = items.get(path); + if (existing !== undefined) return existing; + const item: FakeDirectoryItem = { + isDirectory: () => true, + isExpanded: () => expanded[path] ?? false, + expand: () => { + expanded[path] = true; + }, + collapse: () => { + expanded[path] = false; + }, + }; + items.set(path, item); + return item; + }, + }; +} + +describe("file tree expansion", () => { + it("requires at least one directory and detects whether all are expanded", () => { + const model = makeModel({ "src/": true, "test/": true }); + expect(areAllDirectoriesExpanded(model, [])).toBe(false); + expect(areAllDirectoriesExpanded(model, ["src/", "test/"])).toBe(true); + expect( + areAllDirectoriesExpanded(makeModel({ "src/": true, "test/": false }), ["src/", "test/"]), + ).toBe(false); + }); + + it("expands and collapses every directory", () => { + const expanded = { "src/": true, "test/": false }; + const model = makeModel(expanded); + setAllDirectoriesExpanded(model, ["src/", "test/"], true); + expect(expanded).toEqual({ "src/": true, "test/": true }); + setAllDirectoriesExpanded(model, ["src/", "test/"], false); + expect(expanded).toEqual({ "src/": false, "test/": false }); + }); + + it("skips directories already at the requested state", () => { + const model = makeModel({ "src/": true }); + const item = model.getItem("src/"); + const collapse = vi.spyOn(item, "collapse"); + setAllDirectoriesExpanded(model, ["src/"], true); + expect(collapse).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/components/files/fileTreeExpansion.ts b/apps/web/src/components/files/fileTreeExpansion.ts new file mode 100644 index 000000000000..221e62b64c96 --- /dev/null +++ b/apps/web/src/components/files/fileTreeExpansion.ts @@ -0,0 +1,55 @@ +export interface FileTreeExpansionModel { + getItem(path: string): unknown; +} + +type DirectoryHandle = { + isDirectory(): boolean; + isExpanded(): boolean; + expand(): void; + collapse(): void; +}; + +function asDirectoryHandle(item: unknown): DirectoryHandle | null { + if ( + typeof item !== "object" || + item === null || + !("isDirectory" in item) || + typeof item.isDirectory !== "function" || + !item.isDirectory() || + !("isExpanded" in item) || + typeof item.isExpanded !== "function" || + !("expand" in item) || + typeof item.expand !== "function" || + !("collapse" in item) || + typeof item.collapse !== "function" + ) { + return null; + } + return item as DirectoryHandle; +} + +export function areAllDirectoriesExpanded( + model: FileTreeExpansionModel, + directoryPaths: readonly string[], +): boolean { + return ( + directoryPaths.length > 0 && + directoryPaths.every((path) => { + const item = asDirectoryHandle(model.getItem(path)); + return item !== null && item.isExpanded(); + }) + ); +} + +export function setAllDirectoriesExpanded( + model: FileTreeExpansionModel, + directoryPaths: readonly string[], + expanded: boolean, +): void { + for (const path of directoryPaths) { + const item = asDirectoryHandle(model.getItem(path)); + if (item === null || item.isExpanded() === expanded) continue; + if (expanded) item.expand(); + else item.collapse(); + } +} diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index 5666ddd26e57..0f3a864f8c1c 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -97,6 +97,7 @@ import { DiffPanelLoadingState } from "../DiffPanelShell"; import { PullRequestsUnavailableState } from "./PullRequestsUnavailableState"; import type { PullRequestAgentSelectionInput } from "./PullRequestCodeTab"; import { openOnHostLabel, showPullRequestLinkContextMenu } from "./pullRequestLinkContextMenu"; +import { PullRequestMarkdownContext } from "./PullRequestMarkdown"; import { PullRequestSummaryTab } from "./PullRequestSummaryTab"; import { PullRequestTimelineTab } from "./PullRequestTimelineTab"; import { @@ -1921,7 +1922,7 @@ export function PullRequestDetailPanel({ {...(unavailableGitHubUrl ? { gitHubUrl: unavailableGitHubUrl } : {})} /> ) : detail ? ( - <> + {mountedTabs.has("summary") ? (
) : null} - +
) : null} diff --git a/apps/web/src/components/pullRequest/PullRequestListFilters.test.tsx b/apps/web/src/components/pullRequest/PullRequestListFilters.test.tsx index f1c3013167f7..7ec2629c77b3 100644 --- a/apps/web/src/components/pullRequest/PullRequestListFilters.test.tsx +++ b/apps/web/src/components/pullRequest/PullRequestListFilters.test.tsx @@ -34,7 +34,8 @@ function findLabeledGroup(node: ReactNode, label: string): ReactNode { if (!isValidElement(child)) continue; const props = child.props as { readonly children?: ReactNode; readonly label?: string }; if (props.label === label && typeof child.type === "function") { - return (child.type as (properties: unknown) => ReactNode)(child.props); + const rendered = (child.type as (properties: unknown) => ReactNode)(child.props); + return findLabeledGroup(rendered, label) ?? rendered; } const nested = findLabeledGroup(props.children, label); if (nested !== undefined) return nested; @@ -126,7 +127,7 @@ describe("pull request filters menu", () => { projectEnvironmentId: environmentId, onProject, }); - const radioGroup = findValueChange(view); + const radioGroup = findValueChange(findLabeledGroup(view, "Project")); expect(radioGroup).toBeDefined(); radioGroup?.props.onValueChange(pullRequestProjectKey({ id: projectId, environmentId })); @@ -156,7 +157,7 @@ describe("pull request filters menu", () => { ], onProject, }); - const radioGroup = findValueChange(view); + const radioGroup = findValueChange(findLabeledGroup(view, "Project")); expect(radioGroup).toBeDefined(); radioGroup?.props.onValueChange( diff --git a/apps/web/src/components/pullRequest/PullRequestListFilters.tsx b/apps/web/src/components/pullRequest/PullRequestListFilters.tsx index 67d2d77e4c94..9c3bfbab0c19 100644 --- a/apps/web/src/components/pullRequest/PullRequestListFilters.tsx +++ b/apps/web/src/components/pullRequest/PullRequestListFilters.tsx @@ -18,10 +18,11 @@ import { ListFilterIcon, LoaderIcon, SearchIcon, + TagIcon, + UserRoundIcon, } from "lucide-react"; -import type { ElementType } from "react"; +import { type ElementType, useState } from "react"; -import { cn } from "~/lib/utils"; import { getSourceControlPresentationForKind } from "~/sourceControlPresentation"; import { ProjectFavicon } from "../ProjectFavicon"; import { InputGroup, InputGroupAddon, InputGroupInput } from "../ui/input-group"; @@ -29,27 +30,56 @@ import { Button } from "../ui/button"; import { Menu, + MenuCheckboxItem, MenuGroupLabel, + MenuItem, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuSeparator, + MenuSub, + MenuSubPopup, + MenuSubTrigger, MenuTrigger, } from "../ui/menu"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { + pullRequestLabelColor, + type PullRequestAuthorFacet, + type PullRequestLabelFacet, +} from "./pullRequestList.logic"; +import { PullRequestActorAvatar } from "./pullRequestPresentation"; export interface PullRequestFilterOption { readonly value: Value; readonly label: string; - /** - * Carries the option's own tone, so an icon reads the same here as it does on a row. Left - * uncoloured, which lets the item's selected state stay the thing the eye follows. - */ + /** Uses the option's native icon tone. */ readonly Icon: ElementType<{ className?: string }>; + readonly favicon?: { + readonly environmentId: EnvironmentId; + readonly cwd: string; + }; /** Why it cannot be chosen, carried onto the item as its title. */ readonly unavailable?: string | undefined; } +export function PullRequestFilterOptionIcon({ + option, +}: { + option: PullRequestFilterOption; +}) { + return option.favicon ? ( + + ) : ( + + ); +} + export interface PullRequestExpectedHost { readonly host: string; readonly kind: SourceControlProviderKind; @@ -98,10 +128,8 @@ export function PullRequestSearchInput({ } /** - * Every list filter lives behind the one filter icon so the control row stays two controls - * wide: the search and this. The trigger carries a dot whenever any filter is off its - * default, so a narrowed list is never a mystery. Same menu chrome as the detail panel's - * actions, which also owns its own spacing. + * List narrowings live behind one filter control, separate from sorting. The trigger carries a + * count whenever any filter is off its default, so a narrowed list is never a mystery. */ const ALL_PROJECTS_VALUE = "all"; /** MenuRadioGroup wants a string, so "every host" wears the one value no host can be. */ @@ -169,8 +197,9 @@ function PullRequestFilterRadioGroup({ disabled={option.unavailable !== undefined} > - - {option.label} + + {option.label} + {option.unavailable ? · Unavailable : null} ); @@ -188,7 +217,185 @@ function PullRequestFilterRadioGroup({ ); } +function PullRequestFilterRadioSubmenu({ + label, + value, + options, + onChange, +}: { + label: string; + value: Value; + options: ReadonlyArray>; + onChange: (value: Value) => void; +}) { + const current = options.find((option) => option.value === value) ?? options[0]; + if (!current) return null; + return ( + + + + {label} + + {current.label} + + + + + + + ); +} + +function PullRequestAuthorFilter({ + value, + options, + onChange, +}: { + value: string | undefined; + options: ReadonlyArray; + onChange: (author: string | undefined) => void; +}) { + const [query, setQuery] = useState(""); + const needle = query.trim().toLowerCase(); + const login = value?.toLowerCase() ?? ""; + const selected = options.find((option) => option.actor.login.toLowerCase() === login); + const visible = [ + ...(selected ? [selected] : []), + ...options.filter( + (option) => + option !== selected && + (needle.length === 0 || + option.actor.login.toLowerCase().includes(needle) || + option.actor.name?.toLowerCase().includes(needle)), + ), + ].slice(0, 10); + const select = (next: string) => next.toLowerCase() !== login && onChange(next || undefined); + return ( + + + + Author + + {value ?? "Anyone"} + + + +
+ + + + + setQuery(event.currentTarget.value)} + onKeyDown={(event) => { + if (event.key !== "ArrowDown" && event.key !== "Escape") event.stopPropagation(); + }} + placeholder="Search authors" + aria-label="Search authors" + /> + +
+ + + + + Anyone + + + {visible.map((option) => ( + + + + {option.actor.login} + + {option.mergedCount} merges loaded + + + + ))} + {visible.length === 0 ? No authors found : null} + +
+
+ ); +} + +function PullRequestLabelFilter({ + value, + options, + onChange, +}: { + value: ReadonlyArray; + options: ReadonlyArray; + onChange: (labels: ReadonlyArray) => void; +}) { + const selected = new Set(value.map((name) => name.toLowerCase())); + const visible = [ + ...value + .filter((name) => !options.some((option) => option.name.toLowerCase() === name.toLowerCase())) + .map((name) => ({ name, color: null, count: 0 })), + ...options, + ]; + return ( + + + + Labels + + {value.length === 0 ? "Any" : `${value.length} selected`} + + + + {visible.length === 0 ? ( + No labels in this view + ) : ( + visible.map((option) => { + const key = option.name.toLowerCase(); + const checked = selected.has(key); + const dot = pullRequestLabelColor(option.color); + return ( + + onChange( + next + ? [...value, option.name] + : value.filter((name) => name.toLowerCase() !== option.name.toLowerCase()), + ) + } + > + + + {option.name} + + {option.count} + + + + ); + }) + )} + + + ); +} + export function PullRequestFiltersMenu({ + onOpenChange, state, stateOptions, onState, @@ -197,6 +404,8 @@ export function PullRequestFiltersMenu({ onInvolvement, filters, onFilters, + authorOptions = [], + labelOptions = [], host, hostOptions, onHost, @@ -209,6 +418,7 @@ export function PullRequestFiltersMenu({ unavailable, onProject, }: { + onOpenChange?: (open: boolean) => void; state: PullRequestListState; stateOptions: ReadonlyArray>; onState: (state: PullRequestListState) => void; @@ -218,6 +428,8 @@ export function PullRequestFiltersMenu({ /** The narrowings beyond state and involvement; an absent field is that group unfiltered. */ filters: PullRequestListFilters; onFilters: (filters: PullRequestListFilters) => void; + authorOptions?: ReadonlyArray; + labelOptions?: ReadonlyArray; host: string | undefined; /** * Includes the "all hosts" entry, whose value is the empty string. With fewer than two real @@ -254,82 +466,119 @@ export function PullRequestFiltersMenu({ /** The environment comes with the project id, since picking a row picks a specific server's copy of it. */ onProject: (projectId: ProjectId | undefined, environmentId: EnvironmentId | undefined) => void; }) { - const filtered = - state !== "open" || - involvement !== "all" || - host !== undefined || - server !== undefined || - projectId !== undefined || - Object.keys(filters).length > 0; - /** - * Rebuilt rather than spread so an unfiltered group leaves the record instead of lingering in - * it as an explicit `undefined`, which the listing input does not accept. - */ - const withFilter = (key: keyof PullRequestListFilters, value: string): PullRequestListFilters => - Object.fromEntries( - Object.entries({ ...filters, [key]: value === UNFILTERED_VALUE ? undefined : value }).filter( - ([, held]) => held !== undefined, - ), - ) as PullRequestListFilters; + const selectedLabels = (filters.labels ?? []).flatMap((group) => group); + const filterCount = [ + state !== "open", + involvement !== "all", + host, + server, + projectId, + filters.draft, + filters.review, + filters.checks, + filters.author, + ...selectedLabels, + ].filter(Boolean).length; + const updateFilters = (next: Partial) => + onFilters( + Object.fromEntries( + Object.entries({ ...filters, ...next }).filter(([, value]) => value !== undefined), + ) as PullRequestListFilters, + ); + const updateFilter = (key: keyof PullRequestListFilters, value: string) => + updateFilters({ + [key]: value === UNFILTERED_VALUE ? undefined : value, + } as Partial); + const projectValue = + projectId === undefined || projectEnvironmentId === undefined + ? ALL_PROJECTS_VALUE + : pullRequestProjectKey({ id: projectId, environmentId: projectEnvironmentId }); + const projectOptions: ReadonlyArray> = [ + { value: ALL_PROJECTS_VALUE, label: "All projects", Icon: LayersIcon }, + ...projects + .toSorted( + (left, right) => + Number(unavailable.has(pullRequestProjectKey(left))) - + Number(unavailable.has(pullRequestProjectKey(right))), + ) + .map((project) => ({ + value: pullRequestProjectKey(project), + label: project.title, + Icon: FolderGit2Icon, + favicon: { environmentId: project.environmentId, cwd: project.workspaceRoot }, + ...(unavailable.has(pullRequestProjectKey(project)) + ? { unavailable: unavailable.get(pullRequestProjectKey(project)) } + : {}), + })), + ]; return ( -
+ 0 ? "[--control-icon-color:currentColor]" : undefined} variant="outline" - aria-label="Filter pull requests" /> } > - {filtered ? ( - + Filters + {filterCount > 0 ? ( + + {filterCount} + ) : null} - - + - - - updateFilters({ author })} + /> + + updateFilters({ + labels: labels.length === 0 ? undefined : labels.slice(0, 10).map((label) => [label]), + }) + } + /> + onFilters(withFilter("draft", next))} + onChange={(draft) => updateFilter("draft", draft)} /> - - onFilters(withFilter("review", next))} + onChange={(review) => updateFilter("review", review)} /> - - onFilters(withFilter("checks", next))} + onChange={(checks) => updateFilter("checks", checks)} /> {hostOptions.length > 2 ? ( <> - 2 ? ( <> - ) : null} - { - if (next === ALL_PROJECTS_VALUE) { - if (projectId !== undefined) onProject(undefined, undefined); - return; - } - // The value carries both halves, since the id alone cannot tell two servers' rows - // apart once they share one. + { const project = projects.find((candidate) => pullRequestProjectKey(candidate) === next); - if ( - project !== undefined && - (project.id !== projectId || project.environmentId !== projectEnvironmentId) - ) { - onProject(project.id, project.environmentId); - } + if (project) onProject(project.id, project.environmentId); + else if (projectId !== undefined) onProject(undefined, undefined); }} - > - Project - - - - All projects - - - {/* The ones that can be chosen first: a list that opens with three disabled rows reads - as a broken menu rather than as a workspace with three unreadable repositories. */} - {projects - .toSorted( - (left, right) => - Number(unavailable.has(pullRequestProjectKey(left))) - - Number(unavailable.has(pullRequestProjectKey(right))), - ) - .map((project) => { - const reason = unavailable.get(pullRequestProjectKey(project)); - const item = ( - - - - {project.title} - {reason === undefined ? null : ( - - Unavailable - - )} - - - ); - if (reason === undefined) return item; - return ( - - - - {reason} - - - ); - })} - + /> ); diff --git a/apps/web/src/components/pullRequest/PullRequestMarkdown.tsx b/apps/web/src/components/pullRequest/PullRequestMarkdown.tsx index f782e5be113d..ad06eb21e2ae 100644 --- a/apps/web/src/components/pullRequest/PullRequestMarkdown.tsx +++ b/apps/web/src/components/pullRequest/PullRequestMarkdown.tsx @@ -1,10 +1,14 @@ import { ExternalLinkIcon, PaperclipIcon, PlayIcon } from "lucide-react"; import type { EnvironmentId } from "@t3tools/contracts"; +import { createContext, useContext, useMemo } from "react"; +import type { Options as ReactMarkdownOptions } from "react-markdown"; import { cn } from "~/lib/utils"; import ChatMarkdown from "../ChatMarkdown"; -import { splitPullRequestBody } from "./pullRequestMarkdown.logic"; +import { remarkPullRequestAutolinks, splitPullRequestBody } from "./pullRequestMarkdown.logic"; + +export const PullRequestMarkdownContext = createContext(null); /** * A pull request body, rendered with the app's markdown renderer plus a card for each upload @@ -27,6 +31,11 @@ export function PullRequestMarkdown({ className?: string; }) { const segments = splitPullRequestBody(text); + const repositoryUrl = useContext(PullRequestMarkdownContext); + const extraRemarkPlugins = useMemo>( + () => (repositoryUrl ? [[remarkPullRequestAutolinks, { repositoryUrl }]] : []), + [repositoryUrl], + ); return (
{segments.map((segment) => { @@ -37,6 +46,7 @@ export function PullRequestMarkdown({ text={segment.text} cwd={cwd} environmentId={environmentId} + extraRemarkPlugins={extraRemarkPlugins} /> ); } diff --git a/apps/web/src/components/pullRequest/PullRequestRow.tsx b/apps/web/src/components/pullRequest/PullRequestRow.tsx index c7f731f9be13..2284144d7fd1 100644 --- a/apps/web/src/components/pullRequest/PullRequestRow.tsx +++ b/apps/web/src/components/pullRequest/PullRequestRow.tsx @@ -1,5 +1,5 @@ import { SearchIcon } from "lucide-react"; -import { memo } from "react"; +import { memo, type RefCallback } from "react"; import { cn } from "~/lib/utils"; import { getSourceControlPresentationForKind } from "~/sourceControlPresentation"; @@ -7,7 +7,7 @@ import { formatRelativeTimeLabel } from "~/timestampFormat"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { PullRequestChecksPopover } from "./PullRequestChecksPopover"; -import type { EnvironmentPullRequestEntry } from "./pullRequestList.logic"; +import { pullRequestLabelColor, type EnvironmentPullRequestEntry } from "./pullRequestList.logic"; import { openOnHostLabel, showPullRequestLinkContextMenu } from "./pullRequestLinkContextMenu"; import { PullRequestActorLabel, @@ -16,6 +16,23 @@ import { PullRequestStateGlyph, } from "./pullRequestPresentation"; +function PullRequestRowLabels({ labels }: { labels: EnvironmentPullRequestEntry["labels"] }) { + const label = labels[0]; + if (!label) return null; + const dot = pullRequestLabelColor(label.color); + return ( + + + {label.name} + {labels.length > 1 ? +{labels.length - 1} : null} + + ); +} + function PullRequestRowImpl({ entry, selected, @@ -23,6 +40,8 @@ function PullRequestRowImpl({ showProvider, environmentLabel, matchedElsewhere, + statsKey, + statsRef, onSelect, }: { entry: EnvironmentPullRequestEntry; @@ -37,11 +56,16 @@ function PullRequestRowImpl({ * commit message. Saying so is the difference between a result and an apparently random row. */ matchedElsewhere?: boolean; + /** Used by the list's shared visibility observer to defer optional line-count reads. */ + statsKey?: string; + statsRef?: RefCallback; onSelect: (entry: EnvironmentPullRequestEntry) => void; }) { const { Icon, providerName } = getSourceControlPresentationForKind(entry.provider); return (